Skip to content

Commit 0acbfc5

Browse files
icecrasher321claude
andcommitted
fix(billing): include departed actors in close refresh and gate threshold billing on close currency
Cycle close now unions current members with every actor holding org-stamped ledger rows in the closed period, so a departed member's daily-refresh consumption offsets the final overage exactly like their billed usage. Threshold billing defers with a pending-cycle-close no-op while a subscription's close marker lags its current period, so the shared billedOverageThisPeriod tracker can never mix an elapsed period's settlements with the new period's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e298a42 commit 0acbfc5

4 files changed

Lines changed: 161 additions & 2 deletions

File tree

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ vi.mock('@/lib/posthog/server', () => ({
7979
captureServerEvent: mockCaptureServerEvent,
8080
}))
8181

82-
import { closeElapsedBillingPeriod, sweepBillingCycleCloses } from '@/lib/billing/cycle-close'
82+
import {
83+
closeElapsedBillingPeriod,
84+
isSubscriptionCycleCloseCurrent,
85+
sweepBillingCycleCloses,
86+
} from '@/lib/billing/cycle-close'
8387

8488
type SubInput = Parameters<typeof closeElapsedBillingPeriod>[0]
8589

@@ -210,6 +214,27 @@ describe('closeElapsedBillingPeriod', () => {
210214
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
211215
})
212216

217+
it('includes departed members with billed ledger usage in the refresh actor set', async () => {
218+
// 'departed-1' has org-attributed rows in the closed period but no member
219+
// row anymore; their refresh consumption must still offset the overage.
220+
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(
221+
new Map([
222+
['owner-1', 100],
223+
['departed-1', 50],
224+
])
225+
)
226+
queueOrgCloseReads()
227+
228+
await closeElapsedBillingPeriod(subRow())
229+
230+
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith(
231+
expect.objectContaining({
232+
pooledLedgerUsage: 150,
233+
memberIds: ['owner-1', 'departed-1'],
234+
})
235+
)
236+
})
237+
213238
it('applies organization credits before invoicing and skips Stripe when covered', async () => {
214239
queueOrgCloseReads({ orgRow: { creditBalance: '100' } })
215240

@@ -292,6 +317,37 @@ describe('closeElapsedBillingPeriod', () => {
292317
})
293318
})
294319

320+
describe('isSubscriptionCycleCloseCurrent', () => {
321+
beforeEach(() => {
322+
vi.clearAllMocks()
323+
resetDbChainMock()
324+
})
325+
326+
it('is current when the marker has caught up to the period start', async () => {
327+
queueTableRows(schemaMock.subscription, [
328+
{ periodStart: PERIOD_START, lastClosedPeriodStart: PERIOD_START },
329+
])
330+
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true)
331+
})
332+
333+
it('is pending when the marker lags the period start or was never initialized', async () => {
334+
queueTableRows(schemaMock.subscription, [
335+
{ periodStart: PERIOD_START, lastClosedPeriodStart: PREV_PERIOD_START },
336+
])
337+
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false)
338+
339+
queueTableRows(schemaMock.subscription, [
340+
{ periodStart: PERIOD_START, lastClosedPeriodStart: null },
341+
])
342+
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false)
343+
})
344+
345+
it('is current when the subscription has no period to close', async () => {
346+
queueTableRows(schemaMock.subscription, [{ periodStart: null, lastClosedPeriodStart: null }])
347+
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true)
348+
})
349+
})
350+
295351
describe('sweepBillingCycleCloses', () => {
296352
beforeEach(() => {
297353
vi.clearAllMocks()

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,35 @@ function hasEnterpriseReportingAnchor(sub: SubscriptionRow): boolean {
6363
)
6464
}
6565

66+
/**
67+
* Whether a subscription's previous period has already been closed — i.e. the
68+
* durable close marker has caught up to the current `periodStart`.
69+
*
70+
* Threshold billing gates on this so the shared `billedOverageThisPeriod`
71+
* tracker never mixes periods: after a rollover but before the sweep closes
72+
* the elapsed period, a new-period settlement would be subtracted from the
73+
* elapsed period's final overage and then wiped by the close's tracker reset,
74+
* under-billing one period and double-billing the other. Skipping settlement
75+
* until the close lands (sweep cadence, ≤6h) removes the race; a null marker
76+
* (pre-first-sweep) also gates, and a null `periodStart` cannot race at all.
77+
*/
78+
export async function isSubscriptionCycleCloseCurrent(subscriptionId: string): Promise<boolean> {
79+
const [row] = await db
80+
.select({
81+
periodStart: subscriptionTable.periodStart,
82+
lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart,
83+
})
84+
.from(subscriptionTable)
85+
.where(eq(subscriptionTable.id, subscriptionId))
86+
.limit(1)
87+
88+
if (!row?.periodStart) return true
89+
return (
90+
row.lastClosedPeriodStart !== null &&
91+
row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
92+
)
93+
}
94+
6695
/**
6796
* Advance the durable close marker to `periodStart`, guarded so concurrent
6897
* closers and replays collapse to one winner. Returns false when another
@@ -191,6 +220,14 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise<C
191220
const trackerUserId = orgScoped
192221
? (memberRows.find((row) => row.role === 'owner')?.userId ?? null)
193222
: sub.referenceId
223+
// Every actor whose org-attributed usage is billed at this close, including
224+
// members who departed mid-period: their ledger rows stay stamped to this
225+
// organization's period, so their daily-refresh consumption must offset the
226+
// overage exactly like a current member's. Current members with no rows stay
227+
// in the set for their refresh bounds.
228+
const overageActorIds = orgScoped
229+
? [...new Set([...memberIds, ...usageByUser.keys()])]
230+
: memberIds
194231

195232
// Final overage for the closed period (enterprise never bills overage).
196233
let totalOverage = 0
@@ -203,7 +240,7 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise<C
203240
periodEnd: periodStart,
204241
organizationId: sub.referenceId,
205242
pooledLedgerUsage: closedLedgerUsage,
206-
memberIds,
243+
memberIds: overageActorIds,
207244
})
208245
totalOverage = computed
209246
} else {

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const {
1717
mockIsFree,
1818
mockIsOrgScopedSubscription,
1919
mockIsOrganizationBillingBlocked,
20+
mockIsSubscriptionCycleCloseCurrent,
2021
mockRecordAudit,
2122
mockCaptureServerEvent,
2223
} = vi.hoisted(() => ({
@@ -32,6 +33,7 @@ const {
3233
mockIsFree: vi.fn(),
3334
mockIsOrgScopedSubscription: vi.fn(),
3435
mockIsOrganizationBillingBlocked: vi.fn(),
36+
mockIsSubscriptionCycleCloseCurrent: vi.fn(),
3537
mockRecordAudit: vi.fn(),
3638
mockCaptureServerEvent: vi.fn(),
3739
}))
@@ -61,6 +63,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
6163
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
6264
}))
6365

66+
vi.mock('@/lib/billing/cycle-close', () => ({
67+
isSubscriptionCycleCloseCurrent: mockIsSubscriptionCycleCloseCurrent,
68+
}))
69+
6470
vi.mock('@/lib/billing/plan-helpers', () => ({
6571
isEnterprise: mockIsEnterprise,
6672
isFree: mockIsFree,
@@ -157,6 +163,7 @@ function queueOrgReads({
157163
}
158164

159165
const usableOrgSubscription = {
166+
id: 'sub-db-team-1',
160167
plan: 'team',
161168
seats: 2,
162169
periodStart: new Date('2026-05-01T00:00:00.000Z'),
@@ -178,6 +185,7 @@ describe('checkAndBillOverageThreshold', () => {
178185
mockIsEnterprise.mockReturnValue(false)
179186
mockIsOrgScopedSubscription.mockReturnValue(false)
180187
mockGetBillingPeriodUsageCost.mockResolvedValue(0)
188+
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true)
181189
})
182190

183191
afterAll(() => {
@@ -522,6 +530,42 @@ describe('checkAndBillOverageThreshold', () => {
522530
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
523531
})
524532

533+
it('defers personal settlement while the previous period cycle close is pending', async () => {
534+
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false)
535+
mockCalculateSubscriptionOverage.mockResolvedValue(250)
536+
537+
await expect(
538+
checkAndBillOverageThreshold('user-1', undefined, {
539+
onError: 'throw',
540+
expectedBillingPeriod,
541+
})
542+
).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' })
543+
544+
expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(userSubscription.id)
545+
expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled()
546+
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
547+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
548+
})
549+
550+
it('defers organization settlement while the previous period cycle close is pending', async () => {
551+
mockIsOrgScopedSubscription.mockReturnValue(true)
552+
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
553+
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
554+
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false)
555+
556+
await expect(
557+
checkAndBillOverageThreshold('user-1', undefined, {
558+
onError: 'throw',
559+
expectedBillingPeriod,
560+
})
561+
).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' })
562+
563+
expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(usableOrgSubscription.id)
564+
expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled()
565+
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
566+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
567+
})
568+
525569
it('wraps lock timeouts in markerless strict mode', async () => {
526570
queuePersonalReads()
527571
mockCalculateSubscriptionOverage.mockResolvedValue(250)

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getOrganizationSubscriptionUsable,
1515
} from '@/lib/billing/core/subscription'
1616
import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
17+
import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close'
1718
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
1819
import {
1920
hasUsableSubscriptionAccess,
@@ -53,6 +54,7 @@ export type ThresholdSettlementNoOpReason =
5354
| 'billing-blocked'
5455
| 'billing-ineligible'
5556
| 'no-subscription'
57+
| 'pending-cycle-close'
5658
| 'plan-ineligible'
5759

5860
export type ThresholdSettlementOutcome =
@@ -279,6 +281,15 @@ export async function checkAndBillOverageThreshold(
279281
return checkAndBillOrganizationOverageThreshold(userSubscription.referenceId, options)
280282
}
281283

284+
// Defer settlement while the previous period's cycle close is pending so
285+
// `billedOverageThisPeriod` never mixes periods (see
286+
// `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and
287+
// a later threshold attempt settles normally.
288+
if (!(await isSubscriptionCycleCloseCurrent(userSubscription.id))) {
289+
logger.debug('Previous period cycle close pending; deferring threshold billing', { userId })
290+
return noOp(options, 'pending-cycle-close')
291+
}
292+
282293
const currentOverage = await calculateSubscriptionOverage({
283294
id: userSubscription.id,
284295
plan: userSubscription.plan,
@@ -530,6 +541,17 @@ async function checkAndBillOrganizationOverageThreshold(
530541
return noOp(options, 'billing-blocked')
531542
}
532543

544+
// Defer settlement while the previous period's cycle close is pending so
545+
// `billedOverageThisPeriod` never mixes periods (see
546+
// `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and
547+
// a later threshold attempt settles normally.
548+
if (!(await isSubscriptionCycleCloseCurrent(orgSubscription.id))) {
549+
logger.debug('Previous period cycle close pending; deferring org threshold billing', {
550+
organizationId,
551+
})
552+
return noOp(options, 'pending-cycle-close')
553+
}
554+
533555
logger.debug('Found organization subscription', {
534556
organizationId,
535557
plan: orgSubscription.plan,

0 commit comments

Comments
 (0)