diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
index a359e7c..07307a2 100644
--- a/licensing-service/src/api/buy_page.rs
+++ b/licensing-service/src/api/buy_page.rs
@@ -88,11 +88,33 @@ pub async fn render(
.or_else(|| public_policies.first().cloned());
// The price displayed in the cert card on initial render.
- let displayed_price = initial_policy
+ // For SAT-currency products this is straightforward — show
+ // the sat amount. For USD/EUR-priced products we render the
+ // listed amount (e.g. "$49.00") with the unit cell switched
+ // to the currency code instead of "sats". The tier picker
+ // (when multiple policies are public) currently still
+ // formats per-tier prices as sats; that's a v0.3 polish
+ // when we plumb the rate fetcher into the JS render path.
+ let is_fiat = product.price_currency != "SAT";
+ let displayed_price_sats = initial_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats);
- let price_sats_fmt = format_thousands(displayed_price);
+ let (price_sats_fmt, price_unit_label) = if is_fiat {
+ // price_value is in cents (USD/EUR). Render as e.g. "49.00"
+ // for $49.00 — the symbol/code goes in the unit cell.
+ let cents = product.price_value;
+ let formatted = format!("{}.{:02}", cents / 100, (cents.abs() % 100));
+ let unit = match product.price_currency.as_str() {
+ "USD" => "USD".to_string(),
+ "EUR" => "EUR".to_string(),
+ other => other.to_string(),
+ };
+ (formatted, unit)
+ } else {
+ (format_thousands(displayed_price_sats), "sats".to_string())
+ };
+ let _ = displayed_price_sats; // unused on the fiat path
let initial_policy_slug = initial_policy
.as_ref()
.map(|p| p.slug.clone())
@@ -440,7 +462,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
Price
- {price_sats_fmt}sats
+ {price_sats_fmt}{price_unit_label}
@@ -819,6 +841,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
product_slug = product_slug,
product_description = product_description,
price_sats_fmt = price_sats_fmt,
+ price_unit_label = price_unit_label,
tiers_html = tiers_html,
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
tiers_json = tiers_json,
diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs
index cb47ca8..2ce2b39 100644
--- a/licensing-service/src/api/discount_codes.rs
+++ b/licensing-service/src/api/discount_codes.rs
@@ -24,6 +24,12 @@ pub struct CreateDiscountCodeReq {
pub kind: String,
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
pub amount: i64,
+ /// Currency for the `amount` field when `kind` is `fixed_sats`
+ /// or `set_price`. 'SAT' (default), 'USD', or 'EUR'.
+ /// Currency-agnostic for `kind = 'percent'` — basis points apply
+ /// to whatever currency the product is priced in.
+ #[serde(default)]
+ pub discount_currency: Option,
#[serde(default)]
pub max_uses: Option,
/// ISO-8601 RFC3339 UTC timestamp.
@@ -80,11 +86,31 @@ pub async fn create(
None
};
- let code = repo::create_discount_code(
+ // Validate + normalize discount_currency. Accept SAT (default),
+ // USD, EUR. For 'percent' codes the currency is irrelevant (basis
+ // points are unitless) but we still record it so a future audit
+ // can answer "what did the operator INTEND when they created this
+ // code" — operators sometimes use a percent code with a fiat
+ // mental model.
+ let discount_currency = match req.discount_currency.as_deref() {
+ None | Some("") => "SAT".to_string(),
+ Some(c) => {
+ let c = c.to_uppercase();
+ if !matches!(c.as_str(), "SAT" | "USD" | "EUR") {
+ return Err(AppError::BadRequest(format!(
+ "unsupported discount_currency '{c}'; accepted: SAT, USD, EUR"
+ )));
+ }
+ c
+ }
+ };
+
+ let code = repo::create_discount_code_with_currency(
&state.db,
&req.code,
&req.kind,
req.amount,
+ &discount_currency,
req.max_uses,
req.expires_at.as_deref(),
product_id.as_deref(),
diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs
index 4ff0be4..4d78577 100644
--- a/licensing-service/src/api/purchase.rs
+++ b/licensing-service/src/api/purchase.rs
@@ -108,11 +108,45 @@ pub async fn start(
None
};
- // Effective base price: policy override if set, else product price.
- let base_price = chosen_policy
- .as_ref()
- .and_then(|p| p.price_sats_override)
- .unwrap_or(product.price_sats);
+ // Effective base price in sats. For SAT-priced products this is
+ // straightforward (policy override or product.price_sats). For
+ // fiat-priced products (USD, EUR), we convert the listed value
+ // to sats here using the daemon's rate fetcher — the rate gets
+ // recorded on the invoice row below for audit. The CONTRACT to
+ // the buyer is sat-denominated either way; the listed currency
+ // is just the operator's display preference.
+ //
+ // We capture the listed (currency, value) and the rate-source
+ // tuple so the invoice row carries the full audit trail.
+ let mut listed_currency: Option = None;
+ let mut listed_value: Option = None;
+ let mut exchange_rate_centibps: Option = None;
+ let mut exchange_rate_source: Option = None;
+
+ let base_price: i64 = if product.price_currency == "SAT" {
+ chosen_policy
+ .as_ref()
+ .and_then(|p| p.price_sats_override)
+ .unwrap_or(product.price_sats)
+ } else {
+ // Fiat-priced. Use the policy override (in the same currency
+ // as the product) if set, otherwise the product's listed
+ // value. v0.3 will introduce per-policy currency overrides;
+ // for now policies inherit the product's currency.
+ let listed = chosen_policy
+ .as_ref()
+ .and_then(|p| p.price_sats_override) // legacy column; may carry override in fiat units after admin UI lands
+ .unwrap_or(product.price_value);
+ let conversion =
+ crate::rates::convert_to_sats(&state, &product.price_currency, listed)
+ .await
+ .map_err(|e| AppError::Upstream(format!("rate fetch failed: {e:#}")))?;
+ listed_currency = Some(product.price_currency.clone());
+ listed_value = Some(listed);
+ exchange_rate_centibps = conversion.rate_centibps;
+ exchange_rate_source = Some(conversion.source);
+ conversion.sats
+ };
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
@@ -336,7 +370,7 @@ pub async fn start(
// Use internal_id we pre-generated (and baked into the BTCPay
// redirect_url) as the local row id so /v1/purchase/ and
// /thank-you?invoice_id= all resolve to the same row.
- let invoice = match repo::create_invoice(
+ let invoice = match repo::create_invoice_with_currency(
&state.db,
&internal_id,
&created.provider_invoice_id,
@@ -346,6 +380,10 @@ pub async fn start(
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
+ listed_currency.as_deref(),
+ listed_value,
+ exchange_rate_centibps,
+ exchange_rate_source.as_deref(),
)
.await
{
diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs
index 1000174..67e7025 100644
--- a/licensing-service/src/db/repo.rs
+++ b/licensing-service/src/db/repo.rs
@@ -257,13 +257,55 @@ pub async fn create_invoice(
buyer_email: Option<&str>,
buyer_note: Option<&str>,
policy_id: Option<&str>,
+) -> AppResult {
+ create_invoice_with_currency(
+ pool,
+ id,
+ btcpay_invoice_id,
+ product_id,
+ amount_sats,
+ checkout_url,
+ buyer_email,
+ buyer_note,
+ policy_id,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await
+}
+
+/// Currency-aware invoice creation. For SAT-priced products, the
+/// listed_/exchange_ args are all `None` (sat-priced flows have no
+/// rate to record). For fiat-priced products, the caller passes the
+/// listed currency + value (what the buyer SAW) and the rate
+/// (centibps + source) used to convert to BTC. amount_sats is
+/// always the BTC amount the buyer is actually billed.
+#[allow(clippy::too_many_arguments)]
+pub async fn create_invoice_with_currency(
+ pool: &SqlitePool,
+ id: &str,
+ btcpay_invoice_id: &str,
+ product_id: &str,
+ amount_sats: i64,
+ checkout_url: &str,
+ buyer_email: Option<&str>,
+ buyer_note: Option<&str>,
+ policy_id: Option<&str>,
+ listed_currency: Option<&str>,
+ listed_value: Option,
+ exchange_rate_centibps: Option,
+ exchange_rate_source: Option<&str>,
) -> AppResult {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO invoices
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
- amount_sats, checkout_url, policy_id, created_at, updated_at)
- VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)",
+ amount_sats, checkout_url, policy_id,
+ listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source,
+ created_at, updated_at)
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(btcpay_invoice_id)
@@ -273,6 +315,10 @@ pub async fn create_invoice(
.bind(amount_sats)
.bind(checkout_url)
.bind(policy_id)
+ .bind(listed_currency)
+ .bind(listed_value)
+ .bind(exchange_rate_centibps)
+ .bind(exchange_rate_source)
.bind(&now)
.bind(&now)
.execute(pool)
@@ -1714,6 +1760,45 @@ pub async fn create_discount_code(
applies_to_policy_id: Option<&str>,
referrer_label: Option<&str>,
description: &str,
+) -> AppResult {
+ create_discount_code_with_currency(
+ pool,
+ code,
+ kind,
+ amount,
+ "SAT",
+ max_uses,
+ expires_at,
+ applies_to_product_id,
+ applies_to_policy_id,
+ referrer_label,
+ description,
+ )
+ .await
+}
+
+/// Currency-aware discount code creation. `discount_currency` is
+/// 'SAT' (default), 'USD', or 'EUR' — interpretation depends on
+/// `kind`:
+/// - 'percent': basis points, currency-agnostic (recorded for audit).
+/// - 'fixed_sats': amount is in the smallest unit of currency
+/// (sats for SAT, cents for USD/EUR). Name is
+/// stale post-Phase 6 but kept for back-compat.
+/// - 'set_price': amount is in the smallest unit of currency.
+/// - 'free_license': amount + currency irrelevant.
+#[allow(clippy::too_many_arguments)]
+pub async fn create_discount_code_with_currency(
+ pool: &SqlitePool,
+ code: &str,
+ kind: &str,
+ amount: i64,
+ discount_currency: &str,
+ max_uses: Option,
+ expires_at: Option<&str>,
+ applies_to_product_id: Option<&str>,
+ applies_to_policy_id: Option<&str>,
+ referrer_label: Option<&str>,
+ description: &str,
) -> AppResult {
if !matches!(
kind,
@@ -1767,15 +1852,16 @@ pub async fn create_discount_code(
let stored_amount = if kind == "free_license" { 0 } else { amount };
sqlx::query(
"INSERT INTO discount_codes
- (id, code, kind, amount, max_uses, used_count, expires_at,
+ (id, code, kind, amount, discount_currency, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
description, active, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)",
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)",
)
.bind(&id)
.bind(&normalized)
.bind(kind)
.bind(stored_amount)
+ .bind(discount_currency)
.bind(max_uses)
.bind(expires_at)
.bind(applies_to_product_id)
diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs
index b3cc48b..100c328 100644
--- a/licensing-service/tests/api.rs
+++ b/licensing-service/tests/api.rs
@@ -1140,6 +1140,70 @@ async fn recover_returns_license_key_for_matching_pair() {
assert_eq!(audit_count, 1, "recovery must write an audit row");
}
+/// USD-priced paid purchase records the listed currency, value, and
+/// exchange rate on the invoice row. Uses a manual rate pin so the
+/// test is network-free and the conversion is exactly verifiable.
+#[tokio::test]
+async fn paid_purchase_in_usd_records_listed_currency_and_rate() {
+ let (state, _tmp) = make_test_state_with_mock_provider().await;
+
+ // Pin USD at $50,000 / BTC. $49.00 (4900 cents) → 9800 sats:
+ // sats = 4900 * 1_000_000 / 50000 = 98000... wait
+ // 4900 * 1_000_000 = 4_900_000_000
+ // 4_900_000_000 / 50_000 = 98_000
+ sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '50000', ?)")
+ .bind(Utc::now().to_rfc3339())
+ .execute(&state.db)
+ .await
+ .unwrap();
+
+ // USD-priced product via the typed admin endpoint.
+ let auth = format!("Bearer {}", TEST_ADMIN_KEY);
+ let req = build_request(
+ "POST",
+ "/v1/admin/products",
+ &[("authorization", &auth)],
+ Some(json!({
+ "slug": "usd-app",
+ "name": "USD App",
+ "price_currency": "USD",
+ "price_value": 4900,
+ })),
+ );
+ let resp = send(&state, req).await;
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ // Initiate purchase. Should call create_invoice with the rate
+ // recorded.
+ let req = build_request(
+ "POST",
+ "/v1/purchase",
+ &[("content-type", "application/json")],
+ Some(json!({"product": "usd-app"})),
+ );
+ let resp = send(&state, req).await;
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = body_json(resp).await;
+ assert_eq!(
+ body["amount_sats"], 98_000,
+ "$49.00 at $50k/BTC = 98,000 sats — got {body:?}"
+ );
+
+ // The invoice row carries the audit trail.
+ let row: (Option, Option, Option, Option, i64) = sqlx::query_as(
+ "SELECT listed_currency, listed_value, exchange_rate_centibps, \
+ exchange_rate_source, amount_sats FROM invoices WHERE btcpay_invoice_id = 'mock-inv-1'"
+ )
+ .fetch_one(&state.db)
+ .await
+ .unwrap();
+ assert_eq!(row.0.as_deref(), Some("USD"));
+ assert_eq!(row.1, Some(4900));
+ assert_eq!(row.2, Some(500_000_000), "rate × 10000: 50000 × 10000");
+ assert_eq!(row.3.as_deref(), Some("manual_pin"));
+ assert_eq!(row.4, 98_000);
+}
+
/// Rate fetcher: manual pin in settings table overrides the source
/// chain. Locks in the test-mode + maintenance-window contract that
/// other phases (invoice rate recording, buy-page rendering) rely on.