Skip to content

Commit c51ff3e

Browse files
committed
fix(billing): reload an ended cached period, keep org payers org-scoped, keep blocked accounts blocked
1 parent 3c66b48 commit c51ff3e

5 files changed

Lines changed: 101 additions & 17 deletions

File tree

‎apps/sim/app/api/billing/update-cost/route.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,26 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
11531153
expect(body.usageExceeded).toBe(false)
11541154
})
11551155

1156+
it('reloads a cached current period once it has ended', async () => {
1157+
const ending = {
1158+
...CURRENT_ATTRIBUTION,
1159+
billingPeriod: {
1160+
start: '2026-07-01T00:00:00.000Z',
1161+
end: new Date(Date.now() + 50).toISOString(),
1162+
},
1163+
}
1164+
mockRefreshAttributionPeriod
1165+
.mockResolvedValueOnce(ending)
1166+
.mockResolvedValue(CURRENT_ATTRIBUTION)
1167+
refuseOnlyCurrentPeriod()
1168+
await POST(attributedCallback())
1169+
await sleep(100)
1170+
1171+
const body = await (await POST(attributedCallback())).json()
1172+
1173+
expect(body.usageExceeded).toBe(true)
1174+
})
1175+
11561176
it('never answers a verdict whose card and flag disagree', () => {
11571177
const base = {
11581178
success: true,

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,19 @@ describe('validation lifecycle purposes', () => {
796796
expect(response.status).toBe(402)
797797
})
798798

799+
it('judges a direct-v1 organization payer without a subscription as that organization', async () => {
800+
mockGetOrganizationSubscription.mockResolvedValue(null)
801+
mockCheckUsageStatus.mockImplementation(
802+
async (_userId: string, subscription: { referenceId?: string } | null) => ({
803+
isExceeded: subscription?.referenceId === 'account-org',
804+
currentUsage: 12,
805+
limit: 10,
806+
})
807+
)
808+
809+
expect((await POST(request(body, directHeaders))).status).toBe(402)
810+
})
811+
799812
it('refuses a blocked new turn with the blocked body the contract declares', async () => {
800813
mockCheckAttributedUsageLimits.mockResolvedValueOnce({
801814
isExceeded: true,

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,19 @@ describe('checkServerSideUsageLimits', () => {
230230
expect(result.message ?? '').not.toMatch(/\$/)
231231
})
232232

233+
it('keeps a blocked account blocked when its ledger cannot be read', async () => {
234+
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'dispute' }])
235+
mockGetBillingPeriodUsageCost.mockRejectedValueOnce(new Error('canceling statement'))
236+
237+
const result = await checkServerSideUsageLimits('user-1', null)
238+
239+
expect(result).toMatchObject({
240+
isExceeded: true,
241+
reason: 'billing_blocked',
242+
message: 'Account frozen. Please contact support to resolve this issue.',
243+
})
244+
})
245+
233246
it('keeps blocked accounts blocked while reporting their real ledger usage', async () => {
234247
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }])
235248
const subscription = {

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

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { toError } from '@sim/utils/errors'
4+
import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { eq } from 'drizzle-orm'
66
import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
77
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
@@ -306,6 +306,38 @@ export async function checkBillingEntityBlocked(
306306
* @param userId The ID of the user to check
307307
* @returns An object containing the exceeded status and usage details
308308
*/
309+
/**
310+
* A blocked account's ledger usage, for display only: `/api/users/me/usage-limits` exposes it as
311+
* `currentPeriodCost`. The account is blocked either way, so a failed read reports zero rather
312+
* than turning the blocked refusal into an unreadable-usage one.
313+
*/
314+
async function readBlockedAccountUsage(
315+
userId: string,
316+
preloadedSubscription: UsageLimitSubscription | null | undefined,
317+
preloadedBillingContext: BillingContext | undefined
318+
): Promise<number> {
319+
try {
320+
const sub =
321+
preloadedSubscription !== undefined
322+
? preloadedSubscription
323+
: await getHighestPrioritySubscription(userId)
324+
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
325+
const billingEntity: BillingEntity =
326+
subIsOrgScoped && sub
327+
? { type: 'organization', id: sub.referenceId }
328+
: { type: 'user', id: userId }
329+
const billingPeriod = preloadedBillingContext?.billingPeriod ??
330+
resolveSubscriptionUsagePeriod(sub) ?? { ...defaultBillingPeriod(), source: 'default' }
331+
return await getBillingPeriodUsageCost(billingEntity, billingPeriod)
332+
} catch (error) {
333+
logger.warn('Blocked account usage could not be read', {
334+
userId,
335+
error: getErrorMessage(error),
336+
})
337+
return 0
338+
}
339+
}
340+
309341
export async function checkServerSideUsageLimits(
310342
userId: string,
311343
preloadedSubscription?: UsageLimitSubscription | null,
@@ -331,20 +363,11 @@ export async function checkServerSideUsageLimits(
331363

332364
const blocked = await checkBillingBlocked(userId)
333365
if (blocked.blocked) {
334-
// Enforcement stays blocked, but surfaced usage must be the real ledger
335-
// value — `/api/users/me/usage-limits` exposes it as `currentPeriodCost`.
336-
const sub =
337-
preloadedSubscription !== undefined
338-
? preloadedSubscription
339-
: await getHighestPrioritySubscription(userId)
340-
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
341-
const billingEntity: BillingEntity =
342-
subIsOrgScoped && sub
343-
? { type: 'organization', id: sub.referenceId }
344-
: { type: 'user', id: userId }
345-
const billingPeriod = preloadedBillingContext?.billingPeriod ??
346-
resolveSubscriptionUsagePeriod(sub) ?? { ...defaultBillingPeriod(), source: 'default' }
347-
const currentUsage = await getBillingPeriodUsageCost(billingEntity, billingPeriod)
366+
const currentUsage = await readBlockedAccountUsage(
367+
userId,
368+
preloadedSubscription,
369+
preloadedBillingContext
370+
)
348371
return {
349372
isExceeded: true,
350373
currentUsage,

‎apps/sim/lib/billing/core/mid-run-usage.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ async function currentAttribution(
6060
): Promise<BillingAttributionSnapshot> {
6161
const key = currentPeriodKey(attribution)
6262
const cached = fresh ? undefined : currentPeriodCache.get(key)
63-
if (cached) return cached
63+
// A cached period that has since ended is stale: the payer may already be in the next one.
64+
if (cached && !periodHasEnded(cached)) return cached
6465
const current = await refreshAttributionPeriod(attribution)
6566
currentPeriodCache.set(key, current)
6667
return current
@@ -138,7 +139,21 @@ export async function readMidRunAccountUsageVerdict(
138139
...defaultBillingPeriod(),
139140
source: 'default' as const,
140141
}
141-
const usage = await checkUsageStatus(decision.userId, subscription, {
142+
// An organization payer without a subscription stays organization-scoped on the free plan,
143+
// as `toUsageLimitSubscription` does for attributed runs, never the actor's personal ledger.
144+
const usageSubscription =
145+
subscription ??
146+
(payer.type === 'organization'
147+
? {
148+
referenceId: payer.id,
149+
plan: 'free',
150+
status: null,
151+
seats: null,
152+
periodStart: billingPeriod.start,
153+
periodEnd: billingPeriod.end,
154+
}
155+
: null)
156+
const usage = await checkUsageStatus(decision.userId, usageSubscription, {
142157
billingEntity: payer,
143158
billingPeriod,
144159
})

0 commit comments

Comments
 (0)