From 34dfe181e439d06e4c24a84b78af15364f5dc6ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:37:24 +0000 Subject: [PATCH] Finanzen: Liquiditaetsvorschau, Abo-Kuendigung: cancelAt allein massgeblich, cancelAtPeriodEnd ohne cancelAt keine Verlaengerung Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/forecast.js | 6 ++++-- test/financeForecast.test.js | 30 +++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/server/services/finance/forecast.js b/server/services/finance/forecast.js index 7aaa18a..7612ee5 100644 --- a/server/services/finance/forecast.js +++ b/server/services/finance/forecast.js @@ -146,12 +146,14 @@ export function buildForecast({ if (!isCents(subscription.priceAmount) || subscription.priceAmount <= 0) continue const periodEnd = berlinDayOf(subscription.currentPeriodEnd) 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) + if (!cancelAt && subscription.cancelAtPeriodEnd) continue for (const renewal of recurring(periodEnd, 1)) { const payoutDate = addDays(renewal, PAYOUT_DELAY_DAYS) if (payoutDate > end) break - if (cancelAt && renewal >= cancelAt) break // gekuendigt: ab dem Kuendigungstag keine Verlaengerung mehr - if (subscription.cancelAtPeriodEnd && renewal > periodEnd) break // zum Periodenende gekuendigt: nur die laufende Periode + if (cancelAt && renewal >= cancelAt) break // nur Verlaengerungen vor dem Kuendigungstag if (payoutDate >= today) add(payoutDate, 'Abo-Verlängerung', subscription.priceAmount, ERWARTET) } } diff --git a/test/financeForecast.test.js b/test/financeForecast.test.js index 8d6ccf5..f33d21f 100644 --- a/test/financeForecast.test.js +++ b/test/financeForecast.test.js @@ -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']) }) -test('Abo mit cancelAtPeriodEnd: kein Posten am 09.11.', () => { - const r = base({ horizonDays: 70, subscriptions: [sub({ cancelAtPeriodEnd: true })] }) - assert.ok(!dates(r).includes('2026-11-09')) - assert.deepEqual(dates(r), ['2026-10-09']) +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, cancelAt: null })] }) + assert.deepEqual(r.posten, []) + // 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', () => { @@ -153,6 +158,23 @@ test('Abo mit cancelAt: keine Termine ab cancelAt', () => { 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', () => { const r = base({ subscriptions: [