/* ===================================================================== lib/paypal.js — PayPal Subscriptions API Anbindung (Supporter-Abo). 1:1 portiert aus cloudflare-worker/src/lib/paypal.js, env.X → process.env.X. WICHTIG — offener Punkt (wie im Original): vollständig fertig implementiert (OAuth2, Subscription erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber erst wirklich Geld verarbeiten, sobald folgende echten Werte in der .env gesetzt sind: PAYPAL_CLIENT_ID PAYPAL_CLIENT_SECRET PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto) PAYPAL_WEBHOOK_ID PAYPAL_ENV = "sandbox" | "live" (unkritisch, kein Secret) Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal- Business-Konto + Developer-App sind an seine eigene Identität/Bank gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines Absturzes, solange sie fehlen. NIEMALS in .env.example oder Vault! ===================================================================== */ export class PayPalNotConfiguredError extends Error { constructor() { super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen)."); this.name = "PayPalNotConfiguredError"; this.code = "PAYPAL_NOT_CONFIGURED"; } } function baseUrl() { return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; } function assertConfigured() { if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_PLAN_ID) { throw new PayPalNotConfiguredError(); } } let cachedToken = null; // { value, expiresAt } — pro Serverprozess, spart Requests async function getAccessToken() { assertConfigured(); if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value; const res = await fetch(`${baseUrl()}/v1/oauth2/token`, { method: "POST", headers: { Authorization: `Basic ${btoa(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`); const data = await res.json(); cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 }; return cachedToken.value; } async function paypalFetch(path, options = {}) { const token = await getAccessToken(); const res = await fetch(`${baseUrl()}${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(options.headers || {}), }, }); const text = await res.text(); const data = text ? JSON.parse(text) : null; if (!res.ok) { const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`); err.paypalStatus = res.status; err.paypalBody = data; throw err; } return data; } /** * Erstellt ein PayPal-Abonnement für einen Supporter und liefert den * "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um * das Abo direkt bei PayPal zu bestätigen. custom_id trägt unsere interne * supporter_id, damit der Webhook später weiß, wem die Zahlung gehört. */ export async function createSubscription({ supporterId, returnUrl, cancelUrl }) { const data = await paypalFetch("/v1/billing/subscriptions", { method: "POST", headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` }, body: JSON.stringify({ plan_id: process.env.PAYPAL_PLAN_ID, custom_id: supporterId, application_context: { brand_name: "Dogfather", locale: "de-DE", shipping_preference: "NO_SHIPPING", user_action: "SUBSCRIBE_NOW", return_url: returnUrl, cancel_url: cancelUrl, }, }), }); const approveLink = (data.links || []).find((l) => l.rel === "approve"); return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data }; } export async function getSubscription(paypalSubscriptionId) { return paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`); } /** Kündigung — Zugang bleibt bis Periodenende bestehen. */ export async function cancelSubscription(paypalSubscriptionId, reason) { await paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, { method: "POST", body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }), }); return true; } /** * Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle * Verify-Signature-Funktion. `req` ist ein Express-Request (Header über * req.headers Objekt, nicht .get()/.headers.get() wie bei Cloudflare). */ export async function verifyWebhookSignature(req, rawBody) { assertConfigured(); if (!process.env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError(); const h = req.headers; const verification = await paypalFetch("/v1/notifications/verify-webhook-signature", { method: "POST", body: JSON.stringify({ auth_algo: h["paypal-auth-algo"], cert_url: h["paypal-cert-url"], transmission_id: h["paypal-transmission-id"], transmission_sig: h["paypal-transmission-sig"], transmission_time: h["paypal-transmission-time"], webhook_id: process.env.PAYPAL_WEBHOOK_ID, webhook_event: JSON.parse(rawBody), }), }); return verification.verification_status === "SUCCESS"; }