From d5a7312896dc9367c6b9182a54dbc9615869b842 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:50:24 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20P6:=20Wechsel=20merkt=20sich=20?= =?UTF-8?q?die=20gew=C3=BCnschte=20Funktion=20(details.funktion,=20wechsel?= =?UTF-8?q?Funktion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/abo.js | 46 +++++++++++++++++++-- server/services/planSwitch.js | 78 ++++++++++++++++++++++++----------- test/planSwitch.test.js | 39 ++++++++++++++++++ 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/server/routes/abo.js b/server/routes/abo.js index 59e77cd..b6c2dfd 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -19,10 +19,13 @@ import { previewSwitch, reconcilePendingSwitch, switchToWartung, + wechselFunktionVon, } from '../services/planSwitch.js' -import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' +import { assertOwnProject, handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' import { featureConfig } from '../services/featureRegistry.js' import { istAboModell } from '../services/aboModell.js' +import { ENTHALTEN_IN } from '../services/aboFunktionen.js' +import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -164,6 +167,21 @@ function isPilotAllowed(customer, offer = null) { // Kunde (eingeloggt) // --------------------------------------------------------------------------- +/** + * publicSubscription, in der neuen Ansicht mit wechselFunktion (die beim Wechsel gewuenschte Funktion, die noch einzuschalten ist, sonst null; + * planSwitch.wechselFunktionVon). In der alten Ansicht fehlt der Schluessel. Ein Lesefehler ergibt null (die Seite bleibt nutzbar). + */ +async function subscriptionMitWechselFunktion(req, customerId, sub, events) { + const view = publicSubscription(sub, events) + if (!view || !neueAnsicht(req)) return view + try { + return { ...view, wechselFunktion: await wechselFunktionVon(customerId, events.filter((e) => e.subscriptionId === sub.$id)) } + } catch (err) { + console.error('[abo] Gewünschte Funktion des Wechsels nicht lesbar:', err.message) + return { ...view, wechselFunktion: null } + } +} + /** Katalog, Abo-Status mit Speicherstand und Wechsel-Moeglichkeit, Angebote je Produkt, Stundenkonto (nur Wartung). */ router.get('/', requireSession, async (req, res) => { try { @@ -211,7 +229,7 @@ router.get('/', requireSession, async (req, res) => { }, offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : null }, ledger, - subscription: publicSubscription(sub, events), + subscription: await subscriptionMitWechselFunktion(req, customerId, sub, events), }) } catch (err) { return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' }) @@ -364,6 +382,26 @@ router.post('/switch/preview', requireSession, async (req, res) => { } }) +/** + * Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11) aus dem Body: { key, projectId } oder null. Gueltig ist ein Schluessel + * aus dem Register, den nur das Wartungs-Abo enthaelt (ENTHALTEN_IN nur 'wartung': news, termine, shop, unterkunft, zeiten; nicht analyse) und + * ein Projekt des Kunden (assertOwnProject). Alles andere wird verworfen, nie abgelehnt: die Bequemlichkeit darf den bezahlten Vertrag nicht + * blockieren. Nur die beiden Schluessel key und projectId werden uebernommen. + */ +async function wechselFunktionAusBody(customerId, funktion) { + if (!funktion || typeof funktion !== 'object' || Array.isArray(funktion)) return null + const { key, projectId } = funktion + if (typeof key !== 'string' || typeof projectId !== 'string' || !projectId) return null + const enthalten = Object.hasOwn(ENTHALTEN_IN, key) ? ENTHALTEN_IN[key] : null + if (!enthalten || enthalten.length !== 1 || enthalten[0] !== 'wartung') return null + try { + await assertOwnProject(customerId, projectId) + } catch { + return null + } + return { key, projectId } +} + /** * Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate, amountDueCents } ("Zahlungspflichtig * bestellen"); prorationDate und amountDueCents kommen aus der Vorschau. @@ -391,12 +429,14 @@ router.post('/switch', requireSession, async (req, res) => { } const customerId = getSessionCustomerId(req) const now = new Date().toISOString() + // 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 const { subscription, paymentUrl } = await switchToWartung( customerId, { termsVersion, consentAt: now, earlyStartConsentAt: now, 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 } + { expectedAmountCents: amountDueCents, funktion } ) const events = await listContractEvents(customerId).catch((e) => { console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js index 20064b5..da9b558 100644 --- a/server/services/planSwitch.js +++ b/server/services/planSwitch.js @@ -4,6 +4,7 @@ import { getDocument } from './appwriteAdmin.js' import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { contractAttachmentFor, describeItems } from './products.js' +import { getFeatureDoc } from './siteFeatures.js' import { addMonths, ensurePrice, @@ -335,6 +336,9 @@ const monthlyCentsOf = (facts) => facts.monthlyCents ?? config.products.wartung. // Ein Wechsel je Abo zur selben Zeit (Doppelklick): der zweite wartet nicht, sondern bekommt 409 const switchesInFlight = new Set() +// Obergrenze fuer details des Ereignisses `wechsel` (Schutz des Nachweises): die gewuenschte Funktion (details.funktion) kommt nur hinein, wenn es passt +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). @@ -343,8 +347,11 @@ const switchesInFlight = new Set() * ab, 409 ohne Aenderung bei Stripe. * Liefert { subscription, paymentUrl, event }: paymentUrl nur, wenn Stripe die Aenderung bis zur Zahlung * zurueckhaelt (pending_update, Karte), sonst null - auch bei SEPA, dort zieht Stripe die Lastschrift ein. + * options.funktion ({ key, projectId }, von der Route geprueft; Kundenansicht neu, P6): die Funktion, die der Kunde mit dem Wechsel einschalten + * will, landet in event.details.funktion (GET /api/abo nennt sie als wechselFunktion). Sie ist nur eine Bequemlichkeit: passt sie nicht mehr + * in den Nachweis (details ueber 2000 Zeichen), bleibt sie weg, der Wechsel laeuft in jedem Fall. */ -export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents } = {}) { +export async function switchToWartung(customerId, consent, prorationDate, { expectedAmountCents, funktion = null } = {}) { if (!consent?.termsVersion || !consent?.consentAt) { throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.') } @@ -357,13 +364,13 @@ export async function switchToWartung(customerId, consent, prorationDate, { expe if (switchesInFlight.has(doc.$id)) throw httpError(409, 'Ihr Wechsel wird gerade verarbeitet.') switchesInFlight.add(doc.$id) try { - return await performSwitch(customerId, doc, consent, prorationDate, expectedAmountCents) + return await performSwitch(customerId, doc, consent, prorationDate, expectedAmountCents, funktion) } finally { switchesInFlight.delete(doc.$id) } } -async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents) { +async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents, funktion = null) { const ctx = await switchContext(customerId, current) const { doc, customer, stripeSub } = ctx // Seit der Vorschau hat ein neuer Abrechnungsmonat begonnen: Gutschrift und Betrag stimmen nicht mehr @@ -422,31 +429,34 @@ async function performSwitch(customerId, current, consent, prorationDate, expect })) || doc } + const details = { + from: 'hosting', + amountDueCents, + monthlyCents, + prorationDate, + switchedAt, + paymentMethod: ctx.paymentMethod, + invoiceId: idOf(updated.latest_invoice), + // F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending) + paymentUrl, + // Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook + pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null, + effectiveAt: effective ? switchedAt : null, + paidAt: effective && paid ? switchedAt : null, + // F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann + previousTermStartAt: termStartOf(stripeSub).toISOString(), + previousMetadata: Object.fromEntries( + RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)]) + ), + } + // Gewuenschte Funktion (nur Bequemlichkeit): der Nachweis darf dadurch nie zu gross werden + if (funktion && JSON.stringify({ ...details, funktion }).length <= DETAILS_MAX) details.funktion = funktion const event = await recordSwitchEvent({ doc, customerId, consent, status: effective && paid ? 'erledigt' : 'offen', - details: { - from: 'hosting', - amountDueCents, - monthlyCents, - prorationDate, - switchedAt, - paymentMethod: ctx.paymentMethod, - invoiceId: idOf(updated.latest_invoice), - // F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending) - paymentUrl, - // Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook - pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null, - effectiveAt: effective ? switchedAt : null, - paidAt: effective && paid ? switchedAt : null, - // F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann - previousTermStartAt: termStartOf(stripeSub).toISOString(), - previousMetadata: Object.fromEntries( - RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] == null ? null : String(stripeSub.metadata[key]).slice(0, 300)]) - ), - }, + details, }) const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' } @@ -461,6 +471,28 @@ async function performSwitch(customerId, current, consent, prorationDate, expect return { subscription, paymentUrl, event } } +/** Frist, in der die beim Wechsel gewuenschte Funktion noch angeboten wird (Tage nach der Erledigung des Wechsels). */ +const WECHSEL_FUNKTION_TAGE = 14 + +/** + * Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11), die der Kunde jetzt noch einschalten kann: { key, projectId } oder null. + * events: Vertragsereignisse des aktuellen Abos (listContractEvents, neueste zuerst, details geparst). Es zaehlt das juengste Ereignis + * `wechsel`: es ist erledigt, traegt details.funktion, wurde vor hoechstens 14 Tagen erledigt (doneAt, sonst details.paidAt, sonst + * details.effectiveAt) und das Funktionsdokument des Projekts fehlt oder ist off (die Funktion ist noch nicht eingeschaltet). Gescheitert, + * widerrufen und offen ergeben null, ebenso ein juengerer Wechsel ohne Funktion. Liest hoechstens ein Funktionsdokument; ein Lesefehler wird weitergereicht. + */ +export async function wechselFunktionVon(customerId, events, { now = new Date() } = {}) { + const event = (events || []).find((e) => e.type === 'wechsel') + if (!event || event.status !== 'erledigt') return null + const funktion = event.details?.funktion + if (!funktion || typeof funktion.key !== 'string' || typeof funktion.projectId !== 'string' || !funktion.key || !funktion.projectId) return null + const erledigt = Date.parse(event.doneAt || event.details?.paidAt || event.details?.effectiveAt || '') + if (!Number.isFinite(erledigt) || now.getTime() - erledigt > WECHSEL_FUNKTION_TAGE * 24 * 60 * 60 * 1000) return null + const doc = await getFeatureDoc(customerId, funktion.projectId, funktion.key) + if (doc && doc.state !== 'off') return null + return { key: funktion.key, projectId: funktion.projectId } +} + /** * Ereignis `wechsel` (Nachweis der Bestellung und Stand der Zahlung). Feste ID je Abo und Wechselzeitpunkt * (eventIdFor, 36 Zeichen). Ein Fehler beim Speichern bricht den schon bei Stripe ausgefuehrten Wechsel nicht diff --git a/test/planSwitch.test.js b/test/planSwitch.test.js index 27c741e..666eb16 100644 --- a/test/planSwitch.test.js +++ b/test/planSwitch.test.js @@ -1153,3 +1153,42 @@ test('Abgleich mit veraltetem Ereignis nach dem Webhook: kein zweites T12', asyn assert.equal((await listContractEvents('c1'))[0].$id, stale.$id) assert.equal((await listContractEvents('c1'))[0].doneBy, 'stripe') }) + +// Kundenansicht P6 (Task 12): der Wechsel merkt sich die gewünschte Funktion (details.funktion) und GET /api/abo nennt sie (wechselFunktion) +const NEU_MODUS = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] } +after(() => { + config.kundenansichtNeu.mode = NEU_MODUS.mode + config.kundenansichtNeu.pilotEmails = NEU_MODUS.pilotEmails +}) +const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] } +const F = { key: 'termine', projectId: 'wf_p1' } +aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }); aw.seed('websiteProjects', { $id: 'wf_fremd', customerId: 'c2', projectName: 'Fremd' }) +const bestelle = async (extra = {}) => { + await resetHosting(); st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)); st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } + return post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100, ...extra }, { cookie: customerCookie() }) +} +const wechsel = async () => (await listContractEvents('c1')).find((e) => e.type === 'wechsel') +test('Wechsel mit Funktion: nur in der neuen Ansicht gespeichert; Ungültiges wird verworfen, der Wechsel läuft trotzdem', async () => { + pilot(true); assert.equal((await bestelle({ funktion: F })).status, 200); assert.deepEqual((await wechsel()).details.funktion, F); assert.ok(JSON.stringify((await wechsel()).details).length <= 2000) + for (const funktion of [{ key: 'analyse', projectId: 'wf_p1' }, { key: 'foo', projectId: 'wf_p1' }, { key: 'termine', projectId: 'wf_fremd' }, { key: 'termine' }, 'termine', [], null]) { + assert.equal((await bestelle({ funktion })).status, 200, JSON.stringify(funktion)); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false, JSON.stringify(funktion)) + } + pilot(false); assert.equal((await bestelle({ funktion: F })).status, 200); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false) +}) +test('GET /api/abo: wechselFunktion folgt dem jüngsten erledigten Wechsel, 14 Tage, Funktion off; alte Ansicht ohne den Schlüssel', async () => { + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + const ts = (tage) => new Date(Date.now() - tage * 86400000).toISOString() + const lege = async ({ status = 'erledigt', paidVor = 2, doneVor = null, funktion = F } = {}) => { + const ev = await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', consent, status, details: { from: 'hosting', switchedAt: ts(paidVor), ...(status === 'erledigt' && doneVor === null ? { paidAt: ts(paidVor) } : {}), ...(funktion ? { funktion } : {}) } }) + if (doneVor !== null) await updateContractEvent(ev.$id, { status: 'erledigt', doneAt: ts(doneVor) }) + } + const lies = async () => (await get('/api/abo', { cookie: customerCookie() })).subscription + const neu = async (setup) => { await resetHosting({ patch: { productKey: 'wartung' } }); await lege(setup); return (await lies()).wechselFunktion } + pilot(true) + assert.deepEqual(await neu({}), F); assert.deepEqual(await neu({ paidVor: 20, doneVor: 13 }), F) // sofort bezahlt (paidAt) bzw. später erledigt (doneAt) + for (const setup of [{ status: 'offen' }, { status: 'gescheitert' }, { status: 'widerrufen' }, { paidVor: 20, doneVor: 15 }, { funktion: null }]) assert.equal(await neu(setup), null, JSON.stringify(setup)) + await neu({}); await lege({ funktion: null }); assert.equal((await lies()).wechselFunktion, null) // der jüngere Wechsel ohne Funktion gewinnt + await neu({}); aw.seed('portalFeatures', { $id: 'f_wf_p1_termine', customerId: 'c1', projectId: 'wf_p1', featureKey: 'termine', state: 'setup' }); assert.equal((await lies()).wechselFunktion, null) + await updateDocument('portalFeatures', 'f_wf_p1_termine', { state: 'off' }); assert.deepEqual((await lies()).wechselFunktion, F) + pilot(false); assert.equal(Object.hasOwn(await lies(), 'wechselFunktion'), false) +})