Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 additions & 0 deletions modules/billing/lib/billing.markerBump.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Build the event-ordering marker fields to merge into a direct-write update,
* so a stale Stripe redelivery cannot overwrite the write via updateIfEventNewer.
* Use ONLY in direct-write paths (admin overrides, dunning sweep, cancellation) —
* normal webhook handlers go through updateIfEventNewer which sets these on its own.
*
* @param {'subscription'|'invoice'} family
* @param {string} source - prefix for the synthesised event id (e.g. 'admin-bump', 'dunning')
* @returns {Object} { last{Family}EventCreatedAt, last{Family}EventId }
*/
export const bumpEventMarkers = (family, source) => {
const ms = Date.now();
const fieldPrefix = family === 'invoice' ? 'lastInvoiceEvent' : 'lastSubscriptionEvent';
return {
[`${fieldPrefix}CreatedAt`]: Math.floor(ms / 1000),
[`${fieldPrefix}Id`]: `${source}-${ms}`,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Module dependencies
*/
import mongoose from 'mongoose';
import { bumpEventMarkers } from '../lib/billing.markerBump.js';

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

Expand Down Expand Up @@ -206,7 +207,7 @@ const markUnpaid = (id, threshold) => {
if (!(threshold instanceof Date)) throw new TypeError('threshold must be a Date instance');
return Subscription.findOneAndUpdate(
{ _id: id, status: 'past_due', pastDueSince: { $lte: threshold } },
{ $set: { status: 'unpaid', plan: 'free' } },
{ $set: { status: 'unpaid', plan: 'free', ...bumpEventMarkers('subscription', 'dunning') } },
{ returnDocument: 'after', runValidators: true },
).exec();
};
Expand Down Expand Up @@ -302,6 +303,7 @@ const adminUpdatePlanOnly = (id, planId, adminUserId) => {
plan: planId,
adminUpdatedAt: new Date(),
adminUpdatedBy: safeAdminUserId,
...bumpEventMarkers('subscription', 'admin-bump'),
},
Comment thread
PierreBrisorgueil marked this conversation as resolved.
},
{ returnDocument: 'after', runValidators: true },
Expand Down
2 changes: 2 additions & 0 deletions modules/billing/services/billing.admin.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import config from '../../../config/index.js';
import getStripe from '../lib/stripe.js';
import logger from '../../../lib/services/logger.js';
import { bumpEventMarkers } from '../lib/billing.markerBump.js';
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
import ProcessedStripeEventRepository from '../repositories/billing.processedStripeEvent.repository.js';
import OrganizationRepository from '../../organizations/repositories/organizations.repository.js';
Expand Down Expand Up @@ -272,6 +273,7 @@ const cancelSubscription = async (orgId) => {
_id: existing._id,
plan: 'free',
status: 'canceled',
...bumpEventMarkers('subscription', 'admin-cancel'),
});
Comment on lines 271 to 277

await OrganizationRepository.setPlan(orgId, 'free');
Expand Down
16 changes: 16 additions & 0 deletions modules/billing/tests/billing.admin.service.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,22 @@ describe('BillingAdminService unit tests:', () => {
expect.any(Object),
);
});

// V8 audit C3 — cancelSubscription must bump markers so a stale
// customer.subscription.updated redelivery cannot restore 'active' after
// the admin cancellation wrote 'canceled' via direct SubscriptionRepository.update.
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
const before = Math.floor(Date.now() / 1000);
await BillingAdminService.cancelSubscription(orgId);
const after = Math.floor(Date.now() / 1000);

const updateCall = mockSubscriptionRepository.update.mock.calls[0][0];
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = updateCall;

expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
expect(lastSubscriptionEventId).toMatch(/^admin-cancel-\d+$/);
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => {

expect(mockModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: subId, status: 'past_due', pastDueSince: { $lte: threshold } },
{ $set: { status: 'unpaid', plan: 'free' } },
{ $set: expect.objectContaining({ status: 'unpaid', plan: 'free' }) },
expect.objectContaining({ returnDocument: 'after' }),
);
expect(result).toEqual(updated);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('BillingSubscriptionRepository unit tests:', () => {

expect(mockModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: subId, status: 'past_due', pastDueSince: { $lte: threshold } },
{ $set: { status: 'unpaid', plan: 'free' } },
{ $set: expect.objectContaining({ status: 'unpaid', plan: 'free' }) },
{ returnDocument: 'after', runValidators: true },
);
expect(result).toEqual(updated);
Expand Down Expand Up @@ -392,5 +392,48 @@ describe('BillingSubscriptionRepository unit tests:', () => {
const callArgs = mockModel.findByIdAndUpdate.mock.calls[0];
expect(callArgs[1].$set.adminUpdatedAt).toBeInstanceOf(Date);
});

// V8 audit C3 — event-ordering markers must be bumped so a stale Stripe redelivery
// cannot overwrite the admin plan bump via updateIfEventNewer.
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
const execMock = jest.fn().mockResolvedValue({ _id: subId, plan: 'pro' });
const populateMock = jest.fn().mockReturnValue({ exec: execMock });
mockModel.findByIdAndUpdate.mockReturnValue({ populate: populateMock });

const before = Math.floor(Date.now() / 1000);
await BillingSubscriptionRepository.adminUpdatePlanOnly(subId, 'pro', adminId);
const after = Math.floor(Date.now() / 1000);

const callArgs = mockModel.findByIdAndUpdate.mock.calls[0];
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = callArgs[1].$set;

expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
expect(lastSubscriptionEventId).toMatch(/^admin-bump-\d+$/);
});
});

// ── markUnpaid — V8 audit C3 marker bump ─────────────────────────────────

describe('markUnpaid — V8-C3 event-ordering markers', () => {
const threshold = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);

// V8 audit C3 — markUnpaid must bump markers so a stale invoice.payment_failed
// redelivery cannot restore 'past_due' after the dunning sweep set 'unpaid'.
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
test('V8-C3: bumps lastSubscriptionEventCreatedAt + lastSubscriptionEventId so stale webhook is rejected', async () => {
const updated = { _id: subId, status: 'unpaid', plan: 'free' };
mockModel.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updated) });

const before = Math.floor(Date.now() / 1000);
await BillingSubscriptionRepository.markUnpaid(subId, threshold);
const after = Math.floor(Date.now() / 1000);

const callArgs = mockModel.findOneAndUpdate.mock.calls[0];
const { lastSubscriptionEventCreatedAt, lastSubscriptionEventId } = callArgs[1].$set;

expect(lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
expect(lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
expect(lastSubscriptionEventId).toMatch(/^dunning-\d+$/);
});
});
});
Loading