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