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:
Grant
2026-05-08 13:22:00 -05:00
parent 0dcae66e05
commit 45e0cd2bd1
3 changed files with 150 additions and 20 deletions
+66 -6
View File
@@ -386,6 +386,14 @@ pub async fn delete_product(
/// Patch mutable fields on a product. Slug is NOT editable — it's part
/// of the public buy URL.
///
/// Two pricing forms accepted, mirroring the create endpoint:
/// - Legacy: `price_sats` alone (treated as a SAT-currency update).
/// - Typed: `price_currency` + `price_value`. Either both or neither.
/// Sending a different currency than the product's current one
/// IS allowed — operators can convert a SAT product to USD pricing
/// in place. The daemon doesn't auto-recompute the sat-equivalent
/// for past invoices; future invoices use the new currency.
#[derive(Debug, Deserialize)]
pub struct UpdateProductReq {
#[serde(default)]
@@ -394,6 +402,10 @@ pub struct UpdateProductReq {
pub description: Option<String>,
#[serde(default)]
pub price_sats: Option<i64>,
#[serde(default)]
pub price_currency: Option<String>,
#[serde(default)]
pub price_value: Option<i64>,
}
pub async fn update_product(
@@ -404,17 +416,65 @@ pub async fn update_product(
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if let Some(p) = req.price_sats {
if p < 0 {
return Err(AppError::BadRequest("price_sats must be >= 0".into()));
// Resolve the pricing patch into (currency, value, sats) tuple
// before passing to the repo. This mirrors the create-side
// `resolve_price` validation so the same accept-both-forms
// semantics apply on PATCH.
let pricing_patch: Option<(String, i64)> = match (
req.price_currency.as_deref(),
req.price_value,
req.price_sats,
) {
// Typed form
(Some(cur), Some(value), maybe_legacy) => {
let cur = cur.to_uppercase();
if !ACCEPTED_CURRENCIES.iter().any(|c| *c == cur) {
return Err(AppError::BadRequest(format!(
"unsupported price_currency '{cur}'; accepted: {}",
ACCEPTED_CURRENCIES.join(", ")
)));
}
if value < 0 {
return Err(AppError::BadRequest("price_value must be >= 0".into()));
}
if let Some(legacy) = maybe_legacy {
if cur != "SAT" || legacy != value {
return Err(AppError::BadRequest(
"send price_currency + price_value, OR price_sats alone — \
not both with mismatched values".into(),
));
}
}
Some((cur, value))
}
}
let updated = repo::update_product(
// Legacy SAT-only.
(None, None, Some(sats)) => {
if sats < 0 {
return Err(AppError::BadRequest("price_sats must be >= 0".into()));
}
Some(("SAT".to_string(), sats))
}
(Some(_), None, _) => {
return Err(AppError::BadRequest(
"price_currency was supplied but price_value is missing".into(),
));
}
(None, Some(_), _) => {
return Err(AppError::BadRequest(
"price_value was supplied but price_currency is missing".into(),
));
}
// No pricing change — nothing to validate.
(None, None, None) => None,
};
let updated = repo::update_product_with_currency(
&state.db,
&id,
req.name.as_deref(),
req.description.as_deref(),
req.price_sats,
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
)
.await?;
let _ = repo::insert_audit(
+38 -9
View File
@@ -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();