Multi-currency Phase 2 — admin write path (currency picker)

Backend:
- POST /v1/admin/products accepts both forms:
  - legacy: { price_sats: 50000 }
  - typed:  { price_currency: 'USD', price_value: 4900 }
  Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
  to catch half-migrated clients sending stale price_sats alongside
  fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
  price_value; USD/EUR → price_sats = 0 until first invoice creation
  triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
  pins 6 happy/sad paths.

Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
  Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
  products show "$49.00" with optional "≈ 75k sats" hint.

Test count: 34 (was 33).
This commit is contained in:
Grant
2026-05-08 12:11:36 -05:00
parent 201c081009
commit 356d17fdde
4 changed files with 370 additions and 29 deletions
+90 -6
View File
@@ -74,11 +74,89 @@ pub struct CreateProductReq {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub description: String, 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<i64>,
/// 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<String>,
/// 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<i64>,
#[serde(default)] #[serde(default)]
pub metadata: Value, 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( pub async fn create_product(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -88,20 +166,26 @@ pub async fn create_product(
let (ip, ua) = request_context(&headers); let (ip, ua) = request_context(&headers);
// Tier-cap gate: Creator caps at 5 products. 402 if over. // Tier-cap gate: Creator caps at 5 products. 402 if over.
crate::api::tier::enforce_product_cap(&state).await?; 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() { let metadata = if req.metadata.is_null() {
json!({}) json!({})
} else { } else {
req.metadata req.metadata
}; };
let product = repo::create_product( let product = repo::create_product_with_currency(
&state.db, &state.db,
&req.slug, &req.slug,
&req.name, &req.name,
&req.description, &req.description,
req.price_sats, &price_currency,
price_value,
&metadata, &metadata,
) )
.await?; .await?;
+53
View File
@@ -91,6 +91,59 @@ pub async fn create_product(
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created product not found"))) .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<Product> {
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<()> { pub async fn set_product_active(pool: &SqlitePool, id: &str, active: bool) -> AppResult<()> {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let rows = sqlx::query("UPDATE products SET active = ?, updated_at = ? WHERE id = ?") let rows = sqlx::query("UPDATE products SET active = ?, updated_at = ? WHERE id = ?")
+134
View File
@@ -1139,6 +1139,140 @@ async fn recover_returns_license_key_for_matching_pair() {
assert_eq!(audit_count, 1, "recovery must write an audit row"); 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. /// Community analytics: opt-in toggle + privacy contract.
/// ///
/// Locks in two invariants: /// Locks in two invariants:
+78 -8
View File
@@ -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) 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() { async function copyPubkey() {
const span = document.getElementById('pubkey-preview') const span = document.getElementById('pubkey-preview')
const k = span.dataset.full 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') const target = document.getElementById('route-target')
target.innerHTML = '' 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' }, [ const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'), el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [ el('div', { class: 'body' }, [
formInput('slug', 'Slug', { required: true, hint: 'lowercase, hyphens, e.g. "bitcoin-ticker-pro"' }), formInput('slug', 'Slug', { required: true, hint: 'lowercase, hyphens, e.g. "bitcoin-ticker-pro"' }),
formInput('name', 'Display name', { required: true }), formInput('name', 'Display name', { required: true }),
formInput('description', 'Description', { textarea: true }), formInput('description', 'Description', { textarea: true }),
formInput('price_sats', 'Price (sats)', { type: 'number', required: true, value: '50000' }), el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
el('button', { class: 'btn primary', onclick: async function () { 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…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status) create.querySelector('.body').appendChild(status)
try { 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: { await api('/v1/admin/products', { method: 'POST', body: {
slug: create.querySelector('[name=slug]').value.trim(), slug: create.querySelector('[name=slug]').value.trim(),
name: create.querySelector('[name=name]').value.trim(), name: create.querySelector('[name=name]').value.trim(),
description: create.querySelector('[name=description]').value || '', description: create.querySelector('[name=description]').value || '',
price_sats: parseInt(create.querySelector('[name=price_sats]').value, 10), price_currency: currency,
price_value: priceValue,
metadata: {}, metadata: {},
}}) }})
status.replaceWith(ok('Created. Reloading…')) status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.products, 600) setTimeout(routes.products, 600)
} catch (e) { } catch (e) {
// Tier-cap 402 → upgrade modal; everything else → inline status pill.
if (handleTierCap(e)) status.remove() if (handleTierCap(e)) status.remove()
else status.replaceWith(err(e.message)) else status.replaceWith(err(e.message))
} }
}}, 'Create product'), })
]), return btn
})(),
].filter(Boolean)),
]) ])
target.appendChild(plainCard([ target.appendChild(plainCard([
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'About'), 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, [ const rows = products.map((p) => el('tr', null, [
el('td', null, el('code', null, p.slug)), el('td', null, el('code', null, p.slug)),
el('td', { style: 'font-weight:600; color:var(--navy-950)' }, p.name), 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, el('span', { class: 'muted' }, String(byProduct[p.id] || 0))),
el('td', null, activePill(p.active)), el('td', null, activePill(p.active)),
el('td', { class: 'muted' }, fmtDate(p.created_at)), el('td', { class: 'muted' }, fmtDate(p.created_at)),