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:
2026-08-22 18:06:52 +02:00
co-authored by Claude Opus 5
parent 2010b5ec33
commit 4b3ec450d5
50 changed files with 8264 additions and 0 deletions
+16
View File
@@ -52,6 +52,17 @@ export const PERMISSIONS = {
TEAM_MANAGE: "Team-Mitglieder verwalten (Modis/Scouts/Creator/Manager)",
};
/* Webdesign-Rechte (22.08.2026) — an den Katalog oben angehängt, damit die
bestehende Rechteverwaltung sie ohne Änderung mit anzeigt. */
Object.assign(PERMISSIONS, {
WD_ANFRAGEN_VIEW: "Webdesign: Projektanfragen ansehen",
WD_ANFRAGEN_MANAGE: "Webdesign: Anfragen bearbeiten, Status ändern, archivieren",
WD_KUNDEN_MANAGE: "Webdesign: Kunden anlegen, freischalten, sperren",
WD_PROJEKTE_MANAGE: "Webdesign: Projekte, Termine und Freigaben verwalten",
WD_ZAHLUNGEN_VIEW: "Webdesign: Zahlungen und Beträge einsehen",
WD_INHALTE_EDIT: "Webdesign: Texte und Preise der Seite bearbeiten",
});
export const PERMISSION_GROUPS = [
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
@@ -63,6 +74,11 @@ export const PERMISSION_GROUPS = [
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
{ label: "Stimmen", keys: ["TESTIMONIALS_MANAGE"] },
{ label: "Team", keys: ["TEAM_MANAGE"] },
/* Webdesign-Bereich (22.08.2026). Bewusst EIGENE Rechte statt der
bestehenden Bewerbungs-Rechte: eine Person, die Creator-Bewerbungen
lesen darf, soll damit nicht automatisch Kundendaten, Preise und
Zahlungen einsehen können. Das sind zwei getrennte Geschäfte. */
{ label: "Webdesign", keys: ["WD_ANFRAGEN_VIEW", "WD_ANFRAGEN_MANAGE", "WD_KUNDEN_MANAGE", "WD_PROJEKTE_MANAGE", "WD_ZAHLUNGEN_VIEW", "WD_INHALTE_EDIT"] },
];
export const OWNER_ONLY_ACTIONS = [
+177
View File
@@ -0,0 +1,177 @@
/* =====================================================================
webdesign-helfer.js — geteilte Bausteine für den Webdesign-Bereich.
Bewusst klein gehalten und ohne Abhängigkeit zu den Universe-Modulen,
damit eine Änderung dort diesen Bereich nicht mitreißt.
===================================================================== */
import { randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { db } from "../db.js";
export const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
export const PAKETE = ["onepager", "website", "shop", "verwaltung", "betreuung", "unklar"];
export const ANFRAGE_STATUS = [
"neu", "pruefung", "rueckfrage", "angebot", "angenommen", "abgeschlossen", "abgelehnt",
];
export const PROJEKT_STATUS = [
"briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben", "pausiert", "abgebrochen",
];
export function jetzt() {
return new Date().toISOString();
}
export function neueId() {
return randomUUID();
}
/* Fortlaufende, gut aussprechbare Nummer: "A-2608-0001".
Der Zähler läuft pro Präfix, damit die Nummer im Gespräch kurz bleibt
("die A-2608-7") statt eine 36-stellige Kennung vorlesen zu müssen. */
export function naechsteNummer(tabelle, praefix) {
const d = new Date();
const monat = String(d.getUTCMonth() + 1).padStart(2, "0");
const jahr = String(d.getUTCFullYear()).slice(2);
const stamm = `${praefix}-${jahr}${monat}-`;
const zeile = db
.prepare(`SELECT nummer FROM ${tabelle} WHERE nummer LIKE ? ORDER BY nummer DESC LIMIT 1`)
.get(stamm + "%");
const letzte = zeile ? parseInt(String(zeile.nummer).slice(stamm.length), 10) : 0;
return stamm + String((Number.isFinite(letzte) ? letzte : 0) + 1).padStart(4, "0");
}
/* Zeichenketten aus Formularen einheitlich säubern:
- Rand-Leerzeichen weg
- Länge begrenzt (sonst kann jemand mit einem 10-MB-Feld die Datenbank
aufblähen; die Begrenzung im Body-Parser allein reicht nicht, weil
ein einzelnes Feld auch innerhalb eines gültigen Bodys riesig sein kann)
- Steuerzeichen entfernen, die in Logs und E-Mails Unfug anrichten */
export function text(wert, maxLaenge = 2000) {
if (wert == null) return "";
return String(wert)
/* Steuerzeichen raus, aber Zeilenumbruch und Tabulator behalten --
die braucht ein mehrzeiliges Textfeld.
Geschrieben ueber Unicode-Eigenschaften statt ueber einen Bereich
aus echten Steuerzeichen: solche Zeichen im Quelltext ueberleben
weder Copy-and-paste noch jeden Editor zuverlaessig, und ein
stillschweigend kaputter Filter faellt erst auf, wenn er gebraucht
wird. \P{Cc} bedeutet "kein Steuerzeichen", die Verneinung der
Klasse trifft also genau die Steuerzeichen ausser \n und \t. */
.replace(/[^\P{Cc}\n\t]/gu, "")
.trim()
.slice(0, maxLaenge);
}
/* Sehr bewusst KEINE komplizierte E-Mail-Regex. Die einzige zuverlässige
Prüfung ist der Bestätigungslink; alles andere lehnt entweder gültige
Adressen ab (z.B. neue Endungen, Umlaut-Domains) oder lässt Unsinn durch.
Geprüft wird deshalb nur das strukturell Notwendige. */
export function istEmail(wert) {
const s = text(wert, 254).toLowerCase();
if (!s || s.length < 6) return false;
const teile = s.split("@");
if (teile.length !== 2) return false;
const [lokal, domain] = teile;
if (!lokal || !domain) return false;
if (!domain.includes(".")) return false;
if (/\s/.test(s)) return false;
return true;
}
export function emailNormal(wert) {
return text(wert, 254).toLowerCase();
}
/* IP nur als Hash speichern (Datensparsamkeit). Der Hash reicht, um
wiederholte Absendungen derselben Quelle zu erkennen, lässt sich aber
nicht ohne Weiteres zurückrechnen. Der feste Zusatz verhindert, dass
jemand mit einer fertigen Regenbogentabelle alle IPv4-Adressen durchprobiert. */
export function ipHash(ip) {
if (!ip) return null;
const zusatz = process.env.WD_IP_SALT || "dogfather-webdesign";
return createHash("sha256").update(zusatz + "|" + String(ip)).digest("hex").slice(0, 32);
}
export function tokenHash(token) {
return createHash("sha256").update(String(token)).digest("hex");
}
export function gleichZeitsicher(a, b) {
const pa = Buffer.from(String(a ?? ""), "utf8");
const pb = Buffer.from(String(b ?? ""), "utf8");
if (pa.length !== pb.length) return false; // timingSafeEqual wirft sonst
return timingSafeEqual(pa, pb);
}
/* Änderungsverlauf schreiben (Masterplan S.13). Absichtlich als eigene
Funktion und nicht inline: so ist garantiert, dass jeder Eintrag
dieselben Felder hat und keiner vergessen wird. */
export function verlauf(bereich, objektId, aktion, wer, details) {
db.prepare(
`INSERT INTO wd_verlauf (id, bereich, objekt_id, aktion, wer, details, erstellt_am)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
neueId(),
bereich,
objektId || null,
aktion,
wer || "system",
details ? JSON.stringify(details) : null,
jetzt()
);
}
/* Einfache Mengenbegrenzung, in der Datenbank statt im Arbeitsspeicher —
dieser Dienst kann neu starten, ohne dass ein Absender dadurch seine
Sperre verliert. */
export function zuVieleVersuche(schluessel, maxAnzahl, sperreMinuten) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
if (!zeile) return false;
if (zeile.gesperrt_bis && zeile.gesperrt_bis > jetzt()) return true;
return false;
}
export function versuchZaehlen(schluessel, maxAnzahl = 5, sperreMinuten = 15) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
const anzahl = (zeile?.anzahl || 0) + 1;
const gesperrtBis = anzahl >= maxAnzahl
? new Date(Date.now() + sperreMinuten * 60000).toISOString()
: null;
db.prepare(
`INSERT INTO wd_login_versuche (schluessel, anzahl, gesperrt_bis, zuletzt)
VALUES (?, ?, ?, ?)
ON CONFLICT(schluessel) DO UPDATE SET anzahl = excluded.anzahl,
gesperrt_bis = excluded.gesperrt_bis, zuletzt = excluded.zuletzt`
).run(schluessel, anzahl, gesperrtBis, jetzt());
return { anzahl, gesperrtBis };
}
export function versucheZuruecksetzen(schluessel) {
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ?`).run(schluessel);
}
/* Die echte Besucher-IP. Gleiche Begründung und gleiche Abhängigkeit wie
in server/gate.js: Cloudflare setzt diesen Header und die Firewall lässt
nur Cloudflare an die Ports 80/443. Ändert sich das, muss diese Stelle
zurückgebaut werden. */
export function besucherIp(req) {
const cf = req.headers["cf-connecting-ip"];
if (typeof cf === "string" && cf.trim() && cf.length <= 45) return cf.trim();
return req.ip || req.socket?.remoteAddress || null;
}
/* Sprache aus der Anfrage übernehmen, damit die Antwort-E-Mail in
derselben Sprache kommt, in der gefragt wurde. */
export function spracheOderDe(wert) {
const s = text(wert, 10);
return SPRACHEN.includes(s) ? s : "de";
}
/* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */
export function centZuText(cent, waehrung = "EUR") {
if (cent == null) return null;
const ganz = Math.trunc(cent / 100);
const rest = String(Math.abs(cent % 100)).padStart(2, "0");
return `${ganz},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
}
+381
View File
@@ -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()}`;
}