Skip to content

Commit 60f723b

Browse files
refactor(billing): single Date.now() snapshot for adminSyncMs consistency (#3622)
Use one Date.now() call so lastSubscriptionEventCreatedAt and lastSubscriptionEventId derive from the same instant — avoids a sub-ms drift if a GC pause hits between two separate calls.
1 parent 3f171f2 commit 60f723b

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,19 @@ const syncOrgFromStripe = async (orgId) => {
100100

101101
// Intentional: full Stripe-truth sync — writes status AND plan directly.
102102
// adminUpdatePlanOnly is for the UI plan-bump flow only (audit trail, no Stripe re-sync).
103+
//
104+
// Bump event markers so any subsequent stale Stripe webhook (delayed re-delivery) whose
105+
// event.created is older than this admin sync timestamp is rejected by updateIfEventNewer.
106+
// The admin-sync-{ms} ID format is for traceability; updateIfEventNewer only compares
107+
// lastSubscriptionEventCreatedAt for ordering — the ID acts as a tiebreaker.
108+
const adminSyncMs = Date.now();
103109
const updated = await SubscriptionRepository.update({
104110
_id: existing._id,
105111
plan: newPlan,
106112
status: newStatus,
107113
...(newPeriodStart ? { currentPeriodStart: newPeriodStart } : {}),
114+
lastSubscriptionEventCreatedAt: Math.floor(adminSyncMs / 1000),
115+
lastSubscriptionEventId: `admin-sync-${adminSyncMs}`,
108116
});
109117

110118
// Sync org plan field so quotas + access control reflect the new plan immediately.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import mongoose from 'mongoose';
5+
import { describe, beforeAll, beforeEach, afterAll, afterEach, test, expect, jest } from '@jest/globals';
6+
7+
import config from '../../../config/index.js';
8+
import mongooseService from '../../../lib/services/mongoose.js';
9+
10+
/**
11+
* Integration tests for syncOrgFromStripe event-marker race-window closure (Batch 3b).
12+
*
13+
* Validates:
14+
* 1. Admin sync sets lastSubscriptionEventCreatedAt and lastSubscriptionEventId on the DB doc.
15+
* 2. A stale Stripe webhook (event.created < adminSyncTimestamp) is rejected by updateIfEventNewer
16+
* — the DB stays at admin-set values.
17+
* 3. A fresher Stripe webhook (event.created > adminSyncTimestamp) wins and updates the DB.
18+
*
19+
* Architecture note: BillingAdminService is re-imported per test (jest.resetModules) so that
20+
* mocked Stripe / config are picked up fresh. SubscriptionRepository is imported once in
21+
* beforeAll (after loadModels) and re-injected via unstable_mockModule to avoid the
22+
* MissingSchemaError that happens when it re-registers mongoose.model() on a reset module graph.
23+
*/
24+
describe('syncOrgFromStripe — event marker race-window closure integration tests:', () => {
25+
let Subscription;
26+
let Organization;
27+
let SubscriptionRepository;
28+
let BillingAdminService;
29+
let mockStripeInstance;
30+
31+
beforeAll(async () => {
32+
await mongooseService.loadModels();
33+
await mongooseService.connect();
34+
35+
Subscription = mongoose.model('Subscription');
36+
Organization = mongoose.model('Organization');
37+
await Subscription.syncIndexes();
38+
39+
// Import real SubscriptionRepository once — it references mongoose.model('Subscription')
40+
// at import time, so it must be imported after loadModels. The instance is reused across
41+
// all tests via unstable_mockModule injection (see beforeEach).
42+
SubscriptionRepository = (await import('../repositories/billing.subscription.repository.js')).default;
43+
});
44+
45+
beforeEach(async () => {
46+
jest.resetModules();
47+
await Promise.all([
48+
Subscription.deleteMany({}),
49+
Organization.deleteMany({}),
50+
]);
51+
52+
mockStripeInstance = {
53+
subscriptions: {
54+
retrieve: jest.fn().mockResolvedValue({
55+
id: 'sub_test_batch3b',
56+
status: 'active',
57+
current_period_start: Math.floor(Date.now() / 1000) - 86400,
58+
items: {
59+
data: [{ price: { metadata: { planId: 'pro' } } }],
60+
},
61+
}),
62+
},
63+
};
64+
65+
jest.unstable_mockModule('../../../config/index.js', () => ({
66+
default: {
67+
stripe: { secretKey: 'sk_test_batch3b_fake' },
68+
billing: {
69+
plans: config.billing?.plans ?? ['free', 'starter', 'pro', 'enterprise'],
70+
statuses: config.billing?.statuses ?? ['active', 'trialing', 'past_due', 'unpaid', 'canceled'],
71+
},
72+
},
73+
}));
74+
75+
jest.unstable_mockModule('../lib/stripe.js', () => ({
76+
default: jest.fn().mockReturnValue(mockStripeInstance),
77+
}));
78+
79+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
80+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
81+
}));
82+
83+
jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({
84+
default: {
85+
setPlan: jest.fn().mockResolvedValue({}),
86+
},
87+
}));
88+
89+
// Inject the real SubscriptionRepository (already bound to the registered mongoose model)
90+
// so syncOrgFromStripe hits the real DB while Stripe stays mocked.
91+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
92+
default: SubscriptionRepository,
93+
}));
94+
95+
jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({
96+
default: {
97+
listDeadLetters: jest.fn(),
98+
purgeDeadLetterByEventId: jest.fn(),
99+
deleteByEventId: jest.fn(),
100+
},
101+
}));
102+
103+
jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({
104+
default: { creditCompensation: jest.fn() },
105+
}));
106+
107+
jest.unstable_mockModule('../services/billing.webhook.service.js', () => ({
108+
default: {
109+
withIdempotency: jest.fn(),
110+
handleCheckoutSessionCompleted: jest.fn(),
111+
handleSubscriptionCreated: jest.fn(),
112+
handleSubscriptionUpdated: jest.fn(),
113+
handleSubscriptionDeleted: jest.fn(),
114+
handleInvoicePaymentFailed: jest.fn(),
115+
handleInvoicePaymentSucceeded: jest.fn(),
116+
handleChargeRefunded: jest.fn(),
117+
handleCustomerDeleted: jest.fn(),
118+
handleChargeDisputeCreated: jest.fn(),
119+
handleChargeDisputeFundsWithdrawn: jest.fn(),
120+
handleChargeDisputeFundsReinstated: jest.fn(),
121+
},
122+
}));
123+
124+
jest.unstable_mockModule('../lib/events.js', () => ({
125+
default: { emit: jest.fn() },
126+
}));
127+
128+
BillingAdminService = (await import('../services/billing.admin.service.js')).default;
129+
});
130+
131+
afterEach(() => {
132+
jest.restoreAllMocks();
133+
});
134+
135+
afterAll(async () => {
136+
await mongooseService.disconnect();
137+
});
138+
139+
test('admin sync sets lastSubscriptionEventCreatedAt and lastSubscriptionEventId on the DB doc', async () => {
140+
const orgId = new mongoose.Types.ObjectId();
141+
await Organization.create({ _id: orgId, name: 'Batch3b Org', slug: 'batch3b-org', plan: 'free' });
142+
await Subscription.create({
143+
organization: orgId,
144+
stripeCustomerId: 'cus_batch3b_1',
145+
stripeSubscriptionId: 'sub_test_batch3b',
146+
plan: 'free',
147+
status: 'active',
148+
});
149+
150+
const before = Math.floor(Date.now() / 1000);
151+
await BillingAdminService.syncOrgFromStripe(orgId.toString());
152+
const after = Math.floor(Date.now() / 1000);
153+
154+
const doc = await Subscription.findOne({ organization: orgId }).lean();
155+
156+
expect(doc.lastSubscriptionEventCreatedAt).toBeGreaterThanOrEqual(before);
157+
expect(doc.lastSubscriptionEventCreatedAt).toBeLessThanOrEqual(after);
158+
expect(typeof doc.lastSubscriptionEventId).toBe('string');
159+
expect(doc.lastSubscriptionEventId).toMatch(/^admin-sync-\d+$/);
160+
expect(doc.plan).toBe('pro');
161+
expect(doc.status).toBe('active');
162+
});
163+
164+
test('stale webhook after admin sync is rejected — DB stays at admin-set values', async () => {
165+
const orgId = new mongoose.Types.ObjectId();
166+
await Organization.create({ _id: orgId, name: 'Batch3b Org Stale', slug: 'batch3b-org-stale', plan: 'free' });
167+
await Subscription.create({
168+
organization: orgId,
169+
stripeCustomerId: 'cus_batch3b_2',
170+
stripeSubscriptionId: 'sub_test_batch3b',
171+
plan: 'free',
172+
status: 'active',
173+
});
174+
175+
await BillingAdminService.syncOrgFromStripe(orgId.toString());
176+
177+
const docAfterSync = await Subscription.findOne({ organization: orgId }).lean();
178+
const adminSyncTimestamp = docAfterSync.lastSubscriptionEventCreatedAt;
179+
180+
// Simulate a stale Stripe webhook: event.created is 1 second before the admin sync
181+
const staleEventCreatedAt = adminSyncTimestamp - 1;
182+
const staleEventId = 'evt_stale_before_admin_sync';
183+
184+
const result = await SubscriptionRepository.updateIfEventNewer(
185+
docAfterSync._id.toString(),
186+
staleEventCreatedAt,
187+
staleEventId,
188+
{ plan: 'free', status: 'canceled' },
189+
'subscription',
190+
);
191+
192+
// updateIfEventNewer must return null (guard blocked the write)
193+
expect(result).toBeNull();
194+
195+
const docAfterStale = await Subscription.findOne({ organization: orgId }).lean();
196+
// DB must remain at admin-set values (plan=pro, status=active)
197+
expect(docAfterStale.plan).toBe('pro');
198+
expect(docAfterStale.status).toBe('active');
199+
expect(docAfterStale.lastSubscriptionEventCreatedAt).toBe(adminSyncTimestamp);
200+
});
201+
202+
test('fresher webhook after admin sync wins — DB updates to webhook payload', async () => {
203+
const orgId = new mongoose.Types.ObjectId();
204+
await Organization.create({ _id: orgId, name: 'Batch3b Org Fresh', slug: 'batch3b-org-fresh', plan: 'free' });
205+
await Subscription.create({
206+
organization: orgId,
207+
stripeCustomerId: 'cus_batch3b_3',
208+
stripeSubscriptionId: 'sub_test_batch3b',
209+
plan: 'free',
210+
status: 'active',
211+
});
212+
213+
await BillingAdminService.syncOrgFromStripe(orgId.toString());
214+
215+
const docAfterSync = await Subscription.findOne({ organization: orgId }).lean();
216+
const adminSyncTimestamp = docAfterSync.lastSubscriptionEventCreatedAt;
217+
218+
// Simulate a fresher Stripe webhook: event.created is 1 second after the admin sync
219+
const freshEventCreatedAt = adminSyncTimestamp + 1;
220+
const freshEventId = 'evt_fresh_after_admin_sync';
221+
222+
const plans = config.billing?.plans ?? ['free', 'starter', 'pro', 'enterprise'];
223+
const targetPlan = plans.find((p) => p !== 'pro') ?? 'starter';
224+
225+
const result = await SubscriptionRepository.updateIfEventNewer(
226+
docAfterSync._id.toString(),
227+
freshEventCreatedAt,
228+
freshEventId,
229+
{ plan: targetPlan, status: 'canceled' },
230+
'subscription',
231+
);
232+
233+
// updateIfEventNewer must succeed (fresh event wins)
234+
expect(result).not.toBeNull();
235+
236+
const docAfterFresh = await Subscription.findOne({ organization: orgId }).lean();
237+
expect(docAfterFresh.plan).toBe(targetPlan);
238+
expect(docAfterFresh.status).toBe('canceled');
239+
expect(docAfterFresh.lastSubscriptionEventCreatedAt).toBe(freshEventCreatedAt);
240+
expect(docAfterFresh.lastSubscriptionEventId).toBe(freshEventId);
241+
});
242+
});

0 commit comments

Comments
 (0)