Skip to content

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:

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

ConcernWhereUnit today
Personal balancecredit_balances.balance (migration 20250213_001)INTEGER pages
Personal ledgercredit_transactions (amount, balance_after, metadata, expires_at)INTEGER pages
Team balanceteams.credit_balance + team_credit_transactions (20260322_023)INTEGER pages
Grant RPCadd_credits / add_team_creditspages
Deduct RPCdeduct_credits / deduct_team_creditspages
Grant callroutes/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 serviceservices/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_178cents/page
Flat rate settingsystem_settings.credits_per_page (default 1) via services/system-settings.tscredits/page
Balance displayroutes/credits.ts:64 returns balance; UI: settings/page.tsx, WizardDashboard, CreditEstimatePanel, InsufficientCreditsDialog, control-center, ltipages

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_paid grants amount_paid + bonus(amount_paid) cents. Bonus schedule lives in the catalog (see Β§6).
  • Consumption deducts pages Γ— page_rate_cents cents.
  • 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 cents
ALTER 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 promo
ALTER 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) or 0. 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_cents of 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_at is 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)PayPagesEff. rateAs a flat-$0.75 deposit, equivalent bonus
Pay-as-you-go$2533~$0.76~0%
Starter$250500$0.50~50% ($250 β†’ $375 of pages)
Team$4001,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: replace add_credits(pkg.credits) with add_credits_cents(amount_paid, bonus_for(amount_paid)). Same for add_team_credits. The payment-intent success path must do the same.
  • Consumption services/credits.ts: deductCredits(env, userId, pages, …) keeps its signature; internally compute cents = pages Γ— page_rate_cents and call deduct_credits_cents. ~12 call sites unchanged. (org-chart has its own services/org-chart/credits.ts β€” mirror.)
  • Balance API routes/credits.ts: return balanceCents (and a formatted dollar string). Keep balance (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-customer page_rate_cents override (and/or bonus override) instead of price_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.ts source (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:

  1. Phase 0 (additive): add cents columns + new RPCs + deposit_bonus_tiers. Deploy. No behavior change.
  2. Backfill: run the pageβ†’cents conversion script (Β§6). Verify totals (Ξ£ cents β‰ˆ Ξ£ pages Γ— basis) against Stripe gross.
  3. Dual-write: webhook + deductCredits write both page and cents ledgers for one release, so we can reconcile. Reads still page-based.
  4. Cutover: flip reads/UX to cents; webhook grants cents+bonus; consumption deducts cents.
  5. Cleanup (destructive, after a clean rebuild): drop balance/amount/balance_after page 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 usd assumed; currency already recorded for future use).

12. Open decisions (need answers before build)

  1. Target flat page_rate_cents and the deposit_bonus_tiers schedule (Β§8).
  2. Spend order: bonus-first (recommended) vs paid-first.
  3. Valuation of pre-existing granted/promo page balances with no cost basis (Β§6).
  4. Refundability of bonus dollars (recommend non-refundable) and the shutdown-refund policy.
  5. Whether to keep Starter/Team as named β€œpackages” (suggested deposits) or collapse to a single β€œadd funds” field with the bonus applied automatically.