Files
keysat/licensing-service/migrations/0010_multi_currency.sql
T
Grant d8fcb51d1c Multi-currency schema foundation (Phase 1 of MULTI_CURRENCY_DESIGN)
Migration 0010 adds the columns needed to price products + policies
in something other than satoshis (USD, EUR, BTC at higher denoms)
while keeping every existing operator's data behaviorally identical.
This is the foundation work; admin UI write path, buy page
rendering, and rate fetcher land in subsequent phases. See
MULTI_CURRENCY_DESIGN.md at the parent licensing/ folder for the
full design.

Schema changes (all additive):
- products gain price_currency (TEXT NOT NULL DEFAULT 'SAT') and
  price_value (INTEGER NOT NULL DEFAULT 0). Backfill copies
  price_sats → price_value on every existing row, so SAT-priced
  products carry their information identically through the
  migration.
- policies gain price_currency_override (nullable, NULL = inherit
  from product) and price_value_override (nullable, mirrors the
  existing price_sats_override).
- invoices gain four nullable columns: listed_currency, listed_value,
  exchange_rate_centibps, exchange_rate_source. NULL on every
  current row; populated by the daemon when an invoice is created
  against a fiat-priced product.
- discount_codes gains discount_currency (DEFAULT 'SAT'). 'percent'
  codes are currency-agnostic; 'fixed_sats' and 'set_price' codes
  use this column to express "$10 off" or "set price to $25"
  against fiat-priced products.
- New index idx_products_currency for future "list products by
  currency" admin views.

Read path:
- Product struct gains price_currency + price_value fields
  (#[serde(default)] for back-compat with any cached/persisted
  shapes that predate them).
- row_to_product extracts the new columns; falls back to SAT/
  price_sats if a row predates 0010 (defensive — migration always
  runs at boot, but no reason to crash if it didn't).
- All four product SELECTs add the new columns.

Write path (legacy SAT-only callers):
- create_product dual-writes price_sats AND price_value to the
  same value, with price_currency = 'SAT'.
- update_product dual-writes price_sats and price_value when the
  caller passes a new sat price.

Migration regression test:
- migration_0010_backfills_existing_products_to_sat seeds three
  products (free, $100, $2500-equivalent) and a policy with a
  sat override BEFORE 0010 runs, applies 0010, asserts every row
  ends up with price_currency = 'SAT' and price_value =
  price_sats. Catches any future change that breaks the
  backfill contract.
- migration_0009_is_idempotent now pinned to 0009 by filename
  (was: "the last migration"). 0010+ are not idempotent (ALTER
  TABLE ADD COLUMN can't be retried in SQLite); the
  idempotency test is specifically for 0009 because that
  migration's whole point was being safely re-runnable.

Test count: 33 (was 32; +1 migration_0010_backfills test).

Decisions locked in (per MULTI_CURRENCY_DESIGN open questions):
- Default currency on new products: SAT. Operators explicitly
  pick USD for fiat-priced products.
- Multi-currency available to all tiers (NOT gated behind Pro/
  Patron) — the right product call.
- Rate source priority: Kraken → Coinbase → CoinGecko (lands
  in Phase 4 of the design).
- Recurring subscriptions: SAT-priced subs charge the same sat
  amount each cycle (no rate adjustment needed); USD-priced subs
  re-quote each cycle so the dollar amount is stable.
2026-05-08 12:00:13 -05:00

111 lines
5.9 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Multi-currency pricing foundation.
--
-- Adds the schema needed to price products + policies in something
-- other than satoshis (USD, EUR, BTC at higher denominations) while
-- keeping every existing operator's data behaviorally identical.
-- See MULTI_CURRENCY_DESIGN.md at the repo root for the full design;
-- this migration is its Phase 1.
--
-- Strategy: additive only. New columns get defaults that mean
-- "interpret me as SAT-priced, same as before." `price_sats` stays
-- as the canonical sat amount (dual-written from now on); the new
-- `price_currency` + `price_value` pair carries the operator-facing
-- intent. Daemon code reads either; new code prefers the new pair.
--
-- The new columns are intentionally not yet wired into the buy page
-- or admin UI. That's a v0.3 follow-up — this migration just gives
-- the daemon the storage shape so the read path can begin to use
-- it incrementally without further migrations.
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- products: native currency + value
-- ---------------------------------------------------------------------------
-- price_currency = ISO 4217 fiat code, 'SAT', or 'BTC'.
-- SAT → smallest unit is 1 sat
-- BTC → smallest unit is 1 sat (1 BTC = 100,000,000 sats)
-- USD → smallest unit is 1 cent
-- EUR → smallest unit is 1 cent
-- price_value is in the smallest indivisible unit of that currency.
-- For SAT-priced products, price_value == price_sats.
-- For USD-priced products, price_value is cents and `price_sats` is
-- a stale snapshot from purchase time (or 0 if the product has
-- never been migrated through dual-write).
ALTER TABLE products ADD COLUMN price_currency TEXT NOT NULL DEFAULT 'SAT';
ALTER TABLE products ADD COLUMN price_value INTEGER NOT NULL DEFAULT 0;
-- Backfill: every existing row is SAT-priced. Copy price_sats →
-- price_value so the new pair carries the same information.
UPDATE products SET price_value = price_sats WHERE price_currency = 'SAT';
-- ---------------------------------------------------------------------------
-- policies: optional per-tier currency override
-- ---------------------------------------------------------------------------
-- Mirrors the existing price_sats_override column. NULL on either
-- means "inherit from product"; both NULL is the common "this tier
-- uses the product's price as-is" case.
ALTER TABLE policies ADD COLUMN price_currency_override TEXT;
ALTER TABLE policies ADD COLUMN price_value_override INTEGER;
-- Backfill: existing policies that had a sat override get the new
-- pair filled in. Currency stays NULL (= use the parent product's
-- currency, which after the products backfill is SAT).
UPDATE policies SET price_value_override = price_sats_override
WHERE price_sats_override IS NOT NULL;
-- ---------------------------------------------------------------------------
-- invoices: record listed price + exchange rate at creation
-- ---------------------------------------------------------------------------
-- For sat-priced flows, all four columns stay NULL.
-- For fiat-priced flows, listed_currency + listed_value carry what
-- the buyer SAW (e.g. USD 5000 cents = $50.00) and
-- exchange_rate_centibps + exchange_rate_source record HOW the
-- daemon converted it to the BTC amount the buyer was actually
-- billed (the existing amount_sats column).
--
-- exchange_rate_centibps stores the rate as
-- "<unit-of-listed-currency> per BTC, scaled by 10000".
-- For a $65,000/BTC market, listing a $50 product:
-- listed_currency = 'USD'
-- listed_value = 5000 (cents)
-- exchange_rate_centibps = 650000000 (USD-cents per BTC, ×10000 = ×10^4)
-- amount_sats = 76923 (5000 cents ÷ 65000 USD/BTC × 100M sats/BTC)
-- 10000-bp scaling gives ~6 decimal digits of rate precision — plenty
-- for fiat→BTC where rates are 5-6 figures. No floating-point ops.
ALTER TABLE invoices ADD COLUMN listed_currency TEXT;
ALTER TABLE invoices ADD COLUMN listed_value INTEGER;
ALTER TABLE invoices ADD COLUMN exchange_rate_centibps INTEGER;
ALTER TABLE invoices ADD COLUMN exchange_rate_source TEXT;
-- exchange_rate_source examples: 'btcpay' | 'kraken' | 'coinbase' |
-- 'coingecko' | 'manual_pin' (for testing). The actual source URL
-- + timestamp aren't stored here — the rate fetcher caches those
-- in-memory and exposes them via /v1/admin/rates (a v0.3+ surface).
-- ---------------------------------------------------------------------------
-- discount_codes: currency-aware fixed amounts
-- ---------------------------------------------------------------------------
-- 'percent' codes are currency-agnostic (basis points off whatever
-- the product is priced in) — no change needed.
-- 'fixed_sats' and 'set_price' need a currency tag to express
-- "$10 off" or "set price to $25" against fiat-priced products.
-- Add `discount_currency` with default 'SAT' so existing codes keep
-- their current semantics. v0.3 admin UI lets operators pick.
ALTER TABLE discount_codes ADD COLUMN discount_currency TEXT NOT NULL DEFAULT 'SAT';
-- amount column already exists; for SAT-currency codes it stays in
-- sats. For USD-currency codes it's cents. The kind+currency pair
-- determines interpretation:
-- kind=fixed_sats currency=SAT → amount sats off
-- kind=fixed_sats currency=USD → amount cents off (the column
-- name is now slightly stale but renaming requires a rebuild
-- and the existing value carries forward cleanly)
-- kind=set_price currency=SAT → set price to amount sats
-- kind=set_price currency=USD → set price to amount cents
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
-- Operator search-by-currency is a future admin-UI feature; ship the
-- index now so it's available when the UI shows up.
CREATE INDEX IF NOT EXISTS idx_products_currency ON products(price_currency);