Skip to content

Commit 390eef5

Browse files
committed
address comments
1 parent a3e6ce4 commit 390eef5

4 files changed

Lines changed: 508 additions & 111 deletions

File tree

apps/sim/lib/billing/threshold-billing.test.ts

Lines changed: 207 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ vi.mock('@sim/db/schema', () => ({
6767
billedOverageThisPeriod: 'userStats.billedOverageThisPeriod',
6868
creditBalance: 'userStats.creditBalance',
6969
currentPeriodCost: 'userStats.currentPeriodCost',
70+
lastPeriodCost: 'userStats.lastPeriodCost',
71+
proPeriodCostSnapshot: 'userStats.proPeriodCostSnapshot',
72+
proPeriodCostSnapshotAt: 'userStats.proPeriodCostSnapshotAt',
7073
userId: 'userStats.userId',
7174
},
7275
}))
@@ -148,22 +151,55 @@ function buildSelectChain<T>(rows: T[]) {
148151
}
149152
}
150153

151-
function buildCustomerSelectChain(customerId = 'cus_1') {
152-
return buildSelectChain([{ stripeCustomerId: customerId }])
154+
function buildPersonalSelectChain(customerId = 'cus_1') {
155+
return buildSelectChain([
156+
{
157+
currentPeriodCost: '0',
158+
proPeriodCostSnapshot: '0',
159+
proPeriodCostSnapshotAt: null,
160+
lastPeriodCost: '0',
161+
stripeCustomerId: customerId,
162+
},
163+
])
164+
}
165+
166+
function buildPersonalSnapshotSelectChain({
167+
currentPeriodCost = '0',
168+
proPeriodCostSnapshot = '0',
169+
proPeriodCostSnapshotAt = null,
170+
lastPeriodCost = '0',
171+
}: {
172+
currentPeriodCost?: string
173+
proPeriodCostSnapshot?: string
174+
proPeriodCostSnapshotAt?: Date | null
175+
lastPeriodCost?: string
176+
}) {
177+
return buildSelectChain([
178+
{
179+
currentPeriodCost,
180+
proPeriodCostSnapshot,
181+
proPeriodCostSnapshotAt,
182+
lastPeriodCost,
183+
},
184+
])
153185
}
154186

155187
function buildStatsSelectChain() {
156188
const result = {
189+
for: vi.fn(() => result),
157190
limit: mockTxStatsLimit,
158191
then: (resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
159192
Promise.resolve(mockTxStatsLimit()).then(resolve, reject),
160193
}
161194

162195
return {
163196
from: vi.fn(() => ({
164-
where: vi.fn(() => ({
165-
for: vi.fn(() => result),
197+
leftJoin: vi.fn(() => ({
198+
innerJoin: vi.fn(() => ({
199+
where: vi.fn(() => result),
200+
})),
166201
})),
202+
where: vi.fn(() => result),
167203
})),
168204
}
169205
}
@@ -186,7 +222,7 @@ describe('checkAndBillOverageThreshold', () => {
186222
mockIsFree.mockReturnValue(false)
187223
mockIsEnterprise.mockReturnValue(false)
188224
mockIsOrgScopedSubscription.mockReturnValue(false)
189-
mockDbSelect.mockImplementation(() => buildCustomerSelectChain())
225+
mockDbSelect.mockImplementation(() => buildPersonalSelectChain())
190226
mockTxSelect.mockImplementation(() => buildStatsSelectChain())
191227
mockTxUpdate.mockImplementation(() => buildUpdateChain())
192228
mockTxExecute.mockResolvedValue(undefined)
@@ -209,13 +245,22 @@ describe('checkAndBillOverageThreshold', () => {
209245
periodEnd: userSubscription.periodEnd,
210246
})
211247
expect(mockDbTransaction).not.toHaveBeenCalled()
212-
expect(mockDbSelect).not.toHaveBeenCalled()
248+
expect(mockDbSelect).toHaveBeenCalledTimes(1)
213249
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
214250
})
215251

216252
it('calculates overage before opening the short user_stats transaction', async () => {
217253
mockCalculateSubscriptionOverage.mockResolvedValue(250)
218-
mockTxStatsLimit.mockResolvedValue([{ billedOverageThisPeriod: '0', creditBalance: '0' }])
254+
mockTxStatsLimit.mockResolvedValue([
255+
{
256+
currentPeriodCost: '0',
257+
proPeriodCostSnapshot: '0',
258+
proPeriodCostSnapshotAt: null,
259+
lastPeriodCost: '0',
260+
billedOverageThisPeriod: '0',
261+
creditBalance: '0',
262+
},
263+
])
219264

220265
await checkAndBillOverageThreshold('user-1')
221266

@@ -230,7 +275,16 @@ describe('checkAndBillOverageThreshold', () => {
230275

231276
it('rechecks billed overage while locked before enqueueing an invoice', async () => {
232277
mockCalculateSubscriptionOverage.mockResolvedValue(250)
233-
mockTxStatsLimit.mockResolvedValue([{ billedOverageThisPeriod: '200', creditBalance: '0' }])
278+
mockTxStatsLimit.mockResolvedValue([
279+
{
280+
currentPeriodCost: '0',
281+
proPeriodCostSnapshot: '0',
282+
proPeriodCostSnapshotAt: null,
283+
lastPeriodCost: '0',
284+
billedOverageThisPeriod: '200',
285+
creditBalance: '0',
286+
},
287+
])
234288

235289
await checkAndBillOverageThreshold('user-1')
236290

@@ -240,6 +294,29 @@ describe('checkAndBillOverageThreshold', () => {
240294
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
241295
})
242296

297+
it('skips personal threshold billing when locked usage inputs changed', async () => {
298+
mockCalculateSubscriptionOverage.mockResolvedValue(250)
299+
mockDbSelect
300+
.mockImplementationOnce(() => buildPersonalSnapshotSelectChain({ currentPeriodCost: '250' }))
301+
.mockImplementationOnce(() => buildPersonalSelectChain())
302+
mockTxStatsLimit.mockResolvedValue([
303+
{
304+
currentPeriodCost: '0',
305+
proPeriodCostSnapshot: '0',
306+
proPeriodCostSnapshotAt: null,
307+
lastPeriodCost: '250',
308+
billedOverageThisPeriod: '0',
309+
creditBalance: '0',
310+
},
311+
])
312+
313+
await checkAndBillOverageThreshold('user-1')
314+
315+
expect(mockDbTransaction).toHaveBeenCalled()
316+
expect(mockTxUpdate).not.toHaveBeenCalled()
317+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
318+
})
319+
243320
it('computes organization overage before opening the locked transaction', async () => {
244321
mockIsOrgScopedSubscription.mockReturnValue(true)
245322
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
@@ -267,10 +344,17 @@ describe('checkAndBillOverageThreshold', () => {
267344
effectiveUsage: 350,
268345
})
269346
mockTxStatsLimit
347+
.mockResolvedValueOnce([{ userId: 'owner-1' }])
348+
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
349+
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
270350
.mockResolvedValueOnce([
271-
{ userId: 'owner-1', currentPeriodCost: '350', billedOverageThisPeriod: '0' },
351+
{
352+
userId: 'owner-1',
353+
role: 'owner',
354+
currentPeriodCost: '350',
355+
departedMemberUsage: '25',
356+
},
272357
])
273-
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
274358

275359
await checkAndBillOverageThreshold('user-1')
276360

@@ -319,10 +403,121 @@ describe('checkAndBillOverageThreshold', () => {
319403
effectiveUsage: 350,
320404
})
321405
mockTxStatsLimit
406+
.mockResolvedValueOnce([{ userId: 'owner-1' }])
407+
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
408+
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '75' }])
322409
.mockResolvedValueOnce([
323-
{ userId: 'owner-1', currentPeriodCost: '350', billedOverageThisPeriod: '0' },
410+
{
411+
userId: 'owner-1',
412+
role: 'owner',
413+
currentPeriodCost: '350',
414+
departedMemberUsage: '75',
415+
},
416+
])
417+
418+
await checkAndBillOverageThreshold('user-1')
419+
420+
expect(mockDbTransaction).toHaveBeenCalled()
421+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
422+
expect(mockTxUpdate).not.toHaveBeenCalled()
423+
})
424+
425+
it('rechecks organization billed overage on the locked owner tracker', async () => {
426+
mockIsOrgScopedSubscription.mockReturnValue(true)
427+
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
428+
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
429+
plan: 'team',
430+
seats: 2,
431+
periodStart: new Date('2026-05-01T00:00:00.000Z'),
432+
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
433+
stripeSubscriptionId: 'sub_team_1',
434+
stripeCustomerId: 'cus_team_1',
435+
})
436+
mockDbSelect.mockImplementationOnce(() =>
437+
buildSelectChain([
438+
{
439+
userId: 'owner-1',
440+
role: 'owner',
441+
currentPeriodCost: '350',
442+
departedMemberUsage: '25',
443+
},
444+
])
445+
)
446+
mockComputeOrgOverageAmount.mockResolvedValue({
447+
totalOverage: 250,
448+
baseSubscriptionAmount: 100,
449+
effectiveUsage: 350,
450+
})
451+
mockTxStatsLimit
452+
.mockResolvedValueOnce([{ userId: 'owner-1' }])
453+
.mockResolvedValueOnce([{ billedOverageThisPeriod: '200' }])
454+
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
455+
.mockResolvedValueOnce([
456+
{
457+
userId: 'owner-1',
458+
role: 'owner',
459+
currentPeriodCost: '350',
460+
departedMemberUsage: '25',
461+
},
462+
])
463+
464+
await checkAndBillOverageThreshold('user-1')
465+
466+
expect(mockDbTransaction).toHaveBeenCalled()
467+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
468+
expect(mockTxUpdate).not.toHaveBeenCalled()
469+
})
470+
471+
it('skips stale organization overage when owner identity changed', async () => {
472+
mockIsOrgScopedSubscription.mockReturnValue(true)
473+
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
474+
mockGetOrganizationSubscriptionUsable.mockResolvedValue({
475+
plan: 'team',
476+
seats: 2,
477+
periodStart: new Date('2026-05-01T00:00:00.000Z'),
478+
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
479+
stripeSubscriptionId: 'sub_team_1',
480+
stripeCustomerId: 'cus_team_1',
481+
})
482+
mockDbSelect.mockImplementationOnce(() =>
483+
buildSelectChain([
484+
{
485+
userId: 'owner-1',
486+
role: 'owner',
487+
currentPeriodCost: '350',
488+
departedMemberUsage: '25',
489+
},
490+
{
491+
userId: 'member-1',
492+
role: 'member',
493+
currentPeriodCost: '25',
494+
departedMemberUsage: '25',
495+
},
496+
])
497+
)
498+
mockComputeOrgOverageAmount.mockResolvedValue({
499+
totalOverage: 250,
500+
baseSubscriptionAmount: 100,
501+
effectiveUsage: 350,
502+
})
503+
mockTxStatsLimit
504+
.mockResolvedValueOnce([{ userId: 'member-1' }])
505+
.mockResolvedValueOnce([{ billedOverageThisPeriod: '0' }])
506+
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '25' }])
507+
.mockResolvedValueOnce([
508+
{
509+
userId: 'owner-1',
510+
role: 'member',
511+
currentPeriodCost: '350',
512+
departedMemberUsage: '25',
513+
},
514+
{
515+
userId: 'member-1',
516+
role: 'owner',
517+
currentPeriodCost: '25',
518+
departedMemberUsage: '25',
519+
},
324520
])
325-
.mockResolvedValueOnce([{ creditBalance: '0', departedMemberUsage: '75' }])
326521

327522
await checkAndBillOverageThreshold('user-1')
328523

0 commit comments

Comments
 (0)