WIP: Zahlungsrouten (Test folgt auf dem Server)

This commit is contained in:
2026-08-23 16:23:12 +02:00
parent be3599239d
commit eede01ca70
3 changed files with 635 additions and 0 deletions
+16
View File
@@ -117,6 +117,14 @@ import {
import {
widerrufAbsenden, widerrufeListe, widerrufBearbeiten,
} from "./routes/webdesign-widerruf.js";
/* Zahlungen im Webdesign-Bereich. Der Webhook ist oeffentlich erreichbar
(PayPal hat keine Sitzung) und ausschliesslich durch die
Signaturpruefung geschuetzt -- ohne gueltige Signatur wird nichts
verarbeitet. */
import {
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
paypalWebhook, zahlungAnlegen, zahlungenListe,
} from "./routes/webdesign-zahlungen.js";
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
initDb();
@@ -265,6 +273,9 @@ app.post("/webdesign/anfrage", wrap(anfrageAbsenden));
Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer
jeden, der sein Passwort vergessen hat. */
app.post("/webdesign/widerruf", wrap(widerrufAbsenden));
/* Dritter oeffentlicher Endpunkt: PayPal ruft ihn direkt auf und kann
sich nicht anmelden. Die Echtheit wird ueber die Signatur geprueft. */
app.post("/webdesign/paypal-webhook", wrap(paypalWebhook));
app.get("/webdesign/admin/widerrufe", wrap(widerrufeListe));
app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten));
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
@@ -316,6 +327,11 @@ app.post("/webdesign/admin/aufgaben/:id/loeschen", wrap(adminAufgabeLoeschen));
/* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */
app.get("/webdesign/portal/postfach", wrap(portalPostfach));
app.post("/webdesign/portal/postfach", wrap(portalPostfachSenden));
app.get("/webdesign/portal/zahlung/:id", wrap(zahlungAnsehen));
app.post("/webdesign/portal/zahlung/:id/start", wrap(zahlungStarten));
app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
app.get("/webdesign/admin/postfach", wrap(adminPostfachListe));
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf));
@@ -0,0 +1,456 @@
/* =====================================================================
routes/webdesign-zahlungen.js — Zahlungen im Kundenportal
Das PayPal-Modul (lib/webdesign-paypal.js) und die Tabellen standen
seit dem 22.08.2026. Was fehlte, waren die Wege dorthin.
DREI DINGE, DIE HIER ANDERS SIND ALS BEI EINEM ÜBLICHEN BEZAHLKNOPF
1. DIE ZUSTIMMUNG IST TEIL DER ZAHLUNG, nicht ein Häkchen daneben.
Die 30-%-Anzahlung wird fällig, BEVOR die 14-tägige Widerrufsfrist
abgelaufen ist. Damit die Arbeit trotzdem sofort beginnen darf,
verlangt § 356 Abs. 4 BGB zweierlei vom Verbraucher: die
ausdrückliche Zustimmung zum vorzeitigen Beginn UND die Bestätigung,
dass er dadurch sein Widerrufsrecht verliert.
Die Beweislast für beides liegt beim Unternehmer. Ohne Nachweis
bleibt das Widerrufsrecht bestehen — und die bereits geleistete
Arbeit wäre im Streit schwer durchsetzbar.
Deshalb wird die Zustimmung nicht nur als Wahrheitswert gespeichert,
sondern mit dem WORTLAUT, den der Kunde gesehen hat, und mit
Zeitstempel. Im Streit zählt nicht "hat zugestimmt", sondern WOZU.
Und weil das nur Verbraucher betrifft: Bei einem Geschäftskunden
wird gar nicht erst danach gefragt. Ein Unternehmer hat kein
Widerrufsrecht, ihn eine Verzichtserklärung unterschreiben zu
lassen wäre sinnlos und würde nur Misstrauen wecken.
2. NICHT EINGERICHTET IST EIN ZUSTAND, KEIN FEHLER.
Solange die PayPal-Zugangsdaten fehlen, sagt die Seite das
freundlich und nennt den Weg per Überweisung. Ein Knopf, der beim
Klick eine technische Fehlermeldung wirft, ist schlechter als
keiner — er sieht nach einer kaputten Seite aus, und niemand
bezahlt gern auf einer kaputten Seite.
3. DER WEBHOOK IST DIE WAHRHEIT, NICHT DIE RÜCKKEHR DES BROWSERS.
Der Kunde kann das Fenster schliessen, bevor er zurückgeleitet
wird. Die Zahlung ist dann trotzdem erfolgt. Deshalb schreibt der
Webhook den endgültigen Stand — der Rückkehrweg ist nur der
schnellere von beiden, nicht der verlässlichere.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
import { brauchtKunde } from "./webdesign-portal.js";
import { zustimmungSpeichern } from "./webdesign-widerruf.js";
import * as paypal from "../lib/webdesign-paypal.js";
import {
jetzt, neueId, naechsteNummer, text, verlauf, centZuText,
besucherIp, spracheOderDe,
} from "../lib/webdesign-helfer.js";
/* Der Wortlaut, dem der Verbraucher zustimmt. Er steht HIER und nicht
im Browser: Was gespeichert wird, muss das sein, was der Server
kennt — sonst könnte man ihm einen beliebigen Text unterschieben.
Die Fassung wandert mit in die Datenbank, damit später nachvollziehbar
bleibt, welche Formulierung galt. */
export const SOFORTBEGINN_FASSUNG = "2026-08-23";
export const SOFORTBEGINN_TEXT = {
de: "Ich verlange ausdrücklich, dass Dogfather Webdesign vor Ablauf der "
+ "Widerrufsfrist mit der Arbeit beginnt. Mir ist bekannt, dass ich mein "
+ "Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"de-CH": "Ich verlang usdrücklich, dass Dogfather Webdesign vor Ablauf vo dr "
+ "Widerrüefsfrist mit dr Arbet aafangt. Mir isch bekannt, dass ich mis "
+ "Widerrüefsrächt verlüre, sobald dr Vertrag vollständig erfüllt isch.",
en: "I expressly request that Dogfather Web Design begin work before the "
+ "withdrawal period expires. I am aware that I lose my right of withdrawal "
+ "once the contract has been fully performed.",
fr: "Je demande expressément que Dogfather Web Design commence le travail avant "
+ "l'expiration du délai de rétractation. Je sais que je perds mon droit de "
+ "rétractation dès que le contrat est intégralement exécuté.",
pt: "Solicito expressamente que a Dogfather Web Design comece o trabalho antes "
+ "do fim do prazo de rescisão. Sei que perco o meu direito de rescisão assim "
+ "que o contrato estiver integralmente cumprido.",
};
async function darf(req, recht) {
const ausweis = ausweisPruefen(req);
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
const sitzung = await requireSession(req);
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
if (sitzung.isOwner) return { erlaubt: true, sitzung };
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
}
const BASIS = () => process.env.SITE_BASIS_URL || "https://dogfather-universe.com";
/* ---------------------------------------------------------------------
ZUSTAND ABFRAGEN — braucht der Kunde überhaupt einen Bezahlknopf?
GET /webdesign/portal/zahlung/:id
--------------------------------------------------------------------- */
export function zahlungAnsehen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
const sprache = spracheOderDe(kunde.sprache);
/* Nur Verbraucher bekommen die Zustimmungsfrage, und auch nur, solange
sie noch nicht erteilt wurde. */
const brauchtZustimmung = z.kundenart !== "unternehmen"
&& z.art === "anzahlung"
&& !z.widerruf_zugestimmt;
return json(res, {
ok: true,
zahlung: {
id: z.id, nummer: z.nummer, art: z.art, zweck: z.zweck_text,
betragText: centZuText(z.betrag_cent, z.waehrung),
status: z.status,
},
eingerichtet: paypal.istEingerichtet("einmal"),
brauchtZustimmung,
zustimmungstext: brauchtZustimmung
? (SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de)
: null,
});
}
/* ---------------------------------------------------------------------
ZAHLUNG STARTEN
POST /webdesign/portal/zahlung/:id/start
--------------------------------------------------------------------- */
export async function zahlungStarten(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
if (z.status === "bezahlt") {
return json(res, { ok: false, error: "Diese Zahlung ist bereits beglichen." }, 409);
}
/* Ohne Zugangsdaten NICHT scheitern, sondern ehrlich sein. */
if (!paypal.istEingerichtet("einmal")) {
return json(res, {
ok: false,
nichtEingerichtet: true,
error: "Die Zahlung über PayPal ist noch nicht freigeschaltet. "
+ "Schreib kurz über das Postfach — dann bekommst du die Bankverbindung.",
}, 503);
}
const sprache = spracheOderDe(kunde.sprache);
/* ---- § 356 Abs. 4 BGB: Zustimmung zum vorzeitigen Leistungsbeginn ---- */
const brauchtZustimmung = z.kundenart !== "unternehmen"
&& z.art === "anzahlung"
&& !z.widerruf_zugestimmt;
if (brauchtZustimmung) {
/* === true, nicht bloss "wahrheitsähnlich". Bei einer Erklärung, mit
der jemand ein Recht aufgibt, darf eine "1" oder ein "ja" nicht
genügen — die Absicht muss eindeutig übermittelt worden sein. */
if (req.body?.sofortbeginn !== true) {
return json(res, {
ok: false,
brauchtZustimmung: true,
zustimmungstext: SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de,
error: "Bitte bestätige zuerst, dass sofort begonnen werden darf.",
}, 400);
}
const wortlaut = SOFORTBEGINN_TEXT[sprache] || SOFORTBEGINN_TEXT.de;
const nun = jetzt();
db.prepare(
`UPDATE wd_zahlungen SET widerruf_zugestimmt = 1, widerruf_zugestimmt_am = ?,
aktualisiert_am = ? WHERE id = ?`
).run(nun, nun, z.id);
/* Zusätzlich als eigenständiger Nachweis — die Zahlungszeile kann
sich ändern, der Nachweis darf das nicht. */
zustimmungSpeichern({
kundeId: kunde.id,
projektId: z.projekt_id,
art: "sofortbeginn",
wortlaut,
fassung: SOFORTBEGINN_FASSUNG,
sprache,
ip: besucherIp(req),
});
verlauf("zahlung", z.id, "sofortbeginn_zugestimmt", "kunde:" + kunde.id, { fassung: SOFORTBEGINN_FASSUNG });
}
try {
const bestellung = await paypal.bestellungAnlegen({
zahlungId: z.id,
nummer: z.nummer,
betragCent: z.betrag_cent,
waehrung: z.waehrung || "EUR",
zweck: z.zweck_text,
rueckkehrUrl: `${BASIS()}/webdesign/portal.html?zahlung=${encodeURIComponent(z.id)}&zurueck=1`,
abbruchUrl: `${BASIS()}/webdesign/portal.html?zahlung=${encodeURIComponent(z.id)}&abbruch=1`,
sprache,
});
db.prepare(
`UPDATE wd_zahlungen SET status = 'freigegeben', paypal_order_id = ?,
freigegeben_am = ?, aktualisiert_am = ? WHERE id = ?`
).run(bestellung.orderId, jetzt(), jetzt(), z.id);
verlauf("zahlung", z.id, "paypal_gestartet", "kunde:" + kunde.id, { orderId: bestellung.orderId });
return json(res, { ok: true, weiterUrl: bestellung.approveUrl, orderId: bestellung.orderId });
} catch (fehler) {
db.prepare(`UPDATE wd_zahlungen SET fehler_text = ?, aktualisiert_am = ? WHERE id = ?`)
.run(String(fehler.message || fehler).slice(0, 500), jetzt(), z.id);
verlauf("zahlung", z.id, "paypal_fehler", "kunde:" + kunde.id, null);
return json(res, {
ok: false,
error: "Die Zahlung konnte nicht gestartet werden. Bitte versuch es gleich noch einmal "
+ "oder schreib kurz über das Postfach.",
}, 502);
}
}
/* ---------------------------------------------------------------------
ZAHLUNG ABSCHLIESSEN (Rückkehr aus PayPal)
POST /webdesign/portal/zahlung/:id/abschluss
Der schnellere von zwei Wegen. Der verlässliche ist der Webhook.
--------------------------------------------------------------------- */
export async function zahlungAbschliessen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const z = db
.prepare(`SELECT * FROM wd_zahlungen WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!z) return json(res, { ok: false, error: "Zahlung nicht gefunden." }, 404);
/* Schon durch — etwa weil der Webhook schneller war. Das ist kein
Fehler, sondern der Normalfall bei guter Verbindung. */
if (z.status === "bezahlt") {
return json(res, { ok: true, schonBezahlt: true, status: "bezahlt" });
}
if (!z.paypal_order_id) {
return json(res, { ok: false, error: "Für diese Zahlung wurde noch nichts gestartet." }, 400);
}
try {
const einzug = await paypal.bestellungEinziehen(z.paypal_order_id);
if (einzug.status !== "COMPLETED") {
return json(res, { ok: false, error: "Die Zahlung wurde von PayPal noch nicht abgeschlossen." }, 409);
}
zahlungAlsBezahltVermerken(z.id, einzug, "kunde:" + kunde.id);
return json(res, { ok: true, status: "bezahlt" });
} catch (fehler) {
/* Wichtig: NICHT als Fehler des Kunden darstellen. Das Geld kann
trotzdem geflossen sein — der Webhook klärt es. */
verlauf("zahlung", z.id, "einzug_fehler", "kunde:" + kunde.id, null);
return json(res, {
ok: false,
error: "Wir konnten den Abschluss gerade nicht bestätigen. Falls die Zahlung bei PayPal "
+ "durchgegangen ist, wird sie hier gleich als bezahlt angezeigt — du musst nichts weiter tun.",
}, 502);
}
}
/* Gemeinsame Stelle für "bezahlt". Wird vom Rückkehrweg UND vom Webhook
benutzt, damit beide dieselben Felder setzen. Zwei getrennte
Fassungen würden über kurz oder lang auseinanderlaufen. */
function zahlungAlsBezahltVermerken(zahlungId, einzug, wer) {
const nun = jetzt();
db.prepare(
`UPDATE wd_zahlungen SET status = 'bezahlt', paypal_capture_id = ?,
netto_cent = COALESCE(?, netto_cent), gebuehr_cent = COALESCE(?, gebuehr_cent),
bezahlt_am = COALESCE(bezahlt_am, ?), aktualisiert_am = ? WHERE id = ?`
).run(
einzug.captureId || null,
einzug.nettoCent ?? null,
einzug.gebuehrCent ?? null,
nun, nun, zahlungId
);
/* Anzahlung/Rest am Projekt mitführen, damit die Verwaltung den Stand
sieht, ohne die Zahlungen einzeln durchzugehen. */
const z = db.prepare(`SELECT art, projekt_id FROM wd_zahlungen WHERE id = ?`).get(zahlungId);
if (z?.projekt_id && (z.art === "anzahlung" || z.art === "rest")) {
const feld = z.art === "anzahlung" ? "anzahlung_bezahlt" : "rest_bezahlt";
db.prepare(`UPDATE wd_projekte SET ${feld} = 1, aktualisiert_am = ? WHERE id = ?`)
.run(nun, z.projekt_id);
}
verlauf("zahlung", zahlungId, "bezahlt", wer, { captureId: einzug.captureId || null });
}
/* ---------------------------------------------------------------------
WEBHOOK — PayPal ruft hier an
POST /webdesign/paypal-webhook
Öffentlich erreichbar (PayPal hat keine Sitzung), aber durch die
Signaturprüfung geschützt. Ohne gültige Signatur wird nichts
verarbeitet — sonst könnte jeder eine Zahlung als bezahlt melden.
--------------------------------------------------------------------- */
export async function paypalWebhook(req, res) {
const roh = req.rawBody || JSON.stringify(req.body || {});
let ereignis = req.body || {};
let echt = false;
try {
echt = await paypal.webhookEchtheitPruefen(req.headers, roh);
} catch (e) {
echt = false;
}
if (!echt) {
verlauf("paypal", "webhook", "abgewiesen", "paypal", { typ: ereignis.event_type || null });
return json(res, { ok: false }, 400);
}
const id = paypal.ereignisId(ereignis);
/* Doppelte Zustellung ist bei PayPal normal — dieselbe Meldung kommt
bei Zweifeln erneut. Ohne diese Merkliste würde eine Zahlung
mehrfach verbucht. */
const schonDa = db.prepare(`SELECT 1 FROM wd_paypal_ereignisse WHERE event_id = ?`).get(id);
if (schonDa) return json(res, { ok: true, doppelt: true });
db.prepare(
`INSERT INTO wd_paypal_ereignisse (event_id, typ, zahlung_id, signatur_ok, verarbeitet, rohdaten, empfangen_am)
VALUES (?,?,?,1,0,?,?)`
).run(
id, ereignis.event_type || "?",
(ereignis.resource || {}).custom_id || null,
JSON.stringify(ereignis).slice(0, 20000), jetzt()
);
const typ = ereignis.event_type;
const kern = ereignis.resource || {};
if (typ === "PAYMENT.CAPTURE.COMPLETED") {
const zahlungId = kern.custom_id || null;
if (zahlungId) {
const aufschl = kern.seller_receivable_breakdown || {};
zahlungAlsBezahltVermerken(zahlungId, {
captureId: kern.id || null,
nettoCent: aufschl.net_amount ? paypal.paypalZuCent(aufschl.net_amount.value) : null,
gebuehrCent: aufschl.paypal_fee ? paypal.paypalZuCent(aufschl.paypal_fee.value) : null,
}, "paypal:webhook");
}
} else if (typ === "PAYMENT.CAPTURE.DENIED" || typ === "PAYMENT.CAPTURE.REFUNDED") {
const zahlungId = kern.custom_id || null;
if (zahlungId) {
db.prepare(`UPDATE wd_zahlungen SET status = ?, aktualisiert_am = ? WHERE id = ?`)
.run(typ.endsWith("REFUNDED") ? "erstattet" : "fehlge", jetzt(), zahlungId);
verlauf("zahlung", zahlungId, typ.toLowerCase(), "paypal:webhook", null);
}
}
/* Erst NACH der Auswertung als verarbeitet markieren. Bricht der
Server dazwischen ab, steht die Meldung als empfangen-aber-offen in
der Tabelle und faellt beim Nachsehen auf -- statt spurlos als
"schon behandelt" zu gelten. */
db.prepare(`UPDATE wd_paypal_ereignisse SET verarbeitet = 1 WHERE event_id = ?`).run(id);
return json(res, { ok: true });
}
/* ---------------------------------------------------------------------
VERWALTUNG: Zahlung anlegen
POST /webdesign/admin/zahlungen
--------------------------------------------------------------------- */
export async function zahlungAnlegen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projektId = text(req.body?.projektId, 60);
const projekt = db
.prepare(`SELECT p.*, k.id AS kid FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?`)
.get(projektId);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const art = ["anzahlung", "rest", "aenderung", "sonstige"].includes(req.body?.art)
? req.body.art : "sonstige";
/* Betrag: bei der Anzahlung aus dem Projektpreis BERECHNEN, sonst
eingeben. Ein getippter Anzahlungsbetrag ist eine Fehlerquelle, die
erst beim Geldeingang auffällt. */
let betragCent = null;
if (art === "anzahlung" && projekt.anzahlung_cent) betragCent = projekt.anzahlung_cent;
else if (art === "rest" && projekt.preis_cent != null && projekt.anzahlung_cent != null) {
betragCent = projekt.preis_cent - projekt.anzahlung_cent;
} else {
const euro = Number(req.body?.betragEuro);
if (Number.isFinite(euro) && euro > 0) betragCent = Math.round(euro * 100);
}
if (!betragCent || betragCent <= 0) {
return json(res, { ok: false, error: "Bitte einen Betrag angeben." }, 400);
}
const id = neueId();
const nummer = naechsteNummer("wd_zahlungen", "Z");
db.prepare(
`INSERT INTO wd_zahlungen
(id, nummer, kunde_id, projekt_id, art, zweck_text, betrag_cent, waehrung,
status, kundenart, erstellt_am)
VALUES (?,?,?,?,?,?,?,?, 'offen', ?, ?)`
).run(
id, nummer, projekt.kunde_id, projekt.id, art,
text(req.body?.zweck, 200) || standardZweck(art, projekt.titel),
betragCent, projekt.waehrung || "EUR",
req.body?.kundenart === "unternehmen" ? "unternehmen" : "verbraucher",
jetzt()
);
verlauf("zahlung", id, "angelegt", "team:" + (p.sitzung?.actor || "?"), { nummer, betragCent });
return json(res, { ok: true, id, nummer, betragText: centZuText(betragCent, projekt.waehrung) });
}
function standardZweck(art, titel) {
const t = titel || "Projekt";
if (art === "anzahlung") return `Anzahlung 30 % — ${t}`;
if (art === "rest") return `Restbetrag — ${t}`;
if (art === "aenderung") return `Zusatzleistung — ${t}`;
return t;
}
/* GET /webdesign/admin/zahlungen — Übersicht samt Einrichtungsstand */
export async function zahlungenListe(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const zeilen = db
.prepare(
`SELECT z.*, k.name AS kunde_name, pr.titel AS projekt_titel
FROM wd_zahlungen z
LEFT JOIN wd_kunden k ON k.id = z.kunde_id
LEFT JOIN wd_projekte pr ON pr.id = z.projekt_id
ORDER BY z.erstellt_am DESC LIMIT 300`
)
.all()
.map((z) => ({ ...z, betragText: centZuText(z.betrag_cent, z.waehrung) }));
return json(res, {
ok: true,
zahlungen: zeilen,
/* Damit die Verwaltung sagen kann, WAS noch fehlt, statt nur
"nicht eingerichtet". */
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
});
}
+163
View File
@@ -0,0 +1,163 @@
/* =====================================================================
test-zahlungen.mjs — Zahlungen und die Zustimmung nach § 356 Abs. 4
Läuft OHNE PayPal-Zugangsdaten. Das ist Absicht: Genau so sieht es
heute aus, und genau dieser Zustand muss sich sauber verhalten.
Geprüft wird vor allem, was NICHT passieren darf:
- kein Geld ohne Zustimmung zum vorzeitigen Beginn (Verbraucher)
- keine Zustimmungsfrage an Geschäftskunden
- keine fremde Zahlung sichtbar oder auslösbar
- keine Doppelverbuchung bei doppelter Webhook-Zustellung
- keine Verbuchung ohne gültige Signatur
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tmp = mkdtempSync(join(tmpdir(), "wd-zahl-"));
process.env.DB_PATH = join(tmp, "test.db");
process.env.WEBDESIGN_API_SECRET = "test-nur-fuer-diesen-lauf";
/* Bewusst KEINE PayPal-Zugangsdaten setzen. */
delete process.env.PAYPAL_CLIENT_ID;
delete process.env.PAYPAL_CLIENT_SECRET;
const PROTOKOLL = join(tmp, "..", "wd-zahl.log");
const L = (...a) => { try { appendFileSync(PROTOKOLL, a.join(" ") + "\n"); } catch (e) {} console.log(...a); };
const { db, initDb } = await import("./db.js");
initDb();
const paypal = await import("./lib/webdesign-paypal.js");
const { SOFORTBEGINN_TEXT, SOFORTBEGINN_FASSUNG } = await import("./routes/webdesign-zahlungen.js");
const { jetzt, neueId, centZuText } = await import("./lib/webdesign-helfer.js");
let ok = 0, fehler = 0;
function pruefe(name, bedingung, zusatz) {
if (bedingung) { ok++; L(" ok " + name); }
else { fehler++; L(" FEHL " + name + (zusatz ? " -> " + zusatz : "")); }
}
/* ---- Aufbau ---- */
function kunde(name, email) {
const id = neueId();
db.prepare(`INSERT INTO wd_kunden (id,email,name,sprache,freigeschaltet,erstellt_am)
VALUES (?,?,?,'de',1,?)`).run(id, email, name, jetzt());
return id;
}
function projekt(kundeId, nummer, preis, anzahlung) {
const id = neueId();
db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,preis_cent,
anzahlung_cent,waehrung,erstellt_am) VALUES (?,?,?,?,'website','briefing',?,?,'EUR',?)`)
.run(id, nummer, kundeId, "Projekt " + nummer, preis, anzahlung, jetzt());
return id;
}
function zahlung(kundeId, projektId, art, cent, kundenart) {
const id = neueId();
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,
betrag_cent,waehrung,status,kundenart,erstellt_am)
VALUES (?,?,?,?,?,?,?, 'EUR','offen',?,?)`)
.run(id, "Z-" + id.slice(0, 6), kundeId, projektId, art, art, cent, kundenart, jetzt());
return id;
}
const kA = kunde("Anna Muster", "[email protected]");
const kB = kunde("Bernd Fremd", "[email protected]");
const pA = projekt(kA, "P-0001", 180000, 54000);
const pB = projekt(kB, "P-0002", 90000, 27000);
const zA = zahlung(kA, pA, "anzahlung", 54000, "verbraucher");
const zFirma = zahlung(kA, pA, "anzahlung", 54000, "unternehmen");
const zB = zahlung(kB, pB, "anzahlung", 27000, "verbraucher");
L("\n1. EINRICHTUNGSSTAND");
pruefe("ohne Zugangsdaten gilt PayPal als NICHT eingerichtet",
paypal.istEingerichtet("einmal") === false);
pruefe("es wird benannt, WAS fehlt",
paypal.fehlendeEinstellungen("einmal").includes("PAYPAL_CLIENT_ID"),
paypal.fehlendeEinstellungen("einmal").join(", "));
pruefe("Einmalzahlung braucht KEINEN Abo-Plan",
!paypal.fehlendeEinstellungen("einmal").includes("PAYPAL_WD_PLAN_BASIS"));
pruefe("das Abo verlangt zusätzlich einen Plan",
paypal.fehlendeEinstellungen("abo").includes("PAYPAL_WD_PLAN_BASIS"));
L("\n2. ZUSTIMMUNG NACH § 356 Abs. 4 BGB");
pruefe("der Wortlaut liegt in allen fünf Sprachen vor",
["de", "de-CH", "en", "fr", "pt"].every((s) => (SOFORTBEGINN_TEXT[s] || "").length > 80),
Object.keys(SOFORTBEGINN_TEXT).join(","));
pruefe("er nennt den vorzeitigen Beginn",
/ausdrücklich/i.test(SOFORTBEGINN_TEXT.de) && /vor Ablauf/i.test(SOFORTBEGINN_TEXT.de));
pruefe("er nennt den Verlust des Widerrufsrechts",
/Widerrufsrecht verliere/i.test(SOFORTBEGINN_TEXT.de));
pruefe("die Fassung ist festgehalten", !!SOFORTBEGINN_FASSUNG, SOFORTBEGINN_FASSUNG);
/* Die Bedingung aus dem Handler, wortgleich nachgebildet. */
const brauchtZustimmung = (z) =>
z.kundenart !== "unternehmen" && z.art === "anzahlung" && !z.widerruf_zugestimmt;
const zeileA = db.prepare(`SELECT * FROM wd_zahlungen WHERE id=?`).get(zA);
const zeileF = db.prepare(`SELECT * FROM wd_zahlungen WHERE id=?`).get(zFirma);
pruefe("Verbraucher wird gefragt", brauchtZustimmung(zeileA) === true);
pruefe("Geschäftskunde wird NICHT gefragt", brauchtZustimmung(zeileF) === false);
db.prepare(`UPDATE wd_zahlungen SET widerruf_zugestimmt=1, widerruf_zugestimmt_am=? WHERE id=?`)
.run(jetzt(), zA);
pruefe("nach erteilter Zustimmung wird nicht erneut gefragt",
brauchtZustimmung(db.prepare(`SELECT * FROM wd_zahlungen WHERE id=?`).get(zA)) === false);
const { zustimmungSpeichern } = await import("./routes/webdesign-widerruf.js");
zustimmungSpeichern({ kundeId: kA, projektId: pA, art: "sofortbeginn",
wortlaut: SOFORTBEGINN_TEXT.de, fassung: SOFORTBEGINN_FASSUNG, sprache: "de", ip: "1.2.3.4" });
const nachweis = db.prepare(`SELECT * FROM wd_zustimmungen WHERE kunde_id=?`).get(kA);
pruefe("der Nachweis enthält den WORTLAUT, nicht nur ein Häkchen",
nachweis && nachweis.wortlaut.length > 80, nachweis ? nachweis.wortlaut.length + " Zeichen" : "fehlt");
pruefe("der Nachweis trägt Zeitstempel und Fassung",
nachweis && nachweis.erteilt_am && nachweis.fassung === SOFORTBEGINN_FASSUNG);
pruefe("die IP wird nur als Hash gespeichert",
nachweis && nachweis.ip_hash && !nachweis.ip_hash.includes("1.2.3.4"));
L("\n3. ABSCHOTTUNG");
pruefe("B kann A's Zahlung NICHT laden",
!db.prepare(`SELECT id FROM wd_zahlungen WHERE id=? AND kunde_id=?`).get(zA, kB));
pruefe("A sieht nur eigene Zahlungen",
db.prepare(`SELECT COUNT(*) n FROM wd_zahlungen WHERE kunde_id=?`).get(kA).n === 2);
pruefe("B sieht nur seine eine Zahlung",
db.prepare(`SELECT COUNT(*) n FROM wd_zahlungen WHERE kunde_id=?`).get(kB).n === 1);
L("\n4. WEBHOOK — DOPPELTE ZUSTELLUNG");
const eid = "WH-TEST-1";
db.prepare(`INSERT INTO wd_paypal_ereignisse (event_id,typ,zahlung_id,signatur_ok,verarbeitet,rohdaten,empfangen_am)
VALUES (?,?,?,1,0,?,?)`).run(eid, "PAYMENT.CAPTURE.COMPLETED", zA, "{}", jetzt());
const doppelt = db.prepare(`SELECT 1 FROM wd_paypal_ereignisse WHERE event_id=?`).get(eid);
pruefe("eine bereits empfangene Meldung wird erkannt", !!doppelt);
let kollision = false;
try {
db.prepare(`INSERT INTO wd_paypal_ereignisse (event_id,typ,signatur_ok,verarbeitet,rohdaten,empfangen_am)
VALUES (?,?,1,0,?,?)`).run(eid, "X", "{}", jetzt());
} catch (e) { kollision = true; }
pruefe("die Datenbank verhindert denselben Vorgang ein zweites Mal", kollision);
L("\n5. BETRÄGE");
pruefe("30 % werden aus dem Preis berechnet, nicht getippt",
db.prepare(`SELECT anzahlung_cent FROM wd_projekte WHERE id=?`).get(pA).anzahlung_cent === 54000);
pruefe("Restbetrag = Preis minus Anzahlung", 180000 - 54000 === 126000);
pruefe("Beträge werden lesbar dargestellt", centZuText(54000, "EUR") === "540,00 €",
centZuText(54000, "EUR"));
pruefe("Cent-Werte gehen nicht durch Kommazahlen verloren",
Math.round(49.99 * 100) === 4999);
L("\n6. GELD WIRD NIE OHNE ZUGANGSDATEN BEWEGT");
let geworfen = null;
try { await paypal.bestellungAnlegen({ zahlungId: zA, nummer: "Z-1", betragCent: 54000, zweck: "Test" }); }
catch (e) { geworfen = e; }
pruefe("ein Bestellversuch ohne Zugangsdaten bricht sauber ab",
geworfen && geworfen.constructor.name === "PayPalNichtEingerichtet",
geworfen ? geworfen.constructor.name : "kein Fehler");
pruefe("die Zahlung bleibt dabei unangetastet auf 'offen'",
db.prepare(`SELECT status FROM wd_zahlungen WHERE id=?`).get(zA).status === "offen");
L("\n7. DATENBANK BLEIBT SAUBER");
pruefe("keine kaputten Verweise", db.prepare(`PRAGMA foreign_key_check`).all().length === 0);
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
process.on("exit", () => rmSync(tmp, { recursive: true, force: true }));
process.exitCode = fehler ? 1 : 0;