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:
1 parent
e5cc2f3c82
commit
0d2254ca20
6 files changed
+275
-39
No files matched your search
+71
-19
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user