Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/sim/app/api/billing/update-cost/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,23 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {

expect(body.usageExceeded).toBe(false)
})

it('keeps the exceeded verdict with the plan-upgrade card when the card read outlasts the callback budget', async () => {
billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => {
await sleep(1500)
return { plan: 'pro' }
})
const startedAt = Date.now()

const body = await (await POST(directCallback())).json()

expect(body).toMatchObject({
success: true,
usageExceeded: true,
usageUpgrade: { action: 'upgrade_plan' },
})
expect(Date.now() - startedAt).toBeLessThan(1400)
})
})

describe('a run that outlives its billing period', () => {
Expand Down
13 changes: 10 additions & 3 deletions apps/sim/app/api/billing/update-cost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
* steady-state steps cost no ledger read. The charge is
* already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
* refusal to the next step or re-check rather than ending a paying run on a database blip,
* and so does a read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}.
* and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded
* verdict always pauses the run; a card read past that budget falls back to the plan-upgrade card.
*/
async function readUsageStanding(
userId: string,
Expand All @@ -98,9 +99,10 @@ async function readUsageStanding(
? () => readMidRunAccountUsageVerdict(accountDecision)
: null
if (!isHosted || !readVerdict) return { usageExceeded: false }
const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS
let verdict: MidRunUsageVerdict
try {
verdict = await withinDeadline(readVerdict, Date.now() + USAGE_STANDING_TIMEOUT_MS)
verdict = await withinDeadline(readVerdict, deadlineAt)
} catch {
logger.warn('Usage standing read outlasted the callback budget; answering not exceeded')
return { usageExceeded: false }
Expand All @@ -110,7 +112,12 @@ async function readUsageStanding(
if (verdict.status !== 'exceeded') return { usageExceeded: false }
return {
usageExceeded: true,
usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope),
usageUpgrade: await resolveUsageUpgradePayload(
userId,
billingAttribution,
verdict.scope,
deadlineAt
),
}
}

Expand Down
26 changes: 26 additions & 0 deletions apps/sim/lib/billing/core/usage-log.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,32 @@ describe('Cumulative billing with PostgreSQL', () => {
expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(0.6, 9)
})

it('keeps billing a request whose period start moved forward before its first charge', async () => {
const resetStart = new Date('2025-09-15T00:00:00.000Z')
const resetEnd = new Date('2025-10-15T00:00:00.000Z')
await setSubscriptionWindow(resetStart, resetEnd)

expect(await charge(0.4)).toMatchObject({ billed: true, total: 0.4 })
expect(await charge(1)).toMatchObject({
billed: true,
total: 1,
billingPeriod: { start: resetStart, end: resetEnd },
})
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '1' }])
expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(1, 9)
})

it('refuses a request admitted after the period its first charge was stamped with', async () => {
await setSubscriptionPeriod(0)
await charge(0.4)

await expect(charge(1, { start: periods[1], end: periods[2] })).rejects.toMatchObject({
name: CumulativeUsageContextMismatchError.name,
mismatchedFields: ['billing period'],
})
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
})

it('holds an early period-start move until an in-flight top-up commits', async () => {
const start = new Date(Date.now() - 24 * 60 * 60 * 1000)
const end = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/billing/core/usage-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,10 @@ function assertCumulativeUsageLedgerBinding(
workspaceId?: string
billingContext: BillingContext
eventKey: string
/** A request whose first charge landed after its period closed is stamped with a later one. */
/**
* A request whose first charge landed after its period closed, or after an anchor reset moved
* its start forward, is stamped with a later one.
*/
allowLaterPeriod?: boolean
}
): void {
Expand All @@ -717,10 +720,11 @@ function assertCumulativeUsageLedgerBinding(
const samePeriod =
existing.billingPeriodStart?.getTime() === frozenPeriod.start.getTime() &&
existing.billingPeriodEnd?.getTime() === frozenPeriod.end.getTime()
// The same forward-only rule that rolls a charge into a new period row.
const laterPeriod =
expected.allowLaterPeriod === true &&
existing.billingPeriodStart !== null &&
existing.billingPeriodStart.getTime() >= frozenPeriod.end.getTime()
existing.billingPeriodStart.getTime() > frozenPeriod.start.getTime()
Comment thread
waleedlatif1 marked this conversation as resolved.
if (!samePeriod && !laterPeriod) {
mismatchedFields.push('billing period')
}
Expand Down
11 changes: 8 additions & 3 deletions apps/sim/lib/billing/usage-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { withinDeadline } from '@/lib/core/utils/deadline'

const logger = createLogger('UsageUpgrade')

Expand All @@ -22,12 +23,14 @@ const MEMBER_CAP_MESSAGE =
* increase for a paid one, with copy naming who can raise an organization's limit. A member
* over the cap their organization set gets copy naming who can raise that cap. An attributed
* run reads the plan from its admission snapshot without a query; otherwise the actor's current
* subscription decides, and a failed lookup falls back to the plan-upgrade card.
* subscription decides, and a lookup that fails or outlasts `deadlineAt` falls back to the
* plan-upgrade card.
*/
export async function resolveUsageUpgradePayload(
userId: string,
billingAttribution?: BillingAttributionSnapshot,
scope?: AttributedUsageLimitsResult['scope']
scope?: AttributedUsageLimitsResult['scope'],
deadlineAt?: number
): Promise<UsageUpgradePayload> {
if (scope === 'member') {
return { reason: 'usage_limit', action: 'increase_limit', message: MEMBER_CAP_MESSAGE }
Expand All @@ -39,7 +42,9 @@ export async function resolveUsageUpgradePayload(
plan = billingAttribution.payerSubscription?.plan
orgScoped = billingAttribution.billingEntity.type === 'organization'
} else {
const subscription = await getHighestPrioritySubscription(userId)
const subscription = await (deadlineAt === undefined
? getHighestPrioritySubscription(userId)
: withinDeadline(() => getHighestPrioritySubscription(userId), deadlineAt))
plan = subscription?.plan
orgScoped = isOrgScopedSubscription(subscription, userId)
}
Expand Down
Loading