|
| 1 | +--- |
| 2 | +name: selfhost-billing-architecture |
| 3 | +description: Reference for how Sim's billing, Stripe integration, and credit/usage-limit system is architected — entities, cost metering pipeline, and enforcement layers. Use when a self-hosted fork needs to merge, extend, or reimplement billing (e.g. wiring custom tool costs into the usage ledger, or adding an instance-wide spending limit). |
| 4 | +--- |
| 5 | + |
| 6 | +# Sim Billing Architecture (for self-hosted forks) |
| 7 | + |
| 8 | +Sim's billing is designed to be **optional and additive**: every self-host deployment already writes a full cost ledger (`usage_log`) with no config, and enforcement (blocking execution, Stripe charges) is a separate layer gated behind env flags. This means a fork can adopt as much or as little of this system as it wants — read costs off the ledger for a dashboard, turn on the existing per-user/per-org dollar caps, or build new enforcement on top of the same primitives, without needing Stripe at all. |
| 9 | + |
| 10 | +## 1. The three env flags that control everything |
| 11 | + |
| 12 | +| Flag | Source | Effect when true | |
| 13 | +|---|---|---| |
| 14 | +| `isHosted` | `apps/sim/lib/core/config/env-flags.ts` — true only when `NEXT_PUBLIC_APP_URL` is `sim.ai`/`*.sim.ai` | Gates a few hosted-only behaviors (e.g. per-member org caps, disabling `DISABLE_AUTH`/private DB hosts). Not relevant to a self-hosted fork — leave it false. | |
| 15 | +| `isBillingEnabled` | `isTruthy(env.BILLING_ENABLED)` | **The master switch.** Registers the Stripe plugin with better-auth, turns on hard usage-limit enforcement, Redis concurrency reservation, threshold overage invoicing, and limit-notification emails. Independent of `isHosted`. | |
| 16 | +| `isOrganizationsEnabled` | `isBillingEnabled \|\| ORGANIZATIONS_ENABLED \|\| isAccessControlEnabled` | Lets a fork turn on multi-user orgs without needing Stripe billing at all. | |
| 17 | + |
| 18 | +With `BILLING_ENABLED` unset (the self-host default): the Stripe plugin is never registered, `stripeCustomerId` is never populated, and every enforcement function short-circuits to a non-blocking default (e.g. `checkServerSideUsageLimits` returns `{ isExceeded: false, limit: 99999 }`, `reserveExecutionSlot` no-ops). **The cost ledger keeps writing regardless** — `recordUsage()` is unconditional — so the logs page still shows real dollar costs even with billing fully off. |
| 19 | + |
| 20 | +## 2. Database entities (`packages/db/schema.ts`) |
| 21 | + |
| 22 | +- **`usageLog`** (append-only ledger, the source of truth) — one row per billable event: `userId`, `category` (`'model' | 'fixed' | 'tool'`), `source` (`'workflow' | 'wand' | 'copilot' | ...`), `description` (model/tool name), `cost` (decimal $), `eventKey` (unique, idempotency), `billingEntityType`/`billingEntityId` (`'user'|'organization'` — who actually gets billed, separate from `userId` who caused it), `billingPeriodStart/End`, `workspaceId`, `workflowId`, `executionId`. |
| 23 | +- **`userStats`** — personal billing record: `currentUsageLimit` (personal $ cap), `currentPeriodCost` (baseline for current period; real spend = baseline + `usage_log` rows), `creditBalance` (goodwill/purchased credit, raises the ceiling rather than being decremented per-call), `billingBlocked`/`billingBlockedReason` (account freeze, e.g. from a payment dispute). |
| 24 | +- **`organization`** — `orgUsageLimit` (decimal — **pooled org-wide $ cap**, the closest existing thing to a "global limit"), `creditBalance` (org-level), `departedMemberUsage` (usage snapshot preserved so pooled totals stay correct when a member leaves), `limitNotifications` (jsonb dedup high-water marks for threshold emails). |
| 25 | +- **`organizationMemberUsageLimit`** — per-`(organizationId, userId)` $ cap set by an admin. Explicitly hosted-only in its own comment; `checkOrgMemberUsageLimit` guards on the compound condition `!isHosted || !isBillingEnabled` (i.e. it also requires billing to be on, not just `isHosted`) — a fork wanting per-member caps self-hosted should drop both conditions, not rebuild the table. |
| 26 | +- **`subscription`** — one row per Stripe subscription (or synthetic enterprise sub), keyed by `referenceId` (a user id OR an org id — that's what makes a subscription personal vs. org-scoped). |
| 27 | + |
| 28 | +There is no separate invoice table — Stripe is the system of record for invoices; Sim only persists derived state (subscription row, ledger, credit balances). |
| 29 | + |
| 30 | +## 3. Stripe wiring |
| 31 | + |
| 32 | +All Stripe integration goes through the `@better-auth/stripe` plugin, configured in `apps/sim/lib/auth/auth.ts`. It's registered conditionally: |
| 33 | + |
| 34 | +```ts |
| 35 | +...(isBillingEnabled && stripeClient |
| 36 | + ? [ stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '', ... }) ] |
| 37 | + : []), |
| 38 | +``` |
| 39 | + |
| 40 | +- **There are two separate Stripe client instantiations — don't conflate them.** `auth.ts` builds its own `stripeClient` (lines ~176-183) purely to decide whether to register the plugin and to pass into it; `lib/billing/stripe-client.ts`'s `getStripeClient()`/`requireStripeClient()` singleton is a distinct instantiation used elsewhere (e.g. the invoices route, billing portal). Both read the same `STRIPE_SECRET_KEY`, but they are not the same object in memory. |
| 41 | +- `apps/sim/app/api/auth/webhook/stripe/route.ts` — thin pass-through (`toNextJsHandler(auth.handler)`); better-auth verifies the signature and dispatches. |
| 42 | +- Subscription lifecycle hooks (`onSubscriptionComplete` / `onSubscriptionUpdate` / `onSubscriptionDeleted`) are defined **inline inside the plugin config in `auth.ts` itself**, not in `lib/billing/webhooks/subscription.ts` — that file only holds the delegate functions they call out to (`handleSubscriptionCreated`, `handleSubscriptionDeleted`, etc.). The inline hooks resolve the Sim plan from the Stripe price id, sync usage limits, auto-create an org on Team/Enterprise purchase, and (on deletion) issue a final overage invoice. |
| 43 | +- `onEvent()` manually switches on events the plugin doesn't structurally model: `invoice.payment_succeeded/failed/finalized`, `customer.subscription.created` (manual enterprise onboarding via `webhooks/enterprise.ts`), `checkout.session.expired`, `charge.dispute.created/closed` (account freeze). |
| 44 | +- Money-affecting handlers are wrapped in `lib/billing/webhooks/idempotency.ts` (Postgres-backed, 7-day TTL) since Stripe delivers webhooks at-least-once. |
| 45 | +- A fork not using Stripe can ignore all of this — none of it runs without `STRIPE_SECRET_KEY` + `BILLING_ENABLED`. |
| 46 | + |
| 47 | +## 4. Cost metering pipeline — how a dollar amount gets onto the ledger |
| 48 | + |
| 49 | +1. Every workflow execution incurs a flat `BASE_EXECUTION_CHARGE` (`lib/billing/constants.ts`). |
| 50 | +2. LLM cost: `calculateCost(model, promptTokens, completionTokens, ...)` in `providers/utils.ts` — looks up $/1M-token pricing per model. |
| 51 | +3. **Tool cost — the extension point for a custom tool**: a tool's execute function returns `{ ...output, cost: { total: <dollars>, ...breakdown } }`. Any tool result carrying `output.cost.total` is automatically picked up by `sumToolCosts()` (`providers/utils.ts`) when used inside an agent, or by trace-span cost aggregation for a standalone tool block. There is no separate cost registry to update — emitting `cost.total` on the tool's own output is the entire contract. |
| 52 | + - If the tool should use a Sim-provided (hosted) API key rather than BYOK, declare `hosting: ToolHostingConfig` on the `ToolConfig` (see the `add-hosted-key` skill) and `applyHostedKeyCostToResult()` computes and injects the cost automatically post-execution. |
| 53 | +4. `calculateCostSummary(traceSpans)` (`lib/logs/execution/logging-factory.ts`) aggregates a full execution's trace spans into `{ totalCost, baseExecutionCharge, models, charges }`. |
| 54 | +5. `lib/logs/execution/logger.ts` diffs that against what's already been recorded for the `executionId` (so pause/resume never double-bills) and calls `recordUsage()` (`lib/billing/core/usage-log.ts`), which inserts one `usage_log` row per cost line with a SHA-256 `eventKey` for idempotent inserts (`onConflictDoNothing`). |
| 55 | + |
| 56 | +**This whole pipeline runs unconditionally** — it's the same code path whether or not `BILLING_ENABLED` is set. A custom tool that emits `cost.total` gets billed/tracked correctly with zero changes to the enforcement layer. |
| 57 | + |
| 58 | +## 5. Enforcement — where a limit actually blocks execution |
| 59 | + |
| 60 | +Gated entirely behind `BILLING_ENABLED`. Entry point: `apps/sim/lib/execution/preprocessing.ts` (`preprocessExecution`, runs before every workflow execution): |
| 61 | + |
| 62 | +- **`preprocessing.ts` calls `checkServerSideUsageLimits(actorUserId, subscription)` and `checkOrgMemberUsageLimit(actorUserId, workspaceId)` directly** (both in `lib/billing/calculations/usage-monitor.ts`) — on `isExceeded`, returns HTTP `402` and the workflow never runs. **This is the important gotcha: `preprocessing.ts` does NOT go through `checkActorUsageLimits()`.** They are two independent call paths — `checkActorUsageLimits()` is a separate composer used by other billable surfaces (copilot, voice, wand, etc.), not by the workflow-execution gate itself. Adding a check only inside `checkActorUsageLimits()` will not affect normal workflow runs — see the corrected recipe in §6. |
| 63 | +- `reserveExecutionSlot()` (`lib/billing/calculations/usage-reservation.ts`) — Redis Lua-script atomic admission control bounding concurrent in-flight executions per billing entity (closes the "many parallel requests all read stale usage" race). Per-plan caps live in `MAX_CONCURRENT_EXECUTIONS`. |
| 64 | +- `checkBillingBlocked(userId)` — hard account freeze from `userStats.billingBlocked` (e.g. set by a payment dispute webhook), independent of usage limits. |
| 65 | +- `checkActorUsageLimits()` composes the pooled-org-or-personal check with the per-member check into one `{ isExceeded, message, scope }` result for its own set of callers (copilot, voice, wand, enrichment, KB indexing) — useful if a fork is gating one of those surfaces, but **not** a substitute for a change in `preprocessing.ts` when the goal is limiting workflow execution. |
| 66 | +- **Fail-closed is not universal — check each function.** `checkServerSideUsageLimits` fails closed (blocks on infra error, documented in its own catch block). `checkOrgMemberUsageLimit` explicitly fails **open** on error (per its own TSDoc/catch block) — an infra hiccup lets a per-member-capped user through rather than blocking them. Don't assume every check in this file has the same failure mode; verify each one you build on. |
| 67 | +- Threshold emails (`lib/billing/core/limit-notifications.ts`, 80%/100% with re-arm hysteresis) are a separate, advisory-only layer that runs alongside the hard gates, not instead of them. The email category key (`LimitCategory`) is a **closed union type** (`'storage' | 'tables' | 'seats'` today, not an open/generic string) — adding a new category (e.g. a "global" one) requires extending that type first, not just passing a new string. |
| 68 | + |
| 69 | +## 6. Building a true global (instance-wide) spending limit |
| 70 | + |
| 71 | +Today the "global-est" existing primitive is `organization.orgUsageLimit` — a pooled cap across all members of **one** org. On sim.ai that's the right unit, but a self-hosted instance may have multiple orgs (or none) and want a single cap across the whole deployment. There is no existing table/flag for that — it needs to be added. Recommended approach, reusing every primitive above rather than building a parallel system: |
| 72 | + |
| 73 | +1. **Add an env-driven cap**, e.g. `GLOBAL_SPEND_LIMIT_DOLLARS`, read next to the other billing env vars in `lib/core/config/env.ts`. |
| 74 | +2. **Add an aggregate cost read** in `lib/billing/core/usage-log.ts` style — sum `usage_log.cost` across the current billing period with no `billingEntityId` filter (instance-wide), the same shape as the existing `getBillingPeriodUsageCost*` helpers. |
| 75 | +3. **Add a new check function** alongside `checkServerSideUsageLimits`/`checkOrgMemberUsageLimit` in `usage-monitor.ts` (e.g. `checkGlobalUsageLimit()`). |
| 76 | +4. **Wire it into every enforcement entry point explicitly — do not rely on `checkActorUsageLimits()` alone.** Because `preprocessing.ts` calls `checkServerSideUsageLimits`/`checkOrgMemberUsageLimit` directly (§5), the new check needs its own call added inside `preprocessing.ts` (for workflow execution) as well as inside `checkActorUsageLimits()` (for copilot/voice/wand/enrichment/KB indexing) if the global cap should apply everywhere. Treat this as two call sites to touch, not one — verify each surface you care about actually calls your new function before considering it done. |
| 77 | +5. Decide the failure mode deliberately (fail-closed vs. fail-open) rather than assuming — the two existing functions in this file disagree with each other (§5), so pick and document one explicitly for the new check. |
| 78 | +6. Decide whether this cap should apply even with `BILLING_ENABLED=false` (a pure self-host safety rail with no Stripe involved at all) — if so, gate it on its own env var's presence rather than on `isBillingEnabled`, mirroring how `isOrganizationsEnabled` is independently switchable. |
| 79 | +7. Reuse the existing threshold-email machinery (`limit-notifications.ts`) for an instance-wide 80%/100% warning if desired — note `LimitCategory` is a closed union today (§5), so add a `'global'` member to that type before passing it through. |
| 80 | + |
| 81 | +This keeps the global limit as one more gate composed into the existing primitives, but — unlike the pooled-org cap, which really does have one call site — a true instance-wide cap must be threaded through each enforcement entry point by hand. |
| 82 | + |
| 83 | +## 7. Merging upstream |
| 84 | + |
| 85 | +Because the ledger write path (`recordUsage`) and the tool-cost contract (`cost.total` on tool output) are both unconditional and un-gated, a fork can carry custom tools and a custom global-limit check as a small, mergeable diff on top of this system rather than a divergent billing implementation — conflicts on `git merge`/rebase should mostly be limited to the new check function, its env var, and its two call sites (`preprocessing.ts` and `checkActorUsageLimits()`) from §6. |
0 commit comments