diff --git a/licensing-service/src/api/admin.rs b/licensing-service/src/api/admin.rs index d3c25ce..48708ef 100644 --- a/licensing-service/src/api/admin.rs +++ b/licensing-service/src/api/admin.rs @@ -74,11 +74,89 @@ pub struct CreateProductReq { pub name: String, #[serde(default)] pub description: String, - pub price_sats: i64, + /// Legacy SAT-only price. Optional now; if `price_currency` + + /// `price_value` are supplied, they take precedence. Old SDK + /// callers and the existing admin UI keep using this field + /// without changes. + #[serde(default)] + pub price_sats: Option, + /// New canonical currency. 'SAT' (default), 'USD', or 'EUR'. + /// 'BTC' is intentionally not yet a separate currency code — + /// pricing in BTC is just SAT pricing with a different display. + /// Future v0.3+ may add it as a display alias. + #[serde(default)] + pub price_currency: Option, + /// Price in the smallest indivisible unit of `price_currency`: + /// sats for SAT, cents for USD/EUR. Required when + /// `price_currency` is supplied; ignored otherwise. + #[serde(default)] + pub price_value: Option, #[serde(default)] pub metadata: Value, } +/// Currencies the admin endpoints accept. Whitelist enforced here so +/// a typo or future code error can't write a product with a bogus +/// currency tag that the daemon doesn't know how to convert. +const ACCEPTED_CURRENCIES: &[&str] = &["SAT", "USD", "EUR"]; + +/// Validate + normalize the request's price representation. Returns +/// `(currency, value_in_smallest_unit)`. Errors with 400 on: +/// - both `price_sats` and `price_currency` missing +/// - non-positive value +/// - unknown currency code +/// - both forms supplied with mismatched values (catches half- +/// migrated clients that send stale `price_sats` alongside a +/// fresh `price_value`) +fn resolve_price(req: &CreateProductReq) -> AppResult<(String, i64)> { + match (req.price_currency.as_deref(), req.price_value, req.price_sats) { + // Typed form — preferred. + (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 positive".into())); + } + // If the legacy field was ALSO sent, only accept it if + // the currency is SAT and the numbers match. Anything + // else means the client sent inconsistent state. + 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(), + )); + } + } + Ok((cur, value)) + } + // Legacy form — back-compat. + (None, None, Some(sats)) => { + if sats <= 0 { + return Err(AppError::BadRequest("price_sats must be positive".into())); + } + Ok(("SAT".to_string(), sats)) + } + // Currency without value — incomplete. + (Some(_), None, _) => Err(AppError::BadRequest( + "price_currency was supplied but price_value is missing".into(), + )), + // Value without currency — ambiguous. + (None, Some(_), _) => Err(AppError::BadRequest( + "price_value was supplied but price_currency is missing".into(), + )), + // Nothing. + (None, None, None) => Err(AppError::BadRequest( + "must supply either price_sats (legacy) or price_currency + price_value".into(), + )), + } +} + pub async fn create_product( State(state): State, headers: HeaderMap, @@ -88,20 +166,26 @@ pub async fn create_product( let (ip, ua) = request_context(&headers); // Tier-cap gate: Creator caps at 5 products. 402 if over. crate::api::tier::enforce_product_cap(&state).await?; - if req.price_sats <= 0 { - return Err(AppError::BadRequest("price_sats must be positive".into())); - } + + // Resolve the typed-currency form and the legacy form into a + // single (currency, value) pair before hitting the repo. New + // callers send price_currency + price_value; legacy callers + // send price_sats alone; sending both is allowed only if the + // currency is SAT and the values match (catches mismatched + // updates from a half-migrated client). + let (price_currency, price_value) = resolve_price(&req)?; let metadata = if req.metadata.is_null() { json!({}) } else { req.metadata }; - let product = repo::create_product( + let product = repo::create_product_with_currency( &state.db, &req.slug, &req.name, &req.description, - req.price_sats, + &price_currency, + price_value, &metadata, ) .await?; diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index c4d61e5..1000174 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -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 { + 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 = ?") diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 136a5c8..02e91fa 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1139,6 +1139,140 @@ async fn recover_returns_license_key_for_matching_pair() { assert_eq!(audit_count, 1, "recovery must write an audit row"); } +/// Multi-currency product creation. The admin endpoint accepts both +/// the legacy SAT-only form (`price_sats: N`) and the new typed form +/// (`price_currency + price_value`). Verifies: +/// - legacy form still works, produces a SAT-currency row +/// - typed SAT form works, dual-writes price_sats correctly +/// - typed USD form works, leaves price_sats=0 (filled at invoice time) +/// - unknown currency code → 400 +/// - inconsistent legacy + typed values → 400 (catches half-migrated clients) +/// - typed without value → 400; value without currency → 400 +#[tokio::test] +async fn admin_create_product_accepts_legacy_and_typed_currency_forms() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Legacy SAT form. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({"slug": "legacy", "name": "Legacy", "price_sats": 50000})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["price_sats"], 50_000); + assert_eq!(body["price_currency"], "SAT"); + assert_eq!(body["price_value"], 50_000); + + // Typed SAT form. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "typed-sat", + "name": "Typed SAT", + "price_currency": "SAT", + "price_value": 75000, + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["price_sats"], 75_000); + assert_eq!(body["price_currency"], "SAT"); + assert_eq!(body["price_value"], 75_000); + + // Typed USD form: $49.00 = 4900 cents. price_sats stays 0 until + // the first invoice triggers a rate lookup. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "typed-usd", + "name": "Typed USD", + "price_currency": "USD", + "price_value": 4900, + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["price_currency"], "USD"); + assert_eq!(body["price_value"], 4900); + assert_eq!( + body["price_sats"], 0, + "USD products should have price_sats=0 until first invoice rate-converts them" + ); + + // Bad currency. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "bad-currency", + "name": "Bad", + "price_currency": "GBP", + "price_value": 100, + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Inconsistent legacy + typed (catches half-migrated clients). + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "inconsistent", + "name": "Inconsistent", + "price_sats": 50000, + "price_currency": "USD", + "price_value": 4900, + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "mismatched legacy + typed pricing should 400" + ); + + // Half-form: currency without value. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "half-1", + "name": "Half 1", + "price_currency": "USD", + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Half-form: value without currency. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ + "slug": "half-2", + "name": "Half 2", + "price_value": 4900, + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + /// Community analytics: opt-in toggle + privacy contract. /// /// Locks in two invariants: diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index a2ed615..13f8848 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -1021,6 +1021,29 @@ The request will be refused if there are licenses or invoices tied to it — use if (s.install_uuid) card.appendChild(resetBtn) } + // Render a product's price for table cells. Picks the right + // unit + format based on price_currency. SAT-priced shows + // "50,000 sats"; USD-priced shows "$49.00 ≈ 75k sats" if the + // sat amount has been pinned (after first invoice), or just + // "$49.00" if not yet quoted. + function formatProductPrice(p) { + const currency = (p.price_currency || 'SAT').toUpperCase() + if (currency === 'SAT') { + return (p.price_sats || p.price_value || 0).toLocaleString() + ' sats' + } + const symbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : '' + const amount = (p.price_value || 0) / 100 // cents → main unit + const main = symbol + amount.toFixed(2) + (symbol ? '' : ' ' + currency) + if (p.price_sats && p.price_sats > 0) { + // Sat amount has been pinned by a prior invoice; show as a hint. + const sats = p.price_sats >= 1000 + ? Math.round(p.price_sats / 1000) + 'k' + : String(p.price_sats) + return el('span', null, [main, el('span', { class: 'muted', style: 'font-size:11px; margin-left:6px' }, '≈ ' + sats + ' sats')]) + } + return main + } + async function copyPubkey() { const span = document.getElementById('pubkey-preview') const k = span.dataset.full @@ -1088,34 +1111,81 @@ The request will be refused if there are licenses or invoices tied to it — use const target = document.getElementById('route-target') target.innerHTML = '' - // Create form + // Create form. Currency picker swaps the price-input units in + // place: SAT → integer sats, USD/EUR → dollar/euro amount which + // we convert to cents on the way out (the backend stores + // smallest-unit-of-currency). + const currencyPicker = el('select', { class: 'input' }, [ + el('option', { value: 'SAT' }, 'sats'), + el('option', { value: 'USD' }, 'USD ($)'), + el('option', { value: 'EUR' }, 'EUR (€)'), + ]) + const priceInput = el('input', { + class: 'input', name: 'price_input', type: 'number', + step: '1', min: '0', value: '50000', required: 'required', + }) + const priceHint = el('div', { class: 'muted', style: 'font-size:12px; margin-top:4px' }, + 'sats — whole numbers only.') + currencyPicker.addEventListener('change', () => { + if (currencyPicker.value === 'SAT') { + priceInput.step = '1' + priceInput.value = '50000' + priceHint.textContent = 'sats — whole numbers only.' + } else { + priceInput.step = '0.01' + priceInput.value = '49.00' + priceHint.textContent = + currencyPicker.value === 'USD' + ? 'Dollars — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.' + : 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.' + } + }) const create = el('details', { class: 'disclosure' }, [ el('summary', null, 'Create a new product'), el('div', { class: 'body' }, [ formInput('slug', 'Slug', { required: true, hint: 'lowercase, hyphens, e.g. "bitcoin-ticker-pro"' }), formInput('name', 'Display name', { required: true }), formInput('description', 'Description', { textarea: true }), - formInput('price_sats', 'Price (sats)', { type: 'number', required: true, value: '50000' }), - el('button', { class: 'btn primary', onclick: async function () { - const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') - create.querySelector('.body').appendChild(status) - try { - await api('/v1/admin/products', { method: 'POST', body: { - slug: create.querySelector('[name=slug]').value.trim(), - name: create.querySelector('[name=name]').value.trim(), - description: create.querySelector('[name=description]').value || '', - price_sats: parseInt(create.querySelector('[name=price_sats]').value, 10), - metadata: {}, - }}) - status.replaceWith(ok('Created. Reloading…')) - setTimeout(routes.products, 600) - } catch (e) { - // Tier-cap 402 → upgrade modal; everything else → inline status pill. - if (handleTierCap(e)) status.remove() - else status.replaceWith(err(e.message)) - } - }}, 'Create product'), - ]), + 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, + currencyPicker, + ]), + priceHint, + el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener + ? null : null, // dummy; the real button is below for clarity + (() => { + const btn = el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product') + btn.addEventListener('click', async () => { + const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') + create.querySelector('.body').appendChild(status) + try { + const currency = currencyPicker.value + const rawValue = parseFloat(priceInput.value) + if (!Number.isFinite(rawValue) || rawValue <= 0) { + throw new Error('Price must be a positive number.') + } + // SAT/BTC are sat-denominated already; USD/EUR are + // entered as decimal amounts and converted to cents. + const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100) + await api('/v1/admin/products', { method: 'POST', body: { + slug: create.querySelector('[name=slug]').value.trim(), + name: create.querySelector('[name=name]').value.trim(), + description: create.querySelector('[name=description]').value || '', + price_currency: currency, + price_value: priceValue, + metadata: {}, + }}) + status.replaceWith(ok('Created. Reloading…')) + setTimeout(routes.products, 600) + } catch (e) { + if (handleTierCap(e)) status.remove() + else status.replaceWith(err(e.message)) + } + }) + return btn + })(), + ].filter(Boolean)), ]) target.appendChild(plainCard([ el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'About'), @@ -1134,7 +1204,7 @@ The request will be refused if there are licenses or invoices tied to it — use const rows = products.map((p) => el('tr', null, [ el('td', null, el('code', null, p.slug)), el('td', { style: 'font-weight:600; color:var(--navy-950)' }, p.name), - el('td', null, (p.price_sats || 0).toLocaleString() + ' sats'), + el('td', null, formatProductPrice(p)), el('td', null, el('span', { class: 'muted' }, String(byProduct[p.id] || 0))), el('td', null, activePill(p.active)), el('td', { class: 'muted' }, fmtDate(p.created_at)),