Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 15:53:29 +00:00
1 parent 882438df38
commit 8e0d2b2736
4 files changed
+218

No files matched your search

+2
View File
@@ -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)
+22
View File
@@ -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
+80
View File
@@ -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,
}
}
+114
View File
@@ -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)"))
})