Skip to content

Spend management: personal API keys, attributed spend and budgets - #153

Merged
pumfleet merged 2 commits into
mainfrom
spend-management
Oct 19, 2026
Merged

pumfleet merged 2 commits into
mainfrom
spend-management

Conversation

@pumfleet

Copy link
Copy Markdown
Collaborator

Summary

Spend management for coss.com: API keys can belong to a member, everything metered records who caused it, and owners can cap what members, projects and API keys spend each month.

Personal API keys

  • A key is an organization key (production, staging, shared jobs) or a member's personal key (environment_api_key.owner_user_id); its usage is that member's spend.
  • Members create personal keys for themselves in environments an admin opened (environment.personal_keys; new projects open their sandbox environment, existing sandbox environments are backfilled by 0086). Owners and admins create organization keys and can assign a key to any member (apiKey:manage). Better Auth checks apiKey:create for every key, so members now hold it and keyCreationAllowed decides which keys they may create.
  • Personal keys hold organization-wide permissions (domain changes, billing, audit log) only while their owner's role does. They stop working when the owner leaves, is removed (also by SCIM) or is banned (authenticateApiKey), and are deleted on leave/removal.
  • coss login / coss init keys are personal keys of the approving member, so members can use the CLI where personal keys are allowed.
  • Keys page: owner column, owner picker for admins, self-revoke, the environment's "Personal keys for members" switch (audited), "Paused" badges.

Attribution and spend

  • recordUsage also writes billing_usage_attribution (project, API key, member) — a separate table so older tasks' upserts into billing_usage keep working during the rollout; billing_usage stays the only rating input. Emails, broadcasts and SMS capture the key's owner at acceptance (api_key_owner_id), so revoking a key never erases a member's spend.
  • allocateUsageCharges (pure engine): each meter's charge, net of meter discounts and write-offs, is split by quantity with the largest remainder, so per-source spend adds up to the invoice's usage lines.
  • GET /v1/billing/usage takes group_by=project|api_key|user; usage rows gain project_id, api_key_id, user_id (SDK + changeset).

Budgets

  • billing_budget (default member budget, per member — or an exemption —, per project, per API key) and billing_budget_hold.
  • The worker's usage checks (checkOrganizationSpend, also run right after a dashboard change) alert at each budget's thresholds and pause a source over budget: its live sends fail with budget_exceeded (402 billing_error, SMTP 550), scheduled emails/SMS wait, broadcasts pause and resume by themselves. Lifted by a higher budget or a new month. Organization keys are never paused by a member's budget.
  • Notifications to the billing contacts and the member concerned, push billing_budget_paused, webhook events billing.budget_threshold_reached, billing.budget_hold_placed, billing.budget_hold_lifted (SDK types + changeset).

Dashboard

  • /[orgSlug]/spend: spend this month by member, project, environment and key, per day, with budget progress; members see their own spend only.
  • /[orgSlug]/spend/budgets: owners manage budgets (audited, re-checked at once). A banner tells a paused member.

Docs: personal keys (environments), "Spend and budgets", billing, webhooks, errors, CLI.

Migrations: 0085_spend_management (additive: new tables, nullable/defaulted columns, a push enum value) and 0086_open_sandbox_personal_keys (backfill of existing sandbox environments).

Testing

  • Unit: allocation engine (sums, rounding, discounts, caps, unattributed usage), personal key ceilings, keyCreationAllowed, read-only preset.
  • DB tests: personal key creation rules, permission ceilings, revocation on leave/remove/SCIM/ban, attribution of email, broadcast and SMS usage, spend breakdown, budgets placing/lifting holds across months, send/batch/scheduled enforcement for email and SMS, group_by on the usage API, SDK contract.
  • bun run lint && bun run typecheck && bun run test && bun run build green.
  • Manually in the dashboard with demo data: Spend overview and budgets as an owner, raising and lowering a member's budget (pause lifted and re-placed at once), the member's own Spend view and banner, a member creating a personal key.

Review

Codex review, 2 rounds:

  • Round 1 — fixed: the read-only preset was refused for personal keys (it included billing/audit reads); readOnlyPermissions(ownerRole) now leaves out what the owner's role lacks. Fixed: a member's own Spend page showed no budget before their first live usage; it now shows the default member budget.
  • Round 2 — clean.

🤖 Generated with Claude Code

pumfleet and others added 2 commits October 19, 2026 15:44
API keys are now organization keys or members' personal keys, every unit
of usage records who caused it, and owners can cap what members,
projects and API keys spend each month.

Personal keys
- `environment_api_key.owner_user_id`: a key belongs to the organization
  or is assigned to a member (their spend). Members create their own in
  environments an admin opened (`environment.personal_keys`; new
  projects open the sandbox environment, existing ones are backfilled);
  owners and admins create organization keys and assign keys to members
  (`apiKey:manage`). Better Auth checks `apiKey:create` for every key,
  so members hold it and `keyCreationAllowed` decides which keys.
- Personal keys hold organization-wide permissions (domain changes,
  billing, audit) only while their owner's role does. They stop working
  when the owner leaves, is removed (also by SCIM) or is banned, and are
  deleted on leave/removal.
- `coss login`/`coss init` keys are personal keys of the approver.
- Keys page: owner column and picker, self-revoke, the environment's
  "Personal keys for members" switch (audited).

Attribution and spend
- `recordUsage` also writes `billing_usage_attribution` (project, key,
  member; a separate table so older tasks' upserts keep working during
  a rollout). Emails and broadcasts capture the key's owner at
  acceptance (`api_key_owner_id`).
- `allocateUsageCharges` splits each meter's net charge by quantity
  (largest remainder), so per-source spend adds up to the invoice.
- `GET /v1/billing/usage` takes `group_by=project|api_key|user`.

Budgets
- `billing_budget` (member default, member, project, API key) and
  `billing_budget_hold`. The worker's usage checks alert at thresholds
  and pause a source over budget: its live sends get `budget_exceeded`
  (402, SMTP 550), scheduled sends wait, broadcasts pause; lifted by a
  higher budget or a new month. Notifications (also to the member),
  push, and `billing.budget_*` webhook events (SDK types, changeset).
- SMS too: messages capture the key's owner, their usage is attributed,
  and a used-up budget refuses (or holds) a live SMS send.

Dashboard
- `/[orgSlug]/spend`: spend by member, project, environment and key,
  per day, with budget progress; members see their own spend.
  `/[orgSlug]/spend/budgets`: owners manage budgets (audited, re-checked
  at once). A banner tells a paused member; keys show "Paused".
- Docs: personal keys, "Spend and budgets", API and webhook references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The dashboard's read-only preset for a personal key leaves out the
  organization-wide reads (billing, audit log) its owner's role lacks,
  instead of being refused.
- A member's own spend page shows the default member budget before they
  have any live usage this month.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pumfleet
pumfleet merged commit 9446bde into main Oct 19, 2026
11 checks passed
@pumfleet
pumfleet deleted the spend-management branch October 19, 2026 20:55
@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

ENG-6488

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant