Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"ajv-formats": "^3.0.1",
"amqplib": "^0.10.9",
"async-mutex": "^0.5.0",
"autumn-js": "1.0.0-beta.6",
"autumn-js": "1.0.0-beta.10",
"axios": "^1.13.5",
"body-parser": "^1.20.3",
"bullmq": "^5.56.7",
Expand Down
10 changes: 5 additions & 5 deletions apps/api/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

138 changes: 125 additions & 13 deletions apps/api/src/services/autumn/__tests__/autumn.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { jest } from "@jest/globals";
const mockTrack = jest
.fn<(args: any) => Promise<void>>()
.mockResolvedValue(undefined);
const mockCheck = jest
.fn<(args: any) => Promise<any>>()
.mockResolvedValue({ allowed: true, customerId: "org-1", balance: null });
const mockFinalize = jest
.fn<(args: any) => Promise<void>>()
.mockResolvedValue(undefined);
const mockGetOrCreate = jest
.fn<(args: any) => Promise<unknown>>()
.mockResolvedValue({ id: "org-1" });
Expand All @@ -24,6 +30,8 @@ const mockEntityCreate = jest.fn<(args: any) => Promise<unknown>>();
const mockAutumnClient = {
customers: { getOrCreate: mockGetOrCreate },
entities: { get: mockEntityGet, create: mockEntityCreate },
balances: { finalize: mockFinalize },
check: mockCheck,
track: mockTrack,
};

Expand Down Expand Up @@ -97,6 +105,12 @@ beforeEach(() => {
jest.clearAllMocks();
autumnClientRef = mockAutumnClient;
supabaseStubData = { data: { org_id: "org-1" }, error: null };
mockCheck.mockResolvedValue({
allowed: true,
customerId: "org-1",
balance: null,
});
mockFinalize.mockResolvedValue(undefined);
mockEntityGet.mockResolvedValue(makeEntity(0));
mockEntityCreate.mockResolvedValue({ id: "team-1" });
});
Expand Down Expand Up @@ -260,6 +274,71 @@ describe("ensureTrackingContext warm-cache short-circuit", () => {
});
});

// ---------------------------------------------------------------------------
// lockCredits
// ---------------------------------------------------------------------------

describe("lockCredits", () => {
it("returns null when autumnClient is null", async () => {
autumnClientRef = null;
const svc = makeService();
const result = await svc.lockCredits({ teamId: "team-1", value: 10 });
expect(result).toBeNull();
expect(mockCheck).not.toHaveBeenCalled();
});

it("returns null for preview teams", async () => {
const svc = makeService();
const result = await svc.lockCredits({
teamId: "preview_abc",
value: 10,
});
expect(result).toBeNull();
expect(mockCheck).not.toHaveBeenCalled();
});

it("returns the lockId on happy path", async () => {
const svc = makeService();

const result = await svc.lockCredits({
teamId: "team-1",
value: 42,
lockId: "lock-123",
properties: { source: "billTeam", endpoint: "extract" },
});

expect(result).toBe("lock-123");
expect(mockCheck).toHaveBeenCalledWith(
expect.objectContaining({
customerId: "org-1",
entityId: "team-1",
featureId: "CREDITS",
requiredBalance: 42,
properties: { source: "billTeam", endpoint: "extract" },
lock: expect.objectContaining({
enabled: true,
lockId: "lock-123",
}),
}),
);
});

it("returns null when Autumn denies the lock", async () => {
mockCheck.mockResolvedValue({
allowed: false,
customerId: "org-1",
balance: null,
});
const svc = makeService();
const result = await svc.lockCredits({
teamId: "team-1",
value: 10,
lockId: "lock-123",
});
expect(result).toBeNull();
});
});

// ---------------------------------------------------------------------------
// reserveCredits
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -303,6 +382,35 @@ describe("reserveCredits", () => {
});
});

// ---------------------------------------------------------------------------
// finalizeCreditsLock
// ---------------------------------------------------------------------------

describe("finalizeCreditsLock", () => {
it("calls balances.finalize with confirm", async () => {
const svc = makeService();
await svc.finalizeCreditsLock({
lockId: "lock-123",
action: "confirm",
properties: { source: "test" },
});

expect(mockFinalize).toHaveBeenCalledWith({
lockId: "lock-123",
action: "confirm",
overrideValue: undefined,
properties: { source: "test" },
});
});

it("is a no-op when autumnClient is null", async () => {
autumnClientRef = null;
const svc = makeService();
await svc.finalizeCreditsLock({ lockId: "lock-123", action: "release" });
expect(mockFinalize).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// refundCredits
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -402,44 +510,48 @@ describe("isAutumnEnabled", () => {
});
});

describe("experiment gate on reserveCredits", () => {
describe("experiment gate on lockCredits", () => {
afterEach(() => {
(config as any).AUTUMN_EXPERIMENT = "true";
(config as any).AUTUMN_EXPERIMENT_PERCENT = 100;
});

it("reserveCredits returns false when experiment is disabled", async () => {
it("lockCredits returns null when experiment is disabled", async () => {
(config as any).AUTUMN_EXPERIMENT = undefined;
const svc = makeService();
const result = await svc.reserveCredits({ teamId: "team-1", value: 10 });
expect(result).toBe(false);
expect(mockTrack).not.toHaveBeenCalled();
const result = await svc.lockCredits({ teamId: "team-1", value: 10 });
expect(result).toBeNull();
expect(mockCheck).not.toHaveBeenCalled();
});

it("reserveCredits returns false when org is outside the percent bucket", async () => {
it("lockCredits returns null when org is outside the percent bucket", async () => {
// Supabase returns org whose bucket (16) is >= percent (10).
supabaseStubData = {
data: { org_id: "a1b2c3d4-0000-0000-0000-000000000000" },
error: null,
};
(config as any).AUTUMN_EXPERIMENT_PERCENT = 10;
const svc = makeService();
const result = await svc.reserveCredits({ teamId: "team-1", value: 10 });
expect(result).toBe(false);
expect(mockTrack).not.toHaveBeenCalled();
const result = await svc.lockCredits({ teamId: "team-1", value: 10 });
expect(result).toBeNull();
expect(mockCheck).not.toHaveBeenCalled();
});

it("reserveCredits succeeds when org is inside the percent bucket", async () => {
it("lockCredits succeeds when org is inside the percent bucket", async () => {
// Supabase returns org whose bucket (16) is < percent (50).
supabaseStubData = {
data: { org_id: "a1b2c3d4-0000-0000-0000-000000000000" },
error: null,
};
(config as any).AUTUMN_EXPERIMENT_PERCENT = 50;
const svc = makeService();
const result = await svc.reserveCredits({ teamId: "team-1", value: 10 });
expect(result).toBe(true);
expect(mockTrack).toHaveBeenCalled();
const result = await svc.lockCredits({
teamId: "team-1",
value: 10,
lockId: "lock-123",
});
expect(result).toBe("lock-123");
expect(mockCheck).toHaveBeenCalled();
});

it("refundCredits still works when experiment is disabled (guard is autumnReserved)", async () => {
Expand Down
112 changes: 106 additions & 6 deletions apps/api/src/services/autumn/autumn.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { config } from "../../config";
import { logger } from "../../lib/logger";
import { supabase_rr_service } from "../supabase";
Expand All @@ -7,8 +8,10 @@ import type {
CreateEntityResult,
EnsureOrgProvisionedParams,
EnsureTeamProvisionedParams,
FinalizeCreditsLockParams,
GetEntityParams,
GetOrCreateCustomerParams,
LockCreditsParams,
TrackCreditsParams,
TrackParams,
} from "./types";
Expand Down Expand Up @@ -36,10 +39,10 @@ export function orgBucket(orgId: string): number {
* stable percent gate is also evaluated so the same org always gets the
* same answer.
*
* Only checked at the top-level billing entry point (`reserveCredits`).
* NOT checked by `refundCredits` (already guarded by `autumnReserved`) or
* `ensureTeamProvisioned` (handled by firecrawl-web edge functions
* independently).
* Only checked at the top-level billing entry points (`lockCredits` and the
* legacy direct-track `reserveCredits`).
* NOT checked by `finalizeCreditsLock`, `refundCredits`, or
* `ensureTeamProvisioned`.
*/
export function isAutumnEnabled(orgId?: string): boolean {
if (config.AUTUMN_EXPERIMENT !== "true") return false;
Expand Down Expand Up @@ -324,8 +327,105 @@ export class AutumnService {
}

/**
* Records a credit usage event in Autumn at request time.
* Returns true on success, false if Autumn is unavailable or an error occurs.
* Reserves a team's credits in Autumn without letting Autumn gate usage.
* Returns the lock ID on success, or null if no lock was acquired.
*/
async lockCredits({
teamId,
value,
lockId,
expiresAt,
properties,
}: LockCreditsParams): Promise<string | null> {
if (!isAutumnEnabled() || !autumnClient || this.isPreviewTeam(teamId)) {
return null;
}

const resolvedLockId = lockId ?? `billing_${randomUUID()}`;

try {
const orgId = await this.resolveOrgId(teamId);
if (!isAutumnEnabled(orgId)) return null;

const customerId = await this.ensureTrackingContext(teamId);
const { allowed } = await autumnClient.check({
customerId,
entityId: teamId,
featureId: CREDITS_FEATURE_ID,
requiredBalance: value,
properties,
lock: {
enabled: true,
lockId: resolvedLockId,
expiresAt,
},
});

if (!allowed) {
logger.info("Autumn lockCredits denied", {
teamId,
value,
lockId: resolvedLockId,
});
return null;
}

logger.info("Autumn lockCredits succeeded", {
customerId,
entityId: teamId,
featureId: CREDITS_FEATURE_ID,
value,
lockId: resolvedLockId,
properties,
});
return resolvedLockId;
} catch (error) {
logger.warn("Autumn lockCredits failed", {
teamId,
value,
lockId: resolvedLockId,
error,
});
return null;
}
}

/**
* Finalizes a previously-acquired Autumn lock.
*/
async finalizeCreditsLock({
lockId,
action,
overrideValue,
properties,
}: FinalizeCreditsLockParams): Promise<void> {
if (!autumnClient) return;

try {
await autumnClient.balances.finalize({
lockId,
action,
overrideValue,
properties,
});
logger.info("Autumn finalizeCreditsLock succeeded", {
lockId,
action,
overrideValue,
});
} catch (error) {
logger.warn("Autumn finalizeCreditsLock failed", {
lockId,
action,
overrideValue,
error,
});
}
}

/**
* Records a credit usage event directly in Autumn for flows that do not
* acquire a request-time lock. Returns true on success.
*
* The experiment gate is evaluated here — once per request — using a stable
* bucket derived from the org UUID so the same org always gets the same
Expand Down
Loading
Loading