Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register

Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order:<Kunde>:<Projekt>:
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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:38:50 +00:00
1 parent 1f6ae0f7e0
commit 7fcb268644
5 files changed
+533 -42

No files matched your search

+71 -23
View File
@@ -6,14 +6,15 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { withLocks } from '../services/keyedLock.js' import { withLocks } from '../services/keyedLock.js'
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js' import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
import { import {
DISABLED_MESSAGE,
assertOwnProject, assertOwnProject,
backfillInvoiceLinks, backfillInvoiceLinks,
getFeatureDoc, getFeatureDoc,
hakenTexte,
listCustomerFeatures, listCustomerFeatures,
markOrdered, markOrdered,
runActivation, runActivation,
} from '../services/siteFeatures.js' } from '../services/siteFeatures.js'
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js' import { termineFuerKunde } from '../services/termine/schalter.js'
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js' import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.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 * 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 * (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. * 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) { async function openOrderSession(doc, feature) {
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
if (!doc.stripeSessionId) return null if (!doc.stripeSessionId) return null
let session let session
try { try {
@@ -137,11 +139,45 @@ async function openOrderSession(doc) {
throw conflict() throw conflict()
} }
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
/** "Name <E-Mail>" 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. // 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, // 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, // kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo. // 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
router.post('/:key/checkout', requireSession, async (req, res) => { 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 { try {
// 1 Kundenansicht // 1 Kundenansicht
if (req.session.viewAs) { if (req.session.viewAs) {
@@ -163,7 +199,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
const customerId = getSessionCustomerId(req) const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId) await assertOwnProject(customerId, projectId)
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { 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) // 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
const sperre = featureSperre(key, req.session.email) 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 // 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
await pruefeAbo(feature, customerId) await pruefeAbo(feature, customerId)
// Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register. // Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
// Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch Dokument. // erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein 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
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung // a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register)
// richtet sich nach ihrer gespeicherten Stripe-Sitzung const existing = await getFeatureDoc(customerId, projectId, key)
const existing = await getFeatureDoc(customerId, projectId, 'news')
if (existing?.state === 'setup' || existing?.state === 'active') { 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 === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
if (existing?.state === 'ordered') {
const open = await openOrderSession(existing) const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt })
if (open) return open
// 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 customer = await getDocument(config.collections.customers, customerId)
const now = new Date().toISOString() const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) // 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({ await markOrdered({
customerId, customerId,
projectId, projectId,
key: 'news', key,
sessionId: created.id, 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 return created
}) })
// f die URL geht erst jetzt an den Browser
res.json({ url: session.url }) res.json({ url: session.url })
} catch (err) { } catch (err) {
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.') fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
+10 -2
View File
@@ -25,10 +25,17 @@ async function termineBestellpruefung({ customerId, projectId, feature }) {
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false }) 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-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 immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
* Standard-Haken. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert. * 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 = { const STANDARD_HAKEN = {
news: {}, news: {},
@@ -36,6 +43,7 @@ const STANDARD_HAKEN = {
verfuegbar: (email) => termineFuerKunde(email), verfuegbar: (email) => termineFuerKunde(email),
bestellpruefung: termineBestellpruefung, bestellpruefung: termineBestellpruefung,
unterlagenBereit: async () => true, unterlagenBereit: async () => true,
belehrung: async () => BELEHRUNG_PLATZHALTER,
}, },
} }
+15 -4
View File
@@ -229,13 +229,24 @@ export async function paidFeatureOrders(customerId) {
return result 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 = {} }) { export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
assertKey(key) assertKey(key)
const feature = featureConfig(key)
const doc = await ensureFeatureDoc(customerId, projectId, key) const doc = await ensureFeatureDoc(customerId, projectId, key)
if (doc.state === 'setup' || doc.state === 'active') { if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage)
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet') if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage)
}
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
return updateDocument(config.collections.portalFeatures, doc.$id, { return updateDocument(config.collections.portalFeatures, doc.$id, {
featureKey: key, featureKey: key,
...stateFields('ordered'), ...stateFields('ordered'),
+400 -12
View File
@@ -1,14 +1,17 @@
import { test, after, beforeEach, mock } from 'node:test' import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { startAppwriteMock } from './support/appwriteMock.js' import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js' import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js' import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js' import { schemaKeys } from './support/schema.js'
// Checkout je Funktion, Teil 1 (Task B6): POST /api/funktionen/:key/checkout mit den Vorpruefungen in der // Checkout je Funktion (Tasks B6 und B7): POST /api/funktionen/:key/checkout.
// Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, 5b Unterlagen, // Teil 1 (B6): Vorpruefungen in der Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt,
// 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine Stripe-Sitzung, kein // 5 Schalter, 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine
// featureConsents- und kein portalFeatures-Dokument. Sperre, Zustandspruefung und Nachweis folgen in Task B7. // 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({ const aw = await startAppwriteMock({
schema: { schema: {
portalFeatures: schemaKeys('portalFeatures'), portalFeatures: schemaKeys('portalFeatures'),
@@ -26,6 +29,8 @@ const { default: funktionenRoutes } = await import('../server/routes/funktionen.
const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const reg = await import('../server/services/featureRegistry.js') const reg = await import('../server/services/featureRegistry.js')
const order = await import('../server/services/featureOrder.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 }) const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()])) 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. // ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) ---
// Eine Termine-Bestellung, die alle Vorpruefungen besteht, darf bis dahin weder Sitzung noch Dokument noch Nachweis anlegen; // Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen, Block C fuellt sie).
// B7 ersetzt diesen Test durch den Erfolgsfall. const bereit = () => {
test('Termine-Bestellung nach bestandenen Vorpruefungen legt bis Task B7 noch nichts an (503)', async () => {
T.bestellpruefung = async () => [] 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()) const res = await checkout('termine', bodyT())
assert.equal(res.status, 503) const nach = new Date().toISOString()
assert.equal(res.body.code, 'bestellung_nicht_freigeschaltet') 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 <k@example.invalid>')
// 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 <f@x.de>', 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 <k@example.invalid>')
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() 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 --- // ------------------------------------------------------------------------------------------ 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 () => { test('fortsetzen: ohne Dokument oder ausser ordered ist 400 zustimmung_fehlt, es entsteht nichts', async () => {
const fall = { projectId: 'p1', fortsetzen: true } const fall = { projectId: 'p1', fortsetzen: true }
+37 -1
View File
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau // websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({ 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() const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) 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.doesNotMatch(res.body.error, /Invalid document structure|stripeSessionId/)
assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/) 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 <k@example.invalid>')
// 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)
})