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