Skip to content

Commit f907385

Browse files
icecrasher321claude
andcommitted
fix(billing): scope daily refresh by entity stamps and close lagging periods before deletion settlement
The refresh actor-list threading (userIds + departed-actor unions) violated the entity-stamp principle the ledger sums already follow: org-attributed rows from departed members counted in pooled usage but never consumed refresh on the monitor/resolved-usage paths. Daily refresh now scopes rows by the same write-time entity and period stamps as the ledger — no actor list anywhere — which deletes the unions, the rollup memberIds, and the org-specific refresh variant. Deletion settlement now closes any elapsed-but-unclosed period (grace bypassed — no later sweep revisits a canceled sub) before claiming the terminal period, so a deletion racing the sweep can no longer jump the marker past an unsettled period and silently forgive its final overage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a3fc091 commit f907385

13 files changed

Lines changed: 305 additions & 371 deletions

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

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ const {
88
mockGetBillingPeriodUsageCost,
99
mockGetOrgMemberUsageForBillingPeriod,
1010
mockGetOrgMemberUsageLimit,
11-
mockGetOrgMemberBillingRollup,
1211
mockGetUserUsageLimit,
1312
mockIsOrganizationBillingBlocked,
1413
mockComputeBillingPeriodUsageWithDailyRefresh,
1514
} = vi.hoisted(() => ({
1615
mockGetBillingPeriodUsageCost: vi.fn(),
1716
mockGetOrgMemberUsageForBillingPeriod: vi.fn(),
1817
mockGetOrgMemberUsageLimit: vi.fn(),
19-
mockGetOrgMemberBillingRollup: vi.fn(),
2018
mockGetUserUsageLimit: vi.fn(),
2119
mockIsOrganizationBillingBlocked: vi.fn(),
2220
mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(),
@@ -31,10 +29,9 @@ vi.mock('@/lib/billing/core/access', () => ({
3129
isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
3230
}))
3331

34-
// core/usage pulls in the email-rendering chain at import; stub the two symbols
32+
// core/usage pulls in the email-rendering chain at import; stub the symbol
3533
// usage-monitor imports from it so the module loads in a node test env.
3634
vi.mock('@/lib/billing/core/usage', () => ({
37-
getOrgMemberBillingRollup: mockGetOrgMemberBillingRollup,
3835
getUserUsageLimit: mockGetUserUsageLimit,
3936
}))
4037

@@ -106,7 +103,6 @@ describe('checkUsageStatus', () => {
106103
{ type: 'organization', id: 'org-1' },
107104
billingPeriod
108105
)
109-
expect(mockGetOrgMemberBillingRollup).not.toHaveBeenCalled()
110106
})
111107

112108
it('reads paid personal ledger usage and refresh from one snapshot', async () => {
@@ -128,7 +124,6 @@ describe('checkUsageStatus', () => {
128124
expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({
129125
billingEntity: { type: 'user', id: 'user-1' },
130126
billingPeriod: { start: periodStart, end: periodEnd },
131-
userIds: ['user-1'],
132127
refreshPeriodStart: periodStart,
133128
refreshPeriodEnd: periodEnd,
134129
planDollars: 20,
@@ -202,7 +197,7 @@ describe('checkUsageStatus', () => {
202197
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
203198
})
204199

205-
it('combines paid organization ledger usage with member refresh', async () => {
200+
it('combines paid organization ledger usage with entity-scoped refresh — no roster read', async () => {
206201
const periodStart = new Date('2026-06-01T00:00:00.000Z')
207202
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
208203
const subscription = {
@@ -213,10 +208,6 @@ describe('checkUsageStatus', () => {
213208
periodStart,
214209
periodEnd,
215210
}
216-
mockGetOrgMemberBillingRollup.mockResolvedValue({
217-
memberIds: ['user-1', 'user-2'],
218-
lastPeriodCost: 0,
219-
})
220211
mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({
221212
ledgerUsage: 100,
222213
refreshConsumed: 10,
@@ -228,43 +219,22 @@ describe('checkUsageStatus', () => {
228219
organizationId: 'org-1',
229220
})
230221

222+
// Refresh is scoped by the entity stamps alone, so departed members'
223+
// org-attributed rows participate identically to current members'.
231224
expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({
232225
billingEntity: { type: 'organization', id: 'org-1' },
233226
billingPeriod: expect.objectContaining({
234227
start: periodStart,
235228
end: periodEnd,
236229
source: 'stripe',
237230
}),
238-
userIds: ['user-1', 'user-2'],
239231
refreshPeriodStart: periodStart,
240232
refreshPeriodEnd: periodEnd,
241233
planDollars: expect.any(Number),
242234
seats: 2,
243235
})
244236
expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled()
245237
})
246-
247-
it('returns ledger usage without refresh when an organization has no members', async () => {
248-
const periodStart = new Date('2026-06-01T00:00:00.000Z')
249-
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
250-
const subscription = {
251-
referenceId: 'org-1',
252-
plan: 'team',
253-
status: 'active',
254-
seats: 1,
255-
periodStart,
256-
periodEnd,
257-
}
258-
mockGetOrgMemberBillingRollup.mockResolvedValue({ memberIds: [], lastPeriodCost: 0 })
259-
260-
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
261-
currentUsage: 125,
262-
scope: 'organization',
263-
})
264-
265-
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
266-
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
267-
})
268238
})
269239

270240
describe('checkServerSideUsageLimits', () => {

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
77
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
88
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
99
import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
10-
import {
11-
getOrgMemberBillingRollup,
12-
getUserUsageLimit,
13-
type UsageLimitSubscription,
14-
} from '@/lib/billing/core/usage'
10+
import { getUserUsageLimit, type UsageLimitSubscription } from '@/lib/billing/core/usage'
1511
import {
1612
type BillingContext,
1713
type BillingEntity,
@@ -70,15 +66,9 @@ async function computePooledOrgUsage(
7066
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
7167
}
7268

73-
const { memberIds } = await getOrgMemberBillingRollup(organizationId)
74-
if (memberIds.length === 0) {
75-
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
76-
}
77-
7869
const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({
7970
billingEntity: { type: 'organization', id: organizationId },
8071
billingPeriod,
81-
userIds: memberIds,
8272
refreshPeriodStart: sub.periodStart,
8373
refreshPeriodEnd: sub.periodEnd ?? null,
8474
planDollars,
@@ -151,7 +141,6 @@ export async function checkUsageStatus(
151141
const usage = await computeBillingPeriodUsageWithDailyRefresh({
152142
billingEntity: { type: 'user', id: userId },
153143
billingPeriod,
154-
userIds: [userId],
155144
refreshPeriodStart: sub.periodStart,
156145
refreshPeriodEnd: sub.periodEnd ?? null,
157146
planDollars,

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

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const {
88
mockComputeDailyRefreshConsumed,
99
mockEnsureUserStatsExists,
1010
mockGetBillingPeriodUsageCost,
11-
mockGetBillingPeriodUsageCostByUser,
1211
mockGetBillingPeriodUsageCostWithSourceSubset,
1312
mockGetHighestPriorityPersonalSubscription,
1413
mockGetHighestPrioritySubscription,
@@ -17,7 +16,6 @@ const {
1716
mockComputeDailyRefreshConsumed: vi.fn(),
1817
mockEnsureUserStatsExists: vi.fn(),
1918
mockGetBillingPeriodUsageCost: vi.fn(),
20-
mockGetBillingPeriodUsageCostByUser: vi.fn(),
2119
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
2220
mockGetHighestPriorityPersonalSubscription: vi.fn(),
2321
mockGetHighestPrioritySubscription: vi.fn(),
@@ -39,7 +37,6 @@ vi.mock('@/lib/billing/core/usage', () => ({
3937
vi.mock('@/lib/billing/core/usage-log', () => ({
4038
COPILOT_USAGE_SOURCES: ['copilot'],
4139
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
42-
getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser,
4340
getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset,
4441
}))
4542

@@ -129,17 +126,12 @@ describe('calculateSubscriptionOverage', () => {
129126
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
130127
})
131128

132-
it('includes departed ledger actors in the org refresh deduction', async () => {
129+
it('bills the pooled org ledger with entity-scoped refresh — no roster read', async () => {
133130
queueTableRows(schemaMock.organization, [{ id: 'org-1' }]) // isSubscriptionOrgScoped
134-
queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) // current members
135-
mockGetBillingPeriodUsageCostByUser.mockResolvedValue(
136-
new Map([
137-
['owner-1', 100],
138-
['departed-1', 60],
139-
])
140-
)
131+
// Pooled ledger sum includes departed members' org-stamped rows.
132+
mockGetBillingPeriodUsageCost.mockResolvedValue(160)
141133

142-
await calculateSubscriptionOverage({
134+
const overage = await calculateSubscriptionOverage({
143135
id: 'sub-1',
144136
plan: 'team',
145137
referenceId: 'org-1',
@@ -148,10 +140,22 @@ describe('calculateSubscriptionOverage', () => {
148140
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
149141
})
150142

151-
expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith(
152-
expect.objectContaining({
153-
userIds: expect.arrayContaining(['owner-1', 'departed-1']),
154-
})
143+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(
144+
{ type: 'organization', id: 'org-1' },
145+
{
146+
start: new Date('2026-07-01T00:00:00.000Z'),
147+
end: new Date('2026-08-01T00:00:00.000Z'),
148+
}
155149
)
150+
// Refresh is scoped by the same entity stamps as the ledger sum — no
151+
// actor list, so departed members' rows participate identically.
152+
expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith({
153+
billingEntity: { type: 'organization', id: 'org-1' },
154+
periodStart: new Date('2026-07-01T00:00:00.000Z'),
155+
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
156+
planDollars: 40,
157+
seats: 2,
158+
})
159+
expect(overage).toBe(80)
156160
})
157161
})

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

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { member, organization, subscription, userStats } from '@sim/db/schema'
2+
import { organization, subscription, userStats } from '@sim/db/schema'
33
import { and, desc, eq, inArray } from 'drizzle-orm'
44
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
55
import {
@@ -10,7 +10,6 @@ import { ensureUserStatsExists } from '@/lib/billing/core/usage'
1010
import {
1111
COPILOT_USAGE_SOURCES,
1212
getBillingPeriodUsageCost,
13-
getBillingPeriodUsageCostByUser,
1413
getBillingPeriodUsageCostWithSourceSubset,
1514
} from '@/lib/billing/core/usage-log'
1615
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
@@ -124,7 +123,6 @@ export async function computeOrgOverageAmount(params: {
124123
periodEnd: Date | null
125124
organizationId: string
126125
pooledLedgerUsage: number
127-
memberIds: string[]
128126
}): Promise<{
129127
effectiveUsage: number
130128
baseSubscriptionAmount: number
@@ -135,14 +133,13 @@ export async function computeOrgOverageAmount(params: {
135133

136134
let dailyRefreshDeduction = 0
137135
const planDollars = getPlanTierDollars(params.plan)
138-
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
136+
if (planDollars > 0 && params.periodStart) {
139137
dailyRefreshDeduction = await computeDailyRefreshConsumed({
140-
userIds: params.memberIds,
138+
billingEntity: { type: 'organization', id: params.organizationId },
141139
periodStart: params.periodStart,
142140
periodEnd: params.periodEnd ?? null,
143141
planDollars,
144142
seats: params.seats || 1,
145-
billingEntity: { type: 'organization', id: params.organizationId },
146143
})
147144
}
148145

@@ -180,23 +177,13 @@ export async function calculateSubscriptionOverage(sub: {
180177
const isOrgScoped = await isSubscriptionOrgScoped(sub)
181178

182179
if (isOrgScoped) {
183-
const memberRows = await db
184-
.select({ userId: member.userId })
185-
.from(member)
186-
.where(eq(member.organizationId, sub.referenceId))
187-
const usageByUser =
180+
const ledgerUsage =
188181
sub.periodStart && sub.periodEnd
189-
? await getBillingPeriodUsageCostByUser(
182+
? await getBillingPeriodUsageCost(
190183
{ type: 'organization', id: sub.referenceId },
191184
{ start: sub.periodStart, end: sub.periodEnd }
192185
)
193-
: new Map<string, number>()
194-
let ledgerUsage = 0
195-
for (const cost of usageByUser.values()) ledgerUsage += cost
196-
// Union current members with every actor holding org-attributed rows this
197-
// period: a member who departed mid-period still bills here, so their
198-
// daily-refresh consumption must offset the overage too.
199-
const memberIds = [...new Set([...memberRows.map((row) => row.userId), ...usageByUser.keys()])]
186+
: 0
200187

201188
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
202189
plan: sub.plan,
@@ -205,7 +192,6 @@ export async function calculateSubscriptionOverage(sub: {
205192
periodEnd: sub.periodEnd ?? null,
206193
organizationId: sub.referenceId,
207194
pooledLedgerUsage: ledgerUsage,
208-
memberIds,
209195
})
210196

211197
totalOverageDecimal = toDecimal(totalOverage)
@@ -238,11 +224,10 @@ export async function calculateSubscriptionOverage(sub: {
238224
const planDollars = getPlanTierDollars(sub.plan)
239225
if (planDollars > 0 && sub.periodStart) {
240226
dailyRefreshDeduction = await computeDailyRefreshConsumed({
241-
userIds: [sub.referenceId],
227+
billingEntity: { type: 'user', id: sub.referenceId },
242228
periodStart: sub.periodStart,
243229
periodEnd: sub.periodEnd ?? null,
244230
planDollars,
245-
billingEntity: { type: 'user', id: sub.referenceId },
246231
})
247232
}
248233
}
@@ -322,11 +307,10 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
322307
if (planDollars > 0) {
323308
refreshDeduction = await computeDailyRefreshConsumed(
324309
{
325-
userIds: [userId],
310+
billingEntity: { type: 'user', id: userId },
326311
periodStart: personalSubscription.periodStart,
327312
periodEnd: personalSubscription.periodEnd ?? null,
328313
planDollars,
329-
billingEntity: { type: 'user', id: userId },
330314
},
331315
executor
332316
)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
getBillingPeriodUsageCostByUser,
1111
type UsageQueryPeriod,
1212
} from '@/lib/billing/core/usage-log'
13-
import { computeOrganizationDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
13+
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
1414
import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
1515
import {
1616
getEffectiveSeats,
@@ -274,9 +274,9 @@ export async function getOrganizationBillingData(
274274
if (isPaid(subscription.plan) && subscription.periodStart) {
275275
const planDollars = getPlanTierDollars(subscription.plan)
276276
if (planDollars > 0) {
277-
const refreshConsumed = await computeOrganizationDailyRefreshConsumed(
277+
const refreshConsumed = await computeDailyRefreshConsumed(
278278
{
279-
organizationId: subscription.referenceId,
279+
billingEntity: { type: 'organization', id: subscription.referenceId },
280280
periodStart: subscription.periodStart,
281281
periodEnd: subscription.periodEnd ?? null,
282282
planDollars,

0 commit comments

Comments
 (0)