From 4e0e42f63330a2c1785f69f6ec1b8cde3ec5b857 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:03:46 +0000 Subject: [PATCH] Kundenansicht neu P7: Wechsel Fassung 6, Betrieb ohne Sofortbeginn-Zeitstempel Co-Authored-By: Claude Sonnet 5.5 --- server/routes/abo.js | 60 +++++++++-- server/services/planSwitch.js | 13 ++- test/knWechselFassung6.test.js | 181 +++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 test/knWechselFassung6.test.js diff --git a/server/routes/abo.js b/server/routes/abo.js index a5111d1..43df9ee 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -26,7 +26,7 @@ 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 { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js' +import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, WECHSEL_GESPERRT, 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' @@ -480,25 +480,69 @@ router.post('/switch', requireSession, async (req, res) => { return res.status(403).json({ error: 'In der Kundenansicht kann nicht gewechselt werden.' }) } const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {} + 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 wechseln.' }) + return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' }) } - if (earlyStart !== true) { - return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' }) + if (!sechs) { + // Fassung 6 aus der alten Ansicht oder fremde Version aus der neuen Ansicht: gleich der Hinweis auf die geaenderten Bedingungen + if (fassung === null && (neueAnsicht(req) || istNeueFassung(termsVersion))) { + return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) + } + if (earlyStart !== true) { + return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' }) + } } - if (!termsVersion || !Number.isInteger(prorationDate) || !Number.isInteger(amountDueCents)) { - return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' }) + if (!termsVersion || !Number.isInteger(prorationDate) || !Number.isInteger(amountDueCents) || (sechs && amountDueCents < 0)) { + return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' }) } - if (termsVersion !== config.abo.termsVersion) { + if (termsVersion !== config.abo.termsVersion && !sechs) { return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) } const customerId = getSessionCustomerId(req) const now = new Date().toISOString() + // Fassung 6: Kundenart allein aus dem Kundendokument (ein Lesefehler ist ein Serverfehler, nie ein Rueckfall); Privatkunden brauchen das Haekchen + let kundenart = null + if (sechs) { + try { + kundenart = kundenartWirksam(await getDocument(config.collections.customers, customerId)) + } catch (err) { + console.error('[abo] Kunde für den Wechsel nicht lesbar:', err.message) + return res.status(500).json({ error: FEHLER_NACHWEIS }) + } + if (kundenart !== 'unternehmen' && earlyStart !== true) return res.status(400).json({ error: WECHSEL_GESPERRT }) + } // Gewuenschte Funktion (nur neue Ansicht): geprueft oder verworfen, der Wechsel laeuft in jedem Fall const funktion = neueAnsicht(req) ? await wechselFunktionAusBody(customerId, req.body?.funktion) : null + if (sechs) { + // Der gezeigte Wortlaut wird gespeichert, bevor Stripe etwas aendert; scheitert das, passiert nichts + await speichereNachweisFuerRoute({ + customerId, + art: 'wechsel', + bezug: 'hosting>wartung', + texte: nachweisTexte('wechsel', { + kundenart, + fassung: termsVersion, + beginnGehakt: kundenart !== 'unternehmen', + bestellung: bestellZeile('wechsel', { heuteCents: amountDueCents, monatCents: config.products.wartung.amountCents, mindestMonate: config.abo.minTermMonths }), + }), + fassungen: { zustimmung: termsVersion }, + kundenart, + req, + at: now, + }) + } const { subscription, paymentUrl } = await switchToWartung( customerId, - { termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' }, + { + termsVersion, + consentAt: now, + earlyStartConsentAt: sechs && kundenart === 'unternehmen' ? '' : now, + ...(sechs ? { kundenart } : {}), + consentIp: req.ip, + consentUserAgent: req.get('user-agent') || '', + }, prorationDate, // Fix 1 (8): der in der Bestelluebersicht gezeigte Betrag; weicht die neue Vorschau ab, 409 { expectedAmountCents: amountDueCents, funktion } diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js index da9b558..9857a9a 100644 --- a/server/services/planSwitch.js +++ b/server/services/planSwitch.js @@ -341,7 +341,8 @@ const DETAILS_MAX = 2000 /** * Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt, - * consentIp, consentUserAgent, earlyStartConsentAt } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16). + * consentIp, consentUserAgent, earlyStartConsentAt, kundenart? } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16); einzige Ausnahme + * kundenart 'unternehmen' (Fassung 6, Betrieb): dann ohne Zeitstempel, der Grund steht als details.kundenart im Ereignis. * prorationDate: Zeitpunkt der Vorschau (previewProblem). Voraussetzungen wie previewSwitch (409). Mit * options.expectedAmountCents (Route: angezeigter Betrag) rechnet der Server die Vorschau nach; weicht der Betrag * ab, 409 ohne Aenderung bei Stripe. @@ -355,7 +356,8 @@ export async function switchToWartung(customerId, consent, prorationDate, { expe if (!consent?.termsVersion || !consent?.consentAt) { throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.') } - if (!consent.earlyStartConsentAt) { + // Fassung 6: Ein Betrieb (consent.kundenart 'unternehmen', vom Server aus dem Kundendokument) braucht kein Haekchen und bekommt keinen Zeitstempel + if (!consent.earlyStartConsentAt && consent.kundenart !== 'unternehmen') { throw httpError(400, 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.') } const problem = previewProblem(prorationDate) @@ -392,7 +394,8 @@ async function performSwitch(customerId, current, consent, prorationDate, expect termStartAt: switchedAt, termsVersion: consent.termsVersion, termsAcceptedAt: consent.consentAt, - earlyStartConsentAt: consent.earlyStartConsentAt, + // leer (Betrieb, Fassung 6): Stripe loescht den Schluessel, ein Betrieb erbt den Zeitstempel des Hosting-Abschlusses nicht + earlyStartConsentAt: consent.earlyStartConsentAt || '', consentIp: String(consent.consentIp || '').slice(0, 64), consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300), switchedFrom: 'hosting', @@ -448,6 +451,8 @@ async function performSwitch(customerId, current, consent, prorationDate, expect previousMetadata: Object.fromEntries( RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)]) ), + // Fassung 6: Grund fuer den fehlenden Zeitstempel (Betrieb) + ...(consent.kundenart === 'unternehmen' ? { kundenart: 'unternehmen' } : {}), } // Gewuenschte Funktion (nur Bequemlichkeit): der Nachweis darf dadurch nie zu gross werden if (funktion && JSON.stringify({ ...details, funktion }).length <= DETAILS_MAX) details.funktion = funktion @@ -512,7 +517,7 @@ async function recordSwitchEvent({ doc, customerId, consent, status, details }) consentAt: consent.consentAt, consentIp: consent.consentIp || null, consentUserAgent: consent.consentUserAgent || null, - earlyStartConsentAt: consent.earlyStartConsentAt, + earlyStartConsentAt: consent.earlyStartConsentAt || '', }, status, }) diff --git a/test/knWechselFassung6.test.js b/test/knWechselFassung6.test.js new file mode 100644 index 0000000..bdd3cd1 --- /dev/null +++ b/test/knWechselFassung6.test.js @@ -0,0 +1,181 @@ +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' +import { mailSink } from './support/mailSink.js' + +// Wechsel Hosting -> Wartung in Fassung 6: Kundenart vom Server, Betrieb ohne Sofortbeginn-Zeitstempel, Nachweis vor dem Wechsel +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + 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, post } = await import('./support/testApp.js') +const { hostingDoc, consent, customerCookie, customers, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { switchToWartung } = await import('../server/services/planSwitch.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 { WECHSEL_GESPERRT, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG } = await import('../server/services/bestelltexte.js') + +const app = await startTestApp({ '/api/abo': aboRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) + +const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } }) +st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents)) +st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents)) +st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) + +const nowUnix = () => Math.floor(Date.now() / 1000) +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) +const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' } +const setzeKundenart = (id, wert) => aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) }) + +async function clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} +/** Hosting-Abo sub_h von c1 in Spiegel und Stripe-Nachbau auf den Ausgangsstand setzen, Ereignisse und Mail-Senke leeren. */ +async function resetHosting() { + await clearEvents('c1') + aw.seed('subscriptions', { ...hostingDoc }) + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', default_payment_method: CARD })) + st.nextSubscriptionUpdate = null + mails.sent.length = 0 +} +const line = (priceId, amount, { proration = false, subscription = 'sub_h', periodStart = null } = {}) => ({ + amount, + quantity: 1, + period: { start: periodStart ?? unix(hostingDoc.startedAt), end: unix(hostingDoc.currentPeriodEnd) }, + pricing: { type: 'price_details', price_details: { price: priceId } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration, subscription } }, +}) +const previewInvoice = (...lines) => ({ object: 'invoice', lines: { object: 'list', data: lines, has_more: false }, amount_due: lines.reduce((sum, l) => sum + l.amount, 0) }) +const switchInvoice = (overrides = {}) => ({ id: 'in_wechsel', object: 'invoice', status: 'paid', amount_due: 4100, billing_reason: 'subscription_update', hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel', ...overrides }) + +const NEU = () => config.abo.termsVersionNeu +const wb = (extra = {}) => ({ acceptTerms: true, termsVersion: NEU(), prorationDate: nowUnix(), amountDueCents: 4100, ...extra }) +const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'wechsel') +const bereit = () => { + st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)) + st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } +} +async function wechsel(b, cookie = customerCookie()) { + await resetHosting() + const vor = st.calls.length + bereit() + const res = await post('/api/abo/switch', b, { cookie }) + return { res, call: st.calls.slice(vor).find((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') || null } +} + +test('Betrieb: Wechsel ohne Häkchen, ohne Zeitstempel, Grund im Ereignis, Nachweis ohne Beginn-Häkchen', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + const { res, call } = await wechsel(wb()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual([call.body['metadata[termsVersion]'], call.body['metadata[earlyStartConsentAt]']], [NEU(), '']) + const [ev] = await listContractEvents('c1') + assert.deepEqual([ev.termsVersion, ev.earlyStartConsentAt, ev.details.kundenart], [NEU(), null, 'unternehmen']) + const [z] = zeilen().slice(n0) + assert.deepEqual([z.kundenart, z.projectId, JSON.parse(z.fassungen).bezug], ['unternehmen', '', 'hosting>wartung']) + assert.deepEqual(JSON.parse(z.texte).map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart']) + assert.match(JSON.parse(z.texte)[0].text, /^Wechsel ins Wartungs-Abo, Beginn sofort\. Heute fällig: 41,00 € /) +}) + +test('Privat und leer: ohne Häkchen 400 mit Sperrtext und ohne Schreiben, mit Häkchen Zeitstempel und Häkchen im Nachweis', async () => { + for (const art of ['privat', undefined]) { + setzeKundenart('c1', art) + const n0 = zeilen().length + for (const earlyStart of [undefined, false, 'ja', 1]) { + const { res, call } = await wechsel(wb(earlyStart === undefined ? {} : { earlyStart })) + assert.deepEqual([res.status, res.body.error, call], [400, WECHSEL_GESPERRT, null]) + } + assert.equal(zeilen().length, n0) + const ok = await wechsel(wb({ earlyStart: true })) + assert.equal(ok.res.status, 200, JSON.stringify(ok.res.body)) + assert.match(ok.call.body['metadata[earlyStartConsentAt]'], /^\d{4}-\d\d-\d\dT/) + const [ev] = await listContractEvents('c1') + assert.equal(ev.earlyStartConsentAt, ev.consentAt) + assert.ok(!('kundenart' in ev.details)) + assert.deepEqual(JSON.parse(zeilen().at(-1).texte).map((t) => t.id), ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart']) + } +}) + +test('Kundenart kommt nie vom Client (Review Focus 1)', async () => { + setzeKundenart('c1', 'unternehmen') + const b = await wechsel(wb({ kundenart: 'privat', earlyStart: true })) + assert.equal(b.call.body['metadata[earlyStartConsentAt]'], '') + assert.equal(zeilen().at(-1).kundenart, 'unternehmen') + setzeKundenart('c1', 'privat') + assert.equal((await wechsel(wb({ kundenart: 'unternehmen' }))).res.status, 400) +}) + +test('Die gemerkte Funktion aus P6 läuft im Fassung-6-Wechsel weiter', async () => { + setzeKundenart('c1', 'unternehmen') + aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }) + const { res } = await wechsel(wb({ funktion: { key: 'termine', projectId: 'wf_p1' } })) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual((await listContractEvents('c1'))[0].details.funktion, { key: 'termine', projectId: 'wf_p1' }) +}) + +test('Unvollständige Bestellung in Fassung 6: 400 ohne Schreiben', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + for (const schlecht of [{ acceptTerms: false }, { acceptTerms: undefined }, { prorationDate: 'x' }, { amountDueCents: '4100' }, { prorationDate: undefined }]) { + const { res, call } = await wechsel(wb(schlecht)) + assert.deepEqual([res.status, res.body.error, call], [400, FEHLER_UNVOLLSTAENDIG, null], JSON.stringify(schlecht)) + } + assert.equal(zeilen().length, n0) +}) + +test('Fassungsweiche und Nachweisfehler', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + const fremd = await wechsel(wb(), customerCookie('c2')) + assert.equal(fremd.res.status, 409) + assert.equal(fremd.call, null) + const alt = await wechsel({ acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }) + assert.equal(alt.res.status, 400) // alter Weg verlangt earlyStart: true, auch von einem Betrieb + assert.equal(zeilen().length, n0) + await resetHosting() + bereit() + aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }) + const vor = st.calls.length + const f = await post('/api/abo/switch', wb(), { cookie: customerCookie() }) + assert.deepEqual([f.status, f.body.error], [500, FEHLER_NACHWEIS]) + assert.equal(st.calls.slice(vor).some((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST'), false) +}) + +test('Fassung 5 aus dem Pilot: wie früher mit Zeitstempel, ohne Nachweis, ohne Grund', async () => { + setzeKundenart('c1', 'unternehmen') + const n0 = zeilen().length + const { res, call } = await wechsel({ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.match(call.body['metadata[earlyStartConsentAt]'], /^\d{4}-\d\d-\d\dT/) + const [ev] = await listContractEvents('c1') + assert.deepEqual([ev.termsVersion, 'kundenart' in ev.details], [config.abo.termsVersion, false]) + assert.equal(zeilen().length, n0) +}) + +test('switchToWartung: ohne Zeitstempel nur mit kundenart unternehmen', async () => { + await resetHosting() + const { earlyStartConsentAt: _x, ...ohne } = consent + for (const c of [ohne, { ...ohne, kundenart: 'privat' }, { ...ohne, kundenart: 'firma' }]) await assert.rejects(switchToWartung('c1', c, nowUnix()), (e) => e.status === 400) + bereit() + assert.ok((await switchToWartung('c1', { ...ohne, kundenart: 'unternehmen' }, nowUnix(), { expectedAmountCents: 4100 })).event) +})