Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign. Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js: gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man /webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests. Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en, fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung auseinander. Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests. Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen: 40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im Designsystem statt einzeln pro Seite. PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent. Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server. Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe getrennt. Co-Authored-By: Claude Opus 5 <[email protected]>
382 lines
14 KiB
JavaScript
382 lines
14 KiB
JavaScript
/* =====================================================================
|
|
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()}`;
|
|
}
|