fix(billing): retry missing Autumn balance (#450)

This commit is contained in:
Ben Senescu 2026-08-06 21:33:25 -04:00 committed by GitHub
parent 211907ae32
commit c40a04459c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 65 additions and 4 deletions

View File

@ -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" });

View File

@ -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,
};
}