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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user