Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick

server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung
einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen
als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung,
zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash,
zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register).
paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst
consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf
einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung
verschiebt den Anker nicht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:05:12 +00:00
1 parent d8e4d79891
commit 786df1230e
5 files changed
+627 -4

No files matched your search

+3 -2
View File
@@ -1088,10 +1088,11 @@ async function withdrawFeature(d, receivedAt) {
const customer =
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
const orders = customer ? await paidFeatureOrders(customer.$id) : []
const ends = orders.filter((o) => o.orderedAt).map((o) => withdrawalDeadline(o.orderedAt).endsAt)
// Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt
const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt)
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
const lines = orders
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
.join('\n')
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
+160
View File
@@ -0,0 +1,160 @@
// Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key).
// Nur anfuegen und lesen: es gibt bewusst keine Aenderungs- und Loeschfunktion (wie analyseConsent.js).
// Gespeichert wird, was dem Kunden gezeigt wurde: Texte in Anzeigereihenfolge, die Fassungen der Unterlagen, der
// Zeitpunkt des Klicks und ein SHA-256 ueber die Texte. Vollstaendig oder gar nicht: nichts wird gekuerzt.
import { createHash } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, listDocuments, Query } from './appwriteAdmin.js'
// Grenzen der Collection (scripts/feature-setup.mjs, FEATURE_CONSENT_ATTRIBUTES)
const MAX_ID = 36
const MAX_FEATURE_KEY = 16
const MAX_SESSION = 128
const MAX_FASSUNGEN = 1000
const MAX_TEXTE = 20000
const MAX_IP = 64
const MAX_USER_AGENT = 300
const MAX_KONTO = 255
// Mehr Nachweise je Sitzung gibt es nur, wenn ein Speichern wiederholt wurde; gelesen wird eine kleine Seite
const SITZUNG_SEITE = 25
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** Kennung (Kunde, Projekt, Funktion, Sitzung): wird nie gekuerzt, sondern abgelehnt. */
function kennung(value, max, name, { optional = false } = {}) {
if (optional && (value === undefined || value === null || value === '')) return ''
if (typeof value !== 'string' || !value.trim() || value.length > max) throw httpError(400, `Zustimmungsnachweis: ${name} fehlt oder ist ungültig`)
return value
}
/** Freie Angaben aus der Anfrage (IP, Browser, Konto): fehlt etwas, bleibt es leer; zu Langes wird gekuerzt. */
const kurz = (value, max) => String(value ?? '').slice(0, max)
function zeitpunkt(at) {
const ms = at instanceof Date ? at.getTime() : typeof at === 'string' && /^\d{4}-\d\d-\d\dT/.test(at) ? Date.parse(at) : Number.NaN
if (Number.isNaN(ms)) throw httpError(400, 'Zustimmungsnachweis: Zeitpunkt fehlt oder ist ungültig')
return new Date(ms).toISOString()
}
function pruefeTexte(texte) {
if (!Array.isArray(texte) || texte.length === 0) throw httpError(400, 'Zustimmungsnachweis: Texte fehlen')
for (const eintrag of texte) {
const ok =
eintrag !== null &&
typeof eintrag === 'object' &&
!Array.isArray(eintrag) &&
typeof eintrag.id === 'string' &&
eintrag.id !== '' &&
typeof eintrag.text === 'string' &&
eintrag.text !== '' &&
(eintrag.haken === undefined || typeof eintrag.haken === 'boolean')
if (!ok) throw httpError(400, 'Zustimmungsnachweis: Text ungültig')
}
}
function pruefeFassungen(fassungen) {
if (fassungen === null || typeof fassungen !== 'object' || Array.isArray(fassungen)) {
throw httpError(400, 'Zustimmungsnachweis: Fassungen fehlen')
}
}
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
/** SHA-256 (hex) ueber JSON.stringify der Texte in der gespeicherten Reihenfolge. */
export function texteHash(texte) {
return sha256(JSON.stringify(texte))
}
/**
* Nachweis anfuegen. fassungen: Name => Fassung (z. B. zustimmung, leistungsbeschreibung, avv); texte: [{ id, text, haken }]
* genau in Anzeigereihenfolge. Erlaubt leer: stripeSessionId, ip, userAgent (Nachweis der Aktivierung durch das Team) und
* haken je Text. Wirft 400 (und schreibt nichts), wenn etwas fehlt, ungueltig oder zu lang ist.
* @returns {Promise<object>} das angelegte Dokument
*/
export async function speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at } = {}) {
const daten = {
customerId: kennung(customerId, MAX_ID, 'Kunde'),
projectId: kennung(projectId, MAX_ID, 'Projekt'),
featureKey: kennung(featureKey, MAX_FEATURE_KEY, 'Funktion'),
stripeSessionId: kennung(stripeSessionId, MAX_SESSION, 'Sitzung', { optional: true }),
acceptedAt: zeitpunkt(at),
}
pruefeFassungen(fassungen)
pruefeTexte(texte)
const fassungenJson = JSON.stringify(fassungen)
const texteJson = JSON.stringify(texte)
if (fassungenJson.length > MAX_FASSUNGEN) throw httpError(400, 'Zustimmungsnachweis: Fassungen zu lang')
if (texteJson.length > MAX_TEXTE) throw httpError(400, 'Zustimmungsnachweis: Texte zu lang')
return createDocument(config.collections.featureConsents, {
...daten,
fassungen: fassungenJson,
texte: texteJson,
textHash: sha256(texteJson),
ip: kurz(ip, MAX_IP),
userAgent: kurz(userAgent, MAX_USER_AGENT),
konto: kurz(konto, MAX_KONTO),
})
}
/** Neueste Zustimmung (nach acceptedAt) dieses Kunden zu dieser Funktion im Projekt, sonst null. */
export async function letzteZustimmung({ customerId, projectId, featureKey } = {}) {
const vollstaendig = [customerId, projectId, featureKey].every((value) => typeof value === 'string' && value !== '')
if (!vollstaendig) return null
const [doc] = await listDocuments(config.collections.featureConsents, [
Query.equal('customerId', customerId),
Query.equal('projectId', projectId),
Query.equal('featureKey', featureKey),
Query.orderDesc('acceptedAt'),
Query.limit(1),
])
return doc || null
}
/**
* Nachweis zu genau dieser Stripe-Sitzung, sonst null. Eine leere Sitzung fragt nicht ab: Nachweise ohne Sitzung (Aktivierung
* durch das Team) tragen '' und wuerden sonst ueber idx_session getroffen. Gibt es mehrere Nachweise zur Sitzung (ein
* wiederholtes Speichern), gilt der erste, denn der Klick, der die Sitzung ausgeloest hat, ist der Anker der Widerrufsfrist.
* Lesefehler werden weitergereicht.
*/
export async function zustimmungFuerSitzung(stripeSessionId) {
if (typeof stripeSessionId !== 'string' || !stripeSessionId.trim()) return null
const docs = await listDocuments(config.collections.featureConsents, [Query.equal('stripeSessionId', stripeSessionId), Query.limit(SITZUNG_SEITE)])
if (!docs.length) return null
return [...docs].sort((a, b) => String(a.acceptedAt).localeCompare(String(b.acceptedAt)))[0]
}
/** Dokument mit geparsten Feldern: fassungen als Objekt, texte als Array (das Dokument selbst bleibt unveraendert). */
export function leseZustimmung(doc) {
return { ...doc, fassungen: JSON.parse(doc.fassungen), texte: JSON.parse(doc.texte) }
}
/** Fassungen, die der Kunde mit der Bestellung bestaetigt: die version jedes Registerfelds, das es gibt. */
export function zustimmungsfassungen(feature) {
const fassungen = { zustimmung: feature.consent.version }
for (const name of ['leistungsbeschreibung', 'avv', 'widerruf']) {
if (feature[name]?.version) fassungen[name] = feature[name].version
}
return fassungen
}
/**
* Texte, die der Kunde bei der Bestellung sieht, in Anzeigereihenfolge: alle consent.items, dann (falls uebergeben) der
* gezeigte Belehrungstext samt Muster-Formular, zuletzt der Knopf.
* @param {object} feature Registereintrag
* @param {{ belehrung?: string | null }} [optionen]
* @returns {{ id: string, text: string, haken: boolean }[]}
*/
export function nachweisTexte(feature, { belehrung = null } = {}) {
const texte = feature.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) }))
if (belehrung !== null && belehrung !== undefined) {
if (typeof belehrung !== 'string' || !belehrung) throw new Error('Nachweistexte: Belehrung muss ein nichtleerer Text sein')
texte.push({ id: 'belehrung', text: belehrung, haken: false })
}
texte.push({ id: 'knopf', text: feature.consent.knopf, haken: false })
return texte
}
+21 -1
View File
@@ -5,6 +5,7 @@ import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { featureConfig, featureKeys } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = featureKeys()
@@ -187,9 +188,26 @@ export async function backfillInvoiceLinks(customerId) {
}
}
/**
* Zeitpunkt des Vertragsschlusses einer bezahlten Funktion (Anker der Widerrufsfrist): Zeit des Klicks aus dem Nachweis
* zur bezahlten Stripe-Sitzung, sonst das Zustimmungsdatum am Dokument (Altbestand), sonst orderedAt. Eine spaetere,
* unbezahlte Zustimmung (andere Sitzung) zaehlt nie. Die Widerrufspruefung darf an der Nachweis-Collection nicht
* scheitern: ein Lesefehler faellt auf orderedAt zurueck (mit Log).
*/
async function vertragAt(doc) {
try {
const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId)
if (nachweis?.acceptedAt) return nachweis.acceptedAt
} catch (err) {
console.warn(`[funktionen] Zustimmungsnachweis zu Sitzung ${doc.stripeSessionId} nicht lesbar, Fristanker ist orderedAt:`, err.message)
return doc.orderedAt || ''
}
return doc.consentAt || doc.orderedAt || ''
}
/**
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht.
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. vertragAt ist der Anker der Widerrufsfrist.
*/
export async function paidFeatureOrders(customerId) {
const docs = await listDocuments(config.collections.portalFeatures, [
@@ -200,9 +218,11 @@ export async function paidFeatureOrders(customerId) {
const result = []
for (const doc of paid) {
result.push({
featureKey: doc.featureKey || '',
projekt: await projectName(doc.projectId).catch(() => ''),
state: doc.state,
orderedAt: doc.orderedAt || '',
vertragAt: await vertragAt(doc),
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
})
}
+321
View File
@@ -0,0 +1,321 @@
import { test, after, beforeEach } 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'
// Zustimmungsnachweis je Funktion (server/services/featureConsents.js, Collection featureConsents):
// nur anfuegen und lesen. Die Umgebung steht vor dem ersten Import von server/config.js.
const aw = await startAppwriteMock({ schema: { featureConsents: schemaKeys('featureConsents') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const consents = await import('../server/services/featureConsents.js')
const { featureConfig } = await import('../server/services/featureRegistry.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { paidFeatureOrders } = await import('../server/services/siteFeatures.js')
const { speichereZustimmung, letzteZustimmung, zustimmungFuerSitzung, leseZustimmung, texteHash, zustimmungsfassungen, nachweisTexte } = consents
const COLLECTION = config.collections.featureConsents
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
const TEXTE = [
{ id: 'unterlagen', text: 'Ich stimme der Leistungsbeschreibung zu.', haken: true },
{ id: 'beginn', text: 'Ich verlange den Beginn vor Ablauf der Frist.', haken: true },
{ id: 'knopf', text: 'Zahlungspflichtig bestellen · 149 €', haken: false },
]
const FASSUNGEN = { zustimmung: 'V1', leistungsbeschreibung: 'V1', avv: 'V1' }
function eintrag(patch = {}) {
return {
customerId: 'c1',
projectId: 'p1',
featureKey: 'termine',
stripeSessionId: 'cs_test_1',
fassungen: FASSUNGEN,
texte: TEXTE,
ip: '203.0.113.7',
userAgent: 'Testbrowser/1.0',
konto: 'Kundin <kundin@example.invalid>',
at: '2026-10-03T10:00:00.000Z',
...patch,
}
}
const zeilen = () => [...aw.docs(COLLECTION).values()]
// Jeder Test beginnt mit leerer Collection (der Nachbau liefert aus docs() eine Kopie; geloescht wird ueber die API)
beforeEach(async () => {
for (const id of aw.docs(COLLECTION).keys()) await deleteDocument(COLLECTION, id)
})
test('speichert und liest: Rundlauf mit Fassungen als Objekt, Texten als Array und unabhängig berechnetem Hash', async () => {
const doc = await speichereZustimmung(eintrag())
assert.ok(doc.$id)
assert.equal(doc.customerId, 'c1')
assert.equal(doc.projectId, 'p1')
assert.equal(doc.featureKey, 'termine')
assert.equal(doc.stripeSessionId, 'cs_test_1')
assert.equal(doc.acceptedAt, '2026-10-03T10:00:00.000Z')
assert.equal(doc.ip, '203.0.113.7')
assert.equal(doc.userAgent, 'Testbrowser/1.0')
assert.equal(doc.konto, 'Kundin <kundin@example.invalid>')
// Beides liegt als JSON-Text in der Collection
assert.equal(typeof doc.fassungen, 'string')
assert.equal(typeof doc.texte, 'string')
assert.equal(doc.texte, JSON.stringify(TEXTE))
assert.equal(doc.textHash, sha256(JSON.stringify(TEXTE)))
assert.equal(texteHash(TEXTE), sha256(JSON.stringify(TEXTE)))
const gelesen = leseZustimmung(zeilen()[0])
assert.deepEqual(gelesen.fassungen, FASSUNGEN)
assert.deepEqual(gelesen.texte, TEXTE)
assert.equal(gelesen.textHash, doc.textHash)
assert.equal(gelesen.$id, doc.$id)
assert.equal(gelesen.stripeSessionId, 'cs_test_1')
// leseZustimmung ändert das gespeicherte Dokument nicht
assert.equal(typeof zeilen()[0].texte, 'string')
})
test('speichert den Zeitpunkt aus einem Date-Objekt als ISO-Text', async () => {
const doc = await speichereZustimmung(eintrag({ at: new Date('2026-10-03T12:34:56.789Z') }))
assert.equal(doc.acceptedAt, '2026-10-03T12:34:56.789Z')
})
test('append-only: das Modul exportiert genau die sieben Lese- und Anfügefunktionen', () => {
assert.deepEqual(
Object.keys(consents).sort(),
['leseZustimmung', 'letzteZustimmung', 'nachweisTexte', 'speichereZustimmung', 'texteHash', 'zustimmungFuerSitzung', 'zustimmungsfassungen']
)
assert.equal(Object.keys(consents).filter((name) => /update|delete|remove|aender|loesch/i.test(name)).length, 0)
})
test('neueste Zustimmung: nach acceptedAt, andere Funktion und anderes Projekt stören nicht', async () => {
// Reihenfolge des Anlegens ist absichtlich nicht die Zeitreihenfolge
const spaeter = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
// spätere Einträge, die nicht zählen dürfen
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_n', featureKey: 'news', at: '2026-10-04T09:00:00.000Z' }))
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_p', projectId: 'p2', at: '2026-10-04T10:00:00.000Z' }))
await speichereZustimmung(eintrag({ stripeSessionId: 'cs_c', customerId: 'c2', at: '2026-10-04T11:00:00.000Z' }))
const neueste = await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })
assert.equal(neueste.$id, spaeter.$id)
assert.equal(neueste.stripeSessionId, 'cs_b')
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'news' })).stripeSessionId, 'cs_n')
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p2', featureKey: 'termine' })).stripeSessionId, 'cs_p')
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: 'p9', featureKey: 'termine' }), null)
})
test('neueste Zustimmung: unvollständige Angaben liefern null ohne Abfrage', async () => {
await speichereZustimmung(eintrag())
aw.resetListCalls()
assert.equal(await letzteZustimmung({ customerId: 'c1', projectId: '', featureKey: 'termine' }), null)
assert.equal(await letzteZustimmung({ customerId: 'c1', featureKey: 'termine' }), null)
assert.equal(await letzteZustimmung({}), null)
assert.equal(aw.listCalls(COLLECTION).length, 0)
})
test('zustimmungFuerSitzung: findet den Nachweis genau dieser Sitzung, sonst null; Lesefehler werfen', async () => {
const a = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_a', at: '2026-10-03T09:00:00.000Z' }))
const b = await speichereZustimmung(eintrag({ stripeSessionId: 'cs_b', at: '2026-10-03T12:00:00.000Z' }))
assert.equal((await zustimmungFuerSitzung('cs_a')).$id, a.$id)
assert.equal((await zustimmungFuerSitzung('cs_b')).$id, b.$id)
assert.equal(await zustimmungFuerSitzung('cs_unbekannt'), null)
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
await assert.rejects(() => zustimmungFuerSitzung('cs_a'))
})
test('vollständig oder gar nicht: zu lange Texte werfen 400 und es wird nichts geschrieben', async () => {
const lang = [{ id: 'x', text: 'a'.repeat(20000), haken: true }]
assert.ok(JSON.stringify(lang).length > 20000)
await assert.rejects(() => speichereZustimmung(eintrag({ texte: lang })), (err) => err.status === 400)
assert.equal(zeilen().length, 0)
// genau am Rand wird nichts gekürzt: passt der JSON-Text, kommt er vollständig an
const textLaenge = 20000 - JSON.stringify([{ id: 'x', text: '', haken: true }]).length
const passend = [{ id: 'x', text: 'b'.repeat(textLaenge), haken: true }]
assert.equal(JSON.stringify(passend).length, 20000)
const doc = await speichereZustimmung(eintrag({ texte: passend }))
assert.equal(doc.texte, JSON.stringify(passend))
assert.deepEqual(leseZustimmung(doc).texte, passend)
})
test('vollständig oder gar nicht: zu lange Fassungen, fehlendes oder ungültiges at, leere oder ungültige Texte werfen', async () => {
const vorher = zeilen().length
const bad = async (patch, status = 400) =>
assert.rejects(() => speichereZustimmung(eintrag(patch)), (err) => err.status === status, JSON.stringify(Object.keys(patch)))
await bad({ fassungen: { zustimmung: 'v'.repeat(1000) } })
await bad({ fassungen: null })
await bad({ fassungen: ['V1'] })
await bad({ fassungen: undefined })
await bad({ at: undefined })
await bad({ at: null })
await bad({ at: '' })
await bad({ at: 'gestern' })
await bad({ at: new Date('kaputt') })
await bad({ at: 1759485600000 })
await bad({ texte: [] })
await bad({ texte: undefined })
await bad({ texte: 'Text' })
await bad({ texte: [{ id: 'a', text: '' }] })
await bad({ texte: [{ id: '', text: 'Text' }] })
await bad({ texte: [{ id: 'a', text: 'Text', haken: 'ja' }] })
await bad({ texte: ['Text'] })
await bad({ texte: [null] })
await bad({ customerId: '' })
await bad({ projectId: undefined })
await bad({ featureKey: '' })
await bad({ featureKey: 'x'.repeat(17) })
await bad({ stripeSessionId: 's'.repeat(129) })
assert.equal(zeilen().length, vorher, 'nichts geschrieben')
})
test('IP, User-Agent und Konto werden auf 64, 300 und 255 Zeichen gekürzt', async () => {
const doc = await speichereZustimmung(eintrag({ ip: 'i'.repeat(100), userAgent: 'u'.repeat(400), konto: 'k'.repeat(300) }))
assert.equal(doc.ip, 'i'.repeat(64))
assert.equal(doc.userAgent, 'u'.repeat(300))
assert.equal(doc.konto, 'k'.repeat(255))
// Fehlende Angaben werden leer
const leer = await speichereZustimmung(eintrag({ ip: undefined, userAgent: null, konto: undefined, stripeSessionId: 'cs_leer' }))
assert.equal(leer.ip, '')
assert.equal(leer.userAgent, '')
assert.equal(leer.konto, '')
})
test('Nachweistexte aus dem Register: Haken in Anzeigereihenfolge, zuletzt der Knopf; Belehrung direkt davor', () => {
const termine = featureConfig('termine')
const texte = nachweisTexte(termine)
assert.equal(texte.length, 5)
assert.deepEqual(
texte.map((t) => t.id),
[...termine.consent.items.map((i) => i.id), 'knopf']
)
assert.deepEqual(texte.slice(0, 4), termine.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })))
assert.deepEqual(texte.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
const mit = nachweisTexte(termine, { belehrung: 'Text' })
assert.equal(mit.length, 6)
assert.deepEqual(mit.at(-2), { id: 'belehrung', text: 'Text', haken: false })
assert.deepEqual(mit.at(-1), { id: 'knopf', text: termine.consent.knopf, haken: false })
assert.deepEqual(mit.slice(0, 4), texte.slice(0, 4))
// ohne oder mit null: keine Belehrung
assert.deepEqual(nachweisTexte(termine, { belehrung: null }), texte)
// News: ein Haken und der Knopf
const news = featureConfig('news')
const newsTexte = nachweisTexte(news)
assert.deepEqual(newsTexte.map((t) => t.id), ['beginn', 'knopf'])
assert.equal(newsTexte[0].text, news.consent.items[0].text)
assert.equal(newsTexte[1].text, news.consent.knopf)
// Eine übergebene Belehrung muss ein nichtleerer Text sein (sonst fehlte ein gezeigter Text im Nachweis)
assert.throws(() => nachweisTexte(termine, { belehrung: '' }))
assert.throws(() => nachweisTexte(termine, { belehrung: { text: 'x' } }))
})
test('Fassungen aus dem Register: Zustimmung plus die vorhandenen Fassungsfelder', () => {
const news = featureConfig('news')
assert.deepEqual(zustimmungsfassungen(news), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version })
const termine = featureConfig('termine')
assert.deepEqual(zustimmungsfassungen(termine), {
zustimmung: termine.consent.version,
leistungsbeschreibung: termine.leistungsbeschreibung.version,
avv: termine.avv.version,
})
// widerruf kommt dazu, sobald das Register es führt
const mitWiderruf = { ...termine, widerruf: { version: 'W1', url: '/x' } }
assert.deepEqual(zustimmungsfassungen(mitWiderruf), {
zustimmung: termine.consent.version,
leistungsbeschreibung: termine.leistungsbeschreibung.version,
avv: termine.avv.version,
widerruf: 'W1',
})
// Das Ergebnis passt in den Nachweis
assert.ok(JSON.stringify(zustimmungsfassungen(mitWiderruf)).length <= 1000)
})
test('Staff-Nachweis: ohne Sitzung, IP, User-Agent und Haken gespeichert; zustimmungFuerSitzung("") fragt nicht ab', async () => {
const doc = await speichereZustimmung(
eintrag({
stripeSessionId: '',
ip: '',
userAgent: '',
konto: 'Mitarbeiter: Kenso',
fassungen: { leistungsbeschreibung: 'V1', avv: 'V1' },
texte: [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }],
})
)
assert.equal(doc.stripeSessionId, '')
assert.equal(doc.ip, '')
assert.equal(doc.userAgent, '')
assert.equal(doc.konto, 'Mitarbeiter: Kenso')
assert.deepEqual(leseZustimmung(doc).texte, [{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])
assert.equal(doc.textHash, sha256(JSON.stringify([{ id: 'textform', text: 'Der Kunde hat in Textform zugestimmt.' }])))
// Die Zeile mit leerer Sitzung existiert, darf aber über keine Sitzungsabfrage getroffen werden
assert.equal(zeilen().length, 1)
aw.resetListCalls()
assert.equal(await zustimmungFuerSitzung(''), null)
assert.equal(await zustimmungFuerSitzung(undefined), null)
assert.equal(await zustimmungFuerSitzung(null), null)
assert.equal(await zustimmungFuerSitzung(' '), null)
assert.equal(aw.listCalls(COLLECTION).length, 0, 'keine Listenabfrage an featureConsents')
// und sie steht als neueste Zustimmung des Projekts zur Verfügung
assert.equal((await letzteZustimmung({ customerId: 'c1', projectId: 'p1', featureKey: 'termine' })).$id, doc.$id)
})
test('texteHash: SHA-256 über JSON.stringify der Texte in der gegebenen Reihenfolge', () => {
const umgekehrt = [...TEXTE].reverse()
assert.notEqual(texteHash(umgekehrt), texteHash(TEXTE))
assert.match(texteHash(TEXTE), /^[0-9a-f]{64}$/)
})
test('paidFeatureOrders: featureKey und vertragAt (Nachweis zur bezahlten Sitzung, sonst consentAt, sonst orderedAt)', async () => {
const gebucht = (patch) => ({ customerId: 'c1', enabled: false, unlockedByPurchase: true, ...patch })
aw.seed('portalFeatures', gebucht({ $id: 'f_a', projectId: 'pa', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T08:00:00.000Z', consentAt: '2026-10-01T08:00:00.000Z', stripeSessionId: 'cs_bezahlt' }))
aw.seed('portalFeatures', gebucht({ $id: 'f_b', projectId: 'pb', featureKey: 'news', state: 'active', orderedAt: '2026-09-30T08:00:00.000Z', consentAt: '2026-09-29T08:00:00.000Z', stripeSessionId: 'cs_alt' }))
aw.seed('portalFeatures', gebucht({ $id: 'f_c', projectId: 'pc', featureKey: 'news', state: 'disabled', orderedAt: '2026-09-28T08:00:00.000Z', stripeSessionId: '' }))
aw.seed('portalFeatures', gebucht({ $id: 'f_d', projectId: 'pd', featureKey: 'termine', state: 'ordered', orderedAt: '2026-10-03T09:00:00.000Z', unlockedByPurchase: false, stripeSessionId: 'cs_offen' }))
aw.seed('portalFeatures', gebucht({ $id: 'f_e', customerId: 'c2', projectId: 'pe', featureKey: 'termine', state: 'setup', orderedAt: '2026-10-03T09:00:00.000Z', stripeSessionId: 'cs_fremd' }))
// Klick der bezahlten Sitzung; dazu eine spätere, unbezahlte Zustimmung desselben Kunden/Projekts
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_bezahlt', at: '2026-09-20T10:00:00.000Z' }))
await speichereZustimmung(eintrag({ projectId: 'pa', stripeSessionId: 'cs_zweite', at: '2026-10-03T07:00:00.000Z' }))
try {
aw.resetListCalls()
const orders = await paidFeatureOrders('c1')
assert.deepEqual(
orders.map((o) => [o.featureKey, o.state, o.vertragAt]),
[
['termine', 'setup', '2026-09-20T10:00:00.000Z'],
['news', 'active', '2026-09-29T08:00:00.000Z'],
['news', 'disabled', '2026-09-28T08:00:00.000Z'],
]
)
assert.deepEqual(orders.map((o) => o.orderedAt), ['2026-10-03T08:00:00.000Z', '2026-09-30T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
// Nachweis-Collection nicht lesbar: Rückfall auf orderedAt (nicht auf das Zustimmungsdatum), kein Fehler
const warn = console.warn
console.warn = () => {}
try {
aw.failNext({ collection: COLLECTION, method: 'GET', status: 500 })
const kaputt = await paidFeatureOrders('c1')
assert.deepEqual(kaputt.map((o) => o.vertragAt), ['2026-10-03T08:00:00.000Z', '2026-09-29T08:00:00.000Z', '2026-09-28T08:00:00.000Z'])
} finally {
console.warn = warn
}
} finally {
for (const id of ['f_a', 'f_b', 'f_c', 'f_d', 'f_e']) await deleteDocument(config.collections.portalFeatures, id)
}
})
+122 -1
View File
@@ -16,6 +16,7 @@ const aw = await startAppwriteMock({
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
@@ -531,7 +532,127 @@ test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Fu
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)))
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /KEIN aktives Abo/)
aw.docs('portalFeatures').delete('f_pf1_news')
// Der Nachbau liefert aus docs() eine Kopie: geloescht wird ueber die API, sonst bliebe die Bestellung fuer die naechsten Tests
await deleteDocument('portalFeatures', 'f_pf1_news')
})
/** Zustimmungsnachweis zu einer Sitzung (featureConsents) fuer die Fristanker-Tests. */
function seedZustimmung(id, { projectId = 'pa1', stripeSessionId, acceptedAt, featureKey = 'termine' }) {
aw.seed('featureConsents', {
$id: id,
customerId: 'c1',
projectId,
featureKey,
stripeSessionId,
acceptedAt,
fassungen: '{}',
texte: '[]',
textHash: '',
})
}
/** Bezahlte Funktion mit Zustimmung zur bezahlten Sitzung (vertragAt) und neuerer, unbezahlter Zustimmung derselben Funktion. */
function seedBezahlteBestellung({ klickAt, orderedAt = daysAgo(1) }) {
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
aw.seed('portalFeatures', {
$id: 'f_pa1_termine',
customerId: 'c1',
projectId: 'pa1',
featureKey: 'termine',
state: 'setup',
enabled: false,
unlockedByPurchase: true,
// durch eine Neubestellung ueberschrieben: spaeter als der Klick der bezahlten Sitzung
orderedAt,
stripeSessionId: 'cs_bezahlt',
})
seedZustimmung('fc_bezahlt', { stripeSessionId: 'cs_bezahlt', acceptedAt: klickAt })
// spaetere, unbezahlte Zustimmung desselben Kunden und Projekts (andere Sitzung): verschiebt den Anker nicht
seedZustimmung('fc_offen', { stripeSessionId: 'cs_offen', acceptedAt: daysAgo(0.5) })
}
async function raeumeAnkerTest() {
await deleteDocument('portalFeatures', 'f_pa1_termine')
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
}
test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(13)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /NACH Fristende/)
assert.match(team, /Studio Anker: in Einrichtung/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
await resetSubH({ units: 0 })
const klickAt = daysAgo(20)
seedBezahlteBestellung({ klickAt })
try {
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
assert.match(team, /NACH Fristende/)
assert.doesNotMatch(team, /RECHTZEITIG/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
await resetSubH({ units: 0 })
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
try {
// consentAt (Altbestand, News) vor orderedAt
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
assert.match(t09().text, /NACH Fristende/)
// weder Nachweis noch consentAt: orderedAt
mails.sent.length = 0
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
assert.match(t09().text, /RECHTZEITIG/)
} finally {
await raeumeAnkerTest()
}
})
test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
await resetSubH({ units: 0 })
const orderedAt = daysAgo(2)
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
const warn = console.warn
const warnungen = []
console.warn = (...args) => warnungen.push(args.join(' '))
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const team = t09().text
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
assert.match(team, /RECHTZEITIG/)
assert.ok(warnungen.some((w) => w.includes('Zustimmungsnachweis')), 'Rueckfall wird geloggt')
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
} finally {
console.warn = warn
await raeumeAnkerTest()
}
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {