241 lines · 10.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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 <id> 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: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnFailure, never>;  /**   * 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<void, AutumnFailure, never>;  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<void, never, never>;  /**   * 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<void, never, never>;}>; // ---------------------------------------------------------------------------// 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 = <A>(fn: (client: Autumn) => Promise<A>) =>    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 = <A>(    organizationId: string,    operation: Effect.Effect<A, AutumnFailure>,  ) =>    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<AutumnService, IAutumnService>()(  "@executor-js/cloud/AutumnService",) {  static Default = Layer.effect(this)(make);}