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:
@@ -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
|
||||
-- "<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);
|
||||
@@ -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")?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user