Skip to content

Commit 1757818

Browse files
committed
fix(billing): keep threshold-email usage reads exact and cover cache expiry
A usage email fires on an edge, and a cache TTL bounds how long a sum is stale, not how much usage it omits, so no cached baseline can safely decide a crossing. The execution logger's threshold-email pre-read goes back to the exact sum; the cache now serves only admission gates and the v1 usage report, where an under-count is the safe direction. Adds a test that a cached sum is re-read once it outlives its TTL.
1 parent 21ca52c commit 1757818

6 files changed

Lines changed: 31 additions & 211 deletions

File tree

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,6 @@ 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-
3731
/**
3832
* Default threshold (in dollars) for incremental overage billing
3933
* When unbilled overage reaches this amount, an invoice item is created

‎apps/sim/lib/billing/core/reporting-usage-cache.test.ts‎

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* @vitest-environment node
33
*/
44
import { db } from '@sim/db'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { sleep } from '@sim/utils/helpers'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
67

78
const { mockGetBillingPeriodUsageCost } = vi.hoisted(() => ({
89
mockGetBillingPeriodUsageCost: vi.fn(),
@@ -14,8 +15,8 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
1415

1516
import {
1617
getCachedReportingPeriodUsageCost,
18+
REPORTING_USAGE_CACHE_TTL_MS,
1719
readSoftGateUsageCost,
18-
readThresholdEmailUsageBefore,
1920
} from '@/lib/billing/core/reporting-usage-cache'
2021
import type { UsageQueryPeriod } from '@/lib/billing/core/usage-log'
2122

@@ -45,6 +46,28 @@ function freshOrg() {
4546
describe('getCachedReportingPeriodUsageCost', () => {
4647
beforeEach(() => {
4748
vi.clearAllMocks()
49+
mockGetBillingPeriodUsageCost.mockReset()
50+
})
51+
52+
afterEach(() => {
53+
vi.restoreAllMocks()
54+
})
55+
56+
it('sums the ledger again once a cached sum outlives its TTL', async () => {
57+
const org = freshOrg()
58+
const start = performance.now()
59+
const clock = vi.spyOn(performance, 'now').mockReturnValue(start)
60+
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(10).mockResolvedValueOnce(25)
61+
62+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(10)
63+
/** The cache re-reads its clock only after `ttlResolution` (1 ms) of real time. */
64+
clock.mockReturnValue(start + REPORTING_USAGE_CACHE_TTL_MS - 1)
65+
await sleep(2)
66+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(10)
67+
clock.mockReturnValue(start + REPORTING_USAGE_CACHE_TTL_MS + 1)
68+
await sleep(2)
69+
await expect(getCachedReportingPeriodUsageCost(org, REPORTING)).resolves.toBe(25)
70+
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(2)
4871
})
4972

5073
it('coalesces concurrent and repeated reads of one window into one ledger sum', async () => {
@@ -118,6 +141,7 @@ describe('getCachedReportingPeriodUsageCost', () => {
118141
describe('readSoftGateUsageCost', () => {
119142
beforeEach(() => {
120143
vi.clearAllMocks()
144+
mockGetBillingPeriodUsageCost.mockReset()
121145
})
122146

123147
it('serves reporting windows from the cache', async () => {
@@ -157,54 +181,3 @@ describe('readSoftGateUsageCost', () => {
157181
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(org, REPORTING, undefined, executor)
158182
})
159183
})
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-
})

‎apps/sim/lib/billing/core/reporting-usage-cache.ts‎

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { db } from '@sim/db'
22
import { LRUCache } from 'lru-cache'
3-
import { USAGE_WARNING_EMAIL_PERCENT } from '@/lib/billing/constants'
43
import {
54
type BillingEntity,
65
getBillingPeriodUsageCost,
@@ -81,43 +80,3 @@ export function readSoftGateUsageCost(
8180
}
8281
return getBillingPeriodUsageCost(entity, period, undefined, executor)
8382
}
84-
85-
/**
86-
* How far below the warning threshold, in percent of the limit, a cached sum must sit before it
87-
* is trusted for threshold emails.
88-
*/
89-
const THRESHOLD_EMAIL_EXACT_BAND_PERCENT = 5
90-
91-
/**
92-
* The payer's usage before this boundary's increment, for deciding whether it crossed a usage
93-
* email threshold.
94-
*
95-
* The email fires on an edge — before below a threshold, after at or above it — so an
96-
* under-counted "before" can drop a crossing or repeat one; unlike an admission gate, a stale sum
97-
* is not harmless here. A cached reporting-window sum is used only where it cannot move that
98-
* decision: with no positive limit, where no email is sent; at or above the limit, where the true
99-
* sum is too and neither threshold can be crossed; or more than
100-
* {@link THRESHOLD_EMAIL_EXACT_BAND_PERCENT} points of the limit below the warning threshold,
101-
* where only that much usage inside one TTL window could hide a crossing. In between, where the
102-
* emails actually fire, the sum is read exactly.
103-
*/
104-
export async function readThresholdEmailUsageBefore(
105-
entity: BillingEntity,
106-
period: UsageQueryPeriod,
107-
limit: number
108-
): Promise<number> {
109-
if (period.source !== 'reporting') {
110-
return getBillingPeriodUsageCost(entity, period)
111-
}
112-
113-
const cached = await getCachedReportingPeriodUsageCost(entity, period)
114-
if (limit <= 0) return cached
115-
const percent = (cached / limit) * 100
116-
if (
117-
percent >= 100 ||
118-
percent < USAGE_WARNING_EMAIL_PERCENT - THRESHOLD_EMAIL_EXACT_BAND_PERCENT
119-
) {
120-
return cached
121-
}
122-
return getBillingPeriodUsageCost(entity, period)
123-
}

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
55
import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull, sql } from 'drizzle-orm'
7-
import { USAGE_WARNING_EMAIL_PERCENT } from '@/lib/billing/constants'
87
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
98
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
109
import {
@@ -728,9 +727,7 @@ export async function maybeSendUsageThresholdEmail(params: {
728727
: `${baseUrl}/account/settings/billing`
729728

730729
// Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge)
731-
const crosses80 =
732-
params.percentBefore < USAGE_WARNING_EMAIL_PERCENT &&
733-
params.percentAfter >= USAGE_WARNING_EMAIL_PERCENT
730+
const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80
734731
// Check for 100% threshold — every plan and scope (usage limit reached)
735732
const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100
736733

‎apps/sim/lib/logs/execution/logger.test.ts‎

Lines changed: 1 addition & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import {
66
resetDbChainMock,
77
} from '@sim/testing'
88
import { isPlainRecord } from '@sim/utils/object'
9-
import { afterAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'
10-
import { readThresholdEmailUsageBefore } from '@/lib/billing/core/reporting-usage-cache'
11-
import { maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
9+
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'
1210
import { recordUsage } from '@/lib/billing/core/usage-log'
1311
import { ExecutionLogger } from '@/lib/logs/execution/logger'
1412
import { SECRET_PROJECTION_VERSION } from '@/lib/logs/execution/trace-store'
@@ -68,7 +66,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
6866
billingPeriod: {
6967
start: new Date(attribution.billingPeriod.start),
7068
end: new Date(attribution.billingPeriod.end),
71-
...(attribution.billingPeriod.source ? { source: attribution.billingPeriod.source } : {}),
7269
},
7370
})),
7471
}))
@@ -92,10 +89,6 @@ vi.mock('@/lib/billing/core/usage', () => ({
9289
maybeSendUsageThresholdEmail: vi.fn(() => Promise.resolve()),
9390
}))
9491

95-
vi.mock('@/lib/billing/core/reporting-usage-cache', () => ({
96-
readThresholdEmailUsageBefore: vi.fn(() => Promise.resolve(250)),
97-
}))
98-
9992
vi.mock('@/lib/billing/core/usage-log', () => ({
10093
recordUsage: vi.fn(() => Promise.resolve()),
10194
stableEventKey: vi.fn((parts: Record<string, unknown>) => JSON.stringify(parts)),
@@ -393,99 +386,6 @@ describe('ExecutionLogger', () => {
393386
expect(written?.resolvedSecretTraceProvenance).toEqual(RUN_PROVENANCE)
394387
})
395388

396-
test('reads the threshold-email baseline through the soft reporting-window reader', async () => {
397-
const startedAt = new Date('2026-08-11T00:00:00.000Z')
398-
queueTableRows(workflowExecutionLogs, [
399-
{
400-
id: 'log-1',
401-
workflowId: 'workflow-1',
402-
workspaceId: 'workspace-1',
403-
executionId: 'execution-1',
404-
stateSnapshotId: 'snapshot-1',
405-
level: 'info',
406-
status: 'running',
407-
trigger: 'api',
408-
startedAt,
409-
endedAt: null,
410-
totalDurationMs: null,
411-
executionData: {},
412-
createdAt: startedAt,
413-
},
414-
])
415-
dbChainMockFns.returning.mockResolvedValue([
416-
{ id: 'log-1', executionData: {}, startedAt, createdAt: startedAt },
417-
])
418-
onTestFinished(resetDbChainMock)
419-
const internals = logger as unknown as {
420-
applyPiiRedaction: (workspaceId: string, payload: unknown) => unknown
421-
recordExecutionUsage: () => Promise<number>
422-
}
423-
vi.spyOn(internals, 'applyPiiRedaction').mockImplementation(
424-
async (_workspaceId: string, payload: unknown) => payload
425-
)
426-
vi.spyOn(internals, 'recordExecutionUsage').mockResolvedValue(10)
427-
428-
await logger.completeWorkflowExecution({
429-
executionId: 'execution-1',
430-
endedAt: '2026-08-11T00:00:02.000Z',
431-
totalDurationMs: 2000,
432-
costSummary: {
433-
totalCost: 0,
434-
totalInputCost: 0,
435-
totalOutputCost: 0,
436-
totalTokens: 0,
437-
totalPromptTokens: 0,
438-
totalCompletionTokens: 0,
439-
baseExecutionCharge: 0,
440-
models: {},
441-
},
442-
finalOutput: {},
443-
traceSpans: [],
444-
billingAttribution: {
445-
actorUserId: 'actor-1',
446-
workspaceId: 'workspace-1',
447-
organizationId: 'org-enterprise',
448-
billedAccountUserId: 'owner-1',
449-
billingEntity: { type: 'organization', id: 'org-enterprise' },
450-
billingPeriod: {
451-
start: '2026-01-01T00:00:00.000Z',
452-
end: '2027-01-01T00:00:00.000Z',
453-
source: 'reporting',
454-
},
455-
payerSubscription: {
456-
id: 'sub-1',
457-
plan: 'enterprise',
458-
status: 'active',
459-
seats: 10,
460-
periodStart: '2026-01-01T00:00:00.000Z',
461-
periodEnd: '2027-01-01T00:00:00.000Z',
462-
},
463-
} as unknown as NonNullable<
464-
Parameters<ExecutionLogger['completeWorkflowExecution']>[0]['billingAttribution']
465-
>,
466-
})
467-
468-
expect(readThresholdEmailUsageBefore).toHaveBeenCalledWith(
469-
{ type: 'organization', id: 'org-enterprise' },
470-
{
471-
start: new Date('2026-01-01T00:00:00.000Z'),
472-
end: new Date('2027-01-01T00:00:00.000Z'),
473-
source: 'reporting',
474-
},
475-
1000
476-
)
477-
expect(maybeSendUsageThresholdEmail).toHaveBeenCalledWith(
478-
expect.objectContaining({
479-
scope: 'organization',
480-
organizationId: 'org-enterprise',
481-
percentBefore: 25,
482-
percentAfter: 26,
483-
currentUsageAfter: 260,
484-
limit: 1000,
485-
})
486-
)
487-
})
488-
489389
test('omits the provenance key when the run carried none', async () => {
490390
const written = await completeAndReadWrite({})
491391

‎apps/sim/lib/logs/execution/logger.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,13 +1326,10 @@ export class ExecutionLogger implements IExecutionLoggerService {
13261326
payerSubscription.plan,
13271327
payerSubscription.seats
13281328
)
1329-
const { readThresholdEmailUsageBefore } = await import(
1330-
'@/lib/billing/core/reporting-usage-cache'
1331-
)
1332-
const orgLedger = await readThresholdEmailUsageBefore(
1329+
const { getBillingPeriodUsageCost } = await import('@/lib/billing/core/usage-log')
1330+
const orgLedger = await getBillingPeriodUsageCost(
13331331
billingAttribution.billingEntity,
1334-
exactBillingContext.billingPeriod,
1335-
orgLimit
1332+
exactBillingContext.billingPeriod
13361333
)
13371334
emailContext = {
13381335
scope: 'organization',

0 commit comments

Comments
 (0)