WIP: Zahlungsrouten (Test folgt auf dem Server)
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user