Files

164 lines
8.1 KiB
JavaScript

/* =====================================================================
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;