diff --git a/server/routes/abo.js b/server/routes/abo.js index e32b6fb..62fcb42 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -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 } } diff --git a/server/services/featureConsents.js b/server/services/featureConsents.js new file mode 100644 index 0000000..875f733 --- /dev/null +++ b/server/services/featureConsents.js @@ -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} 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 +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 9f0d2ae..f161bfb 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -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, }) } diff --git a/test/featureConsents.test.js b/test/featureConsents.test.js new file mode 100644 index 0000000..543b228 --- /dev/null +++ b/test/featureConsents.test.js @@ -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 ', + 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 ') + // 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) + } +}) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 89e6ee3..76fcce9 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -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 () => {