diff --git a/server/routes/takeoverCredits.js b/server/routes/takeoverCredits.js index a97917e..5170052 100644 --- a/server/routes/takeoverCredits.js +++ b/server/routes/takeoverCredits.js @@ -4,6 +4,9 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' import { signFileLink } from '../services/signedLinks.js' import { withLocks } from '../services/keyedLock.js' +import { nachweisTexte } from '../services/bestelltexte.js' +import { kundenartWirksam } from '../services/kundenart.js' +import { fassungDerAnfrage, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js' import { answerQuestion, applyTakeoverCredit, @@ -83,6 +86,20 @@ router.post('/', async (req, res) => { const input = validateRequestInput(req.body || {}) const uploads = parseUploads(req.body.files, { required: true }) const customer = await getDocument(config.collections.customers, customerId) + // Fassung 6 (neue Ansicht, body.termsVersion = config.abo.termsVersionNeu): Satz und Knopf werden als Nachweis gespeichert, bevor der + // Antrag entsteht; die Kundenart kommt aus dem Kundendokument, nie aus dem Body. Ohne Fassung 6 bleibt alles wie bisher. + if (fassungDerAnfrage(req, req.body.termsVersion) === 'neu') { + await speichereNachweisFuerRoute({ + customerId, + art: 'gutschrift', + bezug: '', + texte: nachweisTexte('gutschrift', {}), + fassungen: { zustimmung: config.takeover.conditionsVersion }, + kundenart: kundenartWirksam(customer), + req, + at: new Date().toISOString(), + }) + } const doc = await createTakeoverRequest({ customer, input, uploads, consentIp: req.ip }) return res.status(201).json({ request: customerView(doc) }) } catch (err) { diff --git a/test/knBestellQuelltext.test.js b/test/knBestellQuelltext.test.js new file mode 100644 index 0000000..116d5c1 --- /dev/null +++ b/test/knBestellQuelltext.test.js @@ -0,0 +1,28 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +// Review Focus 1 als Quelltext-Pin: Die Kundenart und der Beginn der Bestellung kommen nie aus dem Body der Anfrage +const quelle = (datei) => readFileSync(new URL(`../${datei}`, import.meta.url), 'utf8') + +test('Server-Routen lesen die Kundenart nie aus dem Body', () => { + for (const datei of ['server/routes/abo.js', 'server/routes/takeoverCredits.js']) { + const q = quelle(datei) + assert.doesNotMatch(q, /\b(?:req\.)?body\??\.(kundenart|unternehmer)\b/, datei) + assert.doesNotMatch(q, /\{[^}]*\b(kundenart|unternehmer)\b[^}]*\}\s*=\s*(?:req\.)?body/, datei) + assert.doesNotMatch(q, /\{[^}]*\b(kundenart|unternehmer)\b[^}]*\}\s*=\s*req\.body/, datei) + } +}) + +test('Kundenart stammt aus kundenartWirksam, nicht aus Antwort-Daten des Clients', () => { + assert.match(quelle('server/routes/abo.js'), /kundenartWirksam\(/) + assert.match(quelle('server/routes/takeoverCredits.js'), /kundenartWirksam\(/) +}) + +test('Fassung 6 liest startChoice nur im alten Weg (Fassung 5)', () => { + const q = quelle('server/routes/abo.js') + const checkout = q.slice(q.indexOf("router.post('/checkout'"), q.indexOf("router.post('/portal'")) + // in Fassung 6 bestimmt allein beginnWahl den Beginn; startChoice fließt nur über den alten Zweig ein + assert.match(checkout, /const beginnWahl = sechs \? \(kundenart === 'unternehmen' \|\| earlyStart === true \? 'sofort' : 'nach_frist'\) : startChoice/) + assert.doesNotMatch(checkout, /startChoice:\s*startChoice/) +}) diff --git a/test/knGutschriftFassung6.test.js b/test/knGutschriftFassung6.test.js new file mode 100644 index 0000000..c0fc8db --- /dev/null +++ b/test/knGutschriftFassung6.test.js @@ -0,0 +1,97 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Antrag auf Übernahmegutschrift in Fassung 6: Nachweis mit dem Satz und dem Knopf vor dem Antrag; Bedingungsversion unverändert +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + featureConsents: schemaKeys('featureConsents'), + takeoverCredits: schemaKeys('takeoverCredits'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const uploadDir = await mkdtemp(join(process.env.TMPDIR || tmpdir(), 'kn-takeover-')) +Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid', TAKEOVER_UPLOAD_DIR: uploadDir }) +const { config } = await import('../server/config.js') +const { startTestApp, post } = await import('./support/testApp.js') +const { customerCookie, customers, kundenansichtCookie, seedFixtures } = await import('./support/fixtures.js') +const { default: takeoverRoutes } = await import('../server/routes/takeoverCredits.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { nachweisTexte, FEHLER_NACHWEIS } = await import('../server/services/bestelltexte.js') + +const app = await startTestApp({ '/api/abo/takeover-credits': takeoverRoutes }) +setMailTransportForTests(mailSink()) +after(async () => { + setMailTransportForTests(null) + await Promise.all([app.close(), aw.close(), st.close()]) + await rm(uploadDir, { recursive: true, force: true }) // nur das eigene Verzeichnis dieses Tests +}) +seedFixtures(aw) +config.takeover.maxOpenPerCustomer = 100 // die Grenze offener Anträge ist nicht Gegenstand dieses Tests + +const URL = '/api/abo/takeover-credits' +const setzeKundenart = (id, wert) => aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) }) +const antrag = (extra = {}) => ({ + services: ['hosting'], provider: 'IONOS', domain: 'beispiel.invalid', invoiceAmount: '119.88', billingMonths: 12, + paidUntil: new Date(Date.now() + 200 * 86400000).toISOString().slice(0, 10), message: 'Der Vertrag dort läuft noch bis Ende März.', + email: 'k@example.invalid', consent: true, + files: [{ name: 'rechnung.pdf', data: Buffer.from('%PDF-1.4 test').toString('base64') }], ...extra, +}) +const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'gutschrift') +const anzahl = () => aw.docs('takeoverCredits').size + +test('Fassung 6: Nachweis mit dem Satz und dem Knopf, Kundenart aus dem Dokument, Bedingungsversion unverändert', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + const res = await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu, kundenart: 'privat', unternehmer: false }), { cookie: customerCookie() }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + const [z] = zeilen().slice(n0) + assert.deepEqual([z.kundenart, z.projectId, z.stripeSessionId, z.customerId], ['unternehmen', '', '', 'c1']) + assert.deepEqual(JSON.parse(z.texte), nachweisTexte('gutschrift', {})) + assert.deepEqual(JSON.parse(z.fassungen), { zustimmung: '2026-09-30', bezug: '', wortlaut: 'entwurf' }) + assert.equal(config.takeover.conditionsVersion, '2026-09-30') + assert.equal(aw.docs('takeoverCredits').get(res.body.request.id).conditionsVersion, '2026-09-30') +}) + +test('Privat und leer: Nachweis mit privat, nie mit dem Wert aus dem Body', async () => { + for (const art of ['privat', undefined, 'firma']) { + setzeKundenart('c1', art) + const res = await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu, kundenart: 'unternehmen', unternehmer: true }), { cookie: customerCookie() }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(zeilen().at(-1).kundenart, 'privat', String(art)) + } +}) + +test('Ohne Fassung 6 kein Nachweis; ungültige Eingabe und Nachweisfehler legen keinen Antrag an', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + assert.equal((await post(URL, antrag(), { cookie: customerCookie() })).status, 201) + assert.equal(zeilen().length, n0) + assert.equal((await post(URL, antrag({ termsVersion: config.abo.termsVersion }), { cookie: customerCookie() })).status, 201) + assert.equal(zeilen().length, n0) + const a1 = anzahl() + assert.equal((await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu, provider: '' }), { cookie: customerCookie() })).status, 400) + aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }) + const f = await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu }), { cookie: customerCookie() }) + assert.deepEqual([f.status, f.body.error, anzahl(), zeilen().length], [500, FEHLER_NACHWEIS, a1, n0]) + const fremd = await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu }), { cookie: customerCookie('c2') }) + assert.equal(fremd.status, 201) // c2 ist kein Pilot: Fassung 6 zählt nicht, der Antrag läuft auf dem alten Weg + assert.equal(zeilen().length, n0) +}) + +test('Mitarbeiter-Kundenansicht stellt keinen Antrag und schreibt keinen Nachweis', async () => { + const n0 = zeilen().length + const a0 = anzahl() + const r = await post(URL, antrag({ termsVersion: config.abo.termsVersionNeu }), { cookie: kundenansichtCookie({ customerId: 'c1' }) }) + assert.deepEqual([r.status, zeilen().length, anzahl()], [403, n0, a0]) +})