diff --git a/licensing-service/migrations/0010_multi_currency.sql b/licensing-service/migrations/0010_multi_currency.sql new file mode 100644 index 0000000..6a1a82e --- /dev/null +++ b/licensing-service/migrations/0010_multi_currency.sql @@ -0,0 +1,110 @@ +-- 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 +-- " 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); diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index e124c75..c4d61e5 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -14,10 +14,10 @@ use uuid::Uuid; pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult> { 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 AppResult> { 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 AppResult> { 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 { 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")?, diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 20a102b..f62f3c1 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -8,7 +8,22 @@ pub struct Product { pub slug: String, pub name: String, pub description: String, + /// Sat-denominated price. For SAT-currency products this equals + /// `price_value`. For fiat-priced products (USD, EUR, etc.) this + /// is a snapshot from the most recent invoice creation against + /// the product, kept for back-compat with v0.1 SDKs and admin UI + /// that haven't migrated to the typed currency view yet. The + /// canonical price is `price_currency` + `price_value`. pub price_sats: i64, + /// Operator-facing currency: 'SAT', 'BTC', 'USD', 'EUR' (and + /// future ISO 4217 codes). Defaults to 'SAT' for products + /// created before v0.1.0:48 / migration 0010. + #[serde(default = "default_currency")] + pub price_currency: String, + /// Price in the smallest indivisible unit of `price_currency`: + /// sats for SAT/BTC, cents for USD/EUR. + #[serde(default)] + pub price_value: i64, pub active: bool, /// Arbitrary JSON metadata the developer can attach. pub metadata: serde_json::Value, @@ -16,6 +31,10 @@ pub struct Product { pub updated_at: String, } +fn default_currency() -> String { + "SAT".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum InvoiceStatus { diff --git a/licensing-service/tests/migrations.rs b/licensing-service/tests/migrations.rs index 77db459..15735bb 100644 --- a/licensing-service/tests/migrations.rs +++ b/licensing-service/tests/migrations.rs @@ -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, Option, Option) = 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