Skip to content

Commit 998e302

Browse files
fix(billing): bump event markers on direct-write paths (#3628)
* fix(billing): bump event markers on direct-write paths V8 audit C3: markUnpaid, adminUpdatePlanOnly, and cancelSubscription skipped bumpEventMarkers, allowing a stale Stripe redelivery to pass updateIfEventNewer and silently overwrite the direct write. - Add bumpEventMarkers helper (modules/billing/lib/billing.markerBump.js) - Spread markers into markUnpaid $set ('dunning' source) - Spread markers into adminUpdatePlanOnly $set ('admin-bump' source) - Spread markers into cancelSubscription update payload ('admin-cancel' source) - Add 3 V8-C3 unit tests asserting marker presence post-write - Update 2 existing exact-match assertions to objectContaining * docs(billing): fix stale JSDoc + test comment after marker-bump fix - adminUpdatePlanOnly JSDoc now reflects that lastSubscriptionEvent* markers are bumped (per V8-C3 fix) — the old text said "Never touches … markers" - markUnpaid V8-C3 test comment: was "invoice.payment_failed" but the guard and markers are subscription-family; corrected to "customer.subscription.updated"
1 parent cd3ec29 commit 998e302

6 files changed

Lines changed: 91 additions & 7 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Build the event-ordering marker fields to merge into a direct-write update,
3+
* so a stale Stripe redelivery cannot overwrite the write via updateIfEventNewer.
4+
* Use ONLY in direct-write paths (admin overrides, dunning sweep, cancellation) —
5+
* normal webhook handlers go through updateIfEventNewer which sets these on its own.
6+
*
7+
* @param {'subscription'|'invoice'} family
8+
* @param {string} source - prefix for the synthesised event id (e.g. 'admin-bump', 'dunning')
9+
* @returns {Object} { last{Family}EventCreatedAt, last{Family}EventId }
10+
*/
11+
export const bumpEventMarkers = (family, source) => {
12+
const ms = Date.now();
13+
const fieldPrefix = family === 'invoice' ? 'lastInvoiceEvent' : 'lastSubscriptionEvent';
14+
return {
15+
[`${fieldPrefix}CreatedAt`]: Math.floor(ms / 1000),
16+
[`${fieldPrefix}Id`]: `${source}-${ms}`,
17+
};
18+
};

modules/billing/repositories/billing.subscription.repository.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Module dependencies
33
*/
44
import mongoose from 'mongoose';
5+
import { bumpEventMarkers } from '../lib/billing.markerBump.js';
56

67
const Subscription = mongoose.model('Subscription');
78

@@ -206,7 +207,7 @@ const markUnpaid = (id, threshold) => {
206207
if (!(threshold instanceof Date)) throw new TypeError('threshold must be a Date instance');
207208
return Subscription.findOneAndUpdate(
208209
{ _id: id, status: 'past_due', pastDueSince: { $lte: threshold } },
209-
{ $set: { status: 'unpaid', plan: 'free' } },
210+
{ $set: { status: 'unpaid', plan: 'free', ...bumpEventMarkers('subscription', 'dunning') } },
210211
{ returnDocument: 'after', runValidators: true },
211212
).exec();
212213
};
@@ -273,16 +274,19 @@ const updateIfEventNewer = (id, eventCreatedAt, eventId, fields, family = 'subsc
273274

274275
/**
275276
* @function adminUpdatePlanOnly
276-
* @description Restricted admin update — sets ONLY `plan` and `adminUpdatedAt`. Never touches
277-
* `status`, `stripeCustomerId`, `stripeSubscriptionId`, `currentPeriodStart`, or
278-
* the per-family event-ordering markers. Webhook handlers retain exclusive write
279-
* authority on those fields via `updateIfEventNewer`.
277+
* @description Restricted admin update — sets ONLY `plan`, `adminUpdatedAt`, and the
278+
* subscription-family event-ordering markers. Never touches `status`,
279+
* `stripeCustomerId`, `stripeSubscriptionId`, or `currentPeriodStart`.
280280
*
281281
* Why this restriction matters: a free `update()` call with `{ plan, status }` from
282282
* an admin path would overwrite Stripe-driven status (active/past_due/etc.) with
283283
* whatever the admin sent, breaking dunning + access control. Splitting the surface
284284
* forces admin overrides to plan-only territory.
285285
*
286+
* The subscription-family markers (`lastSubscriptionEvent*`) are bumped so a
287+
* subsequent stale Stripe webhook redelivery (customer.subscription.*) cannot
288+
* overwrite the admin plan change via `updateIfEventNewer`.
289+
*
286290
* @param {string} id - Subscription ObjectId (string).
287291
* @param {string} planId - The new plan identifier (must be in config.billing.plans enum).
288292
* @param {string} adminUserId - The admin user ObjectId (string) who triggered the bump (for audit).
@@ -302,6 +306,7 @@ const adminUpdatePlanOnly = (id, planId, adminUserId) => {
302306
plan: planId,
303307
adminUpdatedAt: new Date(),
304308
adminUpdatedBy: safeAdminUserId,
309+
...bumpEventMarkers('subscription', 'admin-bump'),
305310
},
306311
},
307312
{ returnDocument: 'after', runValidators: true },

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import config from '../../../config/index.js';
55
import getStripe from '../lib/stripe.js';
66
import logger from '../../../lib/services/logger.js';
7+
import { bumpEventMarkers } from '../lib/billing.markerBump.js';
78
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
89
import ProcessedStripeEventRepository from '../repositories/billing.processedStripeEvent.repository.js';
910
import OrganizationRepository from '../../organizations/repositories/organizations.repository.js';
@@ -273,6 +274,7 @@ const cancelSubscription = async (orgId) => {
273274
_id: existing._id,
274275
plan: 'free',
275276
status: 'canceled',
277+
...bumpEventMarkers('subscription', 'admin-cancel'),
276278
});
277279

278280
await OrganizationRepository.setPlan(orgId, 'free');

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,22 @@ describe('BillingAdminService unit tests:', () => {
435435
expect.any(Object),
436436
);
437437
});
438+
439+
// V8 audit C3 — cancelSubscription must bump markers so a stale
440+
// customer.subscription.updated redelivery cannot restore 'active' after
441+
// the admin cancellation wrote 'canceled' via direct SubscriptionRepository.update.
442+
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
443+
const before = Math.floor(Date.now() / 1000);
444+
await BillingAdminService.cancelSubscription(orgId);
445+
const after = Math.floor(Date.now() / 1000);
446+
447+
const updateCall = mockSubscriptionRepository.update.mock.calls[0][0];
448+
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = updateCall;
449+
450+
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
451+
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
452+
expect(lastSubscriptionEventId).toMatch(/^admin-cancel-\d+$/);
453+
});
438454
});
439455

440456
// ─────────────────────────────────────────────────────────────────────────────

modules/billing/tests/billing.cron.dunningSweep.unit.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => {
105105

106106
expect(mockModel.findOneAndUpdate).toHaveBeenCalledWith(
107107
{ _id: subId, status: 'past_due', pastDueSince: { $lte: threshold } },
108-
{ $set: { status: 'unpaid', plan: 'free' } },
108+
{ $set: expect.objectContaining({ status: 'unpaid', plan: 'free' }) },
109109
expect.objectContaining({ returnDocument: 'after' }),
110110
);
111111
expect(result).toEqual(updated);

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe('BillingSubscriptionRepository unit tests:', () => {
105105

106106
expect(mockModel.findOneAndUpdate).toHaveBeenCalledWith(
107107
{ _id: subId, status: 'past_due', pastDueSince: { $lte: threshold } },
108-
{ $set: { status: 'unpaid', plan: 'free' } },
108+
{ $set: expect.objectContaining({ status: 'unpaid', plan: 'free' }) },
109109
{ returnDocument: 'after', runValidators: true },
110110
);
111111
expect(result).toEqual(updated);
@@ -392,5 +392,48 @@ describe('BillingSubscriptionRepository unit tests:', () => {
392392
const callArgs = mockModel.findByIdAndUpdate.mock.calls[0];
393393
expect(callArgs[1].$set.adminUpdatedAt).toBeInstanceOf(Date);
394394
});
395+
396+
// V8 audit C3 — event-ordering markers must be bumped so a stale Stripe redelivery
397+
// cannot overwrite the admin plan bump via updateIfEventNewer.
398+
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
399+
const execMock = jest.fn().mockResolvedValue({ _id: subId, plan: 'pro' });
400+
const populateMock = jest.fn().mockReturnValue({ exec: execMock });
401+
mockModel.findByIdAndUpdate.mockReturnValue({ populate: populateMock });
402+
403+
const before = Math.floor(Date.now() / 1000);
404+
await BillingSubscriptionRepository.adminUpdatePlanOnly(subId, 'pro', adminId);
405+
const after = Math.floor(Date.now() / 1000);
406+
407+
const callArgs = mockModel.findByIdAndUpdate.mock.calls[0];
408+
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = callArgs[1].$set;
409+
410+
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
411+
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
412+
expect(lastSubscriptionEventId).toMatch(/^admin-bump-\d+$/);
413+
});
414+
});
415+
416+
// ── markUnpaid — V8 audit C3 marker bump ─────────────────────────────────
417+
418+
describe('markUnpaid — V8-C3 event-ordering markers', () => {
419+
const threshold = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
420+
421+
// V8 audit C3 — markUnpaid must bump markers so a stale customer.subscription.updated
422+
// redelivery cannot restore 'past_due' after the dunning sweep set 'unpaid'.
423+
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
424+
const updated = { _id: subId, status: 'unpaid', plan: 'free' };
425+
mockModel.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updated) });
426+
427+
const before = Math.floor(Date.now() / 1000);
428+
await BillingSubscriptionRepository.markUnpaid(subId, threshold);
429+
const after = Math.floor(Date.now() / 1000);
430+
431+
const callArgs = mockModel.findOneAndUpdate.mock.calls[0];
432+
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = callArgs[1].$set;
433+
434+
expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
435+
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
436+
expect(lastSubscriptionEventId).toMatch(/^dunning-\d+$/);
437+
});
395438
});
396439
});

0 commit comments

Comments
 (0)