Finanzen: Liquiditaetsvorschau, Abo-Kuendigung: cancelAt allein massgeblich, cancelAtPeriodEnd ohne cancelAt keine Verlaengerung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent 9361000a70
commit 34dfe181e4
2 files changed
+30 -6

No files matched your search

+4 -2
View File
@@ -146,12 +146,14 @@ export function buildForecast({
if (!isCents(subscription.priceAmount) || subscription.priceAmount <= 0) continue if (!isCents(subscription.priceAmount) || subscription.priceAmount <= 0) continue
const periodEnd = berlinDayOf(subscription.currentPeriodEnd) const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
if (!periodEnd) continue if (!periodEnd) continue
// Kuendigung: Ein gesetztes cancelAt gilt allein (es kann mehrere Perioden voraus liegen, z.B. Mindestlaufzeit);
// cancelAtPeriodEnd allein heisst: Stripe bucht zum Periodenende nichts mehr ab, also gar keine Verlaengerung.
const cancelAt = berlinDayOf(subscription.cancelAt) const cancelAt = berlinDayOf(subscription.cancelAt)
if (!cancelAt && subscription.cancelAtPeriodEnd) continue
for (const renewal of recurring(periodEnd, 1)) { for (const renewal of recurring(periodEnd, 1)) {
const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS) const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS)
if (payoutDate > end) break if (payoutDate > end) break
if (cancelAt && renewal >= cancelAt) break // gekuendigt: ab dem Kuendigungstag keine Verlaengerung mehr if (cancelAt && renewal >= cancelAt) break // nur Verlaengerungen vor dem Kuendigungstag
if (subscription.cancelAtPeriodEnd && renewal > periodEnd) break // zum Periodenende gekuendigt: nur die laufende Periode
if (payoutDate >= today) add(payoutDate, 'Abo-Verlängerung', subscription.priceAmount, ERWARTET) if (payoutDate >= today) add(payoutDate, 'Abo-Verlängerung', subscription.priceAmount, ERWARTET)
} }
} }
+26 -4
View File
@@ -135,10 +135,15 @@ test('Abo: Periodenende wird als Berliner Tag gelesen (Mitternachtsrand), Klamme
assert.deepEqual(dates(r), ['2026-11-04', '2026-12-04', '2027-01-04']) assert.deepEqual(dates(r), ['2026-11-04', '2026-12-04', '2027-01-04'])
}) })
test('Abo mit cancelAtPeriodEnd: kein Posten am 09.11.', () => { test('Abo mit cancelAtPeriodEnd ohne cancelAt: gar keine Verlängerung (Stripe bucht zum Periodenende nichts mehr ab)', () => {
const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true })] }) const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true, cancelAt: null })] })
assert.ok(!dates(r).includes('2026-11-09')) assert.deepEqual(r.posten, [])
assert.deepEqual(dates(r), ['2026-10-09']) // gilt auch ohne das Feld cancelAt
const { cancelAt, ...ohneCancelAt } = sub({ cancelAtPeriodEnd: true })
assert.deepEqual(base({ horizonDays: 70, subscriptions: [ohneCancelAt] }).posten, [])
// andere Abos daneben laufen weiter
const mixed = base({ horizonDays: 40, subscriptions: [sub({ cancelAtPeriodEnd: true }), sub({ customerId: 'c2' })] })
assert.deepEqual(mixed.posten.map((p) => [p.date, p.amountCents]), [['2026-10-09', 2000], ['2026-11-09', 2000]])
}) })
test('Abo mit cancelAt: keine Termine ab cancelAt', () => { test('Abo mit cancelAt: keine Termine ab cancelAt', () => {
@@ -153,6 +158,23 @@ test('Abo mit cancelAt: keine Termine ab cancelAt', () => {
assert.deepEqual(r3.posten, []) assert.deepEqual(r3.posten, [])
}) })
test('Abo mit cancelAt mehrere Perioden voraus (Mindestlaufzeit): Verlängerungen bis vor cancelAt, cancelAtPeriodEnd ändert daran nichts', () => {
// Produktionsform: Stripe liefert cancelAtPeriodEnd = true, sobald cancel_at gesetzt ist
const cancelled = (cancelAt, extra = {}) => sub({ cancelAt, cancelAtPeriodEnd: true, ...extra })
// cancelAt 01.01.2027, Horizont 120 (bis 29.01.2027): 05.10., 05.11., 05.12. -> Auszahlung 09.10., 09.11., 09.12.; die vom 05.01. nicht
const r = base({ horizonDays: 120, subscriptions: [cancelled('2027-01-01T10:00:00.000+00:00')] })
assert.deepEqual(dates(r), ['2026-10-09', '2026-11-09', '2026-12-09'])
assert.ok(r.posten.every((p) => p.amountCents === 2000 && p.sicherheit === 'erwartet'))
// cancelAt am Verlängerungstag selbst: diese Verlängerung entfällt, die davor bleiben
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-05T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// einen Tag später: die Verlängerung am 05.01. läuft noch (Auszahlung 09.01.)
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-06T10:00:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09', '2027-01-09'])
// cancelAt wird als Berliner Tag gelesen: 04.01. 23:30 UTC ist in Berlin bereits der 05.01.
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [cancelled('2027-01-04T23:30:00.000+00:00')] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
// ohne cancelAtPeriodEnd (nur cancelAt) dasselbe
assert.deepEqual(dates(base({ horizonDays: 120, subscriptions: [sub({ cancelAt: '2027-01-01T10:00:00.000+00:00' })] })), ['2026-10-09', '2026-11-09', '2026-12-09'])
})
test('Abo: nur aktive zählen, Periodenende in der Vergangenheit wird vorgespult', () => { test('Abo: nur aktive zählen, Periodenende in der Vergangenheit wird vorgespult', () => {
const r = base({ const r = base({
subscriptions: [ subscriptions: [