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

+13
View File
@@ -290,4 +290,17 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
assert.match(renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text, /Abo in Stripe sofort beenden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, keine_frist: true }).text, /keine offene Frist gefunden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text, /Preis zurück auf das Hosting-Abo/)
// Fix 1: Paketzahl in jedem Speicherpfad (K03/T08), Pruefung der Kuendigungsart, T09-Hinweise
assert.match(renderMail('K03', { ...k03, pakete_angabe: 'alle gebuchten Pakete' }, { speicher_angaben: true, pruefen: true }).text, /Pakete: alle gebuchten Pakete/)
assert.match(renderMail('K03', { ...k03, kuendigungsart: 'außerordentliche Kündigung' }, { speicher_kind_pruefung: true }).text, /Ihre außerordentliche Kündigung wird geprüft/)
const t08a = { ...t08, pakete_angabe: '2 Paket(e)', kuendigungsart: 'Außerordentliche Kündigung' }
assert.match(renderMail('T08', t08a, { speicher_angaben: true, wechsel_ausstehend: true }).text, /Pakete: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { wechsel_ausstehend: true }).text, /gekündigt: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { speicher_kind_pruefung: true }).text, /Außerordentliche Kündigung des Zusatzspeichers/)
const t09b = { ...t09, fristende: 'keine Frist gefunden', pruefhinweis: 'Ereignisse nicht lesbar – bitte von Hand prüfen' }
assert.match(renderMail('T09', t09b, { abo_gefunden: true }).text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, nicht_pruefbar: true }).text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, wechsel: true, wechsel_offen: true }).text, /Wechselrechnung noch offen – stornieren/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, abschluss_verspaetet: true }).text, /als ordentliche Kündigung behandeln/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, bereits_widerrufen: true }).text, /schon widerrufen/)
})
+158 -11
View File
@@ -22,7 +22,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState } = await import('../server/routes/abo.js')
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = await import('../server/routes/abo.js')
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
@@ -180,10 +180,17 @@ test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Ände
assert.match(k03().text, /prüfen/)
assert.doesNotMatch(k03().text, /endet zum|verringert/)
assert.match(t08().text, /kein Zusatzspeicher gebucht/i)
// § 312k Abs. 4 BGB: die Bestaetigung nennt den Inhalt der Erklaerung, also auch die Paketzahl
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 0, toUnits: null })
assert.equal(t13(), null)
mails.sent.length = 0
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.match(k03().text, /Pakete: 2 Paket\(e\)/)
assert.match(t08().text, /Pakete: 2 Paket\(e\)/)
})
test('Unbekannte E-Mail: 200, keine Änderung', async () => {
@@ -201,6 +208,8 @@ test('Unbekannte E-Mail: 200, keine Änderung', async () => {
assert.equal(st.calls.length, before)
assert.match(k03().text, /ordnen Ihre Kündigung/)
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
})
test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => {
@@ -247,15 +256,18 @@ test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis of
// Controller: waehrend ein Kartenwechsel aussteht (offenes Ereignis wechsel, Stripe pending_update) darf die
// Verringerung das Stripe-Abo nicht anfassen - Stripe wuerde die Wechselrechnung stornieren
test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wir prüfen“, T08 mit Hinweis', async () => {
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
const switchedAt = new Date().toISOString()
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent, status: 'offen',
})
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
const openSwitch = () =>
recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent, status: 'offen',
})
// Ausloeser 1: nur das offene Ereignis wechsel (Stripe traegt noch kein pending_update, z. B. Webhook-Rueckstand)
await resetSubH({ units: 2 })
await openSwitch()
let before = st.calls.length
let res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
@@ -264,8 +276,26 @@ test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wi
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: 1, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(k03().text, /Pakete: 1 Paket\(e\)/)
assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.match(t08().text, /Pakete: 1 Paket\(e\)/)
assert.equal((await events('wechsel'))[0].status, 'offen')
// Ausloeser 2: nur pending_update am Stripe-Abo (Ereignis nicht gespeichert, nachweis_fehlt beim Wechsel)
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
before = st.calls.length
res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
assert.match(t08().text, /Wechsel ausstehend/)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
// Beide Ausloeser weg: Verringerung laeuft
await resetSubH({ units: 2 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '1' })).status, 200)
assert.equal(storageQuantity(), 1)
})
// F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis
@@ -280,7 +310,9 @@ test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler'
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(t08().text, /nicht automatisch verringert/i)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(t08().text, /Stripe/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.equal((await events('speicher'))[0].status, 'gescheitert')
})
@@ -457,9 +489,124 @@ test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde
resetPublicRateLimit()
assert.equal(publicRateLimitState().ips, 0)
assert.equal((await cancel({})).status, 200)
// Nach einer Stunde ist der Eintrag weg (Zeit im Test vorgegeben)
assert.equal(publicRateLimitState({ now: Date.now() + 3600 * 1000 + 1 }).ips, 0)
assert.equal(publicRateLimitState().ips, 1)
// Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben
purgeRateLimit(Date.now() + 3600 * 1000 - 5000)
assert.equal(publicRateLimitState().ips, 1)
purgeRateLimit(Date.now() + 3600 * 1000 + 1)
assert.equal(publicRateLimitState().ips, 0)
})
// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung
// der Art steht in K03 und T08
test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.equal(ev.details.toUnits, 1)
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
assert.match(k03().text, /außerordentliche Kündigung wird geprüft/)
assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/)
assert.match(t08().text, /Grund: Postfach dauernd voll/)
// ordentlich: kein Pruefungshinweis
await resetSubH({ units: 3 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.doesNotMatch(k03().text, /wird geprüft/)
assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/)
})
// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen"
test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => {
await resetSubH({ units: 0 })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
let before = st.calls.length
let res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/)
assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert')
// Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.equal(subscriptionUpdates(before).length, 1)
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /Ereignisse nicht lesbar/)
// Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert
await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(t09().text, /Abo-Beginn fehlt/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/)
})
// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen"
test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(3)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /RECHTZEITIG/)
mails.sent.length = 0
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /bereits widerrufen/)
assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/)
assert.match(k04().text, /prüfen/)
const widerrufe = await events('widerruf')
assert.equal(widerrufe.length, 2)
assert.equal(widerrufe[0].details.eventId, null)
})
// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs
test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(1)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent: { ...consent, consentAt }, status: 'offen',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/)
// Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis
await resetSubH({ units: 0 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/)
// Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
assert.match(t09().text, /NACH Fristende/)
assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/)
// Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr"
mails.sent.length = 0
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/)
assert.match(t09().text, /keine Bestellung dieser Art/)
})
// ---------------------------------------------------------------------------