Skip to content

Commit 5036e01

Browse files
fix(billing): V8.1 post-merge hardening (#3629)
* fix(billing): v8.1 post-merge hardening F1: wrap syncOrganizationPlan in try/catch in handleInvoicePaymentSucceeded — DB blip no longer dead-letters handler; emits billing.organization.sync_failed F2: prepend '~' to synthetic event IDs in bumpEventMarkers — same-second lex tiebreaker now sorts synthetic bumps after Stripe evt_* IDs F3: validatePlan warns on unrecognized non-empty planId before returning null F4: add 'unpaid' to RECONCILE_STATUSES so reconciler covers unpaid subs F5: add V8-C2b — canceled subscription in meter mode must be fail-closed * fix(billing): include error stacks + cover syncOrganizationPlan failure path Add stack: err?.stack to both logger.error calls in the syncOrganizationPlan catch block to match the convention used throughout billing.webhook.service.js. Add unit test covering the dunning recovery setPlan failure path: asserts non-fatal behaviour, logger.error call, and billing.organization.sync_failed emit. * test(billing): cover evtErr inner catch + validatePlan warn branch for codecov/patch Add two more unit tests to close remaining uncovered lines in the V8.1 patch: - sync_failed listener throws: asserts inner evtErr catch is non-fatal and logs correctly - validatePlan warns on unrecognized non-empty planId (e.g. Stripe product ID)
1 parent cd3cde1 commit 5036e01

7 files changed

Lines changed: 141 additions & 7 deletions

modules/billing/lib/billing.markerBump.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export const bumpEventMarkers = (family, source) => {
1313
const fieldPrefix = family === 'invoice' ? 'lastInvoiceEvent' : 'lastSubscriptionEvent';
1414
return {
1515
[`${fieldPrefix}CreatedAt`]: Math.floor(ms / 1000),
16-
[`${fieldPrefix}Id`]: `${source}-${ms}`,
16+
[`${fieldPrefix}Id`]: `~${source}-${ms}`,
1717
};
1818
};

modules/billing/services/billing.reconcile.service.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const RECONCILE_PAGE_SIZE = 100;
1515
/**
1616
* Statuses reconciled against Stripe.
1717
*/
18-
const RECONCILE_STATUSES = ['active', 'past_due', 'trialing'];
18+
const RECONCILE_STATUSES = ['active', 'past_due', 'trialing', 'unpaid'];
1919

2020
/**
2121
* Valid plan names from config.

modules/billing/services/billing.webhook.service.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ const validPlans = new Set(config.billing?.plans || ['free', 'starter', 'pro', '
4242
* @param {string} plan - The plan name to validate.
4343
* @returns {string|null} The plan name if valid, null otherwise.
4444
*/
45-
const validatePlan = (plan) => (validPlans.has(plan) ? plan : null);
45+
const validatePlan = (plan) => {
46+
if (validPlans.has(plan)) return plan;
47+
if (plan) logger.warn('[billing.webhook] validatePlan: unrecognized planId', { raw: plan, validPlans: [...validPlans] });
48+
return null;
49+
};
4650

4751
/**
4852
* Plan rank lookup — higher index means higher-tier plan.
@@ -601,7 +605,23 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
601605
}
602606

603607
if (isPastDue && resolvedPlan) {
604-
await syncOrganizationPlan(organizationId, resolvedPlan);
608+
try {
609+
await syncOrganizationPlan(organizationId, resolvedPlan);
610+
} catch (syncErr) {
611+
logger.error('[billing.webhook] syncOrganizationPlan failed (non-fatal)', {
612+
organizationId,
613+
error: syncErr?.message ?? String(syncErr),
614+
stack: syncErr?.stack,
615+
});
616+
try {
617+
billingEvents.emit('billing.organization.sync_failed', { organizationId, source: 'dunning_recovery' });
618+
} catch (evtErr) {
619+
logger.error('[billing.webhook] billing.organization.sync_failed listener error (non-fatal)', {
620+
error: evtErr?.message ?? String(evtErr),
621+
stack: evtErr?.stack,
622+
});
623+
}
624+
}
605625
}
606626
};
607627

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ describe('BillingAdminService unit tests:', () => {
449449

450450
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
451451
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
452-
expect(lastSubscriptionEventId).toMatch(/^admin-cancel-\d+$/);
452+
expect(lastSubscriptionEventId).toMatch(/^~admin-cancel-\d+$/);
453453
});
454454
});
455455

modules/billing/tests/billing.quota.unit.tests.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,5 +621,29 @@ describe('requireQuota middleware:', () => {
621621
// Fail-closed branch must short-circuit before the meter check
622622
expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled();
623623
});
624+
625+
test('V8-C2b: canceled subscription in meter mode → 402 METER_EXHAUSTED with free quota (not paid)', async () => {
626+
// Org has status='canceled' (subscription ended). Paid plan must NOT bleed through.
627+
// Fail-closed branch routes to free plan, same as incomplete.
628+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'canceled' });
629+
mockBillingUsageService.getMeter.mockResolvedValue(null);
630+
mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0);
631+
mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' });
632+
633+
await requireQuota('scraps', 'create')(req, res, next);
634+
635+
expect(next).not.toHaveBeenCalled();
636+
expect(res.status).toHaveBeenCalledWith(402);
637+
const payload = res.json.mock.calls[0][0];
638+
const errData = JSON.parse(payload.error);
639+
expect(errData.type).toBe('METER_EXHAUSTED');
640+
// Free plan quota (0), not the pro paid quota
641+
expect(errData.meterQuota).toBe(0);
642+
// getActivePlan called with the free/default plan id, not 'pro'
643+
expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('free');
644+
expect(mockBillingPlanService.getActivePlan).not.toHaveBeenCalledWith('pro');
645+
// Fail-closed branch must short-circuit before the meter check
646+
expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled();
647+
});
624648
});
625649
});

modules/billing/tests/billing.subscription.repository.unit.tests.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ describe('BillingSubscriptionRepository unit tests:', () => {
409409

410410
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
411411
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
412-
expect(lastSubscriptionEventId).toMatch(/^admin-bump-\d+$/);
412+
expect(lastSubscriptionEventId).toMatch(/^~admin-bump-\d+$/);
413413
});
414414
});
415415

@@ -433,7 +433,7 @@ describe('BillingSubscriptionRepository unit tests:', () => {
433433

434434
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
435435
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
436-
expect(lastSubscriptionEventId).toMatch(/^dunning-\d+$/);
436+
expect(lastSubscriptionEventId).toMatch(/^~dunning-\d+$/);
437437
});
438438
});
439439
});

modules/billing/tests/billing.webhook.subscription.unit.tests.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,96 @@ describe('Billing webhook subscription unit tests:', () => {
600600
'invoice',
601601
);
602602
});
603+
604+
// V8.1 — syncOrganizationPlan failure path coverage
605+
test('V8.1 — syncOrganizationPlan failure: non-fatal, logs error + emits sync_failed', async () => {
606+
const existing = {
607+
_id: subId,
608+
organization: orgId,
609+
pastDueSince: new Date('2026-04-01'),
610+
status: 'unpaid',
611+
plan: 'free',
612+
};
613+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
614+
mockStripe.subscriptions.retrieve.mockResolvedValue({
615+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
616+
});
617+
mockOrganizationRepository.setPlan.mockRejectedValue(new Error('DB write failed'));
618+
619+
// The logger mock is registered in beforeEach via jest.unstable_mockModule.
620+
// Import it here (after BillingWebhookService) to get the same mocked instance
621+
// that the service module captured at load time.
622+
const { default: mockLogger } = await import('../../../lib/services/logger.js');
623+
624+
await expect(
625+
BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent()),
626+
).resolves.not.toThrow();
627+
628+
expect(mockLogger.error).toHaveBeenCalledWith(
629+
'[billing.webhook] syncOrganizationPlan failed (non-fatal)',
630+
expect.objectContaining({ organizationId: orgId }),
631+
);
632+
expect(mockEvents.emit).toHaveBeenCalledWith(
633+
'billing.organization.sync_failed',
634+
expect.objectContaining({ organizationId: orgId, source: 'dunning_recovery' }),
635+
);
636+
});
637+
638+
test('V8.1 — sync_failed listener throws: inner evtErr catch is non-fatal', async () => {
639+
const existing = {
640+
_id: subId,
641+
organization: orgId,
642+
pastDueSince: new Date('2026-04-01'),
643+
status: 'unpaid',
644+
plan: 'free',
645+
};
646+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
647+
mockStripe.subscriptions.retrieve.mockResolvedValue({
648+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
649+
});
650+
// Make syncOrganizationPlan throw so we enter the syncErr catch
651+
mockOrganizationRepository.setPlan.mockRejectedValue(new Error('DB write failed'));
652+
// Make billingEvents.emit throw so we enter the inner evtErr catch
653+
mockEvents.emit.mockImplementation(() => { throw new Error('listener crash'); });
654+
655+
const { default: mockLogger } = await import('../../../lib/services/logger.js');
656+
657+
await expect(
658+
BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent()),
659+
).resolves.not.toThrow();
660+
661+
expect(mockLogger.error).toHaveBeenCalledWith(
662+
'[billing.webhook] billing.organization.sync_failed listener error (non-fatal)',
663+
expect.objectContaining({ error: 'listener crash' }),
664+
);
665+
});
666+
667+
test('V8.1 — validatePlan warns on unrecognized non-empty planId (falls back to free)', async () => {
668+
const existing = {
669+
_id: subId,
670+
organization: orgId,
671+
pastDueSince: new Date('2026-04-01'),
672+
status: 'past_due',
673+
plan: 'free',
674+
};
675+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
676+
// Return an unrecognized planId (e.g. a Stripe product ID instead of a plan slug)
677+
mockStripe.subscriptions.retrieve.mockResolvedValue({
678+
items: { data: [{ price: { metadata: { planId: 'prod_unknownXYZ' } } }] },
679+
});
680+
681+
const { default: mockLogger } = await import('../../../lib/services/logger.js');
682+
683+
await expect(
684+
BillingWebhookService.handleInvoicePaymentSucceeded({ subscription: 'sub_456' }, makeEvent()),
685+
).resolves.not.toThrow();
686+
687+
// validatePlan should have logged a warning for the unrecognized plan
688+
expect(mockLogger.warn).toHaveBeenCalledWith(
689+
'[billing.webhook] validatePlan: unrecognized planId',
690+
expect.objectContaining({ raw: 'prod_unknownXYZ' }),
691+
);
692+
});
603693
});
604694

605695
describe('handleInvoicePaymentFailed', () => {

0 commit comments

Comments
 (0)