Files
DogFatherGit e87aca3019 Postfach/Team-Verwaltung/Supporter-Abo als Node.js/Express-Server portiert
Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach)
auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team-
Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo
(PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus)
und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare-
Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/
Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die
echten Zugangsdaten eingetragen werden.
2026-08-05 18:57:19 +02:00

310 lines
14 KiB
JavaScript

/* =====================================================================
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 });
}