Skip to content

Instantly share code, notes, and snippets.

@matthew-gerstman
Last active February 8, 2026 16:06
Show Gist options
  • Select an option

  • Save matthew-gerstman/7f74dec45eba7ff76e614ce854ae00fa to your computer and use it in GitHub Desktop.

Select an option

Save matthew-gerstman/7f74dec45eba7ff76e614ce854ae00fa to your computer and use it in GitHub Desktop.
Plan: User-Scoped Floating Credits

Plan: User-Scoped Floating Credits

Product Decisions

These decisions were made during planning. Revisit if any feel wrong.

  1. 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.

  2. 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.

  3. Users buy credits via separate Stripe checkout — Users get their own Stripe customer, payment methods, and checkout flow independent of any workspace billing.

  4. Existing workspace credits are unchanged — User credits are purely additive. Workspace subscriptions, auto-reload, per-seat allocations all work exactly as before.

  5. Workspace admins can disable floating creditsallowUserCredits setting (default: allowed). When disabled, all members must use workspace credits regardless of their preference.

Open Questions

  • 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?

Summary

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.

Commits

  1. feat: add user floating credits schema (migration + drizzle)
  2. feat: add creditSource column to workspace_members
  3. feat: add UserCreditBalanceService
  4. feat: add user billing routes (Stripe customer, checkout, payment methods)
  5. feat: handle user credit purchases in Stripe webhooks
  6. feat: integrate floating credits into agent execution flow
  7. feat: add workspace admin control + credit source toggle routes
  8. feat: add dashboard API client for user billing
  9. feat: add dashboard UI (credit source toggle, user billing page)
  10. test: add tests for user credit balance service and execution flow

Commit 1: Schema + Migration

Migration SQL (new file in apps/api/drizzle/)

-- 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';

Drizzle schema changes (apps/api/src/db/schema.ts)

  • Add stripeCustomerId and creditBalance columns to users table (~line 1101)
  • Add userCreditAllocations table definition (mirrors creditAllocations but user-keyed)
  • Add userCreditLedger table definition (mirrors creditLedger but user-keyed, with optional workspaceId for context)
  • Add relations for both new tables
  • Export types: UserCreditAllocation, UserCreditLedgerEntry

Why separate tables (not extending existing ones)

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.


Commit 2: creditSource on workspace_members

Migration

ALTER TABLE "workspace_members" ADD COLUMN "credit_source" text DEFAULT 'workspace';

Schema change (apps/api/src/db/schema.ts ~line 1632)

  • Add creditSource: text('credit_source').default('workspace') to workspaceMembers
  • Type: 'workspace' | 'user'

Default 'workspace' means all existing memberships behave identically to today.


Commit 3: UserCreditBalanceService

New file: apps/api/src/services/user-credit-balance.service.ts

Mirrors CreditBalanceService pattern but operates on user tables. Key methods:

  • getBalance(userId) — read cached users.creditBalance, fallback to summing allocations
  • canProceed(userId) — check if user has sufficient floating credits
  • grantCredits({ userId, credits, source, stripePaymentIntentId?, expiresAt? }) — create allocation, update cached balance, idempotent via stripePaymentIntentId
  • deductCredits({ userId, amount, workspaceId, projectId?, threadId?, ... }) — FIFO deduction from userCreditAllocations, create userCreditLedger entry with workspace context, update cached balance
  • calculateBalanceFromAllocations(userId) — sum remaining allocations (integrity check)
  • getTransactionHistory(userId, options?) — query userCreditLedger

Registration: apps/api/src/services/index.ts

  • Import and register userCreditBalanceService singleton

Key detail: FIFO consumption

Expiring allocations first (oldest first), then non-expiring (oldest first). Same pattern as workspace credits.

Key detail: row locking

Lock users row during deduction to prevent double-spend from concurrent executions.


Commit 4: User billing routes

New file: apps/api/src/routes/user-billing.ts

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

Stripe customer creation (apps/api/src/services/stripe.service.ts)

  • Add createUserCustomer(userId, email, name) — creates Stripe customer with metadata: { userId, type: 'user' }
  • Lazy creation: first time user tries to purchase credits
  • Store stripeCustomerId on users table

Route registration: apps/api/src/routes/index.ts

  • Mount userBillingRouter at /user/billing

Commit 5: Webhook handling for user credit purchases

Modify: apps/api/src/routes/stripe-webhooks.ts

Differentiate user vs workspace credit purchases by metadata.type:

  • metadata.type === 'user_credit_purchase' + metadata.userId → route to userCreditBalanceService.grantCredits()
  • metadata.type === 'credit_purchase' + metadata.workspaceId → existing workspace flow (unchanged)

Affected handlers:

  • checkout.session.completed — check metadata type, branch to user grant
  • payment_intent.succeeded — check metadata type, branch to user grant

Commit 6: Integrate into agent execution

Modify: apps/api/src/inngest/obvious-agent-execution.ts

Credit check step (~line 125-196):

  1. After resolving workspaceId, look up workspace_members.creditSource for this user+workspace
  2. Also check workspace.settings.allowUserCredits — if false, override to 'workspace'
  3. If creditSource === 'user':
    • Call userCreditBalanceService.canProceed(userId) instead of creditBalanceService.canProceed(workspaceId)
    • Wait for user auto-reload if in progress (new event: user-credits/auto-reload.completed)
  4. If creditSource === 'workspace': existing behavior unchanged
  5. Store creditSource in the step result so deduction step knows which service to call

Credit deduction step (~line 436-513):

  1. Read creditSource from the credit check result
  2. If 'user': call userCreditBalanceService.deductCredits() with workspace/project/thread context
  3. If 'workspace': existing creditBalanceService.deductCreditsFromBalance() unchanged
  4. canContinue check routes to the appropriate service

Helper method on CreditBalanceService

Add getCreditSourceForMember(workspaceId, userId) that:

  1. Queries workspace_members.creditSource
  2. Checks workspace.settings.allowUserCredits !== false
  3. Returns effective credit source

Commit 7: Admin control + credit source toggle routes

Modify: apps/api/src/routes/billing.ts

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

Modify: billing status response

Add allowUserCredits and memberCreditSource to GET /workspaces/:workspaceId/billing/status response.

Workspace settings type (apps/api/src/db/schema.ts ~line 1248)

Add allowUserCredits?: boolean to the settings $type. Default: true (absence = allowed).


Commit 8: Dashboard API client

New file: dashboard/src/api/user-billing.ts

API functions:

  • getUserBillingStatus()GET /user/billing/status
  • getUserCredits()GET /user/billing/credits
  • purchaseUserCredits(credits)POST /user/billing/credits/checkout
  • getUserPaymentMethods()GET /user/billing/payment-methods
  • createUserSetupIntent()POST /user/billing/setup-intent

Modify: dashboard/src/api/billing.ts

  • Add updateCreditSource(workspaceId, source)PUT /workspaces/:id/billing/credit-source
  • Add updateAllowUserCredits(workspaceId, allowed)PATCH /workspaces/:id/billing/settings
  • Add allowUserCredits and memberCreditSource to billing status types

Commit 9: Dashboard UI

Credit source toggle (in workspace billing page)

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)

Credits indicator

Modify dashboard/src/features/credits-banner/credits-indicator.element.tsx:

  • When user's creditSource === 'user' for current workspace, show user floating balance

User billing page

New route (e.g., /settings/billing):

  • Current floating balance
  • Purchase credits button
  • Payment methods management
  • Transaction history

Commit 10: Tests

apps/api/src/services/__tests__/user-credit-balance.service.test.ts

  • Grant creates allocation + updates cached balance
  • Deduction follows FIFO ordering
  • Idempotency via stripePaymentIntentId
  • canProceed checks balance
  • Ledger entries include workspace context

Execution flow integration tests

  • 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)

Critical Files

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

Key Design Decisions

  1. Separate tables, not extending existingcreditAllocations.workspaceId is NOT NULL with FK constraint. Changing it would break every existing query. Separate tables keep workspace credits untouched.

  2. Cached balance on users.creditBalance — mirrors the workspace pattern (workspaces.creditBalance). Updated atomically during deductions.

  3. 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.

  4. Ledger entries store workspace context — even though credits are user-scoped, the user_credit_ledger records WHERE credits were spent (workspaceId, projectId, threadId) for reporting.

  5. Webhook differentiation via metadata.type — user purchases set metadata: { type: 'user_credit_purchase', userId }, workspace purchases use existing metadata: { type: 'credit_purchase', workspaceId }.

  6. Admin control defaults to allowedworkspace.settings.allowUserCredits defaults to true (absence = allowed), so no breaking change for existing workspaces.

Verification

  1. Schema: Run bun obvious typecheck --changed after schema changes
  2. Migration: Apply migration locally, verify tables created with psql
  3. Service: Run bun obvious test --files apps/api/src/services/__tests__/user-credit-balance.service.test.ts
  4. Routes: Test via curl/Postman: create user Stripe customer, purchase credits, verify balance
  5. Execution flow: Start an agent execution with creditSource='user', verify deduction from user allocations and ledger entry has workspace context
  6. Admin control: Toggle allowUserCredits off, verify user with floating preference falls back to workspace credits
  7. Full suite: bun obvious check --changed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment