Skip to content

Commit 21ca52c

Browse files
committed
fix(billing): cache enterprise reporting-window usage sums for soft usage gates
An enterprise payer's usage is summed over its reporting window, which can be a year long, so every soft usage check re-scanned the whole year of that payer's ledger. Admission gates (pooled organization usage behind checkUsageStatus, checkServerSideUsageLimits and checkAttributedUsageLimits), the execution logger's threshold-email baseline, and the v1 API usage envelope each ran it once per billable event or request, so a busy enterprise organization kept the database busy re-adding the same rows. Reporting-window sums for these soft readers now go through a per-process LRUCache keyed by payer and window (max 1000, 30 s TTL). fetchMethod coalesces concurrent misses into one sum, and a failed sum is evicted rather than cached, so callers keep their existing fail-closed or fail-open handling and the next call sums again. Stripe and default periods are unchanged and always summed exactly, as is any read on a caller-supplied executor. The ledger only grows within a window, so a cached sum can only under-count, by at most the usage written in the last 30 s: an admission gate may let a payer run up to 30 s past its limit, and never refuses one the exact sum would admit. Threshold emails fire on an edge, where a stale baseline could drop or repeat a crossing, so the logger uses the cached sum only when it is at or above the limit or more than 5 points below the warning threshold, and reads exactly in the band where emails actually fire. getBillingPeriodUsageCost itself is unchanged, and invoicing, threshold billing (which already skips enterprise), cycle close, analytics, settings displays and the per-member cap keep reading the ledger exactly.
1 parent 293e883 commit 21ca52c

8 files changed

Lines changed: 515 additions & 8 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,58 @@ describe('checkUsageStatus', () => {
105105
)
106106
})
107107

108+
it('shares one pooled sum across admissions in an enterprise reporting window', async () => {
109+
const billingPeriod = {
110+
start: new Date('2026-01-01T00:00:00.000Z'),
111+
end: new Date('2027-01-01T00:00:00.000Z'),
112+
source: 'reporting' as const,
113+
anchorDate: '2026-01-01',
114+
interval: 'year' as const,
115+
}
116+
const subscription = {
117+
referenceId: 'org-reporting-shared',
118+
plan: 'enterprise',
119+
status: 'active',
120+
seats: 1,
121+
periodStart: billingPeriod.start,
122+
periodEnd: billingPeriod.end,
123+
}
124+
const billingContext = {
125+
billingEntity: { type: 'organization' as const, id: 'org-reporting-shared' },
126+
billingPeriod,
127+
}
128+
129+
await checkUsageStatus('user-1', subscription, billingContext)
130+
await checkUsageStatus('user-2', subscription, billingContext)
131+
132+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
133+
})
134+
135+
it('sums a Stripe-period organization pool exactly on every admission', async () => {
136+
const billingPeriod = {
137+
start: new Date('2026-06-01T00:00:00.000Z'),
138+
end: new Date('2026-07-01T00:00:00.000Z'),
139+
source: 'stripe' as const,
140+
}
141+
const subscription = {
142+
referenceId: 'org-stripe',
143+
plan: 'team',
144+
status: 'active',
145+
seats: 1,
146+
periodStart: null,
147+
periodEnd: null,
148+
}
149+
const billingContext = {
150+
billingEntity: { type: 'organization' as const, id: 'org-stripe' },
151+
billingPeriod,
152+
}
153+
154+
await checkUsageStatus('user-1', subscription, billingContext)
155+
await checkUsageStatus('user-1', subscription, billingContext)
156+
157+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
158+
})
159+
108160
it('reads paid personal ledger usage and refresh from one snapshot', async () => {
109161
const periodStart = new Date('2026-06-01T00:00:00.000Z')
110162
const periodEnd = new Date('2026-07-01T00:00:00.000Z')

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +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 { readSoftGateUsageCost } from '@/lib/billing/core/reporting-usage-cache'
1011
import { getUserUsageLimit, type UsageLimitSubscription } from '@/lib/billing/core/usage'
1112
import {
1213
type BillingContext,
@@ -44,6 +45,11 @@ interface UsageData {
4445
organizationId: string | null
4546
}
4647

48+
/**
49+
* The organization's pooled usage for an admission check. An enterprise reporting window is
50+
* served through {@link readSoftGateUsageCost}; its weekly refresh is zero, so it always takes
51+
* a plain-sum branch below.
52+
*/
4753
async function computePooledOrgUsage(
4854
organizationId: string,
4955
sub: UsageLimitSubscription,
@@ -58,12 +64,12 @@ async function computePooledOrgUsage(
5864
}
5965

6066
if (!isPaid(sub.plan) || !sub.periodStart) {
61-
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
67+
return readSoftGateUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
6268
}
6369

6470
const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(sub.plan)
6571
if (weeklyRefreshDollars <= 0) {
66-
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
72+
return readSoftGateUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
6773
}
6874

6975
const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithWeeklyRefresh({

‎apps/sim/lib/billing/constants.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export const BASE_EXECUTION_CHARGE = 0.005
2828
*/
2929
export const SEARCH_TOOL_COST = 0.01
3030

31+
/**
32+
* Share of a usage limit, in percent, at which a payer is sent the "nearing your limit" email.
33+
* The "limit reached" email fires at 100%.
34+
*/
35+
export const USAGE_WARNING_EMAIL_PERCENT = 80
36+
3137
/**
3238
* Default threshold (in dollars) for incremental overage billing
3339
* When unbilled overage reaches this amount, an invoice item is created
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { db } from '@sim/db'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetBillingPeriodUsageCost } = vi.hoisted(() => ({
8+
mockGetBillingPeriodUsageCost: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/billing/core/usage-log', () => ({
12+
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
13+
}))
14+
15+
import {
16+
getCachedReportingPeriodUsageCost,
17+
readSoftGateUsageCost,
18+
readThresholdEmailUsageBefore,
19+
} from '@/lib/billing/core/reporting-usage-cache'
20+
import type { UsageQueryPeriod } from '@/lib/billing/core/usage-log'
21+
22+
const REPORTING: UsageQueryPeriod = {
23+
start: new Date('2026-01-01T00:00:00.000Z'),
24+
end: new Date('2027-01-01T00:00:00.000Z'),
25+
source: 'reporting',
26+
}
27+
const NEXT_REPORTING: UsageQueryPeriod = {
28+
start: new Date('2027-01-01T00:00:00.000Z'),
29+
end: new Date('2028-01-01T00:00:00.000Z'),
30+
source: 'reporting',
31+
}
32+
const STRIPE: UsageQueryPeriod = {
33+
start: new Date('2026-09-01T00:00:00.000Z'),
34+
end: new Date('2026-10-01T00:00:00.000Z'),
35+
source: 'stripe',
36+
}
37+
38+
let nextOrg = 0
39+
/** A fresh payer per test, since the cache is module state shared across tests. */
40+
function freshOrg() {
41+
nextOrg += 1
42+
return { type: 'organization' as const, id: `org-${nextOrg}` }
43+
}
44+
45+
describe('getCachedReportingPeriodUsageCost', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
})
49+
50+
it('coalesces concurrent and repeated reads of one window into one ledger sum', async () => {
51+
const org = freshOrg()
52+
let resolveSum: (value: number) => void = () => {}
53+
mockGetBillingPeriodUsageCost.mockReturnValueOnce(
54+
new Promise<number>((resolve) => {
55+
resolveSum = resolve
56+
})
57+
)
58+
59+
const concurrent = Promise.all([
60+
getCachedReportingPeriodUsageCost(org, REPORTING),
61+
getCachedReportingPeriodUsageCost(org, REPORTING),
62+
getCachedReportingPeriodUsageCost(org, REPORTING),
63+
])
64+
resolveSum(42)
65+
66+
await expect(concurrent).resolves.toEqual([42, 42, 42])
67+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(42)
68+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
69+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(org, REPORTING)
70+
})
71+
72+
it('serves a zero sum from cache rather than re-reading it', async () => {
73+
const org = freshOrg()
74+
mockGetBillingPeriodUsageCost.mockResolvedValue(0)
75+
76+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(0)
77+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(0)
78+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
79+
})
80+
81+
it('keeps separate sums for different payers and windows', async () => {
82+
const first = freshOrg()
83+
const second = freshOrg()
84+
mockGetBillingPeriodUsageCost
85+
.mockResolvedValueOnce(1)
86+
.mockResolvedValueOnce(2)
87+
.mockResolvedValueOnce(3)
88+
.mockResolvedValueOnce(4)
89+
90+
await expect(getCachedReportingPeriodUsageCost(first, REPORTING)).resolves.toBe(1)
91+
await expect(getCachedReportingPeriodUsageCost(second, REPORTING)).resolves.toBe(2)
92+
await expect(getCachedReportingPeriodUsageCost(first, NEXT_REPORTING)).resolves.toBe(3)
93+
await expect(
94+
getCachedReportingPeriodUsageCost({ type: 'user', id: first.id }, REPORTING)
95+
).resolves.toBe(4)
96+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(4)
97+
})
98+
99+
it('surfaces a failed sum to every waiting caller and never caches it', async () => {
100+
const org = freshOrg()
101+
const failure = new Error('canceling statement due to statement timeout')
102+
mockGetBillingPeriodUsageCost.mockRejectedValueOnce(failure).mockResolvedValueOnce(17)
103+
104+
const results = await Promise.allSettled([
105+
getCachedReportingPeriodUsageCost(org, REPORTING),
106+
getCachedReportingPeriodUsageCost(org, REPORTING),
107+
])
108+
expect(results).toEqual([
109+
{ status: 'rejected', reason: failure },
110+
{ status: 'rejected', reason: failure },
111+
])
112+
113+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(17)
114+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
115+
})
116+
})
117+
118+
describe('readSoftGateUsageCost', () => {
119+
beforeEach(() => {
120+
vi.clearAllMocks()
121+
})
122+
123+
it('serves reporting windows from the cache', async () => {
124+
const org = freshOrg()
125+
mockGetBillingPeriodUsageCost.mockResolvedValue(10)
126+
127+
await readSoftGateUsageCost(org, REPORTING)
128+
await readSoftGateUsageCost(org, REPORTING)
129+
130+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
131+
})
132+
133+
it.each([
134+
['stripe', STRIPE],
135+
['default', { ...STRIPE, source: 'default' as const }],
136+
['unlabelled', { start: STRIPE.start, end: STRIPE.end }],
137+
])('sums %s periods exactly on every call', async (_label, period) => {
138+
const org = freshOrg()
139+
mockGetBillingPeriodUsageCost.mockResolvedValue(10)
140+
141+
await readSoftGateUsageCost(org, period)
142+
await readSoftGateUsageCost(org, period)
143+
144+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
145+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(org, period, undefined, db)
146+
})
147+
148+
it('reads a reporting window exactly on a caller-supplied executor', async () => {
149+
const org = freshOrg()
150+
const executor = { transaction: vi.fn() } as unknown as typeof db
151+
mockGetBillingPeriodUsageCost.mockResolvedValue(10)
152+
153+
await readSoftGateUsageCost(org, REPORTING, executor)
154+
await readSoftGateUsageCost(org, REPORTING, executor)
155+
156+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
157+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(org, REPORTING, undefined, executor)
158+
})
159+
})
160+
161+
describe('readThresholdEmailUsageBefore', () => {
162+
beforeEach(() => {
163+
vi.clearAllMocks()
164+
})
165+
166+
it('sums non-reporting periods exactly', async () => {
167+
const org = freshOrg()
168+
mockGetBillingPeriodUsageCost.mockResolvedValue(10)
169+
170+
await readThresholdEmailUsageBefore(org, STRIPE, 1000)
171+
await readThresholdEmailUsageBefore(org, STRIPE, 1000)
172+
173+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
174+
})
175+
176+
it.each([
177+
['far below the warning threshold', 100],
178+
['at the limit', 1000],
179+
['above the limit', 1500],
180+
])('serves the cached sum %s', async (_label, cachedSum) => {
181+
const org = freshOrg()
182+
mockGetBillingPeriodUsageCost.mockResolvedValue(cachedSum)
183+
184+
await expect(readThresholdEmailUsageBefore(org, REPORTING, 1000)).resolves.toBe(cachedSum)
185+
await expect(readThresholdEmailUsageBefore(org, REPORTING, 1000)).resolves.toBe(cachedSum)
186+
187+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
188+
})
189+
190+
it.each([
191+
['just below the warning band', 760],
192+
['between the thresholds', 900],
193+
])('reads exactly when the cached sum is %s', async (_label, cachedSum) => {
194+
const org = freshOrg()
195+
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(cachedSum).mockResolvedValueOnce(801)
196+
197+
await expect(readThresholdEmailUsageBefore(org, REPORTING, 1000)).resolves.toBe(801)
198+
199+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
200+
expect(mockGetBillingPeriodUsageCost).toHaveBeenLastCalledWith(org, REPORTING)
201+
})
202+
203+
it('serves the cached sum when there is no limit to cross', async () => {
204+
const org = freshOrg()
205+
mockGetBillingPeriodUsageCost.mockResolvedValue(900)
206+
207+
await expect(readThresholdEmailUsageBefore(org, REPORTING, 0)).resolves.toBe(900)
208+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
209+
})
210+
})

0 commit comments

Comments
 (0)