WIP: Zahlungsrouten (Test folgt auf dem Server)
This commit is contained in:
@@ -117,6 +117,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
widerrufAbsenden, widerrufeListe, widerrufBearbeiten,
|
widerrufAbsenden, widerrufeListe, widerrufBearbeiten,
|
||||||
} from "./routes/webdesign-widerruf.js";
|
} 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";
|
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
|
||||||
|
|
||||||
initDb();
|
initDb();
|
||||||
@@ -265,6 +273,9 @@ app.post("/webdesign/anfrage", wrap(anfrageAbsenden));
|
|||||||
Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer
|
Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer
|
||||||
jeden, der sein Passwort vergessen hat. */
|
jeden, der sein Passwort vergessen hat. */
|
||||||
app.post("/webdesign/widerruf", wrap(widerrufAbsenden));
|
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.get("/webdesign/admin/widerrufe", wrap(widerrufeListe));
|
||||||
app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten));
|
app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten));
|
||||||
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
|
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
|
||||||
@@ -316,6 +327,11 @@ app.post("/webdesign/admin/aufgaben/:id/loeschen", wrap(adminAufgabeLoeschen));
|
|||||||
/* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */
|
/* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */
|
||||||
app.get("/webdesign/portal/postfach", wrap(portalPostfach));
|
app.get("/webdesign/portal/postfach", wrap(portalPostfach));
|
||||||
app.post("/webdesign/portal/postfach", wrap(portalPostfachSenden));
|
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", wrap(adminPostfachListe));
|
||||||
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
|
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
|
||||||
app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf));
|
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