diff --git a/apps/sim/app/api/billing/update-cost/route.integration.ts b/apps/sim/app/api/billing/update-cost/route.integration.ts index 2609beb6cf8..e42e2bdfa23 100644 --- a/apps/sim/app/api/billing/update-cost/route.integration.ts +++ b/apps/sim/app/api/billing/update-cost/route.integration.ts @@ -70,9 +70,8 @@ async function createUser(): Promise { 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 { - 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 { const subscriptionId = generateId() await db.insert(subscription).values({ id: subscriptionId, @@ -83,6 +82,13 @@ async function createPayer(period: { start: Date; end: Date }): Promise { 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 { + const userId = await createUser() + const subscriptionId = await subscribePro(userId, period) const decision = serializeAccountBillingDecisionHeader({ userId, billingEntity: { type: 'user', id: userId }, @@ -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. diff --git a/apps/sim/lib/billing/core/billing-period.ts b/apps/sim/lib/billing/core/billing-period.ts index 67b7b48eef7..720a0477037 100644 --- a/apps/sim/lib/billing/core/billing-period.ts +++ b/apps/sim/lib/billing/core/billing-period.ts @@ -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 { + return ( + period.start.getTime() === OPEN_BILLING_PERIOD_START.getTime() && + period.end.getTime() === OPEN_BILLING_PERIOD_END.getTime() + ) +} diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index afb72fb6a36..de11a42f2bb 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -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, @@ -216,6 +217,11 @@ export async function checkAndBillPayerOverageThreshold( billingEntity: BillingEntity, options: ThresholdBillingOptions = {} ): Promise { + // 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') + } if (billingEntity.type === 'organization') { return checkAndBillOrganizationOverageThreshold(billingEntity.id, options) }