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:
@@ -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?;
|
||||||
|
|||||||
@@ -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 = ?")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user