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
+32 -7
View File
@@ -14,10 +14,10 @@ use uuid::Uuid;
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
let q = if only_active {
"SELECT id, slug, name, description, price_sats, active, metadata_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
FROM products WHERE active = 1 ORDER BY name"
} else {
"SELECT id, slug, name, description, price_sats, active, metadata_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
FROM products ORDER BY name"
};
let rows = sqlx::query(q).fetch_all(pool).await?;
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, active, metadata_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
FROM products WHERE slug = ?",
)
.bind(slug)
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
let row = sqlx::query(
"SELECT id, slug, name, description, price_sats, active, metadata_json, created_at, updated_at
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
FROM products WHERE id = ?",
)
.bind(id)
@@ -59,15 +59,21 @@ pub async fn create_product(
let metadata_json = serde_json::to_string(metadata)
.map_err(|e| AppError::BadRequest(format!("invalid metadata JSON: {e}")))?;
// Dual-write: products created via this legacy entry point are
// SAT-priced (price_currency = 'SAT', price_value = price_sats).
// A future `create_product_with_currency` can land alongside
// the fiat-pricing admin-UI work without re-touching this row.
sqlx::query(
"INSERT INTO products (id, slug, name, description, price_sats, active, metadata_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)",
"INSERT INTO products (id, slug, name, description, price_sats, \
price_currency, price_value, active, metadata_json, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, 'SAT', ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(slug)
.bind(name)
.bind(description)
.bind(price_sats)
.bind(price_sats) // price_value mirrors price_sats for SAT-currency rows
.bind(&metadata_json)
.bind(&now)
.bind(&now)
@@ -119,7 +125,12 @@ pub async fn update_product(
sets.push("description = ?");
}
if price_sats.is_some() {
// Dual-write so SAT-currency products keep `price_value`
// in sync. Fiat-priced products will use a separate
// `update_product_currency_value` (lands with the admin
// UI for fiat pricing).
sets.push("price_sats = ?");
sets.push("price_value = ?");
}
if sets.is_empty() {
return get_product_by_id(pool, id)
@@ -138,6 +149,7 @@ pub async fn update_product(
}
if let Some(v) = price_sats {
q = q.bind(v);
q = q.bind(v); // for the paired price_value placeholder
}
q = q.bind(&now).bind(id);
let rows = q.execute(pool).await?.rows_affected();
@@ -153,12 +165,25 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
let metadata_json: String = row.try_get("metadata_json")?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
let active_int: i64 = row.try_get("active")?;
// The new currency columns landed in migration 0010. try_get is
// tolerant — if a row predates 0010 (shouldn't happen since the
// migration always runs at boot, but this is defensive), fall
// back to the SAT defaults so callers get well-formed rows.
let price_currency: String = row
.try_get("price_currency")
.unwrap_or_else(|_| "SAT".to_string());
let price_sats_value: i64 = row.try_get("price_sats")?;
let price_value: i64 = row
.try_get("price_value")
.unwrap_or(price_sats_value);
Ok(Product {
id: row.try_get("id")?,
slug: row.try_get("slug")?,
name: row.try_get("name")?,
description: row.try_get("description")?,
price_sats: row.try_get("price_sats")?,
price_sats: price_sats_value,
price_currency,
price_value,
active: active_int != 0,
metadata,
created_at: row.try_get("created_at")?,