// --------------------------------------------------------------------------- // Autumn billing service — wraps the autumn-js SDK with Effect // --------------------------------------------------------------------------- import { env } from "cloudflare:workers"; import { Autumn } from "autumn-js"; import { Context, Data, Effect, Layer } from "effect"; import { captureCauseEffect } from "../../observability"; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- export class AutumnError extends Data.TaggedError("AutumnError")<{ message: string; cause?: unknown; }> {} /** * Autumn has no customer record for the organization. Split out from * `AutumnError` because it is not an outage and must not be treated like one: * an outage is transient and the right answer is to fail open and page, while * a missing customer is a PERMANENT provisioning gap — every subsequent * balance call 404s the same way, so the org runs unbilled and unmetered * forever. Callers that own an organization id repair it (see * `withProvisionedCustomer`); everything else still surfaces as `AutumnError`. */ export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{ message: string; cause?: unknown; }> {} export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError; // Autumn's own error code for "no such customer", carried in the JSON body of // a 404 (`{"message":"Customer not found","code":"customer_not_found"}`). const CUSTOMER_NOT_FOUND_CODE = "customer_not_found"; /** * True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK * throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match * on the code rather than the status alone so an unrelated 404 (a removed * endpoint, a proxy) is still reported as a genuine failure. */ const isCustomerNotFoundCause = (cause: unknown): boolean => { if (typeof cause !== "object" || cause === null) return false; const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown }; if (statusCode !== 404 || typeof body !== "string") return false; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying a third-party SDK's raw response body; a body that isn't JSON simply isn't this error try { // oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string const parsed: unknown = JSON.parse(body); if (typeof parsed !== "object" || parsed === null) return false; return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE; } catch { return false; } }; // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- export type IAutumnService = Readonly<{ use: (fn: (client: Autumn) => Promise) => Effect.Effect; /** * Provision the organization's Autumn customer, creating it if Autumn has * never seen it. Idempotent — safe to call on every org creation. */ ensureCustomer: (organizationId: string) => Effect.Effect; checkExecutionBalance: ( organizationId: string, ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; /** * Fire-and-forget-safe execution usage tracker. Errors are caught and * logged; the returned Effect never fails. Callers typically * `Effect.runFork` it at the boundary so the billing call can't stall a * user-facing request. */ trackExecution: (organizationId: string) => Effect.Effect; /** * Set the organization's billed seat count to an absolute value. Seats are * a continuous-use feature, so callers recount from the membership source * of truth and this SETS the usage rather than incrementing it — deltas * would drift against seat changes the app never sees. Orgs whose plan * version carries no `members` item (every plan predating seat pricing) * are skipped. Fire-and-forget-safe: errors are caught and logged; the * returned Effect never fails. */ setMemberSeats: (organizationId: string, seats: number) => Effect.Effect; }>; // --------------------------------------------------------------------------- // Implementation // --------------------------------------------------------------------------- const make = Effect.sync(() => { const secretKey = env.AUTUMN_SECRET_KEY; if (!secretKey) { const notConfigured = Effect.fail( new AutumnError({ message: "Autumn not configured: AUTUMN_SECRET_KEY is empty" }), ); return { use: () => notConfigured, ensureCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, } satisfies IAutumnService; } // AUTUMN_API_URL points the real SDK at an Autumn emulator in tests/dev. const client = new Autumn({ secretKey, ...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}), }); const use = (fn: (client: Autumn) => Promise) => Effect.tryPromise({ try: () => fn(client), catch: (cause): AutumnFailure => isCustomerNotFoundCause(cause) ? new AutumnCustomerNotFoundError({ message: "Autumn has no customer for this organization", cause, }) : new AutumnError({ message: "Autumn SDK request failed", cause }), // An inline arrow's `name` is "" — not nullish — so `??` left every // Autumn call tracing as the bare span `autumn.`. }).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`)); const ensureCustomer = (organizationId: string) => Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); /** * Run `operation`; if Autumn answers "no such customer", provision the * organization's customer and run it ONCE more. * * This is the seam that closes the provisioning hole. Both billing paths use * non-creating endpoints, so an organization Autumn never learned about * 404s here forever: the balance gate fails open (correct for an outage, * catastrophic as a steady state) and every usage track is lost. Repairing * the customer makes the retry land the call — and a genuine Autumn outage * still fails with `AutumnError` and still pages, unretried. */ const withProvisionedCustomer = ( organizationId: string, operation: Effect.Effect, ) => operation.pipe( Effect.catchTag("AutumnCustomerNotFoundError", () => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true }); yield* ensureCustomer(organizationId); return yield* operation; }), ), ); const trackExecution = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); yield* withProvisionedCustomer( organizationId, use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), ).pipe( Effect.catch((error) => Effect.gen(function* () { // Silent billing data loss is worth paging on: autumn.trackExecution // is fire-and-forget so the caller doesn't handle it themselves. yield* Effect.sync(() => { console.error("[billing] track failed:", error); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true }); }), ), ); }).pipe(Effect.withSpan("autumn.trackExecution")); const setMemberSeats = (organizationId: string, seats: number) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId, "autumn.members.seats": seats, }); // getOrCreate rather than get: reuses the provisioning repair, so an // org Autumn has never seen gets its customer minted here instead of // 404ing forever. const customer = yield* use((c) => c.customers.getOrCreate({ customerId: organizationId })); // Plans that predate seat pricing have no members balance to set. // Existing subscribers stay on those versions deliberately, so this is // a steady state, not an error. if (customer.balances["members"] == null) { yield* Effect.annotateCurrentSpan({ "autumn.members.skipped": true }); return; } yield* use((c) => c.balances.update({ customerId: organizationId, featureId: "members", usage: seats }), ); }).pipe( Effect.catch((error) => Effect.gen(function* () { // Silent seat drift means wrong invoices, so failures page just // like a lost execution track. yield* Effect.sync(() => { console.error("[billing] seat sync failed:", error); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.members.failed": true }); }), ), Effect.withSpan("autumn.setMemberSeats"), ); const checkExecutionBalance = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); const check = yield* withProvisionedCustomer( organizationId, use((c) => c.check({ customerId: organizationId, featureId: "executions" })), ); return { allowed: check.allowed }; }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); return { use, ensureCustomer, checkExecutionBalance, trackExecution, setMemberSeats, } satisfies IAutumnService; }); export class AutumnService extends Context.Service()( "@executor-js/cloud/AutumnService", ) { static Default = Layer.effect(this)(make); }