diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index c8f26be..62e49c1 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -1,5 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AUTUMN_PAID_PLAN_FEATURE_ID, + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, +} from "@/shared/billing"; const { checkMock, getOrCreateMock, kvGetMock, kvPutMock } = vi.hoisted(() => ({ checkMock: vi.fn(), @@ -32,6 +36,7 @@ vi.mock("@/server/lib/posthog", () => ({ })); import { + assertUsageCreditsAvailable, customerHasPaidPlan, getOrCreateOrganizationCustomer, } from "./subscription"; @@ -43,6 +48,10 @@ describe("subscription billing", () => { kvPutMock.mockResolvedValue(undefined); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("checks the paid plan entitlement", async () => { checkMock.mockResolvedValue({ allowed: true }); @@ -60,6 +69,45 @@ describe("subscription billing", () => { await expect(customerHasPaidPlan("org_123")).resolves.toBe(false); }); + it("retries a missing monthly balance once", async () => { + vi.useFakeTimers(); + let monthlyChecks = 0; + checkMock.mockImplementation( + async ({ featureId }: { featureId: string }) => { + if (featureId === AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID) { + return { balance: null }; + } + if (featureId === AUTUMN_SEO_DATA_BALANCE_FEATURE_ID) { + monthlyChecks += 1; + return monthlyChecks === 1 + ? { balance: null } + : { balance: { remaining: 250 } }; + } + throw new Error(`Unexpected feature ${featureId}`); + }, + ); + + const result = assertUsageCreditsAvailable("org_123"); + await vi.runAllTimersAsync(); + + await expect(result).resolves.toEqual({ monthlyRemaining: 250 }); + expect(monthlyChecks).toBe(2); + }); + + it("fails closed when the retry still has no monthly balance", async () => { + vi.useFakeTimers(); + checkMock.mockResolvedValue({ balance: null }); + + const result = assertUsageCreditsAvailable("org_123"); + const assertion = expect(result).rejects.toMatchObject({ + code: "UPSTREAM_UNAVAILABLE", + }); + await vi.runAllTimersAsync(); + + await assertion; + expect(checkMock).toHaveBeenCalledTimes(3); + }); + it("looks up the billing customer by organization id", async () => { getOrCreateMock.mockResolvedValue({ id: "cust_123" }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 3adc164..d2e699f 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -96,13 +96,26 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{ }), ]); + // Autumn sometimes returns a successful response with no monthly balance + // for a customer that holds the feature. Retry that read once because the + // SDK's retry policy only covers failed HTTP requests. + let monthlyBalance = monthlyCheck.balance; + if (!monthlyBalance) { + await new Promise((resolve) => setTimeout(resolve, 300)); + const retry = await autumn.check({ + customerId, + featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + }); + monthlyBalance = retry.balance; + } + // Every hosted org holds the monthly feature (the free plan is the Autumn // default, attached at customer creation), so a check with no balance data // is a broken read, not an empty wallet. Throwing keeps it out of the // credit math — coercing it to 0 once locked a paying customer with ~9k // credits out of chat (2026-07-20). The topup balance genuinely doesn't // exist until a first top-up, so 0 is the honest reading there. - if (!monthlyCheck.balance) { + if (!monthlyBalance) { throw new AppError( "UPSTREAM_UNAVAILABLE", `Autumn check returned no ${AUTUMN_SEO_DATA_BALANCE_FEATURE_ID} balance for customer ${customerId}`, @@ -110,7 +123,7 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{ } return { - monthlyRemaining: monthlyCheck.balance.remaining, + monthlyRemaining: monthlyBalance.remaining, topupRemaining: topupCheck.balance?.remaining ?? 0, }; }