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:
@@ -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 = [
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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