Kundenansicht neu P7: Fassungsweiche und Vertragsnachweis (V12)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 19:59:06 +00:00
1 parent 71b2543e95
commit 4a3b0030d2
2 files changed
+214

No files matched your search

+114
View File
@@ -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<object>} 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 })
}
}
+100
View File
@@ -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 <k3@example.invalid>', '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 <team@example.invalid>')
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)
})