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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing"; 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(() => ({ const { checkMock, getOrCreateMock, kvGetMock, kvPutMock } = vi.hoisted(() => ({
checkMock: vi.fn(), checkMock: vi.fn(),
@ -32,6 +36,7 @@ vi.mock("@/server/lib/posthog", () => ({
})); }));
import { import {
assertUsageCreditsAvailable,
customerHasPaidPlan, customerHasPaidPlan,
getOrCreateOrganizationCustomer, getOrCreateOrganizationCustomer,
} from "./subscription"; } from "./subscription";
@ -43,6 +48,10 @@ describe("subscription billing", () => {
kvPutMock.mockResolvedValue(undefined); kvPutMock.mockResolvedValue(undefined);
}); });
afterEach(() => {
vi.useRealTimers();
});
it("checks the paid plan entitlement", async () => { it("checks the paid plan entitlement", async () => {
checkMock.mockResolvedValue({ allowed: true }); checkMock.mockResolvedValue({ allowed: true });
@ -60,6 +69,45 @@ describe("subscription billing", () => {
await expect(customerHasPaidPlan("org_123")).resolves.toBe(false); 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 () => { it("looks up the billing customer by organization id", async () => {
getOrCreateMock.mockResolvedValue({ id: "cust_123" }); 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 // 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 // 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 // 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 // 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 // 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. // exist until a first top-up, so 0 is the honest reading there.
if (!monthlyCheck.balance) { if (!monthlyBalance) {
throw new AppError( throw new AppError(
"UPSTREAM_UNAVAILABLE", "UPSTREAM_UNAVAILABLE",
`Autumn check returned no ${AUTUMN_SEO_DATA_BALANCE_FEATURE_ID} balance for customer ${customerId}`, `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 { return {
monthlyRemaining: monthlyCheck.balance.remaining, monthlyRemaining: monthlyBalance.remaining,
topupRemaining: topupCheck.balance?.remaining ?? 0, topupRemaining: topupCheck.balance?.remaining ?? 0,
}; };
} }