diff --git a/server-internal/index.js b/server-internal/index.js index 082aef6a..b7bdb9e5 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -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)); diff --git a/server-internal/routes/webdesign-zahlungen.js b/server-internal/routes/webdesign-zahlungen.js new file mode 100644 index 00000000..cf5688cc --- /dev/null +++ b/server-internal/routes/webdesign-zahlungen.js @@ -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(), + }, + }); +} diff --git a/server-internal/test-zahlungen.mjs b/server-internal/test-zahlungen.mjs new file mode 100644 index 00000000..28b47441 --- /dev/null +++ b/server-internal/test-zahlungen.mjs @@ -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", "anna@example.com"); +const kB = kunde("Bernd Fremd", "bernd@example.com"); +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;