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.
This commit is contained in:
Grant
2026-05-08 12:00:13 -05:00
parent 7ce30008ff
commit d8fcb51d1c
4 changed files with 261 additions and 9 deletions
+100 -2
View File
@@ -363,8 +363,19 @@ async fn migration_0009_is_idempotent() {
.await
.unwrap();
let last = migration_files().into_iter().last().unwrap();
let sql = std::fs::read_to_string(&last).unwrap();
// Pinned to migration 0009 by its filename prefix, not by
// "last in the list" — once 0010+ land they may not be
// idempotent (additive ALTER TABLE statements aren't), but
// 0009's whole point was being safely re-runnable.
let nine = migration_files()
.into_iter()
.find(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map_or(false, |s| s.starts_with("0009_"))
})
.expect("migration 0009 file must be present");
let sql = std::fs::read_to_string(&nine).unwrap();
let mut tx = pool.begin().await.unwrap();
sqlx::raw_sql(&sql)
.execute(&mut *tx)
@@ -386,6 +397,93 @@ async fn migration_0009_is_idempotent() {
assert_db_clean(&pool).await.expect("db clean after re-apply");
}
/// Migration 0010 (multi-currency foundation): verifies that the
/// backfill correctly populates the new `price_currency` and
/// `price_value` columns against products that existed before the
/// migration. This is the contract the rest of the multi-currency
/// build assumes — every existing row must end up with
/// `price_currency = 'SAT'` and `price_value = price_sats`.
#[tokio::test]
async fn migration_0010_backfills_existing_products_to_sat() {
let (pool, _tmp) = make_pool().await;
apply_range(&pool, 0, 9)
.await
.expect("apply 0001..=0009 (everything before 0010)");
// Seed three products with different sat amounts (including 0
// for the free case) before 0010 runs.
sqlx::query(
"INSERT INTO products(id, slug, name, price_sats, created_at, updated_at) \
VALUES('pa', 'a', 'Product A', 0, 't', 't'), \
('pb', 'b', 'Product B', 10000, 't', 't'), \
('pc', 'c', 'Product C', 250000, 't', 't')",
)
.execute(&pool)
.await
.expect("seed products");
// Seed a policy with a price override so the policy backfill
// (price_value_override = price_sats_override) is exercised.
sqlx::query(
"INSERT INTO policies(id, product_id, name, slug, price_sats_override, \
created_at, updated_at) \
VALUES('pol1', 'pb', 'Pro', 'pro', 50000, 't', 't')",
)
.execute(&pool)
.await
.expect("seed policy with override");
// Apply 0010.
apply_range(&pool, 9, 10)
.await
.expect("apply 0010_multi_currency");
// After: every product has price_currency='SAT' and
// price_value matches price_sats.
let rows: Vec<(String, String, i64, i64)> = sqlx::query_as(
"SELECT id, price_currency, price_value, price_sats \
FROM products ORDER BY id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 3);
for (id, currency, value, sats) in &rows {
assert_eq!(currency, "SAT", "{id}: currency must default to SAT");
assert_eq!(value, sats, "{id}: price_value must mirror price_sats");
}
// The policy override was backfilled.
let pol: (Option<String>, Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT price_currency_override, price_value_override, price_sats_override \
FROM policies WHERE id = 'pol1'",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(
pol.0.is_none(),
"currency_override should stay NULL = 'inherit from product'"
);
assert_eq!(pol.1, Some(50000), "price_value_override backfilled");
assert_eq!(pol.2, Some(50000), "original price_sats_override preserved");
// The new currency index exists (uses CREATE INDEX IF NOT
// EXISTS so this is implicit-correct, but assert the index is
// there so a future schema rebuild can't silently lose it).
let idx_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type='index' AND name='idx_products_currency'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(idx_count, 1, "currency index should exist after 0010");
// FK + integrity invariants still hold.
assert_db_clean(&pool).await.expect("db clean after 0010");
}
/// Future-proofing. Always seeds fixtures one migration before the end,
/// then applies the final migration. As new migrations land (0010,
/// 0011, …), they get vetted against populated data automatically; no