fix(billing): retry missing Autumn balance (#450)
This commit is contained in:
parent
211907ae32
commit
c40a04459c
@ -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" });
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user