164 lines
8.1 KiB
JavaScript
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;
|