337 lines · 13.2 KB
import { env } from "cloudflare:workers";
import { accountDb, stmt } from "../scoped-db";
import type { ScopedStatement } from "../scoped-db";
import { locate, registerLocator } from "../scoped-db/locators";
import { randomId } from "../crypto";
import type { MeterId } from "~/lib/meters";
import { parsePlans } from "~/lib/plans";
import { CREDIT_POOLS, METER_PRICES, creditPoolFor } from "~/lib/pricing";
import type { CreditPoolId } from "~/lib/pricing";

/**
 * Prepaid credits: a local ledger per account and credit pool (see CREDIT_POOLS in src/lib/pricing.ts).
 * Compute credit is held in standard-1 runner seconds and spent by every priced compute meter at its price
 * relative to standard-1; AI token credit is spent 1:1 by ai.tokens.
 *
 * Draw-down order for a creditable meter is plan-included first, then credits, then overage (paid plans) or
 * the hard cap (free). Draws happen in the same account batch that inserts the usage event, keyed by the event's
 * idempotency key, so a retried event never draws twice. reserveUsage counts the unspent balance as extra
 * headroom inside its own atomic batch, so concurrent holds can never commit more than plan + credits.
 */
/** Whether usage on `meter` can be paid for with prepaid credit. */
export function isCreditMeter(meter: string): meter is MeterId {
  return creditPoolFor(meter) !== null;
}

export function isCreditPool(pool: string): pool is CreditPoolId {
  return Object.hasOwn(CREDIT_POOLS, pool);
}

/** Older ledgers and pack configs named the compute pool after its base meter. */
export function normalizePool(value: string): CreditPoolId | null {
  if (isCreditPool(value)) return value;
  return value.startsWith("compute.") && isCreditMeter(value) ? "compute" : null;
}

export class CreditError extends Error {}

/** Balances below this are rounding dust and count as empty. */
export const CREDIT_EPSILON = 1e-6;