From ece6956842580a657274652617c7b00401726476 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 5 Aug 2026 21:16:15 +0200 Subject: [PATCH] fix(billing): surface live cancelAt through getSubscription (#4022) fetchSubscriptionDetails computed cancelAt from the live Stripe object but dropped it before returning, so getSubscription's merge kept the stale, DB-only value written by the last webhook. On reactivation (Stripe clears cancel_at, cancel_at_period_end stays false) a consumer reading cancelAt would see a cancellation that no longer exists until the webhook lands. Include cancelAt in the returned object so the live value overrides the persisted one, same treatment cancelAtPeriodEnd and currentPeriodEnd already get. --- modules/billing/services/billing.service.js | 1 + .../tests/billing.checkout.unit.tests.js | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/modules/billing/services/billing.service.js b/modules/billing/services/billing.service.js index caf984e7c..83938450b 100644 --- a/modules/billing/services/billing.service.js +++ b/modules/billing/services/billing.service.js @@ -338,6 +338,7 @@ const fetchSubscriptionDetails = async (stripeSubscriptionId) => { currentPeriodStart: new Date(rawPeriodStart * 1000), currentPeriodEnd: new Date(rawPeriodEnd * 1000), cancelAtPeriodEnd: stripeSub.cancel_at_period_end, + cancelAt, status: stripeSub.status, nextRenewalDate: cancelAt ?? new Date(rawPeriodEnd * 1000), }; diff --git a/modules/billing/tests/billing.checkout.unit.tests.js b/modules/billing/tests/billing.checkout.unit.tests.js index 351752041..68c5a8cf7 100644 --- a/modules/billing/tests/billing.checkout.unit.tests.js +++ b/modules/billing/tests/billing.checkout.unit.tests.js @@ -746,6 +746,44 @@ describe('Billing service unit tests:', () => { expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith('sub_456'); }); + test('should override a stale persisted cancelAt with the live value on reactivation', async () => { + // A prior webhook persisted a cancellation date. The subscription was since + // reactivated in Stripe (cancel_at cleared), but the DB copy still carries + // the stale date. getSubscription must surface the live (null) value, not + // the stale persisted one — same freshness treatment as cancelAtPeriodEnd. + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_sub_reactivate' } }, + })); + + const periodEnd = Math.floor(Date.now() / 1000) + 86400; + mockStripeInstance.subscriptions = { + retrieve: jest.fn().mockResolvedValue({ + current_period_start: periodEnd - 2592000, + current_period_end: periodEnd, + cancel_at_period_end: false, + status: 'active', + cancel_at: null, + }), + }; + + const staleCancelAt = new Date(Date.now() + 3600 * 1000); + const mockSub = { + organization: orgId, + plan: 'pro', + stripeSubscriptionId: 'sub_reactivated', + cancelAt: staleCancelAt, + toJSON: () => ({ organization: orgId, plan: 'pro', stripeSubscriptionId: 'sub_reactivated', cancelAt: staleCancelAt }), + }; + mockSubscriptionRepository.findByOrganization.mockResolvedValue(mockSub); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + const result = await BillingService.getSubscription(orgId); + + expect(result.cancelAt).toBeNull(); + }); + test('should return cached sub without Stripe fetch when no stripeSubscriptionId (free plan)', async () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: { stripe: { secretKey: 'sk_test_sub_free' } },