Skip to content

Commit ad58f83

Browse files
icecrasher321claude
andcommitted
fix(billing): seal cycle-close races and align refresh actors with billed usage
Threshold settlement revalidates the close marker and period under the tracker lock via the same isSubscriptionCycleCloseCurrent predicate the preflight uses, so a rollover between check and transaction aborts as a concurrent-state retry instead of settling against the wrong cycle. Terminal bookkeeping claims the close marker with its tracker reset, so a deletion racing an in-flight sweep close serializes through the one marker and the loser rolls back rather than re-billing settled overage; anchored enterprise deletions only claim the marker since their windows derive from the anchor, not Stripe bounds. A close with overage due but missing Stripe identifiers now defers loudly instead of claiming the marker and silently forgiving the money, the closed window's start derives from the ledger's own period stamps so anchor-day drift cannot misalign the refresh window, calculateSubscriptionOverage unions departed ledger actors into the org refresh deduction like the close does, and blocked accounts report their real ledger usage while staying blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0acbfc5 commit ad58f83

9 files changed

Lines changed: 336 additions & 24 deletions

File tree

apps/sim/lib/billing/calculations/usage-monitor.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
checkBillingBlocked,
5454
checkBillingEntityBlocked,
5555
checkOrganizationMemberUsageLimit,
56+
checkServerSideUsageLimits,
5657
checkUsageStatus,
5758
} from '@/lib/billing/calculations/usage-monitor'
5859

@@ -274,6 +275,39 @@ describe('checkUsageStatus', () => {
274275
})
275276
})
276277

278+
describe('checkServerSideUsageLimits', () => {
279+
beforeEach(() => {
280+
vi.clearAllMocks()
281+
resetDbChainMock()
282+
setEnvFlags({ isHosted: true, isBillingEnabled: true })
283+
mockGetBillingPeriodUsageCost.mockResolvedValue(125)
284+
})
285+
286+
it('keeps blocked accounts blocked while reporting their real ledger usage', async () => {
287+
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }])
288+
const subscription = {
289+
referenceId: 'user-1',
290+
plan: 'pro',
291+
status: 'active',
292+
seats: 1,
293+
periodStart: new Date('2026-06-01T00:00:00.000Z'),
294+
periodEnd: new Date('2026-07-01T00:00:00.000Z'),
295+
}
296+
297+
const result = await checkServerSideUsageLimits('user-1', subscription)
298+
299+
expect(result).toMatchObject({ isExceeded: true, currentUsage: 125, limit: 0 })
300+
expect(result.message).toBeTruthy()
301+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(
302+
{ type: 'user', id: 'user-1' },
303+
expect.objectContaining({
304+
start: subscription.periodStart,
305+
end: subscription.periodEnd,
306+
})
307+
)
308+
})
309+
})
310+
277311
describe('checkBillingBlocked', () => {
278312
beforeEach(() => {
279313
vi.clearAllMocks()

apps/sim/lib/billing/calculations/usage-monitor.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,21 @@ export async function checkServerSideUsageLimits(
332332

333333
const blocked = await checkBillingBlocked(userId)
334334
if (blocked.blocked) {
335-
return { isExceeded: true, currentUsage: 0, limit: 0, message: blocked.message }
335+
// Enforcement stays blocked, but surfaced usage must be the real ledger
336+
// value — `/api/users/me/usage-limits` exposes it as `currentPeriodCost`.
337+
const sub =
338+
preloadedSubscription !== undefined
339+
? preloadedSubscription
340+
: await getHighestPrioritySubscription(userId)
341+
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
342+
const billingEntity: BillingEntity =
343+
subIsOrgScoped && sub
344+
? { type: 'organization', id: sub.referenceId }
345+
: { type: 'user', id: userId }
346+
const billingPeriod = preloadedBillingContext?.billingPeriod ??
347+
resolveSubscriptionUsagePeriod(sub) ?? { ...defaultBillingPeriod(), source: 'default' }
348+
const currentUsage = await getBillingPeriodUsageCost(billingEntity, billingPeriod)
349+
return { isExceeded: true, currentUsage, limit: 0, message: blocked.message }
336350
}
337351

338352
const usageData = await checkUsageStatus(userId, preloadedSubscription, preloadedBillingContext)

apps/sim/lib/billing/core/billing.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMock, dbChainMockFns } from '@sim/testing'
4+
import { dbChainMock, dbChainMockFns, queueTableRows, schemaMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
88
mockComputeDailyRefreshConsumed,
99
mockEnsureUserStatsExists,
1010
mockGetBillingPeriodUsageCost,
11+
mockGetBillingPeriodUsageCostByUser,
1112
mockGetBillingPeriodUsageCostWithSourceSubset,
1213
mockGetHighestPriorityPersonalSubscription,
1314
mockGetHighestPrioritySubscription,
15+
mockGetOrgMemberRefreshBounds,
1416
mockResolveBillingInterval,
1517
} = vi.hoisted(() => ({
1618
mockComputeDailyRefreshConsumed: vi.fn(),
1719
mockEnsureUserStatsExists: vi.fn(),
1820
mockGetBillingPeriodUsageCost: vi.fn(),
21+
mockGetBillingPeriodUsageCostByUser: vi.fn(),
1922
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
2023
mockGetHighestPriorityPersonalSubscription: vi.fn(),
2124
mockGetHighestPrioritySubscription: vi.fn(),
25+
mockGetOrgMemberRefreshBounds: vi.fn(),
2226
mockResolveBillingInterval: vi.fn(),
2327
}))
2428

@@ -37,15 +41,16 @@ vi.mock('@/lib/billing/core/usage', () => ({
3741
vi.mock('@/lib/billing/core/usage-log', () => ({
3842
COPILOT_USAGE_SOURCES: ['copilot'],
3943
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
44+
getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser,
4045
getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset,
4146
}))
4247

4348
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
4449
computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed,
45-
getOrgMemberRefreshBounds: vi.fn(),
50+
getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds,
4651
}))
4752

48-
import { getPersonalBillingSummary } from '@/lib/billing/core/billing'
53+
import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing'
4954

5055
describe('getPersonalBillingSummary', () => {
5156
beforeEach(() => {
@@ -120,3 +125,37 @@ describe('getPersonalBillingSummary', () => {
120125
)
121126
})
122127
})
128+
129+
describe('calculateSubscriptionOverage', () => {
130+
beforeEach(() => {
131+
vi.clearAllMocks()
132+
mockGetOrgMemberRefreshBounds.mockResolvedValue({})
133+
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
134+
})
135+
136+
it('includes departed ledger actors in the org refresh deduction', async () => {
137+
queueTableRows(schemaMock.organization, [{ id: 'org-1' }]) // isSubscriptionOrgScoped
138+
queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) // current members
139+
mockGetBillingPeriodUsageCostByUser.mockResolvedValue(
140+
new Map([
141+
['owner-1', 100],
142+
['departed-1', 60],
143+
])
144+
)
145+
146+
await calculateSubscriptionOverage({
147+
id: 'sub-1',
148+
plan: 'team',
149+
referenceId: 'org-1',
150+
seats: 2,
151+
periodStart: new Date('2026-07-01T00:00:00.000Z'),
152+
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
153+
})
154+
155+
expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith(
156+
expect.objectContaining({
157+
userIds: expect.arrayContaining(['owner-1', 'departed-1']),
158+
})
159+
)
160+
})
161+
})

apps/sim/lib/billing/core/billing.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ensureUserStatsExists } from '@/lib/billing/core/usage'
1010
import {
1111
COPILOT_USAGE_SOURCES,
1212
getBillingPeriodUsageCost,
13+
getBillingPeriodUsageCostByUser,
1314
getBillingPeriodUsageCostWithSourceSubset,
1415
} from '@/lib/billing/core/usage-log'
1516
import {
@@ -188,14 +189,19 @@ export async function calculateSubscriptionOverage(sub: {
188189
.select({ userId: member.userId })
189190
.from(member)
190191
.where(eq(member.organizationId, sub.referenceId))
191-
const memberIds = memberRows.map((row) => row.userId)
192-
const ledgerUsage =
192+
const usageByUser =
193193
sub.periodStart && sub.periodEnd
194-
? await getBillingPeriodUsageCost(
194+
? await getBillingPeriodUsageCostByUser(
195195
{ type: 'organization', id: sub.referenceId },
196196
{ start: sub.periodStart, end: sub.periodEnd }
197197
)
198-
: 0
198+
: new Map<string, number>()
199+
let ledgerUsage = 0
200+
for (const cost of usageByUser.values()) ledgerUsage += cost
201+
// Union current members with every actor holding org-attributed rows this
202+
// period: a member who departed mid-period still bills here, so their
203+
// daily-refresh consumption must offset the overage too.
204+
const memberIds = [...new Set([...memberRows.map((row) => row.userId), ...usageByUser.keys()])]
199205

200206
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
201207
plan: sub.plan,

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
closeElapsedBillingPeriod,
8484
isSubscriptionCycleCloseCurrent,
8585
sweepBillingCycleCloses,
86+
writeFinalPeriodBookkeeping,
8687
} from '@/lib/billing/cycle-close'
8788

8889
type SubInput = Parameters<typeof closeElapsedBillingPeriod>[0]
@@ -214,6 +215,31 @@ describe('closeElapsedBillingPeriod', () => {
214215
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
215216
})
216217

218+
it('defers the close when overage is due but Stripe identifiers are missing', async () => {
219+
const result = await closeElapsedBillingPeriod(subRow({ stripeCustomerId: null }))
220+
221+
expect(result.status).toBe('skipped')
222+
// No marker claim, no money, no bookkeeping — the sweep retries next run.
223+
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
224+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
225+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
226+
})
227+
228+
it('derives the closed window from the ledger period stamps when they drift from calendar math', async () => {
229+
// Rows for the elapsed period are stamped starting Jul 3 (anchor drift);
230+
// the stamped boundary — not periodStart minus one interval — must bound
231+
// the refresh window.
232+
const stampedPrevStart = new Date('2026-07-03T00:00:00.000Z')
233+
queueTableRows(schemaMock.usageLog, [{ start: stampedPrevStart }])
234+
queueOrgCloseReads()
235+
236+
await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: stampedPrevStart }))
237+
238+
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith(
239+
expect.objectContaining({ periodStart: stampedPrevStart, periodEnd: PERIOD_START })
240+
)
241+
})
242+
217243
it('includes departed members with billed ledger usage in the refresh actor set', async () => {
218244
// 'departed-1' has org-attributed rows in the closed period but no member
219245
// row anymore; their refresh consumption must still offset the overage.
@@ -317,6 +343,62 @@ describe('closeElapsedBillingPeriod', () => {
317343
})
318344
})
319345

346+
describe('writeFinalPeriodBookkeeping', () => {
347+
beforeEach(() => {
348+
vi.clearAllMocks()
349+
resetDbChainMock()
350+
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
351+
mockIsEnterprise.mockReturnValue(false)
352+
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 25]]))
353+
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
354+
})
355+
356+
it('resets trackers, writes last-period sums, and claims the terminal marker in one transaction', async () => {
357+
queueTableRows(schemaMock.member, [{ userId: 'owner-1' }])
358+
359+
await writeFinalPeriodBookkeeping({
360+
id: 'sub-1',
361+
plan: 'team',
362+
referenceId: 'org-1',
363+
periodStart: PERIOD_START,
364+
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
365+
})
366+
367+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
368+
const bookkeepingSet = dbChainMockFns.set.mock.calls.find(
369+
(call) => (call[0] as Record<string, unknown>).billedOverageThisPeriod === '0'
370+
)
371+
expect(bookkeepingSet).toBeDefined()
372+
const markerSet = dbChainMockFns.set.mock.calls.find(
373+
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
374+
)
375+
expect(markerSet).toBeDefined()
376+
})
377+
378+
it('only claims the marker for reporting-anchor enterprise subscriptions', async () => {
379+
mockIsEnterprise.mockReturnValue(true)
380+
381+
await writeFinalPeriodBookkeeping({
382+
id: 'sub-1',
383+
plan: 'enterprise',
384+
referenceId: 'org-1',
385+
periodStart: PERIOD_START,
386+
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
387+
metadata: { reportingPeriodAnchorDate: '2026-05-01' },
388+
})
389+
390+
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
391+
const markerSet = dbChainMockFns.set.mock.calls.find(
392+
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
393+
)
394+
expect(markerSet).toBeDefined()
395+
const bookkeepingSet = dbChainMockFns.set.mock.calls.find(
396+
(call) => (call[0] as Record<string, unknown>).billedOverageThisPeriod === '0'
397+
)
398+
expect(bookkeepingSet).toBeUndefined()
399+
})
400+
})
401+
320402
describe('isSubscriptionCycleCloseCurrent', () => {
321403
beforeEach(() => {
322404
vi.clearAllMocks()

0 commit comments

Comments
 (0)