Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,13 @@ const handleInvoicePaymentFailed = async (invoice, event) => {
};

/**
* @description Handle invoice.payment_succeeded event — clear degraded mode (pastDueSince).
* @description Handle invoice.payment_succeeded event — clear degraded mode and restore plan.
* When a past-due invoice is finally paid, remove the pastDueSince marker so
* the subscription exits degraded mode on next request.
* V8 C1: also re-fetches the live Stripe subscription to restore the correct plan,
* guarding against the case where customer.subscription.updated is dead-lettered
* after a dunning sweep downgraded the plan to 'free'. Stripe re-fetch failure is
* non-fatal (warn log, plan not restored, pastDueSince+status update still fires).
* Uses updateIfEventNewer to guard against out-of-order webhook delivery (V5 P1 #1).
* @param {Object} invoice - Stripe invoice object
* @param {Object} event - Full Stripe event (with event.created and event.id for ordering)
Expand All @@ -561,9 +565,29 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
// cheap (no runValidators on user-facing fields) and guards the invoice ordering window.
//
// For past-due subs: include pastDueSince + status so the sub exits degraded mode.
// V8 C1: also restore the plan from Stripe so a dunning-downgraded sub is fully recovered
// even when customer.subscription.updated is dead-lettered.
const isPastDue = existing.pastDueSince !== null && existing.pastDueSince !== undefined;
const fields = isPastDue ? { pastDueSince: null, status: 'active' } : {};

let resolvedPlan = null;
if (isPastDue) {
try {
const stripe = getStripe();
if (!stripe) throw new Error('Stripe not configured');
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
resolvedPlan = resolvePlan(stripeSub);
Comment on lines +573 to +579
fields.plan = resolvedPlan;
Comment on lines +579 to +580
} catch (stripeErr) {
logger.warn('[billing.webhook] invoice.payment_succeeded — Stripe re-fetch failed, plan not restored', {
stripeSubscriptionId,
error: stripeErr?.message ?? String(stripeErr),
});
}
}

const organizationId = String(existing.organization?._id || existing.organization);

const updated = await SubscriptionRepository.updateIfEventNewer(
String(existing._id),
event.created,
Expand All @@ -573,6 +597,11 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
);
if (!updated) {
logger.info('[billing.webhook] skipped stale event', { eventId: event.id, type: event.type });
return;
}

if (isPastDue && resolvedPlan) {
await syncOrganizationPlan(organizationId, resolvedPlan);
}
};

Expand Down
73 changes: 71 additions & 2 deletions modules/billing/tests/billing.webhook.subscription.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Billing webhook subscription unit tests:', () => {
let mockOrganizationRepository;
let mockResetService;
let mockEvents;
let mockStripe;

const orgId = '507f1f77bcf86cd799439011';
const subId = '607f1f77bcf86cd799439022';
Expand Down Expand Up @@ -65,6 +66,14 @@ describe('Billing webhook subscription unit tests:', () => {
default: mockResetService,
}));

mockStripe = {
subscriptions: { retrieve: jest.fn() },
};

jest.unstable_mockModule('../lib/stripe.js', () => ({
default: jest.fn(() => mockStripe),
}));

jest.unstable_mockModule('../lib/events.js', () => ({
default: mockEvents,
}));
Expand Down Expand Up @@ -441,14 +450,17 @@ describe('Billing webhook subscription unit tests:', () => {
status: 'past_due',
};
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockStripe.subscriptions.retrieve.mockResolvedValue({
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
});

await BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent());

expect(mockSubscriptionRepository.updateIfEventNewer).toHaveBeenCalledWith(
subId,
1700000400,
'evt_succeeded',
{ pastDueSince: null, status: 'active' },
expect.objectContaining({ pastDueSince: null, status: 'active' }),
'invoice',
);
});
Expand Down Expand Up @@ -485,14 +497,17 @@ describe('Billing webhook subscription unit tests:', () => {
status: 'past_due',
};
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockStripe.subscriptions.retrieve.mockResolvedValue({
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
});

await BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent());

expect(mockSubscriptionRepository.updateIfEventNewer).toHaveBeenCalledWith(
subId,
1700000400,
'evt_succeeded',
{ pastDueSince: null, status: 'active' },
expect.objectContaining({ pastDueSince: null, status: 'active' }),
'invoice',
);
});
Expand Down Expand Up @@ -520,6 +535,9 @@ describe('Billing webhook subscription unit tests:', () => {
};
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.updateIfEventNewer.mockResolvedValue(null);
mockStripe.subscriptions.retrieve.mockResolvedValue({
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
});

let mockLogger;
jest.unstable_mockModule('../../../lib/services/logger.js', () => {
Expand All @@ -531,6 +549,57 @@ describe('Billing webhook subscription unit tests:', () => {

expect(mockSubscriptionRepository.updateIfEventNewer).toHaveBeenCalled();
});

// V8 audit C1 — dunning recovery plan restoration
test('V8 C1 — dunning recovery: restores plan=pro after unpaid downgrade to free', async () => {
const existing = {
_id: subId,
organization: orgId,
pastDueSince: new Date('2026-04-01'),
status: 'unpaid',
plan: 'free',
};
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockStripe.subscriptions.retrieve.mockResolvedValue({
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
});

await BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent());

expect(mockSubscriptionRepository.updateIfEventNewer).toHaveBeenCalledWith(
subId,
1700000400,
'evt_succeeded',
expect.objectContaining({ plan: 'pro', status: 'active', pastDueSince: null }),
'invoice',
);
expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'pro');
});

test('V8 C1 — Stripe re-fetch failure: falls back gracefully, does not restore plan', async () => {
const existing = {
_id: subId,
organization: orgId,
pastDueSince: new Date('2026-04-01'),
status: 'unpaid',
plan: 'free',
};
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockStripe.subscriptions.retrieve.mockRejectedValue(new Error('Stripe unavailable'));

await expect(
BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent()),
).resolves.not.toThrow();

// update still fires but without plan field
expect(mockSubscriptionRepository.updateIfEventNewer).toHaveBeenCalledWith(
subId,
1700000400,
'evt_succeeded',
expect.not.objectContaining({ plan: expect.anything() }),
'invoice',
);
});
});

describe('handleInvoicePaymentFailed', () => {
Expand Down
Loading