/* ===================================================================== webdesign-paypal.js — PayPal für den Webdesign-Bereich. ZWEI VERSCHIEDENE PAYPAL-WELTEN, BEWUSST GETRENNT 1) Orders API (v2) — Einmalzahlungen: 30 % Anzahlung, Restbetrag, angenommene Zusatzangebote. Beträge unterscheiden sich bei jedem Projekt, deshalb geht das NICHT über Abo-Pläne. 2) Subscriptions API (v1) — die monatliche Betreuung. Fester Betrag, wiederkehrend, braucht einen Plan im PayPal-Konto. lib/paypal.js (Supporter-Abo) kann nur Nummer 2 und ist an den dortigen Plan gebunden. Diese Datei ist deshalb eine eigene, damit eine Änderung am Supporter-Abo keine Kundenzahlungen kaputt macht — und umgekehrt. ⚠️ WANN LANDET DAS GELD AUF DEM KONTO? Bei PayPal gibt es zwei Wege: "authorize" (Betrag nur reservieren, bis zu 29 Tage später einziehen) und "capture" (sofort einziehen). Hier wird ausdrücklich CAPTURE benutzt — Wunsch vom 22.08.2026: "die gelder sollen auch sofort auf meinem paypal landen". Mit capture ist das Geld unmittelbar nach der Bestätigung des Kunden auf dem Business-Konto, ohne Treuhand und ohne Wartezeit. ⚠️ FAIL CLOSED Solange die Zugangsdaten fehlen, wirft jede Funktion einen klaren Fehler, statt so zu tun, als sei etwas bezahlt worden. Eine Zahlung, die "irgendwie durchgeht", obwohl kein Geld geflossen ist, wäre der schlimmste denkbare Fehler in diesem Modul. ===================================================================== */ import { randomUUID } from "node:crypto"; export class PayPalNichtEingerichtet extends Error { constructor(fehlend) { super( "PayPal ist für den Webdesign-Bereich noch nicht eingerichtet. Fehlende Einstellungen: " + (fehlend || []).join(", ") ); this.name = "PayPalNichtEingerichtet"; this.code = "PAYPAL_NICHT_EINGERICHTET"; this.fehlend = fehlend || []; } } /* Testmodus zuerst. Wer vergisst, PAYPAL_ENV auf "live" zu stellen, zahlt in der Spielwiese — ärgerlich, aber harmlos. Andersherum (versehentlich live) würde echtes Geld bewegt. Die ungefährliche Richtung gehört in die Voreinstellung. */ function basis() { return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; } export function istLive() { return process.env.PAYPAL_ENV === "live"; } /* Prüft, ob alles Nötige da ist. `fuer` unterscheidet die beiden Welten: Einmalzahlungen brauchen KEINEN Plan, das Abo schon. Ohne diese Unterscheidung wäre die Anzahlung blockiert, nur weil noch kein Betreuungsplan angelegt wurde. */ export function fehlendeEinstellungen(fuer = "einmal") { const fehlt = []; if (!process.env.PAYPAL_CLIENT_ID) fehlt.push("PAYPAL_CLIENT_ID"); if (!process.env.PAYPAL_CLIENT_SECRET) fehlt.push("PAYPAL_CLIENT_SECRET"); if (fuer === "abo" && !process.env.PAYPAL_WD_PLAN_BASIS) fehlt.push("PAYPAL_WD_PLAN_BASIS"); return fehlt; } export function istEingerichtet(fuer = "einmal") { return fehlendeEinstellungen(fuer).length === 0; } function pruefeEingerichtet(fuer = "einmal") { const fehlt = fehlendeEinstellungen(fuer); if (fehlt.length) throw new PayPalNichtEingerichtet(fehlt); } /* Zugangstoken zwischenspeichern. PayPal begrenzt die Anzahl der Token-Anfragen; ohne Zwischenspeicher holt jede Zahlung ein neues. */ let token = null; // { wert, laeuftAb } async function zugangstoken() { pruefeEingerichtet("einmal"); if (token && token.laeuftAb > Date.now() + 60_000) return token.wert; const anmeldung = Buffer.from( `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}` ).toString("base64"); const antwort = await fetch(`${basis()}/v1/oauth2/token`, { method: "POST", headers: { Authorization: `Basic ${anmeldung}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); if (!antwort.ok) { const txt = await antwort.text().catch(() => ""); throw new Error(`PayPal-Anmeldung fehlgeschlagen (${antwort.status}): ${txt.slice(0, 300)}`); } const daten = await antwort.json(); token = { wert: daten.access_token, laeuftAb: Date.now() + (daten.expires_in || 3600) * 1000, }; return token.wert; } /* Für Tests: Zwischenspeicher leeren, damit ein Wechsel der Zugangsdaten sofort wirkt statt erst nach einer Stunde. */ export function tokenVergessen() { token = null; } async function ruf(pfad, optionen = {}) { const t = await zugangstoken(); const antwort = await fetch(`${basis()}${pfad}`, { ...optionen, headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/json", ...(optionen.headers || {}), }, }); const txt = await antwort.text(); const daten = txt ? JSON.parse(txt) : null; if (!antwort.ok) { const fehler = new Error(`PayPal-Anfrage fehlgeschlagen (${antwort.status}): ${txt.slice(0, 400)}`); fehler.paypalStatus = antwort.status; fehler.paypalBody = daten; throw fehler; } return daten; } /* Cent -> PayPal-Format. PayPal will einen String mit genau zwei Nachkommastellen ("300.00"). Bewusst aus der Ganzzahl gebaut statt über toFixed() auf einer Kommazahl: (betrag/100).toFixed(2) rundet bei krummen Beträgen unter Umständen falsch. */ export function centZuPaypal(cent) { if (!Number.isInteger(cent) || cent < 0) { throw new Error("Betrag muss eine nicht-negative Ganzzahl in Cent sein, war: " + cent); } const ganz = Math.floor(cent / 100); const rest = String(cent % 100).padStart(2, "0"); return `${ganz}.${rest}`; } /* Rückweg: PayPal liefert "300.00" -> 30000 Cent. Über Zeichenketten gerechnet, damit nirgends eine Gleitkommazahl entsteht. */ export function paypalZuCent(wert) { const s = String(wert ?? "0").trim(); if (!/^\d+(\.\d{1,2})?$/.test(s)) return null; const [ganz, rest = ""] = s.split("."); return parseInt(ganz, 10) * 100 + parseInt((rest + "00").slice(0, 2), 10); } /* --------------------------------------------------------------------- 1) EINMALZAHLUNG — Bestellung anlegen Der Kunde wird danach zum "approve"-Link geschickt und bestätigt dort bei PayPal. Erst der anschließende Einzug (siehe unten) bewegt Geld. --------------------------------------------------------------------- */ export async function bestellungAnlegen({ zahlungId, nummer, betragCent, waehrung = "EUR", zweck, rueckkehrUrl, abbruchUrl, sprache = "de", }) { pruefeEingerichtet("einmal"); const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" }; const daten = await ruf("/v2/checkout/orders", { method: "POST", headers: { /* Schützt gegen doppelte Bestellungen, wenn dieselbe Anfrage wegen eines Verbindungsabbruchs zweimal ankommt. PayPal liefert dann dieselbe Bestellung zurück, statt eine zweite anzulegen. */ "PayPal-Request-Id": `wd-order-${zahlungId}`, }, body: JSON.stringify({ intent: "CAPTURE", // sofort einziehen, siehe Kopf der Datei purchase_units: [ { /* Unsere eigene Kennung. Kommt im Webhook zurück und ist der einzige verlässliche Weg, eine PayPal-Meldung der richtigen Zahlung zuzuordnen — die Bestellnummer allein reicht nicht, weil sie bei manchen Ereignissen fehlt. */ custom_id: zahlungId, invoice_id: nummer, description: String(zweck || "Dogfather Webdesign").slice(0, 127), amount: { currency_code: waehrung, value: centZuPaypal(betragCent), }, }, ], payment_source: { paypal: { experience_context: { brand_name: "Dogfather Webdesign", locale: sprachKarte[sprache] || "de-DE", // Keine Lieferadresse abfragen — es gibt nichts zu versenden. shipping_preference: "NO_SHIPPING", // Knopf heißt "Jetzt bezahlen" statt "Weiter": der Kunde soll // wissen, dass mit dem Klick Geld fließt. user_action: "PAY_NOW", return_url: rueckkehrUrl, cancel_url: abbruchUrl, }, }, }, }), }); const freigabe = (daten.links || []).find((l) => l.rel === "payer-action" || l.rel === "approve"); return { orderId: daten.id, freigabeUrl: freigabe ? freigabe.href : null, status: daten.status, }; } /* --------------------------------------------------------------------- Einzug — HIER fließt das Geld auf Dogfathers Konto. --------------------------------------------------------------------- */ export async function bestellungEinziehen(orderId) { pruefeEingerichtet("einmal"); const daten = await ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, { method: "POST", headers: { "PayPal-Request-Id": `wd-capture-${orderId}` }, body: "{}", }); const einheit = (daten.purchase_units || [])[0] || {}; const einzug = ((einheit.payments || {}).captures || [])[0] || {}; const aufschluesselung = (einzug.seller_receivable_breakdown || {}); return { status: daten.status, // COMPLETED wenn alles gut ging captureId: einzug.id || null, captureStatus: einzug.status || null, bruttoCent: paypalZuCent(einzug.amount?.value), /* net_amount ist das, was tatsächlich auf dem Konto ankommt. paypal_fee ist die Gebühr. Beides getrennt festhalten — die Gebühr ist Aufwand und gehört in die Buchhaltung, nicht stillschweigend vom Rechnungsbetrag abgezogen. */ nettoCent: paypalZuCent(aufschluesselung.net_amount?.value), gebuehrCent: paypalZuCent(aufschluesselung.paypal_fee?.value), zahlerEmail: daten.payer?.email_address || null, rohdaten: daten, }; } export async function bestellungAbfragen(orderId) { pruefeEingerichtet("einmal"); return ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}`); } /* --------------------------------------------------------------------- 2) BETREUUNGS-ABO --------------------------------------------------------------------- */ /* Welcher Plan zu welchem Paket gehört. Ein eigener Plan je Paket, weil PayPal den Betrag am Plan festmacht — nicht am einzelnen Abo. */ export function planFuerPaket(paket) { const karte = { basis: process.env.PAYPAL_WD_PLAN_BASIS, plus: process.env.PAYPAL_WD_PLAN_PLUS, premium: process.env.PAYPAL_WD_PLAN_PREMIUM, }; return karte[paket] || karte.basis || null; } export async function aboAnlegen({ aboId, paket, rueckkehrUrl, abbruchUrl, sprache = "de" }) { pruefeEingerichtet("abo"); const planId = planFuerPaket(paket); if (!planId) throw new PayPalNichtEingerichtet([`PAYPAL_WD_PLAN_${String(paket).toUpperCase()}`]); const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" }; const daten = await ruf("/v1/billing/subscriptions", { method: "POST", headers: { "PayPal-Request-Id": `wd-abo-${aboId}` }, body: JSON.stringify({ plan_id: planId, custom_id: aboId, application_context: { brand_name: "Dogfather Webdesign", locale: sprachKarte[sprache] || "de-DE", shipping_preference: "NO_SHIPPING", user_action: "SUBSCRIBE_NOW", return_url: rueckkehrUrl, cancel_url: abbruchUrl, }, }), }); const freigabe = (daten.links || []).find((l) => l.rel === "approve"); return { subscriptionId: daten.id, freigabeUrl: freigabe ? freigabe.href : null, status: daten.status, }; } export async function aboAbfragen(subscriptionId) { pruefeEingerichtet("abo"); return ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}`); } /* Kündigung. Der Zugang bleibt bis zum Ende der bezahlten Periode — der Kunde hat den Monat schließlich bezahlt. */ export async function aboKuendigen(subscriptionId, grund) { pruefeEingerichtet("abo"); await ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`, { method: "POST", body: JSON.stringify({ reason: String(grund || "Kündigung durch Kunde").slice(0, 127) }), }); return true; } /* --------------------------------------------------------------------- 3) WEBHOOK-PRÜFUNG Warum das nicht optional ist: Der Webhook-Endpunkt ist öffentlich erreichbar. Ohne Signaturprüfung könnte jeder eine erfundene Meldung "Zahlung eingegangen" schicken und damit ein Projekt als bezahlt markieren lassen. Die Prüfung ist die einzige Stelle, die eine echte PayPal-Meldung von einer erfundenen unterscheidet. --------------------------------------------------------------------- */ export async function webhookEchtheitPruefen(kopfzeilen, rohkoerper) { pruefeEingerichtet("einmal"); if (!process.env.PAYPAL_WD_WEBHOOK_ID) { throw new PayPalNichtEingerichtet(["PAYPAL_WD_WEBHOOK_ID"]); } const pruefung = await ruf("/v1/notifications/verify-webhook-signature", { method: "POST", body: JSON.stringify({ auth_algo: kopfzeilen["paypal-auth-algo"], cert_url: kopfzeilen["paypal-cert-url"], transmission_id: kopfzeilen["paypal-transmission-id"], transmission_sig: kopfzeilen["paypal-transmission-sig"], transmission_time: kopfzeilen["paypal-transmission-time"], webhook_id: process.env.PAYPAL_WD_WEBHOOK_ID, webhook_event: JSON.parse(rohkoerper), }), }); return pruefung.verification_status === "SUCCESS"; } /* Ereignisse, auf die wir reagieren. Alles andere wird protokolliert und ignoriert — PayPal schickt Dutzende Arten, und auf unbekannte zu reagieren ist gefährlicher als sie liegen zu lassen. */ export const WEBHOOK_EREIGNISSE = { "CHECKOUT.ORDER.APPROVED": "bestellung_freigegeben", "PAYMENT.CAPTURE.COMPLETED": "zahlung_eingegangen", "PAYMENT.CAPTURE.DENIED": "zahlung_abgelehnt", "PAYMENT.CAPTURE.REFUNDED": "zahlung_erstattet", "BILLING.SUBSCRIPTION.ACTIVATED": "abo_aktiv", "BILLING.SUBSCRIPTION.CANCELLED": "abo_gekuendigt", "BILLING.SUBSCRIPTION.SUSPENDED": "abo_pausiert", "BILLING.SUBSCRIPTION.PAYMENT.FAILED": "abo_zahlung_fehlgeschlagen", "PAYMENT.SALE.COMPLETED": "abo_zahlung_eingegangen", }; /* Kennung für ein Ereignis. Fällt PayPals eigene id aus, wird eine eigene erzeugt — dann greift der Doppel-Schutz zwar nicht, aber die Meldung geht wenigstens nicht verloren. */ export function ereignisId(ereignis) { return ereignis?.id || `ohne-id-${randomUUID()}`; }