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.
310 lines
14 KiB
JavaScript
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 });
|
|
}
|