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
{