From 99e1987bc453803cae9ecb82cd99aeeebd7ec117 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:02:04 +0000 Subject: [PATCH] Kundenansicht neu P7: Bestellung Fassung 6 mit Kundenart vom Server und Wortlaut-Nachweis Co-Authored-By: Claude Sonnet 5.5 --- server/routes/abo.js | 85 +++++++++-- server/services/stripeBilling.js | 5 +- server/services/vertragsNachweis.js | 14 ++ test/knCheckoutFassung6.test.js | 224 ++++++++++++++++++++++++++++ test/knVertragsNachweis.test.js | 11 +- 5 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 test/knCheckoutFassung6.test.js diff --git a/server/routes/abo.js b/server/routes/abo.js index 9b7ec0b..a5111d1 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -26,7 +26,9 @@ import { featureConfig } from '../services/featureRegistry.js' import { istAboModell } from '../services/aboModell.js' import { ENTHALTEN_IN, aboModellFuerKunde } from '../services/aboFunktionen.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js' -import { bestelltexteFuerClient } from '../services/bestelltexte.js' +import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js' +import { kundenartWirksam } from '../services/kundenart.js' +import { fassungDerAnfrage, istNeueFassung, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -242,26 +244,51 @@ router.get('/', requireSession, async (req, res) => { } }) -/** Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). */ +/** + * Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). + * Fassung 6 (neue Ansicht, body.termsVersion = config.abo.termsVersionNeu): Die Kundenart kommt allein aus dem Kundendokument + * (kundenartWirksam), nie aus dem Body; Betriebe beginnen sofort ohne Haekchen, Privatkunden nur mit earlyStart === true sofort, sonst + * nach der Widerrufsfrist. Der Nachweis (angezeigter Wortlaut samt Kundenart) wird vor der Stripe-Sitzung geschrieben. Alle anderen + * Anfragen laufen wie bisher (Fassung 5, Body mit startChoice). + */ router.post('/checkout', requireSession, async (req, res) => { try { if (req.session.viewAs) { return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' }) } - const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} + const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart, earlyStart } = req.body || {} if (!PRODUCT_KEYS.includes(productKey)) { return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' }) } + const fassung = fassungDerAnfrage(req, termsVersion) + const sechs = fassung === 'neu' if (acceptTerms !== true) { - return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) + return sechs + ? res.status(400).json({ error: FEHLER_UNVOLLSTAENDIG }) + : res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) } - if (startChoice !== 'sofort' && startChoice !== 'nach_frist') { + // startChoice verlangt nur der alte Weg; eine Bestellung mit Fassung 6 oder aus der neuen Ansicht mit fremder Version bekommt gleich + // den Hinweis auf die geaenderten Bedingungen + const alterWeg = fassung === 'alt' || (fassung === null && !neueAnsicht(req) && !istNeueFassung(termsVersion)) + if (alterWeg && startChoice !== 'sofort' && startChoice !== 'nach_frist') { return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' }) } - if (termsVersion !== config.abo.termsVersion) { - return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) + if (fassung === null) { + return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) + } + const customerId = getSessionCustomerId(req) + let customer + if (sechs) { + // Ein Lesefehler ist ein Serverfehler, nie ein Rueckfall auf eine Kundenart + try { + customer = await getDocument(config.collections.customers, customerId) + } catch (err) { + console.error('[abo] Kunde für die Bestellung nicht lesbar:', err.message) + return res.status(500).json({ error: FEHLER_NACHWEIS }) + } + } else { + customer = await getDocument(config.collections.customers, customerId) } - const customer = await getDocument(config.collections.customers, getSessionCustomerId(req)) // Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat: das offene Angebot fuer // genau dieses Produkt (F32). Gehoert die mitgeschickte Angebots-ID zum jeweils anderen Produkt, // ist die Meldung eindeutiger als das allgemeine "hat sich geändert". @@ -279,25 +306,57 @@ router.post('/checkout', requireSession, async (req, res) => { if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) { return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' }) } + // Beginn: im alten Weg die Wahl des Kunden; in Fassung 6 allein die Kundenart des Servers und earlyStart === true + const kundenart = sechs ? kundenartWirksam(customer) : null + const beginnWahl = sechs ? (kundenart === 'unternehmen' || earlyStart === true ? 'sofort' : 'nach_frist') : startChoice + const haekchenGesetzt = sechs && kundenart !== 'unternehmen' && earlyStart === true // Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht) - const serviceStartAt = startChoice === 'nach_frist' ? lateStartAt() : null + const serviceStartAt = beginnWahl === 'nach_frist' ? lateStartAt() : null if (serviceStartAt && shownLateStart !== serviceStartAt.toISOString()) { return res.status(409).json({ error: 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.' }) } // Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser const now = new Date().toISOString() + const angebot = offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null + if (sechs) { + // Der Wortlaut, den der Kunde gesehen hat, wird gespeichert, bevor Stripe etwas anlegt; scheitert das, passiert nichts + const produkt = productView(productKey) + await speichereNachweisFuerRoute({ + customerId: customer.$id, + art: 'abo', + bezug: productKey, + texte: nachweisTexte('abo', { + kundenart, + fassung: termsVersion, + beginnGehakt: haekchenGesetzt, + bestellung: bestellZeile('abo', { + produkt: productLabel(productKey), + monatCents: produkt.amountCents, + mindestMonate: produkt.minTermMonths, + beginn: beginnWahl, + beginnAm: serviceStartAt?.toISOString(), + angebot: angebot?.text || undefined, + }), + }), + fassungen: { zustimmung: termsVersion }, + kundenart, + req, + at: now, + }) + } const url = await createAboCheckout( customer, { termsAcceptedAt: now, - startChoice, - earlyStartConsentAt: startChoice === 'sofort' ? now : '', + startChoice: beginnWahl, + earlyStartConsentAt: beginnWahl === 'sofort' && (!sechs || haekchenGesetzt) ? now : '', serviceStartAt, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '', + ...(sechs ? { termsVersion } : {}), }, productKey, - offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null + angebot ) return res.json({ url }) } catch (err) { @@ -590,6 +649,8 @@ async function recordAbschluss(sub, session) { amountCents: sub.priceAmount ?? null, checkoutSessionId: session.id || null, ...(sub.offerId ? { offerId: sub.offerId, offerText: sub.offerText || '' } : {}), + // Fassung 6: Sofortbeginn ohne Zeitstempel gibt es nur fuer Betriebe (Grund fuer die Mails und den Nachweis) + ...(istNeueFassung(sub.termsVersion) && sub.startChoice === 'sofort' && !sub.earlyStartConsentAt ? { kundenart: 'unternehmen' } : {}), }, consent: { termsVersion: sub.termsVersion || null, diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 076dc0a..a3167a5 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -574,6 +574,7 @@ function checkoutMessage(product, lateStart, offer) { * Position, Beschreibung, Hinweistext und Testrabatt richten sich nach dem Produkt. * startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung * und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js). + * consent.termsVersion (optional, Fassung 6): die Fassung, der der Kunde zugestimmt hat; ohne sie gilt config.abo.termsVersion. * offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten * bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred). */ @@ -603,8 +604,8 @@ export async function createAboCheckout(customer, consent, productKey, offer = n webklarCustomerId: customer.$id, email: customer.email || '', productKey, - termsVersion: config.abo.termsVersion, - consentTextVersion: config.abo.termsVersion, + termsVersion: consent.termsVersion || config.abo.termsVersion, + consentTextVersion: consent.termsVersion || config.abo.termsVersion, termsAcceptedAt: consent.termsAcceptedAt, startChoice: consent.startChoice, earlyStartConsentAt: consent.earlyStartConsentAt || '', diff --git a/server/services/vertragsNachweis.js b/server/services/vertragsNachweis.js index d8b4e9d..482d643 100644 --- a/server/services/vertragsNachweis.js +++ b/server/services/vertragsNachweis.js @@ -112,3 +112,17 @@ export async function speichereVertragsNachweis({ customerId, art, bezug = '', t throw Object.assign(new Error(FEHLER_NACHWEIS), { status: 500 }) } } + +/** + * Wie speichereVertragsNachweis, fuer die Routen: jeder Fehler (auch eine Pruefung der Eingaben) wird zu einem 500 mit FEHLER_NACHWEIS, + * damit der Kunde nie einen internen Text sieht und die Handlung (Stripe-Sitzung, Wechsel, Speicheraenderung, Antrag) unterbleibt. + */ +export async function speichereNachweisFuerRoute(args) { + try { + return await speichereVertragsNachweis(args) + } catch (err) { + if (err?.status === 500 && err.message === FEHLER_NACHWEIS) throw err + console.error(`[vertragsnachweis] Nachweis abgelehnt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim()) + throw Object.assign(new Error(FEHLER_NACHWEIS), { status: 500 }) + } +} diff --git a/test/knCheckoutFassung6.test.js b/test/knCheckoutFassung6.test.js new file mode 100644 index 0000000..6da8aa4 --- /dev/null +++ b/test/knCheckoutFassung6.test.js @@ -0,0 +1,224 @@ +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 { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Bestellung (POST /api/abo/checkout) in Fassung 6: Kundenart und Beginn kommen vom Server, der Wortlaut wird vor der Stripe-Sitzung gespeichert +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + aboOffers: schemaKeys('aboOffers'), + hourCredits: schemaKeys('hourCredits'), + featureConsents: schemaKeys('featureConsents'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' }) +const { config } = await import('../server/config.js') +const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js') +const { customerCookie, customers, kundenansichtCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { listContractEvents } = await import('../server/services/contractEvents.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { offerText } = await import('../server/services/offers.js') + +const app = await startTestApp({ '/api/abo': aboRoutes }) +setMailTransportForTests(mailSink()) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) + +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) +const setzeKundenart = (id, wert) => aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) }) + +/** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */ +async function checkout(body, { cookie = customerCookie('c3') } = {}) { + const before = st.calls.length + const res = await post('/api/abo/checkout', body, { cookie }) + const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null + return { res, call } +} + +const NEU = () => config.abo.termsVersionNeu +const body = (productKey, extra = {}) => ({ productKey, acceptTerms: true, termsVersion: NEU(), offerId: '', ...extra }) +const meta = (call, key) => call.body[`subscription_data[metadata][${key}]`] +const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'abo') +const ids = (z) => JSON.parse(z.texte).map((t) => t.id) + +test('Betrieb: sofort ohne Häkchen, Fassung 6 in den Metadaten, Nachweis ohne Beginn-Häkchen', async () => { + setzeKundenart('c3', 'unternehmen') + const n0 = zeilen().length + const { res, call } = await checkout(body('wartung')) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(meta(call, 'startChoice'), 'sofort') + assert.ok(!meta(call, 'earlyStartConsentAt')) + assert.deepEqual([meta(call, 'termsVersion'), meta(call, 'consentTextVersion')], [NEU(), NEU()]) + assert.equal(call.body['subscription_data[trial_end]'], undefined) + const [z] = zeilen().slice(n0) + assert.deepEqual([z.customerId, z.projectId, z.kundenart, z.stripeSessionId], ['c3', '', 'unternehmen', '']) + assert.deepEqual(ids(z), ['bestellung', 'linkzeile', 'knopf', 'kundenart']) + assert.deepEqual(JSON.parse(z.fassungen), { zustimmung: NEU(), bezug: 'wartung', wortlaut: 'entwurf' }) + assert.match(JSON.parse(z.texte)[0].text, /^Wartungs-Abo: 50,00 € pro Monat .*Beginn sofort/) +}) + +test('Privat und leer: ohne earlyStart nach der Frist, mit earlyStart sofort mit Zeitstempel und Häkchen im Nachweis', async () => { + for (const art of ['privat', undefined]) { + setzeKundenart('c3', art) + const n0 = zeilen().length + const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) + const frist = await checkout(body('hosting', { lateStartAt })) + assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body)) + assert.equal(meta(frist.call, 'startChoice'), 'nach_frist') + assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) + const sofort = await checkout(body('hosting', { earlyStart: true })) + assert.equal(meta(sofort.call, 'startChoice'), 'sofort') + assert.match(meta(sofort.call, 'earlyStartConsentAt'), /^\d{4}-\d\d-\d\dT/) + const neu = zeilen().slice(n0) + assert.deepEqual(neu.map(ids), [['bestellung', 'linkzeile', 'knopf', 'kundenart'], ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart']]) + assert.deepEqual(neu.map((z) => z.kundenart), ['privat', 'privat']) + assert.match(JSON.parse(neu[0].texte)[0].text, /Beginn nach Ablauf der Widerrufsfrist am \d\d\.\d\d\.\d{4}/) + } +}) + +test('earlyStart zählt nur als Boolean true; veralteter Leistungsbeginn ist 409 ohne Nachweis und Stripe-Aufruf', async () => { + setzeKundenart('c3', 'privat') + const n0 = zeilen().length + const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) + for (const wert of ['ja', 1, 'true', null]) assert.equal(meta((await checkout(body('hosting', { earlyStart: wert, lateStartAt }))).call, 'startChoice'), 'nach_frist', String(wert)) + const alt = await checkout(body('hosting', { lateStartAt: '2026-01-02T05:00:00.000Z' })) + assert.equal(alt.res.status, 409) + assert.equal(alt.call, null) + assert.equal(zeilen().length, n0 + 4) + const ohne = await checkout(body('hosting')) + assert.equal(ohne.res.status, 409, 'ohne gezeigten Leistungsbeginn') + assert.equal(zeilen().length, n0 + 4) +}) + +test('Kundenart und Beginn kommen nie vom Client (Review Focus 1)', async () => { + const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) + setzeKundenart('c3', 'unternehmen') + const b = await checkout(body('wartung', { kundenart: 'privat', unternehmer: false, startChoice: 'nach_frist', earlyStart: true, lateStartAt })) + assert.equal(meta(b.call, 'startChoice'), 'sofort') + assert.ok(!meta(b.call, 'earlyStartConsentAt')) + assert.equal(zeilen().at(-1).kundenart, 'unternehmen') + assert.ok(!ids(zeilen().at(-1)).includes('beginn')) + for (const wert of ['privat', '', 'firma', 'Unternehmen']) { + setzeKundenart('c3', wert === '' ? undefined : wert) + const p = await checkout(body('wartung', { kundenart: 'unternehmen', startChoice: 'sofort', lateStartAt })) + assert.equal(meta(p.call, 'startChoice'), 'nach_frist', wert) + assert.equal(zeilen().at(-1).kundenart, 'privat', wert) + } +}) + +test('Fassungsweiche: Fassung 6 ohne Pilot 409, Fassung 5 aus dem Pilot wie früher, fehlende Version nie neu', async () => { + setzeKundenart('c3', 'unternehmen') + const n0 = zeilen().length + const fremd = await checkout(body('wartung'), { cookie: customerCookie('c2') }) + assert.equal(fremd.res.status, 409) + assert.match(fremd.res.body.error, /Bedingungen/) + assert.equal(fremd.call, null) + const v3 = await checkout(body('wartung', { termsVersion: '2026-09-25-v3' })) + assert.equal(v3.res.status, 409) + assert.equal(v3.res.body.error, 'Die Bedingungen wurden aktualisiert. Bitte lade die Seite neu.') + const alt = await checkout({ productKey: 'wartung', acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '' }) + assert.equal(alt.res.status, 200) + assert.equal(meta(alt.call, 'termsVersion'), config.abo.termsVersion) + assert.equal((await checkout({ productKey: 'wartung', acceptTerms: true, termsVersion: config.abo.termsVersion, offerId: '' })).res.status, 400) // alter Weg verlangt startChoice + const { termsVersion: _t, ...ohne } = body('wartung') + const gemerkt = config.abo.termsVersionNeu + for (const neuWert of [gemerkt, undefined]) { + config.abo.termsVersionNeu = neuWert + assert.equal((await checkout(ohne)).res.status, 409) + } + config.abo.termsVersionNeu = gemerkt + assert.equal(zeilen().length, n0) // weder Fassung 5 noch Fehlerfälle schreiben einen Nachweis +}) + +test('Fehlende Zustimmung in Fassung 6 ist 400 ohne Nachweis und ohne Stripe-Aufruf', async () => { + setzeKundenart('c3', 'unternehmen') + const n0 = zeilen().length + for (const acceptTerms of [false, undefined, 'true', 1]) { + const r = await checkout(body('wartung', { acceptTerms })) + assert.deepEqual([r.res.status, r.res.body.error, r.call], [400, 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.', null], String(acceptTerms)) + } + assert.equal((await checkout(body('gibtEsNicht'))).res.status, 400) + assert.equal(zeilen().length, n0) +}) + +test('Nachweis nicht speicherbar: 500, keine Stripe-Sitzung; Ansichtsmodus 403 ohne Schreiben', async () => { + setzeKundenart('c3', 'unternehmen') + const n0 = zeilen().length + aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }) + const f = await checkout(body('wartung')) + assert.deepEqual([f.res.status, f.res.body.error, f.call], [500, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.', null]) + const v = await checkout(body('wartung'), { cookie: kundenansichtCookie({ customerId: 'c3' }) }) + assert.deepEqual([v.res.status, v.call, zeilen().length], [403, null, n0]) +}) + +test('Kunde nicht lesbar: 500 statt Rückfall auf privat oder Betrieb', async () => { + setzeKundenart('c3', 'unternehmen') + const n0 = zeilen().length + aw.failNext({ collection: config.collections.customers, method: 'GET', status: 500 }) + const r = await checkout(body('wartung')) + assert.equal(r.res.status, 500) + assert.equal(r.call, null) + assert.equal(zeilen().length, n0) +}) + +test('Angebot: Die Zeile im Nachweis nennt das Angebot, das der Server kennt', async () => { + setzeKundenart('c3', 'unternehmen') + const offer = { $id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000, validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '' } + aw.seed('aboOffers', offer) + try { + const { res, call } = await checkout(body('wartung', { offerId: 'o1' })) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(meta(call, 'offerId'), 'o1') + assert.ok(JSON.parse(zeilen().at(-1).texte)[0].text.includes(`individuelles Angebot: ${offerText(offer)}, Beginn sofort`)) + } finally { + await deleteDocument(config.collections.aboOffers, 'o1').catch(() => {}) + } +}) + +// Webhook: Abo-IDs sind 28 Zeichen wie bei Stripe (sub_ + 24), 'abschluss-' + ID wären sonst länger als 36 +const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' +const checkoutCompleted = (subscriptionId, metadata = {}) => ({ + id: `evt_${subscriptionId}`, + object: 'event', + type: 'checkout.session.completed', + created: Math.floor(Date.now() / 1000), + data: { + object: { + id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3', + customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, + metadata: { webklarCustomerId: 'c3', productKey: 'hosting', ...metadata }, + }, + }, +}) +async function cleanupWebhook(subscriptionId) { + for (const e of await listContractEvents('c3')) await deleteDocument(config.collections.contractEvents, e.$id) + await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {}) +} + +test('Webhook: Ereignis abschluss nennt den Grund kundenart nur bei Fassung 6 ohne Zeitstempel', async () => { + const lauf = async (metadata) => { + st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata })) + try { + assert.equal((await postWebhook(checkoutCompleted(SUB_NEW, { productKey: 'wartung' }))).status, 200) + return (await listContractEvents('c3'))[0].details + } finally { + await cleanupWebhook(SUB_NEW) + } + } + assert.equal((await lauf({ termsVersion: NEU(), consentTextVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: '' })).kundenart, 'unternehmen') + assert.equal('kundenart' in (await lauf({ termsVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() })), false) + assert.equal('kundenart' in (await lauf({ termsVersion: NEU(), startChoice: 'nach_frist', earlyStartConsentAt: '' })), false) + assert.equal('kundenart' in (await lauf({ termsVersion: config.abo.termsVersion, startChoice: 'sofort', earlyStartConsentAt: '' })), false) +}) diff --git a/test/knVertragsNachweis.test.js b/test/knVertragsNachweis.test.js index 4356b7e..2ce24d7 100644 --- a/test/knVertragsNachweis.test.js +++ b/test/knVertragsNachweis.test.js @@ -12,7 +12,7 @@ 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 { fassungDerAnfrage, istNeueFassung, speichereVertragsNachweis, speichereNachweisFuerRoute } = await import('../server/services/vertragsNachweis.js') const { nachweisTexte, FEHLER_NACHWEIS } = await import('../server/services/bestelltexte.js') const { texteHash } = await import('../server/services/featureConsents.js') @@ -98,3 +98,12 @@ test('Appwrite-Fehler: 500 mit dem festen Text, keine Ursache nach außen', asyn ) assert.equal(aw.docs('featureConsents').size, vorher) }) + +test('speichereNachweisFuerRoute: auch eine abgelehnte Eingabe ist ein 500 mit dem festen Text, nichts geschrieben', async () => { + const vorher = aw.docs('featureConsents').size + const gut = { customerId: 'c3', art: 'abo', texte: nachweisTexte('gutschrift', {}), fassungen: {}, kundenart: 'privat', req: req(), at: '2026-10-17T08:00:00.000Z' } + await assert.rejects(speichereNachweisFuerRoute({ ...gut, kundenart: '' }), (e) => e.status === 500 && e.message === FEHLER_NACHWEIS) + assert.equal(aw.docs('featureConsents').size, vorher) + assert.equal((await speichereNachweisFuerRoute(gut)).featureKey, 'abo') + assert.equal(aw.docs('featureConsents').size, vorher + 1) +})