From 7fcb268644e282b3bafc75efaf3da78ae8cceea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:38:50 +0000 Subject: [PATCH] Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order::: Zustand aus dem Register (setup/active, disabled, ordered mit offener Sitzung, "Zahlung fortsetzen"), Stripe-Sitzung mit der Zeit des Klicks, Zustimmungsnachweis (Serverfassungen und -texte) vor markOrdered und vor der URL. Standard-Haken belehrung mit BELEHRUNG_PLATZHALTER fuer den Terminplaner. Co-Authored-By: Claude Sonnet 5.5 --- server/routes/funktionen.js | 94 +++++-- server/services/featureRegistry.js | 12 +- server/services/siteFeatures.js | 19 +- test/funktionenCheckout.test.js | 414 ++++++++++++++++++++++++++++- test/funktionenRoutes.test.js | 38 ++- 5 files changed, 534 insertions(+), 43 deletions(-) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 045c70e..e9a0cf2 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -6,14 +6,15 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { withLocks } from '../services/keyedLock.js' import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js' import { - DISABLED_MESSAGE, assertOwnProject, backfillInvoiceLinks, getFeatureDoc, + hakenTexte, listCustomerFeatures, markOrdered, runActivation, } from '../services/siteFeatures.js' +import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js' import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' @@ -120,9 +121,10 @@ router.get('/', requireSession, async (req, res) => { * Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal * (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft * oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409. + * Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage). */ -async function openOrderSession(doc) { - const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) +async function openOrderSession(doc, feature) { + const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 }) if (!doc.stripeSessionId) return null let session try { @@ -137,11 +139,45 @@ async function openOrderSession(doc) { throw conflict() } +const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.' + +/** "Name " der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */ +const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '') + +/** + * Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem + * Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route + * antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log. + */ +async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) { + const belehrung = (await haken.belehrung?.()) ?? null + const texte = nachweisTexte(feature, { belehrung }) + try { + await speichereZustimmung({ + customerId, + projectId, + featureKey: feature.key, + stripeSessionId: sessionId, + fassungen: zustimmungsfassungen(feature), + texte, + ip: req.ip, + userAgent: req.get('user-agent'), + konto: kontoLabel(actingPerson(req.session)), + at: klickAt, + }) + } catch (err) { + throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err }) + } +} + // Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news. // Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung, // kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, // 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo. router.post('/:key/checkout', requireSession, async (req, res) => { + // Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis, + // Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z) + const klickAt = new Date().toISOString() try { // 1 Kundenansicht if (req.session.viewAs) { @@ -163,7 +199,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { const customerId = getSessionCustomerId(req) await assertOwnProject(customerId, projectId) if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { - throw bestellFehler(400, 'Bitte stimme der Erklärung zu, um zu bestellen.', 'zustimmung_fehlt') + throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') } // 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste) const sperre = featureSperre(key, req.session.email) @@ -188,37 +224,49 @@ router.post('/:key/checkout', requireSession, async (req, res) => { // 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv await pruefeAbo(feature, customerId) - // Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register. - // Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch Dokument. - if (key !== 'news') { - throw bestellFehler(503, 'Die Bestellung ist für diese Funktion noch nicht freigeschaltet.', 'bestellung_nicht_freigeschaltet') - } - // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine - // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument + // Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen + // erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { - // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung - // richtet sich nach ihrer gespeicherten Stripe-Sitzung - const existing = await getFeatureDoc(customerId, projectId, 'news') + // a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register) + const existing = await getFeatureDoc(customerId, projectId, key) if (existing?.state === 'setup' || existing?.state === 'active') { - throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) + throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 }) } - if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 }) - if (existing?.state === 'ordered') { - const open = await openOrderSession(existing) - if (open) return open + if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 }) + + const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) + + // b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite + const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null + if (open) { + // d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung) + // gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu + if (!(await zustimmungFuerSitzung(open.id))) { + if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') + await nachweis(open.id) + } + return open } + // keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu + if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') + + // c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt const customer = await getDocument(config.collections.customers, customerId) - const now = new Date().toISOString() - const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) + const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt }) + // d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft + // nach 24 h ab, ein Storno ist nicht noetig) + await nachweis(created.id) + // e erst nach dem Nachweis wird die Bestellung eingetragen await markOrdered({ customerId, projectId, - key: 'news', + key, sessionId: created.id, - consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') }, + consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') }, }) return created }) + // f die URL geht erst jetzt an den Browser res.json({ url: session.url }) } catch (err) { fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.') diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 3ec2529..a169fda 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -25,10 +25,17 @@ async function termineBestellpruefung({ customerId, projectId, feature }) { return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false }) } +/** + * Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der + * Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion(). + */ +export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.' + /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit - * (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung und B11 freischaltbar. News hat keine - * Standard-Haken. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert. + * (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht + * und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag + * im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert. */ const STANDARD_HAKEN = { news: {}, @@ -36,6 +43,7 @@ const STANDARD_HAKEN = { verfuegbar: (email) => termineFuerKunde(email), bestellpruefung: termineBestellpruefung, unterlagenBereit: async () => true, + belehrung: async () => BELEHRUNG_PLATZHALTER, }, } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index f161bfb..bf43684 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -229,13 +229,24 @@ export async function paidFeatureOrders(customerId) { return result } +/** + * Wortlaut der Zustimmung am Dokument (consentText): die Texte der Haken-Items der Funktion, durch eine Leerzeile getrennt. + * Eine bequeme Kopie fuer das Team; massgeblich ist der vollstaendige Nachweis in featureConsents. + */ +export function hakenTexte(feature) { + return feature.consent.items + .filter((item) => item.haken) + .map((item) => item.text) + .join('\n\n') +} + +/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */ export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) + const feature = featureConfig(key) const doc = await ensureFeatureDoc(customerId, projectId, key) - if (doc.state === 'setup' || doc.state === 'active') { - throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet') - } - if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE) + if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage) + if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage) return updateDocument(config.collections.portalFeatures, doc.$id, { featureKey: key, ...stateFields('ordered'), diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js index fcc54a3..56c74c7 100644 --- a/test/funktionenCheckout.test.js +++ b/test/funktionenCheckout.test.js @@ -1,14 +1,17 @@ import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' -// Checkout je Funktion, Teil 1 (Task B6): POST /api/funktionen/:key/checkout mit den Vorpruefungen in der -// Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, 5b Unterlagen, -// 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine Stripe-Sitzung, kein -// featureConsents- und kein portalFeatures-Dokument. Sperre, Zustandspruefung und Nachweis folgen in Task B7. +// Checkout je Funktion (Tasks B6 und B7): POST /api/funktionen/:key/checkout. +// Teil 1 (B6): Vorpruefungen in der Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt, +// 5 Schalter, 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine +// Stripe-Sitzung, kein featureConsents- und kein portalFeatures-Dokument. +// Teil 2 (B7, ab "Sperre, Zustand, Nachweis"): Zustandspruefung, Stripe-Sitzung, Zustimmungsnachweis vor der Weiterleitung, +// markOrdered; Wiederverwendung der offenen Sitzung, "Zahlung fortsetzen", parallele Klicks. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), @@ -26,6 +29,8 @@ const { default: funktionenRoutes } = await import('../server/routes/funktionen. const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const reg = await import('../server/services/featureRegistry.js') const order = await import('../server/services/featureOrder.js') +const consents = await import('../server/services/featureConsents.js') +const sf = await import('../server/services/siteFeatures.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -514,20 +519,403 @@ test('Abo-Memo: je Anfrage ein aboMemo(), News fragt das Abo nie', async () => { } }) -// Zwischenstand bis Task B7: der Rest der Route (Sperre, Zustand, Stripe-Sitzung, Nachweis, markOrdered) gilt noch nur fuer News. -// Eine Termine-Bestellung, die alle Vorpruefungen besteht, darf bis dahin weder Sitzung noch Dokument noch Nachweis anlegen; -// B7 ersetzt diesen Test durch den Erfolgsfall. -test('Termine-Bestellung nach bestandenen Vorpruefungen legt bis Task B7 noch nichts an (503)', async () => { +// ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) --- +// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen, Block C fuellt sie). +const bereit = () => { T.bestellpruefung = async () => [] +} +const featureDoc = (state, extra = {}) => + aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', ...extra }) +const sitzungen = () => [...st.checkoutSessions.values()] +const nachweise = () => [...aw.docs('featureConsents').values()] +const termineDoc = () => aw.docs('portalFeatures').get('f_p1_termine') +const sha256 = (text) => createHash('sha256').update(text).digest('hex') +const belehrungText = async (key) => (await reg.featureHaken(key).belehrung?.()) ?? null +const erwarteteTexte = async (key) => consents.nachweisTexte(reg.featureConfig(key), { belehrung: await belehrungText(key) }) +const hakenTexte = (feature) => feature.consent.items.filter((item) => item.haken).map((item) => item.text).join('\n\n') + +test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihenfolge, Hash, ein Zeitpunkt', async () => { + bereit() + const vor = new Date().toISOString() const res = await checkout('termine', bodyT()) - assert.equal(res.status, 503) - assert.equal(res.body.code, 'bestellung_nicht_freigeschaltet') - keineSpuren() + const nach = new Date().toISOString() + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body), ['url']) + assert.equal(sitzungen().length, 1) + const [session] = sitzungen() + assert.equal(res.body.url, session.url) + + assert.equal(nachweise().length, 1, 'genau ein Nachweis') + const [n] = nachweise() + assert.equal(n.stripeSessionId, session.id) + assert.deepEqual([n.customerId, n.projectId, n.featureKey], ['c1', 'p1', 'termine']) + assert.deepEqual(JSON.parse(n.texte), await erwarteteTexte('termine'), 'Texte in Anzeigereihenfolge') + assert.deepEqual(JSON.parse(n.fassungen), consents.zustimmungsfassungen(T)) + assert.equal(n.textHash, sha256(n.texte), 'SHA-256 ueber die gespeicherten Texte') + assert.equal(n.textHash, consents.texteHash(JSON.parse(n.texte))) + assert.ok(n.ip, 'ip') + assert.ok(n.userAgent, 'userAgent') + assert.equal(n.konto, 'Kunde Eins ') + + // ein Zeitpunkt: Nachweis, Stripe-Metadaten und Dokument tragen dieselbe Zeit des Klicks (Eingang der Anfrage) + const doc = termineDoc() + assert.equal(doc.state, 'ordered') + assert.equal(doc.stripeSessionId, session.id) + assert.equal(n.acceptedAt, session.metadata.consentAt) + assert.equal(n.acceptedAt, doc.consentAt) + assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`) + assert.match(n.acceptedAt, /Z$/, 'UTC-Zeit mit Z') + assert.equal(doc.consentText, hakenTexte(T).slice(0, 1000), 'consentText aus den Haken-Texten') + assert.equal(doc.consentIp, n.ip) + assert.equal(doc.consentUserAgent, n.userAgent) +}) + +test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => { + bereit() + assert.equal(reg.BELEHRUNG_PLATZHALTER, 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.') + assert.equal(await reg.featureHaken('termine').belehrung(), reg.BELEHRUNG_PLATZHALTER) + assert.equal(reg.featureHaken('news').belehrung, undefined) + await checkout('termine', bodyT()) + const texte = JSON.parse(nachweise()[0].texte) + assert.deepEqual(texte.slice(-2), [ + { id: 'belehrung', text: reg.BELEHRUNG_PLATZHALTER, haken: false }, + { id: 'knopf', text: T.consent.knopf, haken: false }, + ]) + assert.deepEqual(texte.map((x) => x.id), [...T.consent.items.map((x) => x.id), 'belehrung', 'knopf']) + + const news = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3')) + assert.equal(news.status, 200) + const newsNachweis = nachweise().find((d) => d.featureKey === 'news') + assert.deepEqual(JSON.parse(newsNachweis.texte).map((x) => x.id), [...config.features.news.consent.items.map((x) => x.id), 'knopf']) +}) + +test('Belehrungs-Haken ueberschreibbar: der gelieferte Text steht im Nachweis, null laesst den Eintrag weg', async () => { + bereit() + T.belehrung = async () => 'Eigene Belehrung.' + await checkout('termine', bodyT()) + assert.deepEqual(JSON.parse(nachweise()[0].texte).at(-2), { id: 'belehrung', text: 'Eigene Belehrung.', haken: false }) + await deleteDocument('featureConsents', nachweise()[0].$id) + await deleteDocument('portalFeatures', 'f_p1_termine') + st.checkoutSessions.clear() + T.belehrung = async () => null + try { + assert.equal((await checkout('termine', bodyT())).status, 200) + assert.equal(JSON.parse(nachweise()[0].texte).some((x) => x.id === 'belehrung'), false) + } finally { + delete T.belehrung + } +}) + +test('Client-Texte zaehlen nicht: gespeichert werden die Registertexte und -fassungen', async () => { + bereit() + const res = await checkout('termine', bodyT({ texte: ['manipuliert'], fassungen: { zustimmung: 'x' }, konto: 'Fremd ', acceptedAt: '2000-01-01T00:00:00Z' })) + assert.equal(res.status, 200) + const [n] = nachweise() + assert.deepEqual(JSON.parse(n.texte), await erwarteteTexte('termine')) + assert.deepEqual(JSON.parse(n.fassungen), consents.zustimmungsfassungen(T)) + assert.equal(n.konto, 'Kunde Eins ') + assert.ok(n.acceptedAt > '2020') + assert.doesNotMatch(JSON.stringify(nachweise()), /manipuliert/) +}) + +test('Fassungswechsel: alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis', async () => { + bereit() + const alt = T.consent.version + try { + T.consent.version = 'ENTWURF-2099-01-01' + const veraltet = await checkout('termine', bodyT({ zustimmung: { version: alt, haken: pflichtHaken() } })) + assert.equal(veraltet.status, 409) + assert.equal(veraltet.body.code, 'fassung_veraltet') + keineSpuren() + const neu = await checkout('termine', bodyT({ zustimmung: { version: 'ENTWURF-2099-01-01', haken: pflichtHaken() } })) + assert.equal(neu.status, 200) + assert.equal(JSON.parse(nachweise()[0].fassungen).zustimmung, 'ENTWURF-2099-01-01') + } finally { + T.consent.version = alt + } +}) + +test('ohne Nachweis keine URL: scheitert das Speichern, gibt es kein ordered, keine Weiterleitung', async () => { + bereit() + const log = stumm() + try { + aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'GEHEIMER-ROHTEXT interner Fehler' }) + const res = await checkout('termine', bodyT()) + assert.equal(res.status, 500) + assert.equal(res.body.url, undefined) + assert.doesNotMatch(JSON.stringify(res.body), /GEHEIMER-ROHTEXT|https?:/) + assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/) + assert.equal(nachweise().length, 0) + const doc = termineDoc() + assert.ok(!doc || doc.state === 'off', 'nicht ordered') + assert.ok(!doc?.orderedAt, 'orderedAt leer') + // die Stripe-Sitzung ist angelegt, aber nie an den Browser gegangen; sie laeuft nach 24 h ab + assert.equal(sitzungen().length, 1) + // ein erneuter Klick geht normal durch: neue Sitzung, ein Nachweis zu ihr + const noch = await checkout('termine', bodyT()) + assert.equal(noch.status, 200) + assert.equal(sitzungen().length, 2) + assert.equal(nachweise().length, 1) + assert.equal(nachweise()[0].stripeSessionId, sitzungen()[1].id) + } finally { + log.mock.restore() + } +}) + +test('Nachweis nicht speicherbar (zu lange Texte): 500 mit allgemeinem Text, kein Rohtext, kein ordered', async () => { + bereit() + const alt = T.consent.items[0].text + const log = stumm() + try { + T.consent.items[0].text = 'x'.repeat(30000) + const res = await checkout('termine', bodyT()) + assert.equal(res.status, 500) + assert.doesNotMatch(JSON.stringify(res.body), /Zustimmungsnachweis|zu lang/) + assert.equal(res.body.url, undefined) + assert.ok(!termineDoc() || termineDoc().state === 'off') + } finally { + T.consent.items[0].text = alt + log.mock.restore() + } +}) + +test('Datenbankfehler beim markOrdered: 500 ohne Rohtext, der Nachweis steht schon, das Dokument nicht ordered', async () => { + bereit() + const log = stumm() + try { + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 400, message: 'Invalid document structure: Attribute "stripeSessionId" has invalid type.' }) + const res = await checkout('termine', bodyT()) + assert.equal(res.status, 500, JSON.stringify(res.body)) + assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/) + assert.doesNotMatch(JSON.stringify(res.body), /Invalid document structure|stripeSessionId|https?:/) + assert.equal(nachweise().length, 1, 'Nachweis kommt vor markOrdered') + assert.equal(termineDoc().state, 'off') + } finally { + log.mock.restore() + } +}) + +test('Wiederverwendung: zweiter Aufruf bei offener Sitzung gibt dieselbe URL, ohne zweite Sitzung und zweiten Nachweis', async () => { + bereit() + const erste = await checkout('termine', bodyT()) + const zweite = await checkout('termine', bodyT()) + assert.equal(zweite.status, 200) + assert.equal(zweite.body.url, erste.body.url) + assert.equal(sitzungen().length, 1) + assert.equal(nachweise().length, 1) + // die Wiederverwendung fasst das Dokument nicht an (orderedAt bleibt der Zeitpunkt der Bestellung) + aw.seed('portalFeatures', { ...termineDoc(), orderedAt: '2026-01-01T00:00:00.000Z' }) + await checkout('termine', bodyT()) + assert.equal(termineDoc().orderedAt, '2026-01-01T00:00:00.000Z') + assert.equal(termineDoc().state, 'ordered') +}) + +test('Wiederverwendung ohne Nachweis: der Nachweis wird nachgetragen, bevor die URL zurueckkommt', async () => { + bereit() + const erste = await checkout('termine', bodyT()) + await deleteDocument('featureConsents', nachweise()[0].$id) + assert.equal(nachweise().length, 0) + const vor = new Date().toISOString() + const zweite = await checkout('termine', bodyT()) + assert.equal(zweite.status, 200) + assert.equal(zweite.body.url, erste.body.url) + assert.equal(sitzungen().length, 1, 'keine zweite Stripe-Sitzung') + assert.equal(nachweise().length, 1) + assert.equal(nachweise()[0].stripeSessionId, sitzungen()[0].id) + assert.ok(nachweise()[0].acceptedAt >= vor, 'Zeit des neuen Klicks') + assert.deepEqual(JSON.parse(nachweise()[0].texte), await erwarteteTexte('termine')) + + // scheitert das Nachtragen, kommt keine URL zurueck + await deleteDocument('featureConsents', nachweise()[0].$id) + const log = stumm() + try { + aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) + const kaputt = await checkout('termine', bodyT()) + assert.equal(kaputt.status, 500) + assert.equal(kaputt.body.url, undefined) + assert.equal(nachweise().length, 0) + } finally { + log.mock.restore() + } +}) + +test('abgelaufene Sitzung: neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs)', async () => { + bereit() + const erste = await checkout('termine', bodyT()) + sitzungen()[0].status = 'expired' + const zweite = await checkout('termine', bodyT()) + assert.equal(zweite.status, 200) + assert.notEqual(zweite.body.url, erste.body.url) + assert.equal(sitzungen().length, 2) + assert.equal(nachweise().length, 2) + assert.equal(new Set(nachweise().map((n) => n.stripeSessionId)).size, 2) + assert.deepEqual(nachweise().map((n) => n.stripeSessionId).sort(), sitzungen().map((s) => s.id).sort()) + assert.equal(termineDoc().stripeSessionId, sitzungen()[1].id) + assert.equal(termineDoc().consentAt, sitzungen()[1].metadata.consentAt) +}) + +test('parallele Klicks: eine Sitzung, ein Nachweis, beide 200 mit gleicher URL', async () => { + bereit() + const [a, b] = await Promise.all([checkout('termine', bodyT()), checkout('termine', bodyT())]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(a.body.url, b.body.url) + assert.equal(sitzungen().length, 1) + assert.equal(nachweise().length, 1) +}) + +// ------------------------------------------------------------------------------------------ Zustaende --- +test('Zustaende: setup und active sind 409 orderedConflictMessage, disabled 409 disabledMessage, nirgends eine neue Sitzung', async () => { + bereit() + for (const [state, text] of [ + ['setup', T.orderedConflictMessage], + ['active', T.orderedConflictMessage], + ['disabled', T.disabledMessage], + ]) { + featureDoc(state) + const vorher = spuren() + const res = await checkout('termine', bodyT()) + assert.equal(res.status, 409, state) + assert.equal(res.body.error, text, state) + assert.equal(res.body.url, undefined, state) + assert.deepEqual(spuren(), vorher, `${state}: nichts angelegt`) + assert.equal(termineDoc().state, state, `${state}: unveraendert`) + } + // News: Meldungen aus dem Register der News + const news = config.features.news + aw.seed('portalFeatures', { $id: 'f_p3_news', customerId: 'c3', projectId: 'p3', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) + const nCookie = customerCookie('c3') + assert.equal((await checkout('news', { projectId: 'p3', consent: true }, nCookie)).body.error, news.orderedConflictMessage) + aw.seed('portalFeatures', { $id: 'f_p3_news', customerId: 'c3', projectId: 'p3', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop' }) + assert.equal((await checkout('news', { projectId: 'p3', consent: true }, nCookie)).body.error, news.disabledMessage) + assert.equal(sitzungen().length, 0) +}) + +test('Zustand complete: 409, keine neue Sitzung, kein neuer Nachweis', async () => { + bereit() + await checkout('termine', bodyT()) + sitzungen()[0].status = 'complete' + const res = await checkout('termine', bodyT()) + assert.equal(res.status, 409) + assert.equal(res.body.error, T.orderedConflictMessage) + assert.equal(sitzungen().length, 1) + assert.equal(nachweise().length, 1) + // auch "Zahlung fortsetzen" bekommt keine URL + const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true }) + assert.equal(fort.status, 409) + assert.equal(fort.body.url, undefined) +}) + +test('Stripe nicht erreichbar: frische Bestellung (< 24 h) ist 409, aeltere bekommt eine neue Sitzung samt Nachweis', async () => { + bereit() + const log = mock.method(console, 'warn', () => {}) + try { + featureDoc('ordered', { stripeSessionId: 'cs_weg', orderedAt: new Date().toISOString() }) + const frisch = await checkout('termine', bodyT()) + assert.equal(frisch.status, 409) + assert.equal(frisch.body.error, T.orderedConflictMessage) + assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: 1 }, 'nichts angelegt') + featureDoc('ordered', { stripeSessionId: 'cs_weg', orderedAt: new Date(Date.now() - 2 * DAY_MS).toISOString() }) + const alt = await checkout('termine', bodyT()) + assert.equal(alt.status, 200) + assert.equal(sitzungen().length, 1) + assert.equal(nachweise().length, 1) + assert.equal(termineDoc().stripeSessionId, sitzungen()[0].id) + // ordered ohne gespeicherte Sitzungs-ID: ebenfalls eine neue Sitzung + featureDoc('ordered', { stripeSessionId: '' }) + assert.equal((await checkout('termine', bodyT())).status, 200) + assert.equal(sitzungen().length, 2) + assert.equal(nachweise().length, 2) + } finally { + log.mock.restore() + } +}) + +// ------------------------------------------------------------------------------------------ Zahlung fortsetzen --- +test('Zahlung fortsetzen: Dokument ordered mit Nachweis gibt dieselbe URL, ohne zweite Sitzung und zweiten Nachweis', async () => { + bereit() + const erste = await checkout('termine', bodyT()) + const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true }) + assert.equal(fort.status, 200, JSON.stringify(fort.body)) + assert.equal(fort.body.url, erste.body.url) + assert.equal(sitzungen().length, 1) + assert.equal(nachweise().length, 1) +}) + +test('Zahlung fortsetzen ohne Nachweis: 409 zustimmung_fehlt ohne URL, danach mit zustimmung 200 und der Nachweis ist wieder da', async () => { + bereit() + const erste = await checkout('termine', bodyT()) + await deleteDocument('featureConsents', nachweise()[0].$id) + const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true }) + assert.equal(fort.status, 409) + assert.equal(fort.body.code, 'zustimmung_fehlt') + assert.equal(fort.body.url, undefined) + assert.doesNotMatch(JSON.stringify(fort.body), /https?:/) + assert.equal(nachweise().length, 0, 'ohne Zustimmung entsteht kein Nachweis') + assert.equal(sitzungen().length, 1) + // der Kunde stimmt im Formular erneut zu + const neu = await checkout('termine', bodyT()) + assert.equal(neu.status, 200) + assert.equal(neu.body.url, erste.body.url) + assert.equal(nachweise().length, 1) + assert.equal(nachweise()[0].stripeSessionId, sitzungen()[0].id) + assert.equal(sitzungen().length, 1) +}) + +test('Zahlung fortsetzen bei abgelaufener Sitzung: 409 zustimmung_fehlt, mit zustimmung eine neue Sitzung', async () => { + bereit() + await checkout('termine', bodyT()) + sitzungen()[0].status = 'expired' + const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true }) + assert.equal(fort.status, 409) + assert.equal(fort.body.code, 'zustimmung_fehlt') + assert.equal(fort.body.url, undefined) + assert.equal(sitzungen().length, 1, 'fortsetzen legt nie eine Sitzung an') + assert.equal(nachweise().length, 1) + const neu = await checkout('termine', bodyT()) + assert.equal(neu.status, 200) + assert.equal(sitzungen().length, 2) + assert.equal(nachweise().length, 2) +}) + +test('Zahlung fortsetzen: ein Lesefehler beim Nachweis ist 500 ohne URL (im Zweifel keine Weiterleitung)', async () => { + bereit() + await checkout('termine', bodyT()) + const log = stumm() + try { + aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' }) + const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true }) + assert.equal(fort.status, 500) + assert.equal(fort.body.url, undefined) + assert.doesNotMatch(JSON.stringify(fort.body), /GEHEIMER-ROHTEXT/) + } finally { + log.mock.restore() + } +}) + +test('Zahlung fortsetzen bei News: gleiche Regeln (Nachweis da, dieselbe URL)', async () => { + const erste = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3')) + const fort = await checkout('news', { projectId: 'p3', fortsetzen: true }, customerCookie('c3')) + assert.equal(fort.status, 200) + assert.equal(fort.body.url, erste.body.url) + assert.equal(nachweise().length, 1) + assert.equal(sitzungen().length, 1) +}) + +// ------------------------------------------------------------------------------------------ Meldungen aus dem Register --- +test('markOrdered: Meldungen kommen aus dem Register der Funktion', async () => { + for (const [key, feature] of [['news', config.features.news], ['termine', T]]) { + for (const [state, text] of [['setup', feature.orderedConflictMessage], ['active', feature.orderedConflictMessage], ['disabled', feature.disabledMessage]]) { + aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, state, enabled: state === 'active', siteKey: 'abcdefghijklmnop' }) + await assert.rejects( + () => sf.markOrdered({ customerId: 'c1', projectId: 'p1', key, sessionId: 'cs_x', consent: {} }), + (err) => err.status === 409 && err.message === text, + `${key} ${state}` + ) + } + } }) // ------------------------------------------------------------------------------------------ fortsetzen --- -const featureDoc = (state, extra = {}) => - aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', ...extra }) test('fortsetzen: ohne Dokument oder ausser ordered ist 400 zustimmung_fehlt, es entsteht nichts', async () => { const fall = { projectId: 'p1', fortsetzen: true } diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index 8c44741..bb38958 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js' // websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau const aw = await startAppwriteMock({ - schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') }, + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) @@ -410,3 +410,39 @@ test('checkout_datenbankfehler_ohne_rohe_meldung', async () => { assert.doesNotMatch(res.body.error, /Invalid document structure|stripeSessionId/) assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/) }) + +// Task B7: auch die News-Bestellung schreibt vor der Weiterleitung einen Zustimmungsnachweis (Texte und Fassungen aus dem +// Register config.features.news statt Literale: Block F2 hebt die Fassung ohne Testbruch an) +test('news_schreibt_auch_einen_nachweis', async () => { + aw.seed('websiteProjects', { $id: 'pn1', customerId: 'c1', projectName: 'Nachweis-Laden' }) + const news = config.features.news + const vor = new Date().toISOString() + const res = await checkout('pn1') + const nach = new Date().toISOString() + assert.equal(res.status, 200) + const session = sessions().at(-1) + const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'pn1') + assert.equal(nachweise.length, 1) + const [n] = nachweise + assert.equal(n.stripeSessionId, session.id) + assert.equal(n.featureKey, 'news') + assert.deepEqual(JSON.parse(n.texte), [ + ...news.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })), + { id: 'knopf', text: news.consent.knopf, haken: false }, + ]) + assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version }) + assert.ok(n.ip && n.userAgent) + assert.equal(n.konto, 'Kunde Eins ') + // ein Zeitpunkt: Nachweis, Stripe-Metadaten und Dokument; das Dokument behaelt den Wortlaut wie bisher + const doc = await sf.getFeatureDoc('c1', 'pn1', 'news') + assert.equal(doc.consentText, config.funktionen.consentText) + assert.equal(n.acceptedAt, doc.consentAt) + assert.equal(n.acceptedAt, session.metadata.consentAt) + assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach) + // Widerrufs-Anker: nach der Zahlung zaehlt die Zeit des Klicks aus dem Nachweis, nicht die Zeit des Dokuments + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: '2026-01-01T00:00:00.000Z', consentAt: '2026-01-02T00:00:00.000Z' }) + assert.equal((await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pn1')))).status, 200) + const order = (await sf.paidFeatureOrders('c1')).find((o) => o.projekt === 'Nachweis-Laden') + assert.equal(order.featureKey, 'news') + assert.equal(order.vertragAt, n.acceptedAt) +})