diff --git a/licensing-service/src/api/admin.rs b/licensing-service/src/api/admin.rs index 48708ef..068883b 100644 --- a/licensing-service/src/api/admin.rs +++ b/licensing-service/src/api/admin.rs @@ -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, #[serde(default)] pub price_sats: Option, + #[serde(default)] + pub price_currency: Option, + #[serde(default)] + pub price_value: Option, } pub async fn update_product( @@ -404,17 +416,65 @@ pub async fn update_product( ) -> AppResult> { 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( diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 67e7025..330982c 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -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, +) -> AppResult { + 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 { 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(); diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 4c0696c..477d477 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -1087,8 +1087,9 @@ The request will be refused if there are licenses or invoices tied to it — use // -------- Products -------- // Edit-product modal. Opens when the operator clicks Edit on a product - // row. Mutable: name, description, price_sats. Slug is intentionally not - // editable (it's part of the public buy URL — changing breaks bookmarks). + // row. Mutable: name, description, price (currency + value). Slug is + // intentionally not editable (it's part of the public buy URL — + // changing it would break bookmarks). function openEditProduct(p) { const overlay = el('div', { 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 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 card = el('div', { 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.'), nameField, 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, el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [ el('button', { class: 'btn primary', onclick: async function () { status.textContent = 'Saving…' try { + const currency = curPicker.value + const rawValue = parseFloat(priceInput.value) || 0 + const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100) const body = { name: card.querySelector('[name=e_p_name]').value.trim(), 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 }) overlay.remove()