Multi-currency Phase 2 — admin write path (currency picker)
Backend:
- POST /v1/admin/products accepts both forms:
- legacy: { price_sats: 50000 }
- typed: { price_currency: 'USD', price_value: 4900 }
Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
to catch half-migrated clients sending stale price_sats alongside
fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
price_value; USD/EUR → price_sats = 0 until first invoice creation
triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
pins 6 happy/sad paths.
Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
products show "$49.00" with optional "≈ 75k sats" hint.
Test count: 34 (was 33).
This commit is contained in:
@@ -91,6 +91,59 @@ pub async fn create_product(
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created product not found")))
|
||||
}
|
||||
|
||||
/// Currency-aware product creation. Behaviorally equivalent to
|
||||
/// `create_product` for SAT currency (price_sats == price_value);
|
||||
/// for fiat currencies, price_sats is initially 0 and gets
|
||||
/// populated at invoice creation time when the rate fetcher
|
||||
/// converts to BTC.
|
||||
pub async fn create_product_with_currency(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
price_currency: &str,
|
||||
price_value: i64,
|
||||
metadata: &serde_json::Value,
|
||||
) -> AppResult<Product> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let metadata_json = serde_json::to_string(metadata)
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid metadata JSON: {e}")))?;
|
||||
|
||||
// For SAT currency, price_sats and price_value are identical
|
||||
// numbers (sats). For USD/EUR, price_sats is 0 until the first
|
||||
// invoice creation populates it via the rate fetcher.
|
||||
let initial_price_sats = if price_currency == "SAT" { price_value } else { 0 };
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO products (id, slug, name, description, price_sats, \
|
||||
price_currency, price_value, active, metadata_json, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(initial_price_sats)
|
||||
.bind(price_currency)
|
||||
.bind(price_value)
|
||||
.bind(&metadata_json)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(db) if db.is_unique_violation() => {
|
||||
AppError::Conflict(format!("product slug '{slug}' already exists"))
|
||||
}
|
||||
other => AppError::Database(other),
|
||||
})?;
|
||||
|
||||
get_product_by_id(pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created product not found")))
|
||||
}
|
||||
|
||||
pub async fn set_product_active(pool: &SqlitePool, id: &str, active: bool) -> AppResult<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let rows = sqlx::query("UPDATE products SET active = ?, updated_at = ? WHERE id = ?")
|
||||
|
||||
Reference in New Issue
Block a user