Skip to content

feat(api): credits wallet + metered generation pipeline (proposal) - #2193

Open
saralaaga wants to merge 1 commit into
kriasoft:mainfrom
saralaaga:factory
Open

saralaaga wants to merge 1 commit into
kriasoft:mainfrom
saralaaga:factory

Conversation

@saralaaga

Copy link
Copy Markdown

Opening this PR to gauge interest — happy to reshape the scope (e.g. wallet-core only), split it up, or adjust the design to fit the project's direction.

Motivation

Usage-based / prepaid-credits pricing is a common need when building on this starter (AI tools in particular). The @better-auth/stripe plugin covers subscriptions well, but there is no building block today for:

  • a prepaid balance with per-use debiting that cannot overdraft under concurrency
  • idempotent crediting from payment webhooks (retries must never double-apply)
  • spend + refund paired with a job's lifecycle

What this adds

DB (db/schema/credits.ts, db/schema/generation.ts, migration 0001):

  • credits_account — one row per user, live balance
  • credits_transaction — append-only ledger; (type, refId) unique index is the idempotency anchor (signup:{userId}, invoice id, checkout session id, generation id, refund:{generationId})
  • generation — processing → succeeded | failed state machine with terminal-only transitions and error-kind columns

Wallet (apps/api/lib/wallet.ts):

  • debit = conditional atomic update (balance >= amount), so concurrent spends cannot drive the balance negative; replayed refs abort the transaction and roll back the speculative update
  • credit/refund are replay-safe no-ops; balance column and ledger row move in one transaction

Generate pipeline (apps/api/routers/generate.ts):

  • debit before the model runs (one balance cannot fund N concurrent in-flight jobs), model/R2 failures refund through the ledger; a failed debit never refunds — the transaction aborted, so no money moved and refunding would mint credits (a real bug the tests caught)
  • guardrails: Turnstile server-side verification (fail-closed once TURNSTILE_SECRET_KEY is set), KV fixed-window rate limit (degrades to pass without the binding), prompt blocklist, zod input caps, server-generated R2 object keys

Reads (routers/credits.ts, routers/history.ts): balance / ledger / my-generations. Read/write live in separate files with all queries in a pure-param layer (lib/generations.ts) — that split also makes a future public gallery trivial to reuse.

Tests

20 new Vitest + PGlite cases (idempotency, concurrency, overdraft refusal, refund-once, failure rollback, guardrails, history isolation). Full suite: 113 passing; tsc --build, oxlint clean.

For maintainers

If a credits layer feels out of scope for the starter as a whole, feedback on splitting the wallet core (generic, provider-agnostic) from the generation example (opinionated) is very welcome. Bindings (ai, r2_buckets, kv_namespaces) are optional in env.ts and degrade explicitly when absent.

…k, abuse guardrails, e2e tests

- db: credits_account / credits_transaction (append-only ledger, (type,refId)
  idempotency anchor) / generation (processing→succeeded|failed state machine)
- lib/wallet: atomic conditional debit (no overdraft under concurrency),
  replay-safe credit/refund, lazy signup grant
- routers/generate: debit-before-model money path; model/R2 failures refund
  through the ledger; debit failures never refund (transaction aborted = no
  money moved); client idempotency keys replay original outcome
- guardrails: Turnstile (fail-closed when secret set), KV fixed-window rate
  limit (degrades to pass without binding), prompt blocklist, zod validation
- routers/credits + history: balance/ledger/my-generations reads
- wrangler: AI / R2 / KV bindings (top-level and dev env)
- tests: 20 new (wallet idempotency, concurrency, overdraft, refund-once,
  generate success/rollback/idempotency, guardrails, history isolation)

This branch has not been deployed

No deployments
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