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:
@@ -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