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:
@@ -386,6 +386,14 @@ pub async fn delete_product(
|
|||||||
|
|
||||||
/// Patch mutable fields on a product. Slug is NOT editable — it's part
|
/// Patch mutable fields on a product. Slug is NOT editable — it's part
|
||||||
/// of the public buy URL.
|
/// 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateProductReq {
|
pub struct UpdateProductReq {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -394,6 +402,10 @@ pub struct UpdateProductReq {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub price_sats: Option<i64>,
|
pub price_sats: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub price_currency: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub price_value: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_product(
|
pub async fn update_product(
|
||||||
@@ -404,17 +416,65 @@ pub async fn update_product(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let actor_hash = require_admin(&state, &headers)?;
|
let actor_hash = require_admin(&state, &headers)?;
|
||||||
let (ip, ua) = request_context(&headers);
|
let (ip, ua) = request_context(&headers);
|
||||||
if let Some(p) = req.price_sats {
|
|
||||||
if p < 0 {
|
// Resolve the pricing patch into (currency, value, sats) tuple
|
||||||
return Err(AppError::BadRequest("price_sats must be >= 0".into()));
|
// 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))
|
||||||
}
|
}
|
||||||
}
|
// Legacy SAT-only.
|
||||||
let updated = repo::update_product(
|
(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,
|
&state.db,
|
||||||
&id,
|
&id,
|
||||||
req.name.as_deref(),
|
req.name.as_deref(),
|
||||||
req.description.as_deref(),
|
req.description.as_deref(),
|
||||||
req.price_sats,
|
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
|
|||||||
@@ -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
|
/// Patch mutable fields on a product. `slug` and `id` are intentionally
|
||||||
/// not editable — slug is part of the public buy URL, and changing it
|
/// not editable — slug is part of the public buy URL, and changing it
|
||||||
/// would break links operators have shared. Each Option is "Some →
|
/// 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(
|
pub async fn update_product(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
id: &str,
|
id: &str,
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
price_sats: Option<i64>,
|
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> {
|
) -> AppResult<Product> {
|
||||||
let mut sets: Vec<&str> = Vec::new();
|
let mut sets: Vec<&str> = Vec::new();
|
||||||
if name.is_some() {
|
if name.is_some() {
|
||||||
@@ -177,12 +201,12 @@ pub async fn update_product(
|
|||||||
if description.is_some() {
|
if description.is_some() {
|
||||||
sets.push("description = ?");
|
sets.push("description = ?");
|
||||||
}
|
}
|
||||||
if price_sats.is_some() {
|
// Pricing patch writes to all three columns (price_sats,
|
||||||
// Dual-write so SAT-currency products keep `price_value`
|
// price_currency, price_value) so the row is internally
|
||||||
// in sync. Fiat-priced products will use a separate
|
// consistent regardless of which currency the operator picks.
|
||||||
// `update_product_currency_value` (lands with the admin
|
if pricing_patch.is_some() {
|
||||||
// UI for fiat pricing).
|
|
||||||
sets.push("price_sats = ?");
|
sets.push("price_sats = ?");
|
||||||
|
sets.push("price_currency = ?");
|
||||||
sets.push("price_value = ?");
|
sets.push("price_value = ?");
|
||||||
}
|
}
|
||||||
if sets.is_empty() {
|
if sets.is_empty() {
|
||||||
@@ -200,9 +224,14 @@ pub async fn update_product(
|
|||||||
if let Some(v) = description {
|
if let Some(v) = description {
|
||||||
q = q.bind(v);
|
q = q.bind(v);
|
||||||
}
|
}
|
||||||
if let Some(v) = price_sats {
|
if let Some((currency, value)) = pricing_patch {
|
||||||
q = q.bind(v);
|
// For SAT, price_sats == price_value; for fiat, price_sats
|
||||||
q = q.bind(v); // for the paired price_value placeholder
|
// 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);
|
q = q.bind(&now).bind(id);
|
||||||
let rows = q.execute(pool).await?.rows_affected();
|
let rows = q.execute(pool).await?.rows_affected();
|
||||||
|
|||||||
@@ -1087,8 +1087,9 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
|
|
||||||
// -------- Products --------
|
// -------- Products --------
|
||||||
// Edit-product modal. Opens when the operator clicks Edit on a product
|
// Edit-product modal. Opens when the operator clicks Edit on a product
|
||||||
// row. Mutable: name, description, price_sats. Slug is intentionally not
|
// row. Mutable: name, description, price (currency + value). Slug is
|
||||||
// editable (it's part of the public buy URL — changing breaks bookmarks).
|
// intentionally not editable (it's part of the public buy URL —
|
||||||
|
// changing it would break bookmarks).
|
||||||
function openEditProduct(p) {
|
function openEditProduct(p) {
|
||||||
const overlay = el('div', {
|
const overlay = el('div', {
|
||||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||||
@@ -1096,7 +1097,41 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
})
|
})
|
||||||
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
|
||||||
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
|
||||||
const priceField = formInput('e_p_price', 'Price (sats)', { type: 'number', value: String(p.price_sats || 0), required: true })
|
|
||||||
|
// Currency-aware price inputs. For SAT-currency products, show
|
||||||
|
// the integer sat amount. For USD/EUR, render the cents value
|
||||||
|
// back to a decimal main-unit string ($49.00) and accept
|
||||||
|
// decimals on save.
|
||||||
|
const initialCurrency = (p.price_currency || 'SAT').toUpperCase()
|
||||||
|
const initialDisplay = initialCurrency === 'SAT'
|
||||||
|
? String(p.price_value || p.price_sats || 0)
|
||||||
|
: ((p.price_value || 0) / 100).toFixed(2)
|
||||||
|
|
||||||
|
const curPicker = el('select', { class: 'input', name: 'e_p_currency' }, [
|
||||||
|
el('option', { value: 'SAT' }, 'sats'),
|
||||||
|
el('option', { value: 'USD' }, 'USD ($)'),
|
||||||
|
el('option', { value: 'EUR' }, 'EUR (€)'),
|
||||||
|
])
|
||||||
|
curPicker.value = initialCurrency
|
||||||
|
const priceInput = el('input', {
|
||||||
|
class: 'input', name: 'e_p_price', type: 'number',
|
||||||
|
step: initialCurrency === 'SAT' ? '1' : '0.01',
|
||||||
|
min: '0', value: initialDisplay, required: 'required',
|
||||||
|
})
|
||||||
|
curPicker.addEventListener('change', () => {
|
||||||
|
priceInput.step = curPicker.value === 'SAT' ? '1' : '0.01'
|
||||||
|
// Don't auto-clobber the value — let the operator decide if
|
||||||
|
// the displayed number still makes sense in the new unit.
|
||||||
|
// Show a hint instead.
|
||||||
|
hint.textContent = curPicker.value === 'SAT'
|
||||||
|
? 'sats — whole numbers only.'
|
||||||
|
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.'
|
||||||
|
})
|
||||||
|
const hint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' },
|
||||||
|
initialCurrency === 'SAT'
|
||||||
|
? 'sats — whole numbers only.'
|
||||||
|
: 'Decimal entry, e.g. 49.00. Converted to BTC at each invoice using the daemon\'s rate fetcher.')
|
||||||
|
|
||||||
const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '')
|
const status = el('div', { class: 'muted', style: 'margin-top:6px; font-size:12.5px;' }, '')
|
||||||
const card = el('div', {
|
const card = el('div', {
|
||||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||||
@@ -1109,16 +1144,22 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
'Slug is not editable — it is part of your public /buy/' + p.slug + ' URL. Disable + create a new product if you need to rename.'),
|
'Slug is not editable — it is part of your public /buy/' + p.slug + ' URL. Disable + create a new product if you need to rename.'),
|
||||||
nameField,
|
nameField,
|
||||||
descField,
|
descField,
|
||||||
priceField,
|
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
|
||||||
|
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]),
|
||||||
|
hint,
|
||||||
status,
|
status,
|
||||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||||||
el('button', { class: 'btn primary', onclick: async function () {
|
el('button', { class: 'btn primary', onclick: async function () {
|
||||||
status.textContent = 'Saving…'
|
status.textContent = 'Saving…'
|
||||||
try {
|
try {
|
||||||
|
const currency = curPicker.value
|
||||||
|
const rawValue = parseFloat(priceInput.value) || 0
|
||||||
|
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
|
||||||
const body = {
|
const body = {
|
||||||
name: card.querySelector('[name=e_p_name]').value.trim(),
|
name: card.querySelector('[name=e_p_name]').value.trim(),
|
||||||
description: card.querySelector('[name=e_p_description]').value || '',
|
description: card.querySelector('[name=e_p_description]').value || '',
|
||||||
price_sats: Math.max(0, parseInt(card.querySelector('[name=e_p_price]').value, 10) || 0),
|
price_currency: currency,
|
||||||
|
price_value: Math.max(0, priceValue),
|
||||||
}
|
}
|
||||||
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
|
||||||
overlay.remove()
|
overlay.remove()
|
||||||
|
|||||||
Reference in New Issue
Block a user