Skip to content

Commit 8d3fb3f

Browse files
icecrasher321claude
andcommitted
fix(billing): hold cycle close for a settlement grace after rollover
Billing attribution is frozen at run start, so a run straddling a rollover can insert elapsed-period-stamped rows after the period ends. Closing only once the rollover is older than any possible in-flight run guarantees the close's ledger sums are final; the sweep picks the period up on a later run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 426a764 commit 8d3fb3f

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ describe('closeElapsedBillingPeriod', () => {
223223
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
224224
})
225225

226+
it('defers the close inside the settlement grace after a rollover', async () => {
227+
// A run whose frozen attribution predates the rollover could still insert
228+
// elapsed-period rows; the close waits until sums are final.
229+
const result = await closeElapsedBillingPeriod(
230+
subRow({
231+
periodStart: new Date(Date.now() - 60_000),
232+
lastClosedPeriodStart: new Date(Date.now() - 60_000 - 31 * 24 * 60 * 60 * 1000),
233+
})
234+
)
235+
236+
expect(result.status).toBe('skipped')
237+
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
238+
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
239+
})
240+
226241
it('defers the close when overage is due but Stripe identifiers are missing', async () => {
227242
const result = await closeElapsedBillingPeriod(subRow({ stripeCustomerId: null }))
228243

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ const logger = createLogger('BillingCycleClose')
3535
*/
3636
const MIN_CLOSE_INVOICE_DOLLARS = 0.5
3737

38+
/**
39+
* Settlement grace after a rollover before its elapsed period may close.
40+
* Billing attribution is frozen at run start (the payer is immutable for the
41+
* run), so a run that started just before the rollover can insert rows
42+
* stamped with the elapsed period after it ends. Closing only once the
43+
* rollover is older than any possible in-flight run guarantees the close's
44+
* ledger sums are final — no straggler row is orphaned from the final
45+
* overage or bookkeeping. Non-enterprise execution timeouts are far below
46+
* this bound; the sweep simply picks the period up on a later run.
47+
*/
48+
const CLOSE_SETTLEMENT_GRACE_MS = 60 * 60 * 1000
49+
3850
type SubscriptionRow = typeof subscriptionTable.$inferSelect
3951

4052
export type CycleCloseStatus = 'initialized' | 'current' | 'closed' | 'already-closed' | 'skipped'
@@ -199,6 +211,12 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise<C
199211
return { ...base, status: 'initialized' }
200212
}
201213

214+
if (Date.now() - periodStart.getTime() < CLOSE_SETTLEMENT_GRACE_MS) {
215+
// Rollover too recent — a run started before it could still insert rows
216+
// stamped with the elapsed period. A later sweep closes it with final sums.
217+
return base
218+
}
219+
202220
const marker = sub.lastClosedPeriodStart
203221
const orgScoped = await isSubscriptionOrgScoped(sub)
204222
const billingEntity = orgScoped

0 commit comments

Comments
 (0)