Skip to content

Commit 3322087

Browse files
fix(billing): admin sync reads period from items.data[0] (Stripe API ≥ 2025-08-27)
syncOrgFromStripe was reading current_period_start from the top-level Stripe subscription object, which no longer exists on API version ≥ 2025-08-27. The field moved to items.data[0].current_period_start. After an admin force-sync, currentPeriodStart was written as null, silently dropping that subscription from the weekly-reset sweep (findAllDueForResetByLastReset filters null out). Mirror the dual-read fallback already used by the webhook handler: items.data[0].current_period_start ?? current_period_start (top-level). Fixes #3727.
1 parent d42eb12 commit 3322087

2 files changed

Lines changed: 44 additions & 2 deletions

File tree

modules/billing/services/billing.admin.service.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ const syncOrgFromStripe = async (orgId) => {
9494
const stripeSub = await stripe.subscriptions.retrieve(existing.stripeSubscriptionId);
9595
const newPlan = resolveStripePlan(stripeSub);
9696
const newStatus = stripeSub.status;
97-
const newPeriodStart = stripeSub.current_period_start
98-
? new Date(stripeSub.current_period_start * 1000)
97+
// Stripe API ≥ 2025-08-27 moved current_period_start to items.data[0].
98+
// Read from items first, fall back to top-level for older API versions (mirrors webhook handler).
99+
const rawPeriodStart = stripeSub.items?.data?.[0]?.current_period_start ?? stripeSub.current_period_start;
100+
const newPeriodStart = rawPeriodStart
101+
? new Date(rawPeriodStart * 1000)
99102
: null;
100103

101104
const previous = { plan: existing.plan, status: existing.status };

modules/billing/tests/billing.admin.service.unit.tests.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,45 @@ describe('BillingAdminService unit tests:', () => {
239239
test('throws 422 for invalid orgId format', async () => {
240240
await expect(BillingAdminService.syncOrgFromStripe('bad-id')).rejects.toMatchObject({ status: 422 });
241241
});
242+
243+
test('writes non-null currentPeriodStart when period is only in items.data[0] (Stripe API ≥ 2025-08-27)', async () => {
244+
// Simulate Stripe API ≥ 2025-08-27: top-level current_period_start is absent,
245+
// period lives in items.data[0].current_period_start only.
246+
const periodStart = 1750000000;
247+
mockStripeInstance.subscriptions.retrieve.mockResolvedValue({
248+
id: stripeSubId,
249+
status: 'active',
250+
current_period_start: undefined, // absent on new API
251+
items: {
252+
data: [{
253+
current_period_start: periodStart,
254+
price: { metadata: { planId: 'pro' } },
255+
}],
256+
},
257+
});
258+
259+
await BillingAdminService.syncOrgFromStripe(orgId);
260+
261+
const updateCall = mockSubscriptionRepository.update.mock.calls[0][0];
262+
expect(updateCall.currentPeriodStart).toEqual(new Date(periodStart * 1000));
263+
});
264+
265+
test('falls back to top-level current_period_start when items.data[0] has none (legacy API)', async () => {
266+
const periodStart = 1699000000;
267+
mockStripeInstance.subscriptions.retrieve.mockResolvedValue({
268+
id: stripeSubId,
269+
status: 'active',
270+
current_period_start: periodStart,
271+
items: {
272+
data: [{ price: { metadata: { planId: 'pro' } } }], // no current_period_start on item
273+
},
274+
});
275+
276+
await BillingAdminService.syncOrgFromStripe(orgId);
277+
278+
const updateCall = mockSubscriptionRepository.update.mock.calls[0][0];
279+
expect(updateCall.currentPeriodStart).toEqual(new Date(periodStart * 1000));
280+
});
242281
});
243282

244283
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)