Multi-currency Phases 3, 5, 6 — buy page, invoice rate recording, discount currency

Phase 5 (invoice records the rate):
- repo::create_invoice_with_currency takes the listed currency,
  listed value, exchange_rate_centibps, and exchange_rate_source as
  optional params; create_invoice (the legacy form) becomes a thin
  wrapper that passes None for all four. SAT-priced flows are
  unchanged.
- purchase::start now branches on product.price_currency: SAT keeps
  the existing path; USD/EUR calls rates::convert_to_sats and pins
  the listed price + rate to the local invoice row for audit. The
  buyer is still billed in BTC (BTCPay invoice is sat-denominated)
  but the audit trail records what they SAW vs what they were
  charged.
- Test paid_purchase_in_usd_records_listed_currency_and_rate seeds
  a manual rate pin ($50k/BTC), creates a USD-priced product
  ($49.00), runs through purchase, asserts the invoice row carries
  listed_currency='USD', listed_value=4900, rate_centibps=
  500_000_000, source='manual_pin', amount_sats=98_000.

Phase 3 (buy page renders fiat):
- Server-rendered initial price respects product.price_currency:
  USD products show "49.00 USD" (cents converted to display dollars)
  instead of sats. Tier-picker JS still formats per-tier prices in
  sats — that's a v0.3 polish when we plumb the rate into the JS
  render path. Most operators ship single-policy products at first,
  so the static initial render is the high-leverage piece.

Phase 6 (currency-aware discount codes):
- POST /v1/admin/discount-codes accepts optional `discount_currency`
  field ('SAT' default, 'USD', 'EUR'). Whitelisted in the handler.
- repo::create_discount_code is now a thin wrapper around
  create_discount_code_with_currency; the new helper persists
  discount_currency to the column added in 0010. Existing SAT-only
  codes keep working unchanged.

Test count: 37 (was 36; +1 paid_purchase_in_usd test).

Multi-currency design phases 1-6 all shipped (1: schema in :48; 2:
admin UI write in :48-:49; 3: buy page; 4: rate fetcher; 5: invoice
audit; 6: discount currency). Phase 7 (recurring subscriptions
re-quote) is v0.3 territory — needs the recurring-billing scaffolding
from Zaprite first.
This commit is contained in:
Grant
2026-05-08 12:21:26 -05:00
parent eb885502ba
commit d8aa9c22b9
5 changed files with 251 additions and 14 deletions
+26 -3
View File
@@ -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); }}
<div class="price-label" id="price-label">Price</div>
<div class="price" id="price-display">
<span id="price-strike-line" class="price-strike" style="display:none"></span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">sats</span>
<span id="price-current">{price_sats_fmt}</span><span class="unit">{price_unit_label}</span>
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
</div>
</div>
@@ -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,
+27 -1
View File
@@ -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<String>,
#[serde(default)]
pub max_uses: Option<i64>,
/// 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(),
+44 -6
View File
@@ -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<String> = None;
let mut listed_value: Option<i64> = None;
let mut exchange_rate_centibps: Option<i64> = None;
let mut exchange_rate_source: Option<String> = 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/<id> and
// /thank-you?invoice_id=<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
{
+90 -4
View File
@@ -257,13 +257,55 @@ pub async fn create_invoice(
buyer_email: Option<&str>,
buyer_note: Option<&str>,
policy_id: Option<&str>,
) -> AppResult<Invoice> {
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<i64>,
exchange_rate_centibps: Option<i64>,
exchange_rate_source: Option<&str>,
) -> AppResult<Invoice> {
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<DiscountCode> {
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<i64>,
expires_at: Option<&str>,
applies_to_product_id: Option<&str>,
applies_to_policy_id: Option<&str>,
referrer_label: Option<&str>,
description: &str,
) -> AppResult<DiscountCode> {
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)
+64
View File
@@ -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<String>, Option<i64>, Option<i64>, Option<String>, 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.