Skip to content
Open
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
58 changes: 55 additions & 3 deletions apps/sim/app/api/billing/update-cost/route.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ async function createUser(): Promise<string> {
return userId
}

/** A user on a pro subscription for `period`, whose close marker has caught up to it. */
async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
const userId = await createUser()
/** Puts `userId` on a pro subscription for `period`, whose close marker has caught up to it. */
async function subscribePro(userId: string, period: { start: Date; end: Date }): Promise<string> {
const subscriptionId = generateId()
await db.insert(subscription).values({
id: subscriptionId,
Expand All @@ -83,6 +82,13 @@ async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
periodEnd: period.end,
lastClosedPeriodStart: period.start,
})
return subscriptionId
}

/** A user on a pro subscription for `period`. */
async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
const userId = await createUser()
const subscriptionId = await subscribePro(userId, period)
const decision = serializeAccountBillingDecisionHeader({
userId,
billingEntity: { type: 'user', id: userId },
Expand Down Expand Up @@ -245,6 +251,52 @@ describe('direct-v1 cost callbacks in PostgreSQL', () => {
})
})

/**
* A free payer who subscribes mid-run: the run's spend stays in the open default period it was
* admitted in, which no subscription period ever matches, so each callback must still settle
* rather than answer a retryable refusal on every attempt.
*/
describe('a free payer who subscribes mid-run', () => {
it.each([
{
protocol: 'direct-v1',
admit: async () => {
const payer = await createFreePayer()
return {
userId: payer.userId,
send: (key: string, cost: number) => callback(payer, key, cost),
}
},
},
{
protocol: 'attribution-v1',
admit: async () => {
const attribution = await createFreeWorkspacePayer()
return {
userId: attribution.billedAccountUserId,
send: (key: string, cost: number) => attributedCallback(attribution, key, cost),
}
},
},
])('settles $protocol spend in the period it was admitted in', async ({ admit }) => {
const { userId, send } = await admit()
const requestKey = generateId()
const periodStart = new Date(Date.now() - 60 * 60 * 1000)

expect((await POST(send(requestKey, 0.5), {})).status).toBe(200)
await subscribePro(userId, {
start: periodStart,
end: new Date(periodStart.getTime() + 30 * DAY_MS),
})
expect((await POST(send(requestKey, 0.8), {})).status).toBe(200)
expect((await POST(send(requestKey, 0.8), {})).status).toBe(409)

const rows = await requestRows(requestKey)
expect(rows.map((row) => Number(row.cost))).toEqual([0.8])
expect(rows[0]?.billingPeriodStart?.getTime()).toBe(defaultBillingPeriod().start.getTime())
})
})

/**
* Each payer's first callback caches an admission ($1 of a $5 limit), and its second, inside the
* near-limit cache window, records spend that takes it to $6.
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/billing/core/billing-period.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ export function defaultBillingPeriod(): { start: Date; end: Date } {
end: OPEN_BILLING_PERIOD_END,
}
}

export function isDefaultBillingPeriod(period: { start: Date; end: Date }): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The exported helper lacks documentation

isDefaultBillingPeriod is a new exported API without TSDoc. The repository requires TSDoc for exported APIs, so this must be addressed before merging. Add a short comment explaining that it recognizes the open fallback period by its dates, without requiring a source field.

Suggested change
export function isDefaultBillingPeriod(period: { start: Date; end: Date }): boolean {
/** Recognizes the open fallback billing period by its dates, without requiring a source field. */
export function isDefaultBillingPeriod(period: { start: Date; end: Date }): boolean {

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return (
period.start.getTime() === OPEN_BILLING_PERIOD_START.getTime() &&
period.end.getTime() === OPEN_BILLING_PERIOD_END.getTime()
)
}
6 changes: 6 additions & 0 deletions apps/sim/lib/billing/threshold-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { and, eq, sql } from 'drizzle-orm'
import { BILLING_LOCK_TIMEOUT_MS, DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing'
import { isDefaultBillingPeriod } from '@/lib/billing/core/billing-period'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import {
getHighestPriorityPersonalSubscription,
Expand Down Expand Up @@ -216,6 +217,11 @@ export async function checkAndBillPayerOverageThreshold(
billingEntity: BillingEntity,
options: ThresholdBillingOptions = {}
): Promise<ThresholdSettlementOutcome | undefined> {
// Spend admitted without a subscription stays in the open period, which no subscription the
// payer gains later ever spans, so it has no threshold to settle.
if (options.expectedBillingPeriod && isDefaultBillingPeriod(options.expectedBillingPeriod)) {

@cubic-dev-ai cubic-dev-ai Bot Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This guard conflates an open period with having no subscription. A paid or entitled subscription with missing billing dates is serialized with the default period, so this line acknowledges the callback without resolving or settling its overage instead of raising the existing retryable required_state_missing error; only bypass the check after confirming that the payer has no subscription.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/billing/threshold-billing.ts, line 222:

<comment>This guard conflates an open period with having no subscription. A paid or entitled subscription with missing billing dates is serialized with the default period, so this line acknowledges the callback without resolving or settling its overage instead of raising the existing retryable `required_state_missing` error; only bypass the check after confirming that the payer has no subscription.</comment>

<file context>
@@ -216,6 +217,11 @@ export async function checkAndBillPayerOverageThreshold(
 ): Promise<ThresholdSettlementOutcome | undefined> {
+  // Spend admitted without a subscription stays in the open period, which no subscription the
+  // payer gains later ever spans, so it has no threshold to settle.
+  if (options.expectedBillingPeriod && isDefaultBillingPeriod(options.expectedBillingPeriod)) {
+    return noOp(options, 'no-subscription')
+  }
</file context>
Fix with cubic

return noOp(options, 'no-subscription')
}
if (billingEntity.type === 'organization') {
return checkAndBillOrganizationOverageThreshold(billingEntity.id, options)
}
Expand Down
Loading