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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user