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
+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)