Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal
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]>
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
/* =====================================================================
|
||||
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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user