From 8e0d2b2736da669ddcccfd409796a448bad9052e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:53:29 +0000 Subject: [PATCH] Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen) Co-Authored-By: Claude Sonnet 5.5 --- server/index.js | 2 + server/routes/fristen.js | 22 +++++++ server/services/fristen.js | 80 ++++++++++++++++++++++++++ test/fristen.test.js | 114 +++++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 server/routes/fristen.js create mode 100644 server/services/fristen.js create mode 100644 test/fristen.test.js diff --git a/server/index.js b/server/index.js index 7c4d3cc..d864d1e 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import customerDataRoutes from './routes/customerData.js' import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' +import fristenRoutes from './routes/fristen.js' import termineRoutes from './routes/termine.js' import shopRoutes from './routes/shop.js' import shopWebhookRoutes from './routes/shopWebhook.js' @@ -100,6 +101,7 @@ app.use('/api/chat', chatRoutes) app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) +app.use('/api/fristen', fristenRoutes) app.use('/api/termine', termineRoutes) // Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers app.use('/api/shop/stripe-webhook', shopWebhookRoutes) diff --git a/server/routes/fristen.js b/server/routes/fristen.js new file mode 100644 index 0000000..bff1de2 --- /dev/null +++ b/server/routes/fristen.js @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { sammleFristen } from '../services/fristen.js' +import { einschaltenDeps } from '../services/zeiten/einschalten.js' + +/** + * Gesammelte Widerrufsfristen des angemeldeten Kunden (V6). Mount: /api/fristen. Nur lesend, daher auch in der + * Kundenansicht erlaubt (der Wächter sperrt nur Schreibzugriffe). + */ +const router = Router() + +router.get('/', requireSession, async (req, res) => { + res.set('Cache-Control', 'no-store') + try { + return res.json(await sammleFristen(getSessionCustomerId(req), { now: einschaltenDeps.jetzt() })) + } catch (err) { + console.error('[fristen] Laden fehlgeschlagen:', err.message) + return res.status(500).json({ error: 'Die Fristen konnten gerade nicht geladen werden.' }) + } +}) + +export default router diff --git a/server/services/fristen.js b/server/services/fristen.js new file mode 100644 index 0000000..d1072a6 --- /dev/null +++ b/server/services/fristen.js @@ -0,0 +1,80 @@ +import { config } from '../config.js' +import { getDocument } from './appwriteAdmin.js' +import { listContractEvents, openWithdrawals } from './contractEvents.js' +import { withdrawalDeadline } from './deadlines.js' +import { FEATURE_LABELS, paidFeatureOrders } from './siteFeatures.js' +import { listAuftraege } from './auftraege/auftragStore.js' +import { auftraegeAktiv } from './auftraege/schalter.js' +import { kundenartWirksam } from './kundenart.js' + +/** + * Gesammelte Widerrufsfristen eines Kunden (V6, Spec 5.6): jede noch offene Frist aus Abo-Abschluss, Wechsel ins + * Wartungs-Abo, Zusatzspeicher (nur Plus), bezahlten Funktionen (Altbestand mit Kauf) und Auftragsbestätigungen + * (nur mit Schalter und Snapshot-Kundenart privat). Offen ist eine Frist bis einschließlich ihres Endes + * (23:59:59.999 Berliner Zeit am letzten Tag). `anzeigen` ist nur für Privatkunden wahr; Betriebe haben kein + * Widerrufsrecht und sehen keine Fristen (die Liste bleibt trotzdem gefüllt). Lesefehler werden weitergereicht. + */ + +const BEZEICHNUNG = { + abschluss: 'Abo-Abschluss', + wechsel: 'Wechsel ins Wartungs-Abo', + speicher: 'Zusatzspeicher', +} + +const zeitpunkt = (wert) => (wert instanceof Date ? wert.getTime() : Date.parse(wert)) + +/** Kundendokument einmal lesen; ein unbekannter Kunde ergibt {}. */ +async function leseKunde(customerId) { + try { + return await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) return {} + throw err + } +} + +/** Ende der Frist zu einem Vertragsschluss; kein lesbarer Zeitpunkt ergibt null (keine Frist, nie ein Fehler). */ +function fristEnde(vertragAt) { + const ms = zeitpunkt(vertragAt || '') + return Number.isNaN(ms) ? null : withdrawalDeadline(new Date(ms)).endsAt +} + +/** + * @returns {Promise<{ fristen: Array<{ quelle: 'abschluss'|'wechsel'|'speicher'|'funktion'|'projekt', bezeichnung: string, bis: string, featureKey?: string, projektName?: string }>, fruehestesEnde: string|null, anzeigen: boolean }>} + */ +export async function sammleFristen(customerId, { now = new Date() } = {}) { + const kunde = await leseKunde(customerId) + const fristen = [] + + for (const f of openWithdrawals(await listContractEvents(customerId), now)) { + fristen.push({ quelle: f.type, bezeichnung: BEZEICHNUNG[f.type], bis: new Date(zeitpunkt(f.until)).toISOString() }) + } + + for (const order of await paidFeatureOrders(customerId)) { + const ende = fristEnde(order.vertragAt) + if (!ende || now.getTime() > ende.getTime()) continue + fristen.push({ + quelle: 'funktion', + bezeichnung: FEATURE_LABELS[order.featureKey]?.name || order.featureKey, + bis: ende.toISOString(), + featureKey: order.featureKey, + projektName: order.projekt, + }) + } + + if (auftraegeAktiv(kunde)) { + for (const auftrag of await listAuftraege({ kundenId: customerId })) { + if (auftrag.kunde?.kundenart !== 'privat' || !['versendet', 'bestaetigt'].includes(auftrag.status)) continue + const ende = fristEnde(auftrag.gesendetAm) + if (!ende || now.getTime() > ende.getTime()) continue + fristen.push({ quelle: 'projekt', bezeichnung: `Auftrag ${auftrag.nummer}`, bis: ende.toISOString() }) + } + } + + fristen.sort((a, b) => Date.parse(a.bis) - Date.parse(b.bis)) + return { + fristen, + fruehestesEnde: fristen[0]?.bis ?? null, + anzeigen: kundenartWirksam(kunde) === 'privat' && fristen.length > 0, + } +} diff --git a/test/fristen.test.js b/test/fristen.test.js new file mode 100644 index 0000000..4861172 --- /dev/null +++ b/test/fristen.test.js @@ -0,0 +1,114 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +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 { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Gesammelte Widerrufsfristen (V6): Abo-Abschluss, Wechsel, Zusatzspeicher (nur Plus), bezahlte Funktionen, Auftraege +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + portalFeatures: schemaKeys('portalFeatures'), + featureConsents: schemaKeys('featureConsents'), + auftraege: schemaKeys('auftraege'), + }, + datetimes: AUFTRAEGE_DATETIMES, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { customers, customerCookie, kundenansichtCookie, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { sammleFristen } = await import('../server/services/fristen.js') +const { default: fristenRoutes } = await import('../server/routes/fristen.js') +const app = await startTestApp({ '/api/fristen': fristenRoutes }) +const modusVorher = config.auftraege.mode +after(() => { + config.auftraege.mode = modusVorher + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +const JETZT = new Date('2026-10-17T08:00:00.000Z') +const ereignis = (id, type, contractAt, over = {}) => + aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over }) +const aufbau = () => { // Ende je Quelle: wechsel Mo 26.10., funktion Do 29.10., speicher Fr 30.10., abschluss Mo 02.11. + ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z') + ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') + ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) }) + ereignis('ev_m', 'speicher', '2026-10-16T10:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T10:00:00.000Z', fromUnits: 2, toUnits: 1 }) }) // Minus: keine Frist + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt: '2026-10-15T09:00:00.000Z' }) +} + +beforeEach(async () => { + for (const collection of ['contractEvents', 'portalFeatures', 'auftraege', 'subscriptions', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + aw.seed('customers', { ...customers.c1 }) + aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' }) + config.auftraege.mode = 'off' +}) + +test('mehrere Quellen, aufsteigend nach Ende; Speicher-Plus zählt, Minus nicht', async () => { + aufbau() + const r = await sammleFristen('c1', { now: JETZT }) + assert.deepEqual(r.fristen, [ + { quelle: 'wechsel', bezeichnung: 'Wechsel ins Wartungs-Abo', bis: '2026-10-26T22:59:59.999Z' }, + { quelle: 'funktion', bezeichnung: 'News-Pinnwand', bis: '2026-10-29T22:59:59.999Z', featureKey: 'news', projektName: 'Bäckerei' }, + { quelle: 'speicher', bezeichnung: 'Zusatzspeicher', bis: '2026-10-30T22:59:59.999Z' }, + { quelle: 'abschluss', bezeichnung: 'Abo-Abschluss', bis: '2026-11-02T22:59:59.999Z' }, + ]) + assert.deepEqual([r.fruehestesEnde, r.anzeigen], ['2026-10-26T22:59:59.999Z', true]) +}) + +test('Kundenart: Betrieb hat Fristen, aber anzeigen false; privat, leer und unbekannt zeigen', async () => { + aufbau() + for (const [kundenart, anzeigen] of [['unternehmen', false], ['privat', true], ['', true], ['kaputt', true]]) { + aw.seed('customers', { ...customers.c1, kundenart }) + const r = await sammleFristen('c1', { now: JETZT }) + assert.deepEqual([r.fristen.length, r.anzeigen], [4, anzeigen], kundenart) + } +}) + +test('abgelaufene oder fehlende Fristen: leer, fruehestesEnde null, anzeigen false', async () => { + ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z') // Ende Mo 05.10. + assert.deepEqual(await sammleFristen('c1', { now: JETZT }), { fristen: [], fruehestesEnde: null, anzeigen: false }) +}) + +test('Aufträge zählen nur mit Schalter und Snapshot-Kundenart privat', async () => { + const ab = (kundenart, status = 'versendet') => + aw.seed('auftraege', { $id: 'ab1', nummer: 'AB-2026-0007', kundenId: 'c1', status, gesendetAm: '2026-10-16T09:00:00.000Z', kundeJson: JSON.stringify({ kundenart }) }) + ab('privat') + config.auftraege.mode = 'off' + assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, []) + config.auftraege.mode = 'on' + assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [{ quelle: 'projekt', bezeichnung: 'Auftrag AB-2026-0007', bis: '2026-10-30T22:59:59.999Z' }]) + ab('privat', 'bestaetigt') + assert.equal((await sammleFristen('c1', { now: JETZT })).fristen.length, 1) + ab('privat', 'widerrufen') + assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, []) + ab('unternehmen') + assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, []) +}) + +test('Lesefehler werden weitergereicht', async () => { + aufbau() + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + await assert.rejects(() => sammleFristen('c1', { now: JETZT })) +}) + +test('GET /api/fristen: 401 ohne Anmeldung, Form, no-store, Kundenansicht erlaubt; Mount in server/index.js', async () => { + assert.equal((await rawGet('/api/fristen')).status, 401) + const res = await rawGet('/api/fristen', { cookie: customerCookie('c1') }) + assert.deepEqual([res.status, Object.keys(res.body).sort(), res.headers.get('cache-control')], [200, ['anzeigen', 'fristen', 'fruehestesEnde'], 'no-store']) + assert.equal((await rawGet('/api/fristen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).status, 200) + const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf('app.use(sessionMiddleware())')) + assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") < src.indexOf('app.use(express.static(publicDir))')) + assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf("app.use('/api/funktionen', funktionenRoutes)")) +})