Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
882438df38
commit
8e0d2b2736
4 files changed
+218
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user