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:
@@ -88,11 +88,33 @@ pub async fn render(
|
|||||||
.or_else(|| public_policies.first().cloned());
|
.or_else(|| public_policies.first().cloned());
|
||||||
|
|
||||||
// The price displayed in the cert card on initial render.
|
// 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()
|
.as_ref()
|
||||||
.and_then(|p| p.price_sats_override)
|
.and_then(|p| p.price_sats_override)
|
||||||
.unwrap_or(product.price_sats);
|
.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
|
let initial_policy_slug = initial_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.slug.clone())
|
.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-label" id="price-label">Price</div>
|
||||||
<div class="price" id="price-display">
|
<div class="price" id="price-display">
|
||||||
<span id="price-strike-line" class="price-strike" style="display:none"></span>
|
<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>
|
<span id="price-discount-tag" class="price-discount-tag" style="display:none"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -819,6 +841,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
|||||||
product_slug = product_slug,
|
product_slug = product_slug,
|
||||||
product_description = product_description,
|
product_description = product_description,
|
||||||
price_sats_fmt = price_sats_fmt,
|
price_sats_fmt = price_sats_fmt,
|
||||||
|
price_unit_label = price_unit_label,
|
||||||
tiers_html = tiers_html,
|
tiers_html = tiers_html,
|
||||||
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
|
slug_json = serde_json::to_string(&product.slug).unwrap_or_else(|_| "\"\"".into()),
|
||||||
tiers_json = tiers_json,
|
tiers_json = tiers_json,
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ pub struct CreateDiscountCodeReq {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
|
/// Basis points if kind == 'percent' (0..=10000); sats if kind == 'fixed_sats'.
|
||||||
pub amount: i64,
|
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)]
|
#[serde(default)]
|
||||||
pub max_uses: Option<i64>,
|
pub max_uses: Option<i64>,
|
||||||
/// ISO-8601 RFC3339 UTC timestamp.
|
/// ISO-8601 RFC3339 UTC timestamp.
|
||||||
@@ -80,11 +86,31 @@ pub async fn create(
|
|||||||
None
|
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,
|
&state.db,
|
||||||
&req.code,
|
&req.code,
|
||||||
&req.kind,
|
&req.kind,
|
||||||
req.amount,
|
req.amount,
|
||||||
|
&discount_currency,
|
||||||
req.max_uses,
|
req.max_uses,
|
||||||
req.expires_at.as_deref(),
|
req.expires_at.as_deref(),
|
||||||
product_id.as_deref(),
|
product_id.as_deref(),
|
||||||
|
|||||||
@@ -108,11 +108,45 @@ pub async fn start(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Effective base price: policy override if set, else product price.
|
// Effective base price in sats. For SAT-priced products this is
|
||||||
let base_price = chosen_policy
|
// straightforward (policy override or product.price_sats). For
|
||||||
.as_ref()
|
// fiat-priced products (USD, EUR), we convert the listed value
|
||||||
.and_then(|p| p.price_sats_override)
|
// to sats here using the daemon's rate fetcher — the rate gets
|
||||||
.unwrap_or(product.price_sats);
|
// 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
|
// Resolve and validate the discount code if one was supplied. The
|
||||||
// ordering here matters: we must atomically reserve a counter slot
|
// 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
|
// Use internal_id we pre-generated (and baked into the BTCPay
|
||||||
// redirect_url) as the local row id so /v1/purchase/<id> and
|
// redirect_url) as the local row id so /v1/purchase/<id> and
|
||||||
// /thank-you?invoice_id=<id> all resolve to the same row.
|
// /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,
|
&state.db,
|
||||||
&internal_id,
|
&internal_id,
|
||||||
&created.provider_invoice_id,
|
&created.provider_invoice_id,
|
||||||
@@ -346,6 +380,10 @@ pub async fn start(
|
|||||||
req.buyer_email.as_deref(),
|
req.buyer_email.as_deref(),
|
||||||
req.buyer_note.as_deref(),
|
req.buyer_note.as_deref(),
|
||||||
chosen_policy.as_ref().map(|p| p.id.as_str()),
|
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
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -257,13 +257,55 @@ pub async fn create_invoice(
|
|||||||
buyer_email: Option<&str>,
|
buyer_email: Option<&str>,
|
||||||
buyer_note: Option<&str>,
|
buyer_note: Option<&str>,
|
||||||
policy_id: 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> {
|
) -> AppResult<Invoice> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO invoices
|
"INSERT INTO invoices
|
||||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||||
amount_sats, checkout_url, policy_id, created_at, updated_at)
|
amount_sats, checkout_url, policy_id,
|
||||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)",
|
listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(btcpay_invoice_id)
|
.bind(btcpay_invoice_id)
|
||||||
@@ -273,6 +315,10 @@ pub async fn create_invoice(
|
|||||||
.bind(amount_sats)
|
.bind(amount_sats)
|
||||||
.bind(checkout_url)
|
.bind(checkout_url)
|
||||||
.bind(policy_id)
|
.bind(policy_id)
|
||||||
|
.bind(listed_currency)
|
||||||
|
.bind(listed_value)
|
||||||
|
.bind(exchange_rate_centibps)
|
||||||
|
.bind(exchange_rate_source)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -1714,6 +1760,45 @@ pub async fn create_discount_code(
|
|||||||
applies_to_policy_id: Option<&str>,
|
applies_to_policy_id: Option<&str>,
|
||||||
referrer_label: Option<&str>,
|
referrer_label: Option<&str>,
|
||||||
description: &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> {
|
) -> AppResult<DiscountCode> {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
kind,
|
kind,
|
||||||
@@ -1767,15 +1852,16 @@ pub async fn create_discount_code(
|
|||||||
let stored_amount = if kind == "free_license" { 0 } else { amount };
|
let stored_amount = if kind == "free_license" { 0 } else { amount };
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO discount_codes
|
"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,
|
applies_to_product_id, applies_to_policy_id, referrer_label,
|
||||||
description, active, created_at, updated_at)
|
description, active, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&normalized)
|
.bind(&normalized)
|
||||||
.bind(kind)
|
.bind(kind)
|
||||||
.bind(stored_amount)
|
.bind(stored_amount)
|
||||||
|
.bind(discount_currency)
|
||||||
.bind(max_uses)
|
.bind(max_uses)
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
.bind(applies_to_product_id)
|
.bind(applies_to_product_id)
|
||||||
|
|||||||
@@ -1140,6 +1140,70 @@ async fn recover_returns_license_key_for_matching_pair() {
|
|||||||
assert_eq!(audit_count, 1, "recovery must write an audit row");
|
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
|
/// Rate fetcher: manual pin in settings table overrides the source
|
||||||
/// chain. Locks in the test-mode + maintenance-window contract that
|
/// chain. Locks in the test-mode + maintenance-window contract that
|
||||||
/// other phases (invoice rate recording, buy-page rendering) rely on.
|
/// other phases (invoice rate recording, buy-page rendering) rely on.
|
||||||
|
|||||||
Reference in New Issue
Block a user