Skip to content

Commit a3fc091

Browse files
icecrasher321claude
andcommitted
fix(billing): claim the terminal period before deletion settlement and delete vestigial refresh bounds
Subscription deletion now claims the close marker from the fresh subscription row before computing or charging final overage, serializing with the cycle-close sweep so both paths can never bill the same period — an in-flight close fails its guarded claim and rolls back, and the deletion settles against the row's real period instead of a possibly stale webhook payload. The per-user refresh bounds machinery is deleted outright: its only source was proPeriodCostSnapshotAt, which this PR stopped writing, and ledger entity stamps already scope refresh to org-attributed rows — a joiner's pre-join usage is user-stamped and can never enter the org refresh scan, while a departed member's org-stamped rows participate exactly like a current member's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6016cc4 commit a3fc091

11 files changed

Lines changed: 112 additions & 225 deletions

File tree

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const {
1212
mockGetUserUsageLimit,
1313
mockIsOrganizationBillingBlocked,
1414
mockComputeBillingPeriodUsageWithDailyRefresh,
15-
mockGetOrgMemberRefreshBounds,
1615
} = vi.hoisted(() => ({
1716
mockGetBillingPeriodUsageCost: vi.fn(),
1817
mockGetOrgMemberUsageForBillingPeriod: vi.fn(),
@@ -21,7 +20,6 @@ const {
2120
mockGetUserUsageLimit: vi.fn(),
2221
mockIsOrganizationBillingBlocked: vi.fn(),
2322
mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(),
24-
mockGetOrgMemberRefreshBounds: vi.fn(),
2523
}))
2624

2725
vi.mock('@/lib/billing/organizations/member-limits', () => ({
@@ -46,7 +44,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
4644

4745
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
4846
computeBillingPeriodUsageWithDailyRefresh: mockComputeBillingPeriodUsageWithDailyRefresh,
49-
getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds,
5047
}))
5148

5249
import {
@@ -74,7 +71,6 @@ describe('checkUsageStatus', () => {
7471
ledgerUsage: 125,
7572
refreshConsumed: 25,
7673
})
77-
mockGetOrgMemberRefreshBounds.mockResolvedValue({})
7874
})
7975

8076
it('reads reporting-period organization usage without loading the member roster', async () => {
@@ -206,10 +202,9 @@ describe('checkUsageStatus', () => {
206202
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
207203
})
208204

209-
it('combines paid organization ledger usage with bounded member refresh', async () => {
205+
it('combines paid organization ledger usage with member refresh', async () => {
210206
const periodStart = new Date('2026-06-01T00:00:00.000Z')
211207
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
212-
const userStart = new Date('2026-06-10T00:00:00.000Z')
213208
const subscription = {
214209
referenceId: 'org-1',
215210
plan: 'team',
@@ -222,7 +217,6 @@ describe('checkUsageStatus', () => {
222217
memberIds: ['user-1', 'user-2'],
223218
lastPeriodCost: 0,
224219
})
225-
mockGetOrgMemberRefreshBounds.mockResolvedValue({ 'user-2': { userStart } })
226220
mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({
227221
ledgerUsage: 100,
228222
refreshConsumed: 10,
@@ -246,7 +240,6 @@ describe('checkUsageStatus', () => {
246240
refreshPeriodEnd: periodEnd,
247241
planDollars: expect.any(Number),
248242
seats: 2,
249-
userBounds: { 'user-2': { userStart } },
250243
})
251244
expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled()
252245
})
@@ -271,7 +264,6 @@ describe('checkUsageStatus', () => {
271264

272265
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
273266
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
274-
expect(mockGetOrgMemberRefreshBounds).not.toHaveBeenCalled()
275267
})
276268
})
277269

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ import {
1919
type UsageQueryPeriod,
2020
} from '@/lib/billing/core/usage-log'
2121
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
22-
import {
23-
computeBillingPeriodUsageWithDailyRefresh,
24-
getOrgMemberRefreshBounds,
25-
} from '@/lib/billing/credits/daily-refresh'
22+
import { computeBillingPeriodUsageWithDailyRefresh } from '@/lib/billing/credits/daily-refresh'
2623
import {
2724
getOrgMemberUsageForBillingPeriod,
2825
getOrgMemberUsageLimit,
@@ -78,7 +75,6 @@ async function computePooledOrgUsage(
7875
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
7976
}
8077

81-
const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart)
8278
const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({
8379
billingEntity: { type: 'organization', id: organizationId },
8480
billingPeriod,
@@ -87,7 +83,6 @@ async function computePooledOrgUsage(
8783
refreshPeriodEnd: sub.periodEnd ?? null,
8884
planDollars,
8985
seats: sub.seats || 1,
90-
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
9186
})
9287

9388
return Math.max(0, ledgerUsage - refreshConsumed)

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const {
1212
mockGetBillingPeriodUsageCostWithSourceSubset,
1313
mockGetHighestPriorityPersonalSubscription,
1414
mockGetHighestPrioritySubscription,
15-
mockGetOrgMemberRefreshBounds,
1615
mockResolveBillingInterval,
1716
} = vi.hoisted(() => ({
1817
mockComputeDailyRefreshConsumed: vi.fn(),
@@ -22,7 +21,6 @@ const {
2221
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
2322
mockGetHighestPriorityPersonalSubscription: vi.fn(),
2423
mockGetHighestPrioritySubscription: vi.fn(),
25-
mockGetOrgMemberRefreshBounds: vi.fn(),
2624
mockResolveBillingInterval: vi.fn(),
2725
}))
2826

@@ -47,7 +45,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
4745

4846
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
4947
computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed,
50-
getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds,
5148
}))
5249

5350
import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing'
@@ -129,7 +126,6 @@ describe('getPersonalBillingSummary', () => {
129126
describe('calculateSubscriptionOverage', () => {
130127
beforeEach(() => {
131128
vi.clearAllMocks()
132-
mockGetOrgMemberRefreshBounds.mockResolvedValue({})
133129
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
134130
})
135131

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import {
1313
getBillingPeriodUsageCostByUser,
1414
getBillingPeriodUsageCostWithSourceSubset,
1515
} from '@/lib/billing/core/usage-log'
16-
import {
17-
computeDailyRefreshConsumed,
18-
getOrgMemberRefreshBounds,
19-
} from '@/lib/billing/credits/daily-refresh'
16+
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
2017
import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
2118
import {
2219
ENTITLED_SUBSCRIPTION_STATUSES,
@@ -139,14 +136,12 @@ export async function computeOrgOverageAmount(params: {
139136
let dailyRefreshDeduction = 0
140137
const planDollars = getPlanTierDollars(params.plan)
141138
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
142-
const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart)
143139
dailyRefreshDeduction = await computeDailyRefreshConsumed({
144140
userIds: params.memberIds,
145141
periodStart: params.periodStart,
146142
periodEnd: params.periodEnd ?? null,
147143
planDollars,
148144
seats: params.seats || 1,
149-
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
150145
billingEntity: { type: 'organization', id: params.organizationId },
151146
})
152147
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
6060

6161
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
6262
computeDailyRefreshConsumed: vi.fn(),
63-
getOrgMemberRefreshBounds: vi.fn(),
6463
}))
6564

6665
const {

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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ import {
2323
resolveSubscriptionUsagePeriod,
2424
} from '@/lib/billing/core/reporting-period'
2525
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
26-
import {
27-
computeDailyRefreshConsumed,
28-
getOrgMemberRefreshBounds,
29-
} from '@/lib/billing/credits/daily-refresh'
26+
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
3027
import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers'
3128
import {
3229
canEditUsageLimit,
@@ -284,19 +281,13 @@ export async function getResolvedUserUsageData(
284281
if (planDollars > 0) {
285282
if (orgScoped) {
286283
if (orgMemberIds.length > 0) {
287-
const userBounds = await getOrgMemberRefreshBounds(
288-
subscription.referenceId,
289-
billingPeriodStart,
290-
executor
291-
)
292284
dailyRefreshConsumed = await computeDailyRefreshConsumed(
293285
{
294286
userIds: orgMemberIds,
295287
periodStart: billingPeriodStart,
296288
periodEnd: billingPeriodEnd,
297289
planDollars,
298290
seats: subscription.seats || 1,
299-
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
300291
billingEntity: { type: 'organization', id: subscription.referenceId },
301292
},
302293
executor
@@ -713,23 +704,13 @@ export async function getEffectiveCurrentPeriodCost(
713704
const planDollars = getPlanTierDollars(subscription.plan)
714705
if (planDollars <= 0) return rawCost
715706

716-
const userBounds =
717-
orgScoped && subscription.periodStart
718-
? await getOrgMemberRefreshBounds(
719-
subscription.referenceId,
720-
subscription.periodStart,
721-
executor
722-
)
723-
: {}
724-
725707
const refreshConsumed = await computeDailyRefreshConsumed(
726708
{
727709
userIds: refreshUserIds,
728710
periodStart: subscription.periodStart,
729711
periodEnd: subscription.periodEnd ?? null,
730712
planDollars,
731713
seats: subscription.seats || 1,
732-
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
733714
billingEntity:
734715
orgScoped && subscription
735716
? { type: 'organization', id: subscription.referenceId }

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,28 +89,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => {
8989
reportingEnd
9090
)
9191
})
92-
93-
it('preserves a bounded user refresh window without narrowing the ledger total', async () => {
94-
const userStart = new Date('2026-03-10T00:00:00.000Z')
95-
const userEnd = new Date('2026-03-20T00:00:00.000Z')
96-
dbChainMockFns.groupBy.mockResolvedValueOnce([
97-
{ ledgerTotal: '30.00', refreshDayTotal: '0.25' },
98-
])
99-
100-
await computeBillingPeriodUsageWithDailyRefresh({
101-
billingEntity: { type: 'organization', id: 'org-1' },
102-
billingPeriod: { start: periodStart, end: periodEnd },
103-
userIds: ['bounded-user'],
104-
refreshPeriodStart: periodStart,
105-
refreshPeriodEnd: periodEnd,
106-
planDollars: 25,
107-
userBounds: { 'bounded-user': { userStart, userEnd } },
108-
})
109-
110-
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.userId, 'bounded-user')
111-
expect(drizzleOrmMock.gte).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userStart)
112-
expect(drizzleOrmMock.lt).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userEnd)
113-
})
11492
})
11593

11694
describe('computeDailyRefreshConsumed', () => {

0 commit comments

Comments
 (0)