These decisions were made during planning. Revisit if any feel wrong.
-
Credit source is per-workspace, user's choice — Each user picks "workspace credits" or "personal credits" per workspace. This is a toggle on their membership, not a global setting.
-
No fallback between sources — If a user picks "personal credits" and runs out, they get blocked (CREDITS_EXHAUSTED). They must buy more or switch back to workspace credits. No silent fallback.
-
Users buy credits via separate Stripe checkout — Users get their own Stripe customer, payment methods, and checkout flow independent of any workspace billing.
-
Existing workspace credits are unchanged — User credits are purely additive. Workspace subscriptions, auto-reload, per-seat allocations all work exactly as before.
-
Workspace admins can disable floating credits —
allowUserCreditssetting (default: allowed). When disabled, all members must use workspace credits regardless of their preference.
- Auto-reload for user credits? — Should users be able to configure auto-reload on their personal balance? (Plan includes the schema for it but routes/UI could be deferred.)
- User credit pricing — Same $0.85/credit as workspace purchases, or different pricing?
- Personal workspace interaction — When a user is in their own personal workspace, should credits always come from the workspace (current behavior), or should they be able to use floating credits there too?
- Usage reporting — Should workspace admins see how much of their members' personal credits were spent in the workspace? Or is that private to the user?
Add user-scoped credits that float across workspaces. Users buy credits via their own Stripe checkout, choose per-workspace whether to use personal or workspace credits, and workspace admins can control whether floating credits are allowed.
feat: add user floating credits schema (migration + drizzle)feat: add creditSource column to workspace_membersfeat: add UserCreditBalanceServicefeat: add user billing routes (Stripe customer, checkout, payment methods)feat: handle user credit purchases in Stripe webhooksfeat: integrate floating credits into agent execution flowfeat: add workspace admin control + credit source toggle routesfeat: add dashboard API client for user billingfeat: add dashboard UI (credit source toggle, user billing page)test: add tests for user credit balance service and execution flow
-- Add stripeCustomerId to users
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;
CREATE INDEX "users_stripe_customer_idx" ON "users" ("stripe_customer_id")
WHERE "stripe_customer_id" IS NOT NULL;
-- User credit allocations (floating credits owned by user, not tied to workspace)
CREATE TABLE "user_credit_allocations" (
"pk" serial PRIMARY KEY NOT NULL,
"id" text NOT NULL,
"user_id" text NOT NULL,
"credits_granted" numeric(12, 6) NOT NULL,
"credits_remaining" numeric(12, 6) NOT NULL,
"source" text NOT NULL,
"stripe_payment_intent_id" text,
"stripe_invoice_id" text,
"stripe_invoice_pdf_url" text,
"stripe_invoice_hosted_url" text,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_credit_allocations_id_unique" UNIQUE("id")
);
CREATE INDEX "user_credit_alloc_user_idx" ON "user_credit_allocations" ("user_id");
CREATE INDEX "user_credit_alloc_active_idx" ON "user_credit_allocations" ("user_id", "credits_remaining")
WHERE "credits_remaining"::numeric > 0;
CREATE UNIQUE INDEX "user_credit_alloc_payment_intent_unique"
ON "user_credit_allocations" ("stripe_payment_intent_id")
WHERE "stripe_payment_intent_id" IS NOT NULL;
ALTER TABLE "user_credit_allocations"
ADD CONSTRAINT "user_credit_allocations_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id");
-- User credit ledger (audit trail with workspace context)
CREATE TABLE "user_credit_ledger" (
"pk" serial PRIMARY KEY NOT NULL,
"id" text NOT NULL,
"user_id" text NOT NULL,
"allocation_id" text,
"type" text NOT NULL,
"amount" numeric(12, 6) NOT NULL,
"balance_after" numeric(12, 6) NOT NULL,
"workspace_id" text,
"project_id" text,
"thread_id" text,
"message_id" text,
"resource_type" text,
"model" text,
"provider" text,
"mode_id" text,
"input_tokens" integer,
"output_tokens" integer,
"runtime_ms" integer,
"pricing_version" text,
"description" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_credit_ledger_id_unique" UNIQUE("id")
);
CREATE INDEX "user_credit_ledger_user_idx" ON "user_credit_ledger" ("user_id", "created_at");
CREATE INDEX "user_credit_ledger_workspace_idx" ON "user_credit_ledger" ("workspace_id", "created_at");
CREATE INDEX "user_credit_ledger_type_idx" ON "user_credit_ledger" ("user_id", "type");
ALTER TABLE "user_credit_ledger"
ADD CONSTRAINT "user_credit_ledger_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id");
ALTER TABLE "user_credit_ledger"
ADD CONSTRAINT "user_credit_ledger_allocation_id_fk" FOREIGN KEY ("allocation_id") REFERENCES "user_credit_allocations"("id");
-- Cached user credit balance on users table
ALTER TABLE "users" ADD COLUMN "credit_balance" numeric(12, 6) DEFAULT '0';- Add
stripeCustomerIdandcreditBalancecolumns touserstable (~line 1101) - Add
userCreditAllocationstable definition (mirrorscreditAllocationsbut user-keyed) - Add
userCreditLedgertable definition (mirrorscreditLedgerbut user-keyed, with optionalworkspaceIdfor context) - Add relations for both new tables
- Export types:
UserCreditAllocation,UserCreditLedgerEntry
The existing creditAllocations.workspaceId is NOT NULL with a foreign key constraint. Making it nullable would require changing every query and index that assumes it's present. Separate tables keep the workspace credit system completely untouched.
ALTER TABLE "workspace_members" ADD COLUMN "credit_source" text DEFAULT 'workspace';- Add
creditSource: text('credit_source').default('workspace')toworkspaceMembers - Type:
'workspace' | 'user'
Default 'workspace' means all existing memberships behave identically to today.
Mirrors CreditBalanceService pattern but operates on user tables. Key methods:
getBalance(userId)— read cachedusers.creditBalance, fallback to summing allocationscanProceed(userId)— check if user has sufficient floating creditsgrantCredits({ userId, credits, source, stripePaymentIntentId?, expiresAt? })— create allocation, update cached balance, idempotent viastripePaymentIntentIddeductCredits({ userId, amount, workspaceId, projectId?, threadId?, ... })— FIFO deduction fromuserCreditAllocations, createuserCreditLedgerentry with workspace context, update cached balancecalculateBalanceFromAllocations(userId)— sum remaining allocations (integrity check)getTransactionHistory(userId, options?)— queryuserCreditLedger
- Import and register
userCreditBalanceServicesingleton
Expiring allocations first (oldest first), then non-expiring (oldest first). Same pattern as workspace credits.
Lock users row during deduction to prevent double-spend from concurrent executions.
Routes (all require authenticated session, NOT workspace-scoped):
GET /user/billing/status — { hasPaymentMethod, balance, stripeCustomerId }
POST /user/billing/setup-intent — create Stripe SetupIntent for saving payment method
GET /user/billing/payment-methods — list user's Stripe payment methods
POST /user/billing/credits/checkout — create Stripe checkout for floating credits
GET /user/billing/credits — balance + allocations
GET /user/billing/transactions — ledger history
- Add
createUserCustomer(userId, email, name)— creates Stripe customer withmetadata: { userId, type: 'user' } - Lazy creation: first time user tries to purchase credits
- Store
stripeCustomerIdonuserstable
- Mount
userBillingRouterat/user/billing
Differentiate user vs workspace credit purchases by metadata.type:
metadata.type === 'user_credit_purchase'+metadata.userId→ route touserCreditBalanceService.grantCredits()metadata.type === 'credit_purchase'+metadata.workspaceId→ existing workspace flow (unchanged)
Affected handlers:
checkout.session.completed— check metadata type, branch to user grantpayment_intent.succeeded— check metadata type, branch to user grant
Credit check step (~line 125-196):
- After resolving
workspaceId, look upworkspace_members.creditSourcefor this user+workspace - Also check
workspace.settings.allowUserCredits— iffalse, override to'workspace' - If
creditSource === 'user':- Call
userCreditBalanceService.canProceed(userId)instead ofcreditBalanceService.canProceed(workspaceId) - Wait for user auto-reload if in progress (new event:
user-credits/auto-reload.completed)
- Call
- If
creditSource === 'workspace': existing behavior unchanged - Store
creditSourcein the step result so deduction step knows which service to call
Credit deduction step (~line 436-513):
- Read
creditSourcefrom the credit check result - If
'user': calluserCreditBalanceService.deductCredits()with workspace/project/thread context - If
'workspace': existingcreditBalanceService.deductCreditsFromBalance()unchanged canContinuecheck routes to the appropriate service
Add getCreditSourceForMember(workspaceId, userId) that:
- Queries
workspace_members.creditSource - Checks
workspace.settings.allowUserCredits !== false - Returns effective credit source
New endpoints:
PUT /workspaces/:workspaceId/billing/credit-source
Body: { creditSource: 'workspace' | 'user' }
— Updates workspace_members.creditSource for authenticated user
— Validates: workspace allows user credits, user has stripeCustomerId
— Returns: updated source + user floating balance
PATCH /workspaces/:workspaceId/billing/settings
Body: { allowUserCredits: boolean }
— Owner/admin only
— Updates workspace.settings JSONB
Add allowUserCredits and memberCreditSource to GET /workspaces/:workspaceId/billing/status response.
Add allowUserCredits?: boolean to the settings $type. Default: true (absence = allowed).
API functions:
getUserBillingStatus()→GET /user/billing/statusgetUserCredits()→GET /user/billing/creditspurchaseUserCredits(credits)→POST /user/billing/credits/checkoutgetUserPaymentMethods()→GET /user/billing/payment-methodscreateUserSetupIntent()→POST /user/billing/setup-intent
- Add
updateCreditSource(workspaceId, source)→PUT /workspaces/:id/billing/credit-source - Add
updateAllowUserCredits(workspaceId, allowed)→PATCH /workspaces/:id/billing/settings - Add
allowUserCreditsandmemberCreditSourceto billing status types
Modify dashboard/src/features/workspace-billing/ — add a toggle section:
- Radio: "Use workspace credits" / "Use your personal credits"
- If personal selected + no user billing → CTA to set up personal billing
- If admin disabled
allowUserCredits→ toggle hidden/disabled - Admin section: "Allow members to use personal credits" toggle (owner/admin only)
Modify dashboard/src/features/credits-banner/credits-indicator.element.tsx:
- When user's
creditSource === 'user'for current workspace, show user floating balance
New route (e.g., /settings/billing):
- Current floating balance
- Purchase credits button
- Payment methods management
- Transaction history
- Grant creates allocation + updates cached balance
- Deduction follows FIFO ordering
- Idempotency via stripePaymentIntentId
- canProceed checks balance
- Ledger entries include workspace context
- User with
creditSource='user'→ deducted from user allocations - User with
creditSource='workspace'→ existing behavior - Admin disables allowUserCredits → falls back to workspace source
- Empty user balance → CREDITS_EXHAUSTED (no fallback to workspace)
| File | Action |
|---|---|
apps/api/src/db/schema.ts |
Add 2 tables, modify users + workspaceMembers + workspace settings type |
apps/api/drizzle/YYYYMMDD_add_user_floating_credits.sql |
New migration |
apps/api/src/services/user-credit-balance.service.ts |
New — core service |
apps/api/src/services/index.ts |
Register new service |
apps/api/src/routes/user-billing.ts |
New — user billing API |
apps/api/src/routes/index.ts |
Mount new router |
apps/api/src/routes/billing.ts |
Add credit-source toggle + admin settings endpoints |
apps/api/src/routes/stripe-webhooks.ts |
Branch on metadata.type for user vs workspace |
apps/api/src/services/stripe.service.ts |
Add createUserCustomer + user checkout session |
apps/api/src/inngest/obvious-agent-execution.ts |
Branch credit check/deduction on creditSource |
dashboard/src/api/user-billing.ts |
New — API client |
dashboard/src/api/billing.ts |
Add credit source + admin setting functions |
dashboard/src/features/workspace-billing/ |
Add credit source toggle UI |
dashboard/src/features/credits-banner/credits-indicator.element.tsx |
Show user balance when applicable |
-
Separate tables, not extending existing —
creditAllocations.workspaceIdis NOT NULL with FK constraint. Changing it would break every existing query. Separate tables keep workspace credits untouched. -
Cached balance on
users.creditBalance— mirrors the workspace pattern (workspaces.creditBalance). Updated atomically during deductions. -
No fallback between sources — if user picks "personal" and runs out, they get CREDITS_EXHAUSTED. Must buy more or switch back. This is the user's explicit choice.
-
Ledger entries store workspace context — even though credits are user-scoped, the
user_credit_ledgerrecords WHERE credits were spent (workspaceId, projectId, threadId) for reporting. -
Webhook differentiation via metadata.type — user purchases set
metadata: { type: 'user_credit_purchase', userId }, workspace purchases use existingmetadata: { type: 'credit_purchase', workspaceId }. -
Admin control defaults to allowed —
workspace.settings.allowUserCreditsdefaults totrue(absence = allowed), so no breaking change for existing workspaces.
- Schema: Run
bun obvious typecheck --changedafter schema changes - Migration: Apply migration locally, verify tables created with
psql - Service: Run
bun obvious test --files apps/api/src/services/__tests__/user-credit-balance.service.test.ts - Routes: Test via curl/Postman: create user Stripe customer, purchase credits, verify balance
- Execution flow: Start an agent execution with
creditSource='user', verify deduction from user allocations and ledger entry has workspace context - Admin control: Toggle
allowUserCreditsoff, verify user with floating preference falls back to workspace credits - Full suite:
bun obvious check --changed