From 4a3b0030d2951880ae5fb91a13181e9b02879e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:59:06 +0000 Subject: [PATCH] Kundenansicht neu P7: Fassungsweiche und Vertragsnachweis (V12) Co-Authored-By: Claude Sonnet 5.5 --- server/services/vertragsNachweis.js | 114 ++++++++++++++++++++++++++++ test/knVertragsNachweis.test.js | 100 ++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 server/services/vertragsNachweis.js create mode 100644 test/knVertragsNachweis.test.js diff --git a/server/services/vertragsNachweis.js b/server/services/vertragsNachweis.js new file mode 100644 index 0000000..d8b4e9d --- /dev/null +++ b/server/services/vertragsNachweis.js @@ -0,0 +1,114 @@ +import { config } from '../config.js' +import { createDocument } from './appwriteAdmin.js' +import { actingPerson, texteHash } from './featureConsents.js' +import { neueAnsicht } from './kundenansicht/schalter.js' +import { KUNDENARTEN } from './kundenart.js' +import { FEHLER_NACHWEIS, WORTLAUT_STATUS } from './bestelltexte.js' + +/** + * Weiche der Fassungen und Nachweis der Bestellseiten (Kundenansicht neu, P7, V12). + * + * fassungDerAnfrage entscheidet, ob eine Anfrage die Fassung 6 (neue Ansicht) oder die bisherige Fassung meint; alle Routen + * verzweigen nur bei 'neu'. speichereVertragsNachweis schreibt den angezeigten Wortlaut als Zeile in featureConsents (ohne Projekt), + * bevor die unumkehrbare Handlung geschieht (Stripe-Sitzung, Wechsel, Speicheraenderung, Antrag). + */ + +const ARTEN = ['abo', 'wechsel', 'speicher', 'gutschrift'] + +// Grenzen der Collection featureConsents (wie featureConsents.js) +const MAX_ID = 36 +const MAX_FASSUNGEN = 1000 +const MAX_TEXTE = 20000 +const MAX_IP = 64 +const MAX_USER_AGENT = 300 +const MAX_KONTO = 255 + +const nichtLeer = (wert) => typeof wert === 'string' && wert.trim() !== '' + +/** + * 'alt' | 'neu' | null. 'neu': termsVersion ist nichtleer, gleich config.abo.termsVersionNeu UND die Anfrage sieht die neue Ansicht. + * 'alt': termsVersion ist gleich config.abo.termsVersion. Alles andere (auch eine fehlende Neu-Fassung) ist null. Die Neu-Fassung + * wird zuerst geprueft: bei gleichen Werten gilt neueAnsicht(req) ? 'neu' : null (P10 aendert hier nichts). + */ +export function fassungDerAnfrage(req, termsVersion) { + if (!nichtLeer(termsVersion)) return null + if (istNeueFassung(termsVersion)) return neueAnsicht(req) ? 'neu' : null + return termsVersion === config.abo.termsVersion ? 'alt' : null +} + +/** Nichtleer und gleich config.abo.termsVersionNeu (ohne Sitzungsbezug; fuer Mails und Ereignisse). */ +export function istNeueFassung(termsVersion) { + return nichtLeer(termsVersion) && nichtLeer(config.abo.termsVersionNeu) && termsVersion === config.abo.termsVersionNeu +} + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +const kurz = (wert, max) => String(wert ?? '').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, 'Vertragsnachweis: Zeitpunkt fehlt oder ist ungültig') + return new Date(ms).toISOString() +} + +function pruefeTexte(texte) { + if (!Array.isArray(texte) || texte.length === 0) throw httpError(400, 'Vertragsnachweis: Texte fehlen') + for (const eintrag of texte) { + const ok = + eintrag !== null && + typeof eintrag === 'object' && + !Array.isArray(eintrag) && + nichtLeer(eintrag.id) && + nichtLeer(eintrag.text) && + (eintrag.haken === undefined || typeof eintrag.haken === 'boolean') + if (!ok) throw httpError(400, 'Vertragsnachweis: Text ungültig') + } +} + +/** + * Nachweis der Bestellseiten anlegen. featureKey ist die Art, projectId und stripeSessionId bleiben leer (der Nachweis entsteht vor der + * Handlung; die Zuordnung laeuft ueber Zeit, Konto und bezug). fassungen bekommt bezug und den Status des Wortlauts dazu. + * Pruefungen vor dem Schreiben (400, nichts geschrieben); jeder Fehler beim Schreiben wird zu einem 500 mit FEHLER_NACHWEIS, die Ursache + * steht nur im Log. + * @param {{ customerId: string, art: 'abo'|'wechsel'|'speicher'|'gutschrift', bezug?: string, texte: Array<{ id: string, text: string, haken?: boolean }>, + * fassungen: object, kundenart: 'privat'|'unternehmen', req: object, at: string|Date }} p + * @returns {Promise} das angelegte Dokument + */ +export async function speichereVertragsNachweis({ customerId, art, bezug = '', texte, fassungen, kundenart, req, at } = {}) { + if (!ARTEN.includes(art)) throw httpError(400, 'Vertragsnachweis: Art ungültig') + if (!nichtLeer(customerId) || customerId.length > MAX_ID) throw httpError(400, 'Vertragsnachweis: Kunde fehlt oder ist ungültig') + if (!KUNDENARTEN.includes(kundenart)) throw httpError(400, 'Vertragsnachweis: Kundenart ungültig') + if (typeof bezug !== 'string') throw httpError(400, 'Vertragsnachweis: Bezug ungültig') + if (fassungen === null || typeof fassungen !== 'object' || Array.isArray(fassungen)) throw httpError(400, 'Vertragsnachweis: Fassungen fehlen') + pruefeTexte(texte) + const acceptedAt = zeitpunkt(at) + const fassungenJson = JSON.stringify({ ...fassungen, bezug, wortlaut: WORTLAUT_STATUS }) + const texteJson = JSON.stringify(texte) + if (fassungenJson.length > MAX_FASSUNGEN) throw httpError(400, 'Vertragsnachweis: Fassungen zu lang') + if (texteJson.length > MAX_TEXTE) throw httpError(400, 'Vertragsnachweis: Texte zu lang') + try { + const person = actingPerson(req.session) + const konto = person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '' + return await createDocument(config.collections.featureConsents, { + customerId, + projectId: '', + featureKey: art, + stripeSessionId: '', + acceptedAt, + kundenart, + fassungen: fassungenJson, + texte: texteJson, + textHash: texteHash(texte), + ip: kurz(req.ip, MAX_IP), + userAgent: kurz(req.get('user-agent'), MAX_USER_AGENT), + konto: kurz(konto, MAX_KONTO), + }) + } catch (err) { + console.error(`[vertragsnachweis] ${art} nicht gespeichert: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim()) + throw Object.assign(new Error(FEHLER_NACHWEIS), { status: 500 }) + } +} diff --git a/test/knVertragsNachweis.test.js b/test/knVertragsNachweis.test.js new file mode 100644 index 0000000..4356b7e --- /dev/null +++ b/test/knVertragsNachweis.test.js @@ -0,0 +1,100 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Fassungsweiche (V12) und Vertragsnachweis: eine Zeile in featureConsents ohne Projekt, mit Kundenart, Hash und Konto +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 }) +Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'k3@example.invalid' }) +const { config } = await import('../server/config.js') +const { fassungDerAnfrage, istNeueFassung, speichereVertragsNachweis } = await import('../server/services/vertragsNachweis.js') +const { nachweisTexte, FEHLER_NACHWEIS } = await import('../server/services/bestelltexte.js') +const { texteHash } = await import('../server/services/featureConsents.js') + +const req = (email = 'k3@example.invalid') => ({ + ip: '203.0.113.9', + get: (h) => (h === 'user-agent' ? 'UA/1' : ''), + session: { customerId: 'c3', appwriteUserId: 'u3', name: 'Kunde Drei', email }, +}) + +test('fassungDerAnfrage: alt, neu, null; fehlende Neu-Fassung ist nie neu', () => { + const NEU = config.abo.termsVersionNeu + const modus = config.kundenansichtNeu.mode // P8 setzt die Neu-Fassung, der Test liest sie nur + try { + assert.equal(fassungDerAnfrage(req('fremd@example.invalid'), config.abo.termsVersion), 'alt') + assert.equal(fassungDerAnfrage(req('k3@example.invalid'), NEU), 'neu') + assert.equal(fassungDerAnfrage(req('fremd@example.invalid'), NEU), null) // nicht Pilot + assert.equal(fassungDerAnfrage(req('k3@example.invalid'), '2026-09-25-v3'), null) + for (const v of [undefined, null, '', 6, {}]) assert.equal(fassungDerAnfrage(req('k3@example.invalid'), v), null) + config.abo.termsVersionNeu = undefined + assert.equal(fassungDerAnfrage(req('k3@example.invalid'), undefined), null) + config.abo.termsVersionNeu = NEU + config.kundenansichtNeu.mode = 'off' + assert.equal(fassungDerAnfrage(req('k3@example.invalid'), NEU), null) // off schlägt die Liste + assert.deepEqual([istNeueFassung(NEU), istNeueFassung(''), istNeueFassung(undefined), istNeueFassung(config.abo.termsVersion)], [true, false, false, false]) + // gleiche Werte (nach P10): nur noch mit neuer Ansicht 'neu', sonst null + config.kundenansichtNeu.mode = 'pilot' + config.abo.termsVersionNeu = config.abo.termsVersion + assert.equal(fassungDerAnfrage(req('k3@example.invalid'), config.abo.termsVersion), 'neu') + assert.equal(fassungDerAnfrage(req('fremd@example.invalid'), config.abo.termsVersion), null) + // eine Sitzung ohne Konto-Kennung ist keine Kunden-Sitzung (P0) + config.abo.termsVersionNeu = NEU + const ohneKonto = req(); delete ohneKonto.session.appwriteUserId + assert.equal(fassungDerAnfrage(ohneKonto, NEU), null) + } finally { + config.abo.termsVersionNeu = NEU + config.kundenansichtNeu.mode = modus + } +}) + +test('speichereVertragsNachweis schreibt eine Zeile ohne Projekt, mit Kundenart, Hash und Konto', async () => { + const FASSUNG = config.abo.termsVersionNeu + const texte = nachweisTexte('abo', { kundenart: 'privat', fassung: FASSUNG, bestellung: 'Zeile', beginnGehakt: true }) + const d = await speichereVertragsNachweis({ customerId: 'c3', art: 'abo', bezug: 'wartung', texte, fassungen: { zustimmung: FASSUNG }, kundenart: 'privat', req: req('k3@example.invalid'), at: '2026-10-17T08:00:00.000Z' }) + assert.deepEqual([d.featureKey, d.projectId, d.stripeSessionId, d.kundenart, d.konto, d.ip, d.userAgent], ['abo', '', '', 'privat', 'Kunde Drei ', '203.0.113.9', 'UA/1']) + assert.deepEqual(JSON.parse(d.fassungen), { zustimmung: FASSUNG, bezug: 'wartung', wortlaut: 'entwurf' }) + assert.deepEqual(JSON.parse(d.texte), texte) + assert.equal(d.textHash, texteHash(texte)) + assert.equal(d.acceptedAt, '2026-10-17T08:00:00.000Z') + assert.equal(aw.docs('featureConsents').get(d.$id).customerId, 'c3') +}) + +test('Mitarbeiter-Kundenansicht: das Konto ist die handelnde Person, nicht der Kunde', async () => { + const r = req() + r.session.adminName = 'Team Mitglied' + r.session.adminEmail = 'team@example.invalid' + const d = await speichereVertragsNachweis({ customerId: 'c3', art: 'wechsel', texte: nachweisTexte('gutschrift', {}), fassungen: {}, kundenart: 'unternehmen', req: r, at: '2026-10-17T08:00:00.000Z' }) + assert.equal(d.konto, 'Team Mitglied ') + assert.deepEqual([d.kundenart, JSON.parse(d.fassungen).bezug], ['unternehmen', '']) +}) + +test('Ungültiges wird vor dem Schreiben mit 400 abgelehnt, nie als privat ergänzt', async () => { + const vorher = aw.docs('featureConsents').size + const gut = { customerId: 'c3', art: 'abo', texte: [{ id: 'knopf', text: 'Zahlungspflichtig bestellen', haken: false }], fassungen: {}, kundenart: 'privat', req: req(), at: '2026-10-17T08:00:00.000Z' } + for (const schlecht of [ + { art: 'feature' }, { art: undefined }, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: undefined }, + { texte: [] }, { texte: undefined }, { texte: [{ id: 'x', text: '' }] }, { texte: [{ id: '', text: 'a' }] }, { texte: [{ id: 'x', text: 'a', haken: 'ja' }] }, + { texte: [{ id: 'x', text: 'a'.repeat(20001) }] }, { fassungen: { zustimmung: 'a'.repeat(1001) } }, { fassungen: null }, + { customerId: '' }, { customerId: undefined }, { at: 'gestern' }, { at: undefined }, + ]) { + await assert.rejects(speichereVertragsNachweis({ ...gut, ...schlecht }), (e) => e.status === 400, JSON.stringify(Object.keys(schlecht))) + } + assert.equal(aw.docs('featureConsents').size, vorher) + for (const art of ['abo', 'wechsel', 'speicher', 'gutschrift']) await speichereVertragsNachweis({ ...gut, art }) + assert.equal(aw.docs('featureConsents').size, vorher + 4) +}) + +test('Appwrite-Fehler: 500 mit dem festen Text, keine Ursache nach außen', async () => { + const vorher = aw.docs('featureConsents').size + aw.failNext({ collection: config.collections.featureConsents, method: 'POST', status: 500, message: 'geheimer Appwrite-Text' }) + await assert.rejects( + speichereVertragsNachweis({ customerId: 'c3', art: 'gutschrift', texte: nachweisTexte('gutschrift', {}), fassungen: {}, kundenart: 'unternehmen', req: req(), at: '2026-10-17T08:00:00.000Z' }), + (e) => e.status === 500 && e.message === FEHLER_NACHWEIS && !JSON.stringify(e).includes('geheimer'), + ) + assert.equal(aw.docs('featureConsents').size, vorher) +})