Edit-product currency support — operators can switch SAT ↔ USD/EUR in place
Closes the last multi-currency gap before v0.2.0:0 cutover. Operators who created a product in one currency can now switch to another via the Edit modal — no need to disable + recreate. Backend: - PATCH /v1/admin/products/:id accepts price_currency + price_value alongside the legacy price_sats. Same validation shape as the create endpoint (whitelist SAT|USD|EUR, mismatched legacy + typed → 400). - repo::update_product_with_currency replaces the SAT-only update_product as the canonical entry; the SAT-only function is now a thin wrapper that always passes "SAT". For SAT updates, price_sats and price_value are dual-written. For fiat updates, price_sats is reset to 0 — gets repopulated by the rate fetcher on the next invoice creation against the product. Frontend (Products → Edit modal): - Currency picker dropdown next to the price input. Initial value reads from the product's current currency. - For fiat products, the displayed price renders as decimal main units ($49.00); save converts to cents on the way out. - Hint text + step swap as the operator changes currency. - Doesn't auto-clobber the displayed value when currency changes — operator decides if the same number still makes sense. No schema changes (column shape from migration 0010 is sufficient). Test count unchanged at 38 — pure handler + UI work, behavior covered by the existing currency tests on create.
This commit is contained in:
@@ -162,13 +162,37 @@ pub async fn set_product_active(pool: &SqlitePool, id: &str, active: bool) -> Ap
|
||||
/// Patch mutable fields on a product. `slug` and `id` are intentionally
|
||||
/// not editable — slug is part of the public buy URL, and changing it
|
||||
/// would break links operators have shared. Each Option is "Some →
|
||||
/// update, None → leave alone."
|
||||
/// update, None → leave alone." Pricing patch goes through
|
||||
/// `update_product_with_currency`; this is the legacy SAT-only entry.
|
||||
pub async fn update_product(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
price_sats: Option<i64>,
|
||||
) -> AppResult<Product> {
|
||||
update_product_with_currency(
|
||||
pool,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price_sats.map(|s| ("SAT", s)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Currency-aware update_product. The pricing patch is `(currency, value)`;
|
||||
/// `value` is in the smallest indivisible unit of `currency` (sats for
|
||||
/// SAT, cents for USD/EUR). For SAT-currency updates `price_sats` is
|
||||
/// also written (keeping the legacy column in sync). For fiat updates
|
||||
/// `price_sats` is set to 0 — the next invoice creation will populate
|
||||
/// it via the rate fetcher.
|
||||
pub async fn update_product_with_currency(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
pricing_patch: Option<(&str, i64)>,
|
||||
) -> AppResult<Product> {
|
||||
let mut sets: Vec<&str> = Vec::new();
|
||||
if name.is_some() {
|
||||
@@ -177,12 +201,12 @@ pub async fn update_product(
|
||||
if description.is_some() {
|
||||
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).
|
||||
// Pricing patch writes to all three columns (price_sats,
|
||||
// price_currency, price_value) so the row is internally
|
||||
// consistent regardless of which currency the operator picks.
|
||||
if pricing_patch.is_some() {
|
||||
sets.push("price_sats = ?");
|
||||
sets.push("price_currency = ?");
|
||||
sets.push("price_value = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
@@ -200,9 +224,14 @@ pub async fn update_product(
|
||||
if let Some(v) = description {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = price_sats {
|
||||
q = q.bind(v);
|
||||
q = q.bind(v); // for the paired price_value placeholder
|
||||
if let Some((currency, value)) = pricing_patch {
|
||||
// For SAT, price_sats == price_value; for fiat, price_sats
|
||||
// is reset to 0 (gets populated by rate fetcher at next
|
||||
// invoice creation).
|
||||
let initial_sats = if currency == "SAT" { value } else { 0 };
|
||||
q = q.bind(initial_sats);
|
||||
q = q.bind(currency);
|
||||
q = q.bind(value);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
let rows = q.execute(pool).await?.rows_affected();
|
||||
|
||||
Reference in New Issue
Block a user