Skip to content

Commit 482413e

Browse files
icecrasher321claude
andcommitted
fix(billing): pair the overage tracker with the marker's period and pin test clocks
billedOverageThisPeriod only ever holds collections for the period that began at the close marker — the threshold gate blocks settlement whenever the marker lags. Both consumers now honor that pairing: a close that skipped forgiven periods counts nothing from the tracker against the period it bills, and the deletion settlement ignores the tracker when the marker was still lagging at claim time. Ignoring is provably safe in both cases because a lagging marker means no current-period collections exist. The cycle-close and daily-refresh suites pin the system clock: their grace and window checks compare fixed period fixtures against Date.now(), which made them dependent on the host date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a538a2f commit 482413e

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

apps/sim/lib/billing/credits/daily-refresh.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { dbChainMockFns, drizzleOrmMock, schemaMock } from '@sim/testing'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
vi.mock('drizzle-orm', () => {
88
const sqlTag = () => {
@@ -25,6 +25,21 @@ import {
2525
computeDailyRefreshConsumed,
2626
} from '@/lib/billing/credits/daily-refresh'
2727

28+
/**
29+
* Refresh caps windows at `Date.now()`, so the suite pins the clock after
30+
* every fixture period to stay hermetic on any host date.
31+
*/
32+
const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z')
33+
34+
beforeEach(() => {
35+
vi.useFakeTimers()
36+
vi.setSystemTime(FROZEN_NOW)
37+
})
38+
39+
afterAll(() => {
40+
vi.useRealTimers()
41+
})
42+
2843
describe('computeBillingPeriodUsageWithDailyRefresh', () => {
2944
const periodStart = new Date('2026-03-01T00:00:00.000Z')
3045
const periodEnd = new Date('2026-04-01T00:00:00.000Z')

apps/sim/lib/billing/cycle-close.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ type SubInput = Parameters<typeof closeElapsedBillingPeriod>[0]
9595
const PERIOD_START = new Date('2026-08-01T00:00:00.000Z')
9696
const PREV_PERIOD_START = new Date('2026-07-01T00:00:00.000Z')
9797

98+
/**
99+
* The grace gate and lagging checks compare fixed period boundaries against
100+
* `Date.now()`, so the suite pins the clock to stay hermetic on any host date.
101+
*/
102+
const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z')
103+
104+
beforeEach(() => {
105+
vi.useFakeTimers()
106+
vi.setSystemTime(FROZEN_NOW)
107+
})
108+
109+
afterAll(() => {
110+
vi.useRealTimers()
111+
})
112+
98113
function subRow(overrides: Partial<Record<string, unknown>> = {}): SubInput {
99114
return {
100115
id: 'sub-1',
@@ -305,6 +320,35 @@ describe('closeElapsedBillingPeriod', () => {
305320
})
306321
})
307322

323+
it('subtracts the current period tracker from the final overage', async () => {
324+
queueOrgCloseReads({ trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' } })
325+
326+
const result = await closeElapsedBillingPeriod(subRow())
327+
328+
expect(result.status).toBe('closed')
329+
expect(result.overageBilled).toBe(40)
330+
const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0]
331+
expect(payload).toMatchObject({ amountCents: 4000 })
332+
})
333+
334+
it('ignores the stale tracker when the close skipped forgiven periods', async () => {
335+
// Marker two intervals back: the close forgives the older period and
336+
// bills [Jul 1, Aug 1) only. The tracker's collections belong to the
337+
// period that began at the marker, so none of them offset this close.
338+
const staleMarker = new Date('2026-05-01T00:00:00.000Z')
339+
queueOrgCloseReads({
340+
markerRow: { lastClosedPeriodStart: staleMarker },
341+
trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' },
342+
})
343+
344+
const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: staleMarker }))
345+
346+
expect(result.status).toBe('closed')
347+
expect(result.overageBilled).toBe(70)
348+
const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0]
349+
expect(payload).toMatchObject({ amountCents: 7000 })
350+
})
351+
308352
it('applies organization credits before invoicing and skips Stripe when covered', async () => {
309353
queueOrgCloseReads({ orgRow: { creditBalance: '100' } })
310354

@@ -508,24 +552,43 @@ describe('claimTerminalPeriod', () => {
508552

509553
it('claims the marker from the fresh row period and returns it for settlement', async () => {
510554
queueTableRows(schemaMock.subscription, [
511-
{ periodStart: PERIOD_START, periodEnd: new Date('2026-09-01T00:00:00.000Z') },
555+
{
556+
periodStart: PERIOD_START,
557+
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
558+
lastClosedPeriodStart: PERIOD_START,
559+
},
512560
])
513561

514562
const terminal = await claimTerminalPeriod('sub-1')
515563

516564
expect(terminal.periodStart).toEqual(PERIOD_START)
565+
expect(terminal.markerWasCurrent).toBe(true)
517566
const markerSet = dbChainMockFns.set.mock.calls.find(
518567
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
519568
)
520569
expect(markerSet).toBeDefined()
521570
})
522571

572+
it('reports a lagging marker so the terminal settlement ignores the stale tracker', async () => {
573+
queueTableRows(schemaMock.subscription, [
574+
{
575+
periodStart: PERIOD_START,
576+
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
577+
lastClosedPeriodStart: PREV_PERIOD_START,
578+
},
579+
])
580+
581+
const terminal = await claimTerminalPeriod('sub-1')
582+
583+
expect(terminal.markerWasCurrent).toBe(false)
584+
})
585+
523586
it('returns nulls without claiming when the subscription has no period', async () => {
524587
queueTableRows(schemaMock.subscription, [{ periodStart: null, periodEnd: null }])
525588

526589
const terminal = await claimTerminalPeriod('sub-1')
527590

528-
expect(terminal).toEqual({ periodStart: null, periodEnd: null })
591+
expect(terminal).toEqual({ periodStart: null, periodEnd: null, markerWasCurrent: true })
529592
expect(dbChainMockFns.set).not.toHaveBeenCalled()
530593
})
531594
})

apps/sim/lib/billing/cycle-close.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,25 +196,41 @@ export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string):
196196
* including its outbox invoice — so deletion and sweep can never both bill
197197
* the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging
198198
* elapsed period is settled rather than jumped. Returns the fresh period
199-
* bounds for the deletion flow to settle against.
199+
* bounds for the deletion flow to settle against, plus `markerWasCurrent`:
200+
* whether the close marker had already caught up to the terminal period
201+
* before this claim. The `billedOverageThisPeriod` tracker only ever holds
202+
* collections for the period that began at the marker (the threshold gate
203+
* blocks settlement whenever the marker lags), so the terminal settlement
204+
* must ignore the tracker when the marker was still lagging — its contents
205+
* belong to a forgiven elapsed period, never to the terminal window.
200206
*/
201-
export async function claimTerminalPeriod(
202-
subscriptionId: string
203-
): Promise<{ periodStart: Date | null; periodEnd: Date | null }> {
207+
export async function claimTerminalPeriod(subscriptionId: string): Promise<{
208+
periodStart: Date | null
209+
periodEnd: Date | null
210+
markerWasCurrent: boolean
211+
}> {
204212
return db.transaction(async (tx) => {
205213
const [row] = await tx
206214
.select({
207215
periodStart: subscriptionTable.periodStart,
208216
periodEnd: subscriptionTable.periodEnd,
217+
lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart,
209218
})
210219
.from(subscriptionTable)
211220
.where(eq(subscriptionTable.id, subscriptionId))
212221
.for('update')
213222
.limit(1)
214223

215-
if (!row?.periodStart) return { periodStart: null, periodEnd: null }
224+
if (!row?.periodStart) {
225+
// Mirrors the threshold gate: a null `periodStart` cannot race a
226+
// rollover, so any tracked collections are legitimately current.
227+
return { periodStart: null, periodEnd: null, markerWasCurrent: true }
228+
}
229+
const markerWasCurrent =
230+
!!row.lastClosedPeriodStart &&
231+
row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
216232
await claimCloseMarker(tx, subscriptionId, row.periodStart)
217-
return { periodStart: row.periodStart, periodEnd: row.periodEnd }
233+
return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent }
218234
})
219235
}
220236

@@ -514,7 +530,16 @@ export async function closeElapsedBillingPeriod(
514530
.where(eq(userStats.userId, trackerUserId))
515531
.limit(1)
516532

517-
const alreadyBilled = toNumber(toDecimal(tracker?.billedOverageThisPeriod))
533+
// The tracker's collections belong to the period that began at the
534+
// marker — the threshold gate blocks settlement whenever the marker
535+
// lags, so nothing newer can be in it. When this close skipped
536+
// forgiven periods (`closeFrom` advanced past the marker), those
537+
// collections offset a forgiven period's overage, not this one's:
538+
// count nothing against this close. The reset below still clears them.
539+
const alreadyBilled =
540+
closeFrom.getTime() === marker.getTime()
541+
? toNumber(toDecimal(tracker?.billedOverageThisPeriod))
542+
: 0
518543
let remaining = Math.max(0, totalOverage - alreadyBilled)
519544

520545
if (remaining > 0) {

apps/sim/lib/billing/webhooks/subscription.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,15 @@ export async function handleSubscriptionDeleted(
350350
return { totalOverage: 0, kind: 'enterprise' as const }
351351
}
352352

353-
const billedOverage = await getBilledOverageForSubscription(subscription)
353+
// The tracker only ever holds collections for the period that began
354+
// at the close marker — the threshold gate blocks settlement while
355+
// the marker lags. If the marker was still lagging at claim time
356+
// (the elapsed close above deferred), the tracked amount belongs to
357+
// that forgiven elapsed period, not the terminal window: subtracting
358+
// it would under-bill the final invoice, so count nothing.
359+
const billedOverage = terminal.markerWasCurrent
360+
? await getBilledOverageForSubscription(subscription)
361+
: 0
354362
const remainingOverage = Math.max(0, totalOverage - billedOverage)
355363

356364
logger.info('Subscription deleted overage calculation', {

0 commit comments

Comments
 (0)