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 { 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" });
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user