From d8aa9c22b9d190747c914bf7e9348f476d2814fe Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 12:21:26 -0500 Subject: [PATCH] =?UTF-8?q?Multi-currency=20Phases=203,=205,=206=20?= =?UTF-8?q?=E2=80=94=20buy=20page,=20invoice=20rate=20recording,=20discoun?= =?UTF-8?q?t=20currency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- licensing-service/src/api/buy_page.rs | 29 ++++++- licensing-service/src/api/discount_codes.rs | 28 +++++- licensing-service/src/api/purchase.rs | 50 +++++++++-- licensing-service/src/db/repo.rs | 94 ++++++++++++++++++++- licensing-service/tests/api.rs | 64 ++++++++++++++ 5 files changed, 251 insertions(+), 14 deletions(-) diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index a359e7c..07307a2 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -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); }}
Price
- {price_sats_fmt}sats + {price_sats_fmt}{price_unit_label}
@@ -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, diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs index cb47ca8..2ce2b39 100644 --- a/licensing-service/src/api/discount_codes.rs +++ b/licensing-service/src/api/discount_codes.rs @@ -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, #[serde(default)] pub max_uses: Option, /// 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(), diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index 4ff0be4..4d78577 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -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 = None; + let mut listed_value: Option = None; + let mut exchange_rate_centibps: Option = None; + let mut exchange_rate_source: Option = 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/ and // /thank-you?invoice_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 { diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 1000174..67e7025 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -257,13 +257,55 @@ pub async fn create_invoice( buyer_email: Option<&str>, buyer_note: Option<&str>, policy_id: Option<&str>, +) -> AppResult { + 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, + exchange_rate_centibps: Option, + exchange_rate_source: Option<&str>, ) -> AppResult { 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 { + 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, + expires_at: Option<&str>, + applies_to_product_id: Option<&str>, + applies_to_policy_id: Option<&str>, + referrer_label: Option<&str>, + description: &str, ) -> AppResult { 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) diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index b3cc48b..100c328 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1140,6 +1140,70 @@ async fn recover_returns_license_key_for_matching_pair() { 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, Option, Option, Option, 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 /// chain. Locks in the test-mode + maintenance-window contract that /// other phases (invoice rate recording, buy-page rendering) rely on.