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