Kundenansicht neu P7: Fassungsweiche und Vertragsnachweis (V12)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
71b2543e95
commit
4a3b0030d2
2 files changed
+214
No files matched your search
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user