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.