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
+93 -23
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)
}
// 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)),