Accounts, Orders & Credits
Understand how users, orders, and the credits ledger work together in the Sushi SaaS template. Learn the balance formula, expiry handling, and the APIs/services to grant, consume, and inspect credits.
Why This Matters
This template ships with authentication, Stripe‑friendly order tracking, and a credit balance system. Knowing how the tables connect helps you add products, integrate payments, or debug “where did my credits go?” without guesswork.
Meet The Cast (Core Tables)
users— Profile source of truth. Columns:uuid(our cross‑table key), email, locale, role, etc. Better Auth populates this.accounts&sessions— Better Auth internals that back sign‑in. Useful for audits; not part of the ledger math.orders— Every checkout attempt/result. Columns:order_no,status,amount, subscription fields (sub_*),credits,expired_at.credits— Append‑only ledger. Positive rows = grants/additions; negative rows = consumption. Columns:trans_no,user_uuid,trans_type,credits,order_no,expired_at.tasks— Usage jobs (e.g., text‑to‑video). Columns:credits_used,credits_trans_nolink the task to the exact consumption ledger row.- Cameos:
apikeys,affiliatesalso hang offuser_uuidfor API access and referrals.
ER-ish cheat sheet (no foreign keys, but stable links):
users.uuid→orders.user_uuid|credits.user_uuid|tasks.user_uuid|apikeys.user_uuid|affiliates.user_uuidorders.order_no→credits.order_no(grants from purchases)credits.trans_no→tasks.credits_trans_no(which consumption paid for a task)
Each table timestamps its changes so you can reconstruct history and power analytics.
The Analogy: Wallet + Receipts
- Think of
creditsas a transparent wallet. Every deposit or spend is a receipt you drop into the wallet. - Deposits (positive rows) add to your cash. Spends (negative rows) take cash out. Expired gift cards? They’re still receipts, just no longer spendable.
- Your balance is simply: sum(all non‑expired positives) − sum(all negatives).
This is why the ledger is append‑only: instead of mutating old gifts, we add new receipts so history is always explainable.
One Action → Many Rows (Walkthroughs)
Checkout paid (Stripe)
orders: the row fororder_noflips topaidwith charge metadata.credits: we add a positive grant row{ trans_type: "order_pay", credits: <order.credits>, order_no }.affiliates: optional reward update ties back to this order.
Spend credits on a task (Text → Video)
credits: we add a negative row{ trans_type: "task_text_to_video", credits: -N }and return itstrans_no.tasks: we insert the task{ credits_used: N, credits_trans_no: <that trans_no> }so you can trace “who spent what, where”.
Tiny ping demo (1 credit)
credits: negative row{ trans_type: "ping", credits: -1 }.
Expiry
- Any positive row with
expired_atin the past doesn’t count toward balance, but stays in history for audits.
Useful Services & APIs
src/services/credit.ts#getUserCreditSummary— returnsbalance,granted,consumed,expired,expiringSoon[], and a trimmedledger[].src/services/credit.ts#getUserCredits— lightweight snapshot for gating:{ left_credits, is_pro, is_recharged }.src/services/credit.ts#decreaseCredits— appends a negative ledger row; throws on insufficient credits.src/services/credit.ts#increaseCredits— appends a positive ledger row; used by Stripe and grants.src/services/stripe.ts#handleCheckoutSession— turns a paid session into an order status + grant row.src/services/tasks.ts#createTextToVideoTask— decrements credits, then inserts atasksrow referencing that spend.
HTTP endpoints
POST /api/account/credits— credit summary; accepts{ includeLedger, ledgerLimit, includeExpiring }.POST /api/account/profile— profile + credit summary; send{ includeCreditLedger: false }to keep it light.POST /api/ping— demo consumption; spendsCreditsAmount.PingCost.
All responses use our respJson helpers so the shape is { code, message, data }.
Try It Locally
-
Run the app:
pnpm dev(requires a Postgres URL in.env.local). -
Create a user: visit
/en/signup, register, and sign in. -
Add credits manually (dev only) by inserting into
credits:insert into credits (trans_no, user_uuid, trans_type, credits, created_at) values ('dev-grant-1', '<the-user-uuid>', 'manual_adjustment', 100, now());You can run this with
pnpm drizzle-kit studioor your preferred SQL client. -
Fetch balances: call
/api/account/creditsvia HTTPie or curl:curl -X POST http://localhost:3000/api/account/credits \ -H "Content-Type: application/json" \ -H "Cookie: <copy auth cookies from the browser>" -
Spend credits: call
/api/pingwith a message to subtractCreditsAmount.PingCost(defaults to 1 credit) and then re-run the balance query.curl -X POST http://localhost:3000/api/ping \ -H "Content-Type: application/json" \ -H "Cookie: <copy auth cookies from the browser>" \ -d '{\"message\":\"hello\"}'
Customize the System
Change Credit Expiry Windows
Edit EXPIRING_WINDOW_DAYS in src/services/credit.ts to adjust the “expiring soon” warning horizon. To enforce auto-expiry, write a cron job that zeroes out rows and adds negative adjustments when expired_at is passed.
Seed Onboarding Bonuses
Use insertCredit from src/models/credit.ts right after insertUser to give new accounts a welcome balance. Keep the transaction number (trans_no) unique to avoid duplicates.
Integrate Real Payments
- The template assumes Stripe but any processor works: call
insertOrderwith your provider’s payload. - Log the granted credits in
creditsand setorder_noso reconciliations are simple. - Update the docs or UI to show subscription metadata (
sub_*columns) if you offer recurring plans.
Track More Context
Add columns (e.g. meta JSON) to credits/orders for feature flags or experiment IDs. Drizzle’s migration system will update the schema snapshot automatically.
Gotchas & Tips
- Balance math is simple sums; we don’t “consume” specific positive rows. Negative entries reference a likely source via
order_nofor traceability, but no mutation happens to old grants. getUserValidCreditsorders positive grants byexpired_atso consumption “tags” a nearby source for audits.- Need strict FIFO or per‑grant drawdowns? Extend the model and keep the append‑only ledger for audits.
- Keep
users.uuidstable; it’s the glue across orders, credits, tasks, keys, and affiliates.
Next Steps
- Add dashboard charts:
getUserCreditSummaryalready exposes totals you can graph. - Use the
/api/pinghandler as a template for other credit‑consuming features. - Write unit tests for credit math; mock a list of ledger rows and assert on
balance,expired, andexpiringSoon. - Want this in other languages? I can sync the updates to FR/ES/JA/ZH.
Email Service (Resend)
Integrate transactional email with Resend. Verify your domain, create API keys, render welcome and payment emails on the server, and send through Resend with environment-based configuration.
Credits-Based Tasks — Text to Video
Add usage-based monetization with a generic tasks table, a credit ledger, and a pluggable text-to-video generator. Learn the schema, APIs, config constants, and a minimal UI to ship paid AI features fast.