Kündigung und Widerruf: Paketzahl in jeder Bestätigung, klare Hinweise fürs Team, Tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 10:15:51 +00:00
1 parent e5cc2f3c82
commit 0d2254ca20
6 files changed
+275 -39

No files matched your search

+71 -19
View File
@@ -645,7 +645,8 @@ const RATE_WINDOW_MS = 60 * 60 * 1000
const RATE_MAX = 10
const recentRequests = new Map() // ip -> [timestamps]
function purgeRateLimit(now) {
/** Eintraege aelter als eine Stunde (zum Zeitpunkt now) entfernen; laeuft bei jeder Anfrage, exportiert fuer Tests. */
export function purgeRateLimit(now = Date.now()) {
for (const [ip, list] of recentRequests) {
const fresh = list.filter((t) => now - t < RATE_WINDOW_MS)
if (fresh.length) recentRequests.set(ip, fresh)
@@ -666,10 +667,9 @@ export function resetPublicRateLimit() {
recentRequests.clear()
}
/** Nur fuer Tests: Zahl der IP-Adressen, die zum Zeitpunkt now noch gezaehlt wuerden (aendert nichts). */
export function publicRateLimitState({ now = Date.now() } = {}) {
const ips = [...recentRequests.values()].filter((list) => list.some((t) => now - t < RATE_WINDOW_MS)).length
return { ips }
/** Nur fuer Tests: Zahl der IP-Adressen, die gerade in der Map stehen. */
export function publicRateLimitState() {
return { ips: recentRequests.size }
}
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
@@ -892,9 +892,14 @@ router.post('/public/cancel', async (req, res) => {
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
grund: d.reason,
vertragsende: fmtDate(effectiveAt),
// {{pakete}} = gekuendigte Pakete (F31); solange nichts verringert wurde, der Wunsch des Kunden
pakete: reduced ? result.details.packages : d.packages ?? 'alle',
// {{pakete}} = tatsaechlich gekuendigte Pakete (F31, nur nach einer Verringerung); {{pakete_angabe}} = die
// Erklaerung des Kunden (Inhalt der Erklaerung, § 312k Abs. 4 BGB) in jedem Speicherpfad
pakete: reduced ? result.details.packages : '',
pakete_angabe: d.packages ? `${d.packages} Paket(e)` : 'alle gebuchten Pakete',
}
// Eine ausserordentliche Kuendigung oder Geschaeftsaufgabe des Speichers wird trotzdem zum Periodenende
// verringert; die Pruefung der Art kommt dazu (Fix 1, 6)
const storageKindCheck = storage && d.kind !== 'ordentlich'
await sendCustomerMail(d.email, {
template: 'K03',
@@ -910,6 +915,8 @@ router.post('/public/cancel', async (req, res) => {
kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich',
zusatzspeicher: reduced,
pruefen: storage && Boolean(sub) && !reduced,
speicher_angaben: storage,
speicher_kind_pruefung: storageKindCheck && reduced,
},
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
@@ -934,6 +941,8 @@ router.post('/public/cancel', async (req, res) => {
wechsel_ausstehend: result.outcome === 'wechsel_ausstehend',
speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen',
nachweis_fehlt: Boolean(sub) && !result.event,
speicher_angaben: storage,
speicher_kind_pruefung: storageKindCheck,
},
})
@@ -952,13 +961,27 @@ const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > l
*/
async function withdrawOrder(sub, d, receivedAt) {
const type = ORDER_EVENT_TYPE[d.order]
const events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type)
// Ereignisse nicht lesbar: nichts automatisch aendern (ausser dem Rueckfall auf startedAt beim Abschluss),
// T09 sagt "von Hand pruefen" (Fix 1, 4)
let events = []
let unreadable = false
try {
events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type)
} catch (err) {
unreadable = true
console.error(`[abo] Vertragsereignisse für Widerruf (Abo ${sub.$id}) nicht lesbar:`, err.message)
}
let open = openWithdrawals(events, receivedAt)
let deadline = latestUntil(open)
if (type === 'abschluss' && !events.length && sub.startedAt) {
const { endsAt } = withdrawalDeadline(sub.startedAt)
deadline = endsAt
if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }]
let startMissing = false
if (type === 'abschluss' && !events.length) {
if (sub.startedAt) {
const { endsAt } = withdrawalDeadline(sub.startedAt)
deadline = endsAt
if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }]
} else {
startMissing = true
}
}
if (!deadline) {
// Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team
@@ -992,7 +1015,23 @@ async function withdrawOrder(sub, d, receivedAt) {
}
}
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
return { sub, inTime, deadline, withdrawn, canceled, cancelFailed, event }
return {
sub,
inTime,
deadline,
withdrawn,
canceled,
cancelFailed,
event,
unreadable,
startMissing,
// Dieselbe Bestellung wurde schon widerrufen (Fix 1, 5)
alreadyWithdrawn: !inTime && events.some((e) => e.status === 'widerrufen'),
// "NACH Fristende" nur, wenn die Frist wirklich vorbei ist
late: !inTime && Boolean(deadline) && receivedAt > deadline,
// Kartenwechsel, der noch auf die Zahlung wartet: die offene Wechselrechnung muss storniert werden (Fix 1, 7)
openSwitchWithdrawn: withdrawn.some((e) => e.type === 'wechsel' && !e.details?.effectiveAt),
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
@@ -1012,7 +1051,7 @@ router.post('/public/withdraw', async (req, res) => {
const receivedAt = new Date()
let sub = null
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null }
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
try {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub) {
@@ -1023,6 +1062,8 @@ router.post('/public/withdraw', async (req, res) => {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
}
const { inTime } = result
// Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
await sendCustomerMail(d.email, {
template: 'K04',
@@ -1052,15 +1093,22 @@ router.post('/public/withdraw', async (req, res) => {
bestellung: orderLabel(d.order, sub),
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: fmtDate(result.deadline),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: !sub
? 'ungeprüft (kein Abo gefunden)'
: inTime
? 'RECHTZEITIG'
: result.deadline
? 'NACH Fristende, vor einer Ablehnung bitte prüfen'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
: result.alreadyWithdrawn
? 'bereits widerrufen'
: pruefhinweis
? `ungeprüft (${pruefhinweis})`
: result.late
? 'NACH Fristende, vor einer Ablehnung bitte prüfen'
: result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: describeWithdrawn(result.withdrawn),
pruefhinweis,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
@@ -1071,8 +1119,12 @@ router.post('/public/withdraw', async (req, res) => {
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
keine_frist: Boolean(sub) && !inTime,
keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn,
nachweis_fehlt: Boolean(sub) && !result.event,
wechsel_offen: result.openSwitchWithdrawn,
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
},
})