/* ===================================================================== routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8). 1:1 portiert aus cloudflare-worker/src/routes/supporter-paypal.js. WICHTIG: `req.rawBody` (der unveränderte Roh-Text des Request-Bodys, von index.js im express.json()-verify-Callback gesetzt) wird für die PayPal-Signaturprüfung gebraucht — req.body ist bereits geparst und könnte beim Neuzusammenbau minimal vom Original abweichen (Reihenfolge/ Whitespace), was die Signatur ungültig machen würde. ===================================================================== */ import { db } from "../db.js"; import { generateId, nowIso } from "../lib/crypto.js"; import { json } from "../lib/http.js"; import { logAction } from "../lib/audit.js"; import { createSubscription, verifyWebhookSignature, cancelSubscription, PayPalNotConfiguredError } from "../lib/paypal.js"; import { allUnlockedMilestones } from "../lib/supporter-cycle.js"; import { sendMilestoneUnlocked } from "../lib/supporter-mail.js"; const REDEEM_LABELS = { teddy_klein: "Kleiner Hasen-Teddy", teddy_mittel: "Mittelgroßer Hasen-Teddy", teddy_gross: "Großer Hasen-Teddy", tasse_karte: "Tasse & Autogrammkarte", hoodie: "Exklusiver Hoodie", }; function paypalNotConfiguredResponse(res) { return json( res, { ok: false, code: "PAYPAL_NOT_CONFIGURED", error: "PayPal ist auf dieser Website noch nicht eingerichtet. Der Websitebetreiber muss zuerst " + "PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_PLAN_ID und PAYPAL_WEBHOOK_ID setzen " + "(siehe cloudflare-worker/README.md, Abschnitt 'Supporter-Abo Setup').", }, 503 ); } /** Startet ein PayPal-Abonnement für den eingeloggten Supporter (Abschnitt 6, Schritt 6). */ export async function startPaypalSubscription(req, res, supporter) { const body = req.body || {}; const origin = body.origin || "https://dogfather-universe.com"; try { const { paypalSubscriptionId, approveUrl } = await createSubscription({ supporterId: supporter.id, returnUrl: `${origin}/supporter.html?paypal=success`, cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`, }); if (!approveUrl) return json(res, { ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502); const subId = generateId(); db.prepare( `INSERT INTO supporter_subscriptions (id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)` ).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso()); logAction("system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId }); return json(res, { ok: true, approveUrl }); } catch (err) { if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); return json(res, { ok: false, error: "PayPal-Abo konnte nicht erstellt werden." }, 502); } } /** * Gegenstück zu den PayPal Smart-Payment-Buttons im Frontend: dort erzeugt * das PayPal-JS-SDK die Subscription direkt im Browser (zeigt dabei * automatisch alle für die Käuferin/den Käufer verfügbaren Zahlarten an — * PayPal-Guthaben, Bankkonto, Kreditkarte als Gast, je nach Land/Konto), * damit landet das Geld direkt auf Dogis PayPal-Konto. Der Browser * übergibt uns danach nur noch die fertige `subscriptionID`, die wir hier * mit dem Supporter-Konto verknüpfen. Der tatsächliche Aktivierungsstatus * kommt weiterhin ausschließlich über den geprüften Webhook (Abschnitt 6: * "Eine reine Weiterleitung... darf nicht ausreichen, um das Abo als aktiv * zu markieren"). */ export async function confirmPaypalSubscription(req, res, supporter) { const body = req.body || {}; const paypalSubscriptionId = String(body.subscriptionId || "").trim(); if (!paypalSubscriptionId) return json(res, { ok: false, error: "Keine PayPal-Subscription-ID übergeben." }, 400); // Rechts-Update 04.08.2026: Nachweis der Widerrufsrecht-Zustimmung // (sofortiger Leistungsbeginn bei digitalen Inhalten, § 356 Abs. 5 BGB / // entspr. Regelung, siehe agb.html Ziffer 6). Die Checkbox im Frontend // (abonnieren.html) blockiert das Laden der PayPal-Buttons bereits // clientseitig — diese serverseitige Prüfung verhindert zusätzlich eine // Umgehung per direktem API-Aufruf ohne echte Zustimmung. const widerrufConsentAt = String(body.widerrufConsentAt || "").trim(); const consentDatum = widerrufConsentAt ? new Date(widerrufConsentAt) : null; const consentGueltig = consentDatum && !Number.isNaN(consentDatum.getTime()) && consentDatum.getTime() <= Date.now(); if (!consentGueltig) { return json(res, { ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400); } const existing = db.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId); if (existing) return json(res, { ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler const subId = generateId(); db.prepare( `INSERT INTO supporter_subscriptions (id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)` ).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso()); logAction("system", "supporter.subscription_confirmed_client_side", supporter.id, { paypalSubscriptionId, widerrufConsentAt: consentDatum.toISOString(), }); return json(res, { ok: true }); } export async function cancelPaypalSubscription(req, res, supporter) { const sub = db .prepare( `SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1` ) .get(supporter.id); if (!sub) return json(res, { ok: false, error: "Kein aktives Abonnement gefunden." }, 404); try { if (sub.paypal_subscription_id) await cancelSubscription(sub.paypal_subscription_id); } catch (err) { if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); return json(res, { ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502); } db.prepare(`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`).run( nowIso(), nowIso(), sub.id ); logAction("system", "supporter.subscription_cancelled", supporter.id, null); return json(res, { ok: true }); } /* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */ function findSubscriptionByPaypalId(paypalSubscriptionId) { if (!paypalSubscriptionId) return null; return db.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId); } function alreadyProcessed(eventId) { if (!eventId) return false; const row = db.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).get(eventId); return !!row; } /** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */ async function handleSuccessfulPayment(sub, event, resource) { const isSandbox = process.env.PAYPAL_ENV !== "live"; const eventId = event.id; const paymentId = generateId(); db.prepare( `INSERT INTO supporter_payments (id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency, payment_status, is_sandbox, counted_for_progress, paid_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?, ?, ?)` ).run( paymentId, sub.supporter_id, sub.id, resource.id || null, eventId, resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR", isSandbox ? 1 : 0, isSandbox ? 0 : 1, nowIso(), nowIso() ); if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34). const oldTotal = sub.total_paid_months; const newTotal = oldTotal + 1; db.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`).run(newTotal, nowIso(), sub.id); const before = new Set(allUnlockedMilestones(oldTotal).map((m) => `${m.cycleNumber}:${m.month}`)); const after = allUnlockedMilestones(newTotal); const newlyUnlocked = after.filter((m) => !before.has(`${m.cycleNumber}:${m.month}`)); for (const m of newlyUnlocked) { // Kollektion für diesen Zyklus ermitteln (per collection_number = cycleNumber), // fällt auf Kollektion 1 zurück, falls eine spätere Kollektion im Admin- // Bereich noch nicht angelegt wurde — damit niemand ins Leere freigeschaltet wird. const collection = db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(m.cycleNumber) || db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).get(); if (!collection) continue; db.prepare( `INSERT OR IGNORE INTO supporter_premiums (id, supporter_id, collection_id, cycle_number, milestone_month, premium_type, unlocked_at, redeem_status) VALUES (?, ?, ?, ?, ?, ?, ?, 'verfuegbar')` ).run(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso()); const supporterRow = db.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`).get(sub.supporter_id); if (supporterRow) { await sendMilestoneUnlocked({ to: supporterRow.email, name: supporterRow.display_name, premiumLabel: REDEEM_LABELS[m.type] || m.type, }).catch(() => {}); // E-Mail-Fehler dürfen die Webhook-Verarbeitung nie blockieren. } logAction("system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type }); } } function handleReversedPayment(sub, event, resource) { const eventId = event.id; const paymentId = generateId(); db.prepare( `INSERT INTO supporter_payments (id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency, payment_status, is_sandbox, counted_for_progress, paid_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)` ).run( paymentId, sub.supporter_id, sub.id, resource.id || null, eventId, resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR", event.event_type.includes("REFUND") ? "refunded" : "reversed", process.env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso() ); // Zurückgebuchte/zurückerstattete Zahlung: betroffenen Monat zur manuellen // Prüfung markieren statt automatisch zu löschen (Abschnitt 24 — "muss // manuell durch einen Administrator geprüft werden"). logAction("admin", "supporter.payment_flagged_for_review", sub.supporter_id, { eventType: event.event_type, transactionId: resource.id, }); } export async function handlePaypalWebhook(req, res) { const rawBody = req.rawBody || JSON.stringify(req.body || {}); let event; try { event = JSON.parse(rawBody); } catch { return json(res, { ok: false, error: "Ungültiger Webhook-Body." }, 400); } let verified = false; try { verified = await verifyWebhookSignature(req, rawBody); } catch (err) { if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); return json(res, { ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400); } if (!verified) return json(res, { ok: false, error: "Ungültige Webhook-Signatur." }, 400); if (alreadyProcessed(event.id)) return json(res, { ok: true, duplicate: true }); const resource = event.resource || {}; const type = event.event_type; try { if (type === "BILLING.SUBSCRIPTION.ACTIVATED") { const sub = findSubscriptionByPaypalId(resource.id); if (sub) { db.prepare( `UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?), next_payment_at = ?, updated_at = ? WHERE id = ?` ).run(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id); } } else if (type === "BILLING.SUBSCRIPTION.UPDATED") { const sub = findSubscriptionByPaypalId(resource.id); if (sub) { db.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`).run( resource.billing_info?.next_billing_time || sub.next_payment_at, nowIso(), sub.id ); } } else if (type === "BILLING.SUBSCRIPTION.CANCELLED") { const sub = findSubscriptionByPaypalId(resource.id); if (sub) { db.prepare( `UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, paid_period_end_at = COALESCE(paid_period_end_at, next_payment_at), updated_at = ? WHERE id = ?` ).run(nowIso(), nowIso(), sub.id); } } else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") { const sub = findSubscriptionByPaypalId(resource.id); if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id); } else if (type === "BILLING.SUBSCRIPTION.EXPIRED") { const sub = findSubscriptionByPaypalId(resource.id); if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id); } else if (type === "PAYMENT.SALE.COMPLETED") { const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); if (sub) await handleSuccessfulPayment(sub, event, resource); } else if (type === "PAYMENT.SALE.DENIED") { const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); if (sub) logAction("system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id }); } else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") { const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); if (sub) handleReversedPayment(sub, event, resource); } // Unbekannte/nicht relevante Ereignistypen werden bewusst stillschweigend bestätigt // (PayPal wiederholt Zustellversuche sonst unnötig). } catch (err) { // Bewusst 200 statt 500 an PayPal zurückgeben nur, wenn der Fehler NICHT // die Zahlungsverarbeitung selbst betrifft — hier lassen wir echte // Fehler durchschlagen, damit PayPal es später erneut zustellt. return json(res, { ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500); } return json(res, { ok: true }); }