First site audit over MCP fails with a raw billing error (#525)

This commit is contained in:
Ben Senescu 2026-08-26 09:59:35 -04:00 committed by GitHub
parent 25c8f7be26
commit 8db0dd3246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 39 additions and 17 deletions

View File

@ -1,12 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { isHostedMock, hasManagedAccessMock, hasPaidPlanMock } = vi.hoisted( const {
() => ({ isHostedMock,
isHostedMock: vi.fn(), hasManagedAccessMock,
hasManagedAccessMock: vi.fn(), hasPaidPlanMock,
hasPaidPlanMock: vi.fn(), getOrCreateCustomerMock,
}), } = vi.hoisted(() => ({
); isHostedMock: vi.fn(),
hasManagedAccessMock: vi.fn(),
hasPaidPlanMock: vi.fn(),
getOrCreateCustomerMock: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/runtime-env", () => ({ vi.mock("@/server/lib/runtime-env", () => ({
@ -15,6 +19,7 @@ vi.mock("@/server/lib/runtime-env", () => ({
vi.mock("@/server/billing/subscription", () => ({ vi.mock("@/server/billing/subscription", () => ({
customerHasManagedAccess: hasManagedAccessMock, customerHasManagedAccess: hasManagedAccessMock,
customerHasPaidPlan: hasPaidPlanMock, customerHasPaidPlan: hasPaidPlanMock,
getOrCreateOrganizationCustomer: getOrCreateCustomerMock,
})); }));
vi.mock("@/server/features/audit/repositories/AuditRepository", () => ({ vi.mock("@/server/features/audit/repositories/AuditRepository", () => ({
AuditRepository: {}, AuditRepository: {},
@ -26,6 +31,12 @@ vi.mock("@/server/lib/audit/progress-kv", () => ({ AuditProgressKV: {} }));
import { AuditService } from "@/server/features/audit/services/AuditService"; import { AuditService } from "@/server/features/audit/services/AuditService";
const customer = {
organizationId: "org-1",
userEmail: "user@example.com",
userId: "user-1",
};
describe("resolveAuditLimitTier", () => { describe("resolveAuditLimitTier", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -36,10 +47,20 @@ describe("resolveAuditLimitTier", () => {
it("uses the uncapped self-hosted tier without consulting billing", async () => { it("uses the uncapped self-hosted tier without consulting billing", async () => {
isHostedMock.mockResolvedValue(false); isHostedMock.mockResolvedValue(false);
await expect(AuditService.resolveAuditLimitTier("org-1")).resolves.toBe( await expect(AuditService.resolveAuditLimitTier(customer)).resolves.toBe(
"self_hosted", "self_hosted",
); );
expect(getOrCreateCustomerMock).not.toHaveBeenCalled();
expect(hasManagedAccessMock).not.toHaveBeenCalled(); expect(hasManagedAccessMock).not.toHaveBeenCalled();
expect(hasPaidPlanMock).not.toHaveBeenCalled(); expect(hasPaidPlanMock).not.toHaveBeenCalled();
}); });
it("ensures the Autumn customer before checking entitlements in hosted mode", async () => {
isHostedMock.mockResolvedValue(true);
await expect(AuditService.resolveAuditLimitTier(customer)).resolves.toBe(
"paid",
);
expect(getOrCreateCustomerMock).toHaveBeenCalledWith(customer);
});
}); });

View File

@ -2,6 +2,7 @@ import { env } from "cloudflare:workers";
import { import {
customerHasManagedAccess, customerHasManagedAccess,
customerHasPaidPlan, customerHasPaidPlan,
getOrCreateOrganizationCustomer,
type BillingCustomerContext, type BillingCustomerContext,
} from "@/server/billing/subscription"; } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
@ -29,12 +30,16 @@ import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
// small audit at a time, paid keeps the full limits, and customers with no // small audit at a time, paid keeps the full limits, and customers with no
// Autumn product at all are turned away. Self-hosted isn't gated. // Autumn product at all are turned away. Self-hosted isn't gated.
async function resolveAuditLimitTier( async function resolveAuditLimitTier(
organizationId: string, customer: BillingCustomerContext,
): Promise<AuditLimitTier> { ): Promise<AuditLimitTier> {
if (!(await isHostedServerAuthMode())) return "self_hosted"; if (!(await isHostedServerAuthMode())) return "self_hosted";
// An org minted outside a billing path (better-auth hooks, MCP auth) has no
// Autumn customer yet, and `check` 404s instead of reporting no access — a
// brand-new MCP user's first audit failed with a raw billing error.
await getOrCreateOrganizationCustomer(customer);
const [hasManagedAccess, hasPaidPlan] = await Promise.all([ const [hasManagedAccess, hasPaidPlan] = await Promise.all([
customerHasManagedAccess(organizationId), customerHasManagedAccess(customer.organizationId),
customerHasPaidPlan(organizationId), customerHasPaidPlan(customer.organizationId),
]); ]);
if (!hasManagedAccess) { if (!hasManagedAccess) {
throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits"); throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");

View File

@ -87,9 +87,7 @@ export const runSiteAuditTool = {
// many-minute wait, which chat agents handle badly. The app UI passes its // many-minute wait, which chat agents handle badly. The app UI passes its
// own explicit lighthouseStrategy, so this default only governs agents. // own explicit lighthouseStrategy, so this default only governs agents.
const lighthouseStrategy = (args.runLighthouse ?? false) ? "auto" : "none"; const lighthouseStrategy = (args.runLighthouse ?? false) ? "auto" : "none";
const limitTier = await AuditService.resolveAuditLimitTier( const limitTier = await AuditService.resolveAuditLimitTier(context.billing);
context.auth.organizationId,
);
let auditId: string; let auditId: string;
try { try {
({ auditId } = await AuditService.startAudit({ ({ auditId } = await AuditService.startAudit({

View File

@ -16,9 +16,7 @@ export const startAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(startAuditSchema) .validator(startAuditSchema)
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const limitTier = await AuditService.resolveAuditLimitTier( const limitTier = await AuditService.resolveAuditLimitTier(context);
context.organizationId,
);
const result = await AuditService.startAudit({ const result = await AuditService.startAudit({
actorUserId: context.userId, actorUserId: context.userId,