Dollar Balance + Deposit Bonuses β Migration Plan
Status: Proposal for review (engineering + accounting). No code written yet. Decision owner: Larry. Author: drafted with Claude Code.
1. Why
Today the prepaid balance is tracked in credits = pages (credit_balances.balance INTEGER).
The per-tier volume discount (Pay-as-you-go $0.75/page, Starter $0.50, Team $0.40) is
baked in at purchase by converting dollars β a discounted page count. That works for
consumption (1 page = 1 credit, flat) but has two costs:
- Liability/refund valuation is indirect. The live balance is pages, not dollars, so the refund/deferred-revenue value of an unspent balance must be reconstructed from the transaction ledger using a costing convention (pages are fungible across purchases made at different rates). See [credit-pricing.md] and the liability discussion.
- UX mismatch. Customers now expect the βLLM modelβ: deposit dollars, draw down at a published rate, see a dollar balance, top up any amount.
Target model (Option 2): balance is dollars (stored as integer cents), drawn down at a single flat per-page rate, with deposit bonuses (extra dollars granted on larger top-ups) replacing the tiered per-page discount. Recurring annual plans (Division/Institution) are unchanged β they are the separate βsubscriptionβ tier.
Why bonuses instead of tiered rates: a bonus is still dollars, so the ledger stays pure dollars, liability = the dollar balance, refund = the dollar balance (minus non-refundable bonus, see Β§7). We keep βbuy more, get moreβ without reintroducing βwhich dollar bought what.β
2. Key insight that bounds the blast radius
Every consumption path (convert.ts Γ5, photos, forms, links, url-remediate, org-chart, mcp β ~12 call sites) funnels through one service function:
workers/api/src/services/credits.ts β deductCredits(env, userId, pages, fileId, description)If we change that functionβs internals to deduct pages Γ FLAT_RATE_CENTS from a cents
balance, the ~12 call sites keep passing pages and need no change. Symmetrically,
add_credits becomes βgrant cents.β This is what makes the migration tractable.
3. Current state (verified)
| Concern | Where | Unit today |
|---|---|---|
| Personal balance | credit_balances.balance (migration 20250213_001) | INTEGER pages |
| Personal ledger | credit_transactions (amount, balance_after, metadata, expires_at) | INTEGER pages |
| Team balance | teams.credit_balance + team_credit_transactions (20260322_023) | INTEGER pages |
| Grant RPC | add_credits / add_team_credits | pages |
| Deduct RPC | deduct_credits / deduct_team_credits | pages |
| Grant call | routes/stripe.ts:137 β add_credits(p_amount: pkg.credits); metadata already records amount_paid (cents), package_id, currency (stripe.ts:107) | pages in, cents recorded in metadata |
| Deduct service | services/credits.ts:120 deductCredits(pages) β deduct_credits(p_amount: pages) | pages |
| Per-page rate (catalog) | credit_packages.per_page_cents (75/50/40) added in 20260618_178 | cents/page |
| Flat rate setting | system_settings.credits_per_page (default 1) via services/system-settings.ts | credits/page |
| Balance display | routes/credits.ts:64 returns balance; UI: settings/page.tsx, WizardDashboard, CreditEstimatePanel, InsufficientCreditsDialog, control-center, lti | pages |
Good news for accounting: credit_transactions.metadata.amount_paid already captures the
dollars paid on every checkout-session purchase. (Verify the embedded payment_intent path
does too β see Β§8 risk.)
4. Target state
- Balance unit: integer cents.
credit_balances.balance_cents,teams.credit_balance_cents. - Flat per-page price: one rate for all self-serve usage, e.g.
system_settings.page_rate_cents(default e.g. 75). No per-tier consumption rate. - Deposit = dollars + bonus dollars. A top-up of
amount_paidgrantsamount_paid + bonus(amount_paid)cents. Bonus schedule lives in the catalog (see Β§6). - Consumption deducts
pages Γ page_rate_centscents. - Liability = Ξ£ balances (cents). Refund = balance (less non-refundable bonus, Β§7).
5. Schema changes (additive first)
-- Phase 0: additive β add cents columns alongside the page columns.ALTER TABLE public.credit_balances ADD COLUMN IF NOT EXISTS balance_cents BIGINT NOT NULL DEFAULT 0;ALTER TABLE public.teams ADD COLUMN IF NOT EXISTS credit_balance_cents BIGINT NOT NULL DEFAULT 0;
-- Ledger: record cents movements + bonus provenance. Keep page columns during transition.ALTER TABLE public.credit_transactions ADD COLUMN IF NOT EXISTS amount_cents BIGINT; -- +grant / -spend in centsALTER TABLE public.credit_transactions ADD COLUMN IF NOT EXISTS balance_cents_after BIGINT;ALTER TABLE public.credit_transactions ADD COLUMN IF NOT EXISTS bonus_cents BIGINT DEFAULT 0; -- portion of a purchase that was promoALTER TABLE public.team_credit_transactions ADD COLUMN IF NOT EXISTS amount_cents BIGINT;ALTER TABLE public.team_credit_transactions ADD COLUMN IF NOT EXISTS balance_cents_after BIGINT;ALTER TABLE public.team_credit_transactions ADD COLUMN IF NOT EXISTS bonus_cents BIGINT DEFAULT 0;
-- Catalog: flat rate + bonus schedule.INSERT INTO public.system_settings (key, value) VALUES ('page_rate_cents', '75') ON CONFLICT (key) DO NOTHING;-- Bonus tiers: deposit threshold (cents) β bonus percent. Either a small table or JSON setting.CREATE TABLE IF NOT EXISTS public.deposit_bonus_tiers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), min_cents BIGINT NOT NULL, -- deposit β₯ this bonus_percent NUMERIC(5,2) NOT NULL, -- e.g. 10.00 active BOOLEAN NOT NULL DEFAULT TRUE);New RPCs (parallel to the page ones, so we can dual-run):
add_credits_cents(p_user_id, p_amount_cents, p_bonus_cents, p_type, p_description, p_metadata)
and deduct_credits_cents(p_user_id, p_amount_cents, p_description, p_file_id, p_pages) returning the
new cents balance, plus team equivalents. Mirror the existing add_credits/deduct_credits
balance-checks and ledger-insert logic.
6. Existing-balance conversion (the careful part)
Convert each userβs page balance to cents at their weighted-average purchase basis from the ledger β fair and reconstructable:
basis_cents_per_page(user) = Ξ£(purchase.metadata.amount_paid) / Ξ£(purchase.amount /*pages*/)balance_cents(user) = round(balance_pages Γ basis_cents_per_page(user))Edge cases (decide policy):
- No purchase history (only granted/promo pages): value at
page_rate_cents(list) or0. Recommend list rate for goodwill unless the grant was promotional. - Teams: same formula over
team_credit_transactions. - Run as a one-off backfill script (transactional, idempotent, logs a
type='adjustment'row per user recording the pageβcents conversion and the basis used β important audit trail).
7. Liability & refund treatment (for the accountant)
- Prepaid balances are a contract liability (deferred revenue, ASC 606). With cents balances, liability = SUM(credit_balances.balance_cents) + SUM(teams.credit_balance_cents). No costing convention needed β it is the literal dollar balance.
- Revenue recognition: recognize when pages are consumed (each spend row is
pages Γ page_rate_centsof revenue). Breakage on expiry (annual tiers) per policy. - Bonus dollars: track separately via
bonus_cents. Standard treatment is bonus is non-refundable promotional credit and is recognized as a discount/contra-revenue, not cash. Refund exposure =balance_cents β unspent_bonus_cents. Spending should draw paid dollars first or bonus first β pick one (recommend bonus first so refunds favor the house and the refundable portion shrinks as they use the service). - Never-expire policy: Pay-as-you-go / Starter / Team balances do not expire β permanent
liability, no breakage. Division/Institution annual allotments expire yearly β breakage on
expiry. This matches current intent; ensure
expires_atis set only on the annual tiers.
8. Pricing-strategy decision required (read before sizing bonuses)
Converting todayβs steep tier discounts into bonuses at a flat $0.75/page produces large bonus percentages, because the current discounts are deep:
| Tier (today) | Pay | Pages | Eff. rate | As a flat-$0.75 deposit, equivalent bonus |
|---|---|---|---|---|
| Pay-as-you-go | $25 | 33 | ~$0.76 | ~0% |
| Starter | $250 | 500 | $0.50 | ~50% ($250 β $375 of pages) |
| Team | $400 | 1,000 | $0.40 | ~87% ($400 β $750 of pages) |
So either (a) keep matching todayβs economics and advertise 50β87% deposit bonuses (optically
huge, and locks in thin margins), or (b) flatten the discount curve as part of this change
(e.g. flat $0.75 with modest 10β20% bonuses). This is a margin/pricing decision, not an
engineering one β the mechanics support any schedule via deposit_bonus_tiers. Recommend
deciding the target rate + bonus curve before implementation.
9. App / API changes
- Webhook
routes/stripe.ts: replaceadd_credits(pkg.credits)withadd_credits_cents(amount_paid, bonus_for(amount_paid)). Same foradd_team_credits. The payment-intent success path must do the same. - Consumption
services/credits.ts:deductCredits(env, userId, pages, β¦)keeps its signature; internally computecents = pages Γ page_rate_centsand calldeduct_credits_cents. ~12 call sites unchanged. (org-chart has its ownservices/org-chart/credits.tsβ mirror.) - Balance API
routes/credits.ts: returnbalanceCents(and a formatted dollar string). Keepbalance(pages) during transition for old clients, or convert in one release. - Estimates/UX:
credit-estimator.ts,CreditEstimatePanel,InsufficientCreditsDialog,WizardDashboard,settings/page.tsx, control-center, lti components β show dollars and ββ N pages at $X/pageβ. Low-balance threshold becomes a dollar amount. - Custom pricing
customer_pricing: re-interpret as a per-customerpage_rate_centsoverride (and/or bonus override) instead ofprice_per_credit_cents/discount_percent. - Marketing/pricing catalog: Pay-as-you-go becomes a true βtop up any amount β₯ $25β; Starter/
Team become suggested deposit amounts with their bonus. Update
pricing.generated.tssource (admin/pricing editor) and home/web pricing sections.
10. Rollout ordering (respects the Node+Lambda deploy hazard)
Strictly additive migrations deploy freely; destructive cleanup waits for rebuild-server.sh
(see [reference_node_rebuild]). Suggested phases:
- Phase 0 (additive): add cents columns + new RPCs +
deposit_bonus_tiers. Deploy. No behavior change. - Backfill: run the pageβcents conversion script (Β§6). Verify totals (Ξ£ cents β Ξ£ pages Γ basis) against Stripe gross.
- Dual-write: webhook +
deductCreditswrite both page and cents ledgers for one release, so we can reconcile. Reads still page-based. - Cutover: flip reads/UX to cents; webhook grants cents+bonus; consumption deducts cents.
- Cleanup (destructive, after a clean rebuild): drop
balance/amount/balance_afterpage columns and the page RPCs once reconciliation is clean for N days.
11. Out of scope / non-goals
- Annual plan mechanics (Division/Institution) β unchanged.
- Switching the consumption unit away from pages (still pages Γ rate; only the ledger unit becomes cents).
- Multi-currency (single
usdassumed;currencyalready recorded for future use).
12. Open decisions (need answers before build)
- Target flat
page_rate_centsand thedeposit_bonus_tiersschedule (Β§8). - Spend order: bonus-first (recommended) vs paid-first.
- Valuation of pre-existing granted/promo page balances with no cost basis (Β§6).
- Refundability of bonus dollars (recommend non-refundable) and the shutdown-refund policy.
- Whether to keep Starter/Team as named βpackagesβ (suggested deposits) or collapse to a single βadd fundsβ field with the bonus applied automatically.