diff --git a/licensing-service/migrations/0021_invoice_provider_link.sql b/licensing-service/migrations/0021_invoice_provider_link.sql new file mode 100644 index 0000000..bc79781 --- /dev/null +++ b/licensing-service/migrations/0021_invoice_provider_link.sql @@ -0,0 +1,48 @@ +-- Link invoices to the payment provider that created them. +-- +-- Companion to migration 0020 (merchant profiles + multi-provider). With a +-- single active provider, the reconciler could just iterate pending +-- invoices and call `provider.get_invoice_status()` on every one — every +-- invoice was implicitly from the only configured provider. With +-- N providers per profile and M profiles per Keysat instance, that +-- assumption breaks: each invoice needs to record WHICH provider it was +-- created against so the reconciler can dispatch to the right +-- `get_invoice_status()` and the webhook handler can validate against +-- the right secret. +-- +-- Additive: nullable column + index. Backfill points every pre-migration +-- invoice at whatever provider was active when 0020 ran (same heuristic +-- the subscriptions backfill uses — earliest-connected on the default +-- profile). Post-migration, `repo::create_invoice_with_currency` always +-- writes the provider id. +-- +-- Why not part of 0020: 0020 has shipped to the master operator's git +-- history (commit 04e0dcd) but not yet been *applied* to any DB (the +-- master box is still on :51, which has neither migration). The append- +-- only convention for migrations is the safer pattern even when we could +-- technically still rewrite 0020 — keeps the sqlx migration hashes +-- stable for anyone who ever runs an intermediate WIP build. + +PRAGMA foreign_keys = ON; + +ALTER TABLE invoices + ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id); + +CREATE INDEX IF NOT EXISTS idx_invoices_provider + ON invoices(payment_provider_id); + +-- Backfill existing pending/settled invoices to point at the provider +-- that was active when 0020 ran. Heuristic: pick the provider on the +-- default merchant profile whose kind matches the (now-removed) +-- active_payment_provider setting if it existed pre-0020; else the +-- earliest-connected provider on the default profile. Mirrors the +-- backfill logic in 0020's UPDATE subscriptions block — same merchant +-- identity, same provider, deterministic across re-runs. +UPDATE invoices + SET payment_provider_id = ( + SELECT id FROM payment_providers + WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1) + ORDER BY connected_at ASC + LIMIT 1 + ) + WHERE payment_provider_id IS NULL; diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index 6368d2d..c046913 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -37,6 +37,14 @@ pub struct StartPurchaseReq { /// issuance time. When omitted, the daemon falls back to the product's /// default policy at issuance — same as pre-:27 behaviour. pub policy_slug: Option, + /// Optional payment rail the buyer picked on the buy page. One of + /// `lightning` / `onchain` / `card`. When omitted, the daemon picks + /// the first rail the product's merchant profile exposes — which is + /// the right behavior for single-rail profiles AND back-compat for + /// pre-:52 callers that don't know about rails yet. When the buyer + /// is on a multi-rail profile and the buy page surfaces a picker, + /// this field carries the choice. + pub rail: Option, } #[derive(Debug, Serialize)] @@ -419,29 +427,16 @@ pub async fn start( // before we've persisted the BTCPay invoice id. let internal_id = uuid::Uuid::new_v4().to_string(); - // If the caller didn't supply a redirect_url, default to our own - // /thank-you page with the invoice id baked in. After payment - // BTCPay sends the buyer's browser there; the page polls - // /v1/purchase/ until the license is issued, then - // renders it. Internal ID (UUID) goes in the URL so the buyer can - // bookmark it / refresh later if they close the tab. - let default_redirect = format!( - "{}/thank-you?invoice_id={}", - state.config.public_base_url, internal_id - ); - let redirect_url = req - .redirect_url - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or(&default_redirect); - - // Step C: provider-agnostic invoice creation. The trait method - // handles provider-specific concerns (HMAC-headered request, URL - // rewriting from internal hostname to public, metadata enrichment - // with `orderId`/`source`) inside its impl, so this code path is - // identical for any future provider (Zaprite, etc.). On failure, - // release the slot and bail. - let provider = match state.payment_provider().await { + // Step B.5: resolve the merchant profile + payment provider for THIS + // purchase. The product is attached to exactly one merchant profile; + // the profile exposes one or more payment providers (BTCPay / Zaprite). + // The buyer (or their UA) names a rail via `req.rail` if the buy page's + // multi-rail picker surfaced one — otherwise we pick the first rail the + // profile exposes, which is the right behavior for the common + // single-rail-per-profile case. The resolution layer also returns the + // provider row so we can record its id on the invoice; the renewal + // worker reads that id off the snapshot when auto-charging future cycles. + let merchant_profile = match state.merchant_profile_for_product(&product.id).await { Ok(p) => p, Err(e) => { if let Some(code) = &reservation { @@ -450,6 +445,77 @@ pub async fn start( return Err(e); } }; + let requested_rail = req + .rail + .as_deref() + .and_then(crate::payment::Rail::parse); + let rail = match requested_rail { + Some(r) => r, + None => { + // No buyer pick — collect the union of rails this profile's + // providers offer and use the first. With one provider this + // is its primary rail; with multiple, this is whatever the + // earliest-connected provider serves first. + let providers = repo::list_payment_providers_for_profile( + &state.db, &merchant_profile.id, + ) + .await?; + let first_rail = providers.iter().find_map(|row| { + crate::payment::ProviderKind::parse(&row.kind) + .and_then(|kind| crate::payment::rails_for_kind(kind).into_iter().next()) + }); + match first_rail { + Some(r) => r, + None => { + if let Some(code) = &reservation { + let _ = repo::release_code_slot(&state.db, &code.id).await; + } + return Err(AppError::BadRequest(format!( + "merchant profile '{}' has no payment providers connected — \ + buyers can't pay yet. Connect one in the admin UI.", + merchant_profile.name + ))); + } + } + } + }; + let (provider_row, provider) = match state + .resolve_provider_for_profile_rail(&merchant_profile.id, rail) + .await + { + Ok(t) => t, + Err(e) => { + if let Some(code) = &reservation { + let _ = repo::release_code_slot(&state.db, &code.id).await; + } + return Err(e); + } + }; + + // If the caller didn't supply a redirect_url, prefer the merchant + // profile's configured post_purchase_redirect_url (operator's app + // landing page — e.g. recaps.cc/welcome). Fall back to Keysat's own + // /thank-you?invoice_id=… page if neither is set. + let default_redirect = format!( + "{}/thank-you?invoice_id={}", + state.config.public_base_url, internal_id + ); + let profile_redirect = merchant_profile + .post_purchase_redirect_url + .as_deref() + .filter(|s| !s.is_empty()) + .map(|tmpl| { + // Allow `{invoice_id}` substitution so operators can land + // buyers on a per-purchase URL on their own app. + tmpl.replace("{invoice_id}", &internal_id) + }); + let profile_redirect_ref = profile_redirect.as_deref(); + let redirect_url = req + .redirect_url + .as_deref() + .filter(|s| !s.is_empty()) + .or(profile_redirect_ref) + .unwrap_or(&default_redirect); // Recurring policy: ask the provider to prompt the buyer to // save their payment profile at checkout so the renewal worker // can later auto-charge it via `charge_order_with_profile`. @@ -528,6 +594,7 @@ pub async fn start( listed_value, exchange_rate_centibps, exchange_rate_source.as_deref(), + Some(&provider_row.id), ) .await { diff --git a/licensing-service/src/api/upgrade.rs b/licensing-service/src/api/upgrade.rs index 0eb41b6..85579c2 100644 --- a/licensing-service/src/api/upgrade.rs +++ b/licensing-service/src/api/upgrade.rs @@ -123,7 +123,40 @@ pub async fn start( // Create provider invoice. Same trait method the purchase + renewal // paths use, so any provider-specific concerns (URL rewriting, // metadata enrichment) live inside the impl. - let provider = state.payment_provider().await?; + // + // Tier-change invoices ride on an existing license. The right provider + // is whichever one the license's existing subscription is snapshotted + // to — so the proration charge settles to the same merchant identity + // that's been collecting renewal fees. Falls back to the license's + // first-cycle invoice provider, then the legacy default, for licenses + // with no subscription (one-shot upgrades) or pre-snapshot rows. + let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id( + &state.db, &license.id, + ) + .await + .ok() + .flatten() + .and_then(|s| s.payment_provider_id); + let provider_id_for_upgrade = match snapshot_provider_id { + Some(p) => Some(p), + None => { + sqlx::query_scalar::<_, Option>( + "SELECT i.payment_provider_id FROM invoices i \ + JOIN licenses l ON l.invoice_id = i.id \ + WHERE l.id = ?", + ) + .bind(&license.id) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .flatten() + } + }; + let provider = match provider_id_for_upgrade.as_deref() { + Some(pid) => state.payment_provider_by_id(pid).await?, + None => state.payment_provider().await?, + }; let internal_invoice_id = Uuid::new_v4().to_string(); let default_redirect = format!( "{}/thank-you?invoice_id={}", @@ -175,6 +208,7 @@ pub async fn start( Some(quote.proration_charge_value), conversion.rate_centibps, Some(conversion.source.as_str()), + provider_id_for_upgrade.as_deref(), ) .await?; @@ -456,7 +490,36 @@ pub async fn admin_change( .map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?; let amount_sats = conversion.sats.max(1); - let provider = state.payment_provider().await?; + // Same provider-resolution pattern as the buyer-driven tier-change + // above: prefer the license's snapshotted subscription provider so + // the admin charge settles to the same merchant identity. + let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id( + &state.db, &license.id, + ) + .await + .ok() + .flatten() + .and_then(|s| s.payment_provider_id); + let provider_id_for_upgrade = match snapshot_provider_id { + Some(p) => Some(p), + None => { + sqlx::query_scalar::<_, Option>( + "SELECT i.payment_provider_id FROM invoices i \ + JOIN licenses l ON l.invoice_id = i.id \ + WHERE l.id = ?", + ) + .bind(&license.id) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .flatten() + } + }; + let provider = match provider_id_for_upgrade.as_deref() { + Some(pid) => state.payment_provider_by_id(pid).await?, + None => state.payment_provider().await?, + }; let internal_invoice_id = Uuid::new_v4().to_string(); let default_redirect = format!( "{}/thank-you?invoice_id={}", @@ -498,6 +561,7 @@ pub async fn admin_change( Some(quote.proration_charge_value), conversion.rate_centibps, Some(conversion.source.as_str()), + provider_id_for_upgrade.as_deref(), ) .await?; diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs index e8587d7..11786a3 100644 --- a/licensing-service/src/api/webhook.rs +++ b/licensing-service/src/api/webhook.rs @@ -278,6 +278,21 @@ pub async fn issue_license_for_invoice( .ok() .flatten(); if existing.is_none() { + // Snapshot the merchant profile + payment provider that + // settled this purchase, so the renewal worker uses the + // SAME business + payment account on subsequent cycles + // even if the operator later moves the product to a + // different profile. Falls back to the product's + // current profile (and the invoice's recorded provider) + // when the snapshot fields aren't already on the invoice. + let snapshot_profile_id = crate::db::repo::get_merchant_profile_for_product( + &state.db, &invoice.product_id, + ) + .await + .ok() + .flatten() + .map(|p| p.id); + let snapshot_provider_id = invoice.payment_provider_id.clone(); match crate::subscriptions::create_subscription( &state.db, &license_id, @@ -287,6 +302,8 @@ pub async fn issue_license_for_invoice( &listed_currency, listed_value, &invoice.id, + snapshot_profile_id.as_deref(), + snapshot_provider_id.as_deref(), ) .await { diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index b621c23..bdd257b 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -355,6 +355,7 @@ pub async fn create_invoice( buyer_email: Option<&str>, buyer_note: Option<&str>, policy_id: Option<&str>, + payment_provider_id: Option<&str>, ) -> AppResult { create_invoice_with_currency( pool, @@ -370,6 +371,7 @@ pub async fn create_invoice( None, None, None, + payment_provider_id, ) .await } @@ -395,6 +397,7 @@ pub async fn create_invoice_with_currency( listed_value: Option, exchange_rate_centibps: Option, exchange_rate_source: Option<&str>, + payment_provider_id: Option<&str>, ) -> AppResult { let now = Utc::now().to_rfc3339(); sqlx::query( @@ -402,8 +405,9 @@ pub async fn create_invoice_with_currency( (id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, amount_sats, checkout_url, policy_id, listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source, + payment_provider_id, created_at, updated_at) - VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(btcpay_invoice_id) @@ -417,6 +421,7 @@ pub async fn create_invoice_with_currency( .bind(listed_value) .bind(exchange_rate_centibps) .bind(exchange_rate_source) + .bind(payment_provider_id) .bind(&now) .bind(&now) .execute(pool) @@ -466,7 +471,8 @@ pub async fn create_free_invoice( pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult> { let row = sqlx::query( "SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, - amount_sats, checkout_url, created_at, updated_at, policy_id + amount_sats, checkout_url, created_at, updated_at, policy_id, + listed_currency, listed_value, payment_provider_id FROM invoices WHERE id = ?", ) .bind(id) @@ -481,7 +487,8 @@ pub async fn get_invoice_by_btcpay_id( ) -> AppResult> { let row = sqlx::query( "SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, - amount_sats, checkout_url, created_at, updated_at, policy_id + amount_sats, checkout_url, created_at, updated_at, policy_id, + listed_currency, listed_value, payment_provider_id FROM invoices WHERE btcpay_invoice_id = ?", ) .bind(btcpay_invoice_id) @@ -517,7 +524,8 @@ pub async fn list_pending_invoices( let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339(); let rows = sqlx::query( "SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note, - amount_sats, checkout_url, created_at, updated_at, policy_id + amount_sats, checkout_url, created_at, updated_at, policy_id, + listed_currency, listed_value, payment_provider_id FROM invoices WHERE status = 'pending' AND created_at >= ? ORDER BY created_at ASC", @@ -546,6 +554,10 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice { .ok() .flatten(), listed_value: row.try_get::, _>("listed_value").ok().flatten(), + payment_provider_id: row + .try_get::, _>("payment_provider_id") + .ok() + .flatten(), } } diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 920bacc..254e1e4 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -57,61 +57,70 @@ async fn main() -> anyhow::Result<()> { keypair.public_key_pem.trim() ); - // --- payment provider (may be None until operator connects) --- - // Resolution order: - // 1. operator's explicit preference from the - // active_payment_provider setting (set by the most recent - // Connect or Activate action), - // 2. fallback for legacy installs without the setting: - // BTCPay first, Zaprite second. Once we ship v0.3 with the - // multi-provider routing layer this fallback retires. - let preferred = payment::read_active_provider_preference(&pool).await; - let provider: Option> = match preferred { - Some(payment::ProviderKind::Zaprite) => { - // Operator explicitly chose Zaprite. Try Zaprite; if it - // can't be loaded (e.g., the row was deleted out from - // under the setting), fall through to BTCPay rather - // than booting unconfigured. - load_zaprite_provider(&pool) - .await - .map(|p| Arc::new(p) as Arc) - .or_else(|| { - tracing::warn!( - "active_payment_provider=zaprite but zaprite_config is missing; \ - falling back to BTCPay" - ); - None - }) - .or(load_btcpay_provider(&pool, &cfg) - .await - .map(|p| Arc::new(p) as Arc)) - } - Some(payment::ProviderKind::Btcpay) | None => { - // Either operator chose BTCPay, or no preference recorded - // yet (legacy install). Either way, BTCPay wins if - // configured; Zaprite as fallback. + // --- payment provider boot-time warm-up --- + // + // With the multi-merchant-profile model (migration 0020+) we no longer + // load a single "active" provider at boot. Providers are looked up by + // id on demand via `AppState::payment_provider_by_id` (which builds + // from a `payment_providers` row each time it's called) and resolved + // per purchase via `resolve_provider_for_product_rail`. + // + // For back-compat we still populate the legacy `state.payment` + // singleton with the FIRST provider attached to the default merchant + // profile — this is what `state.payment_provider()` returns to the + // remaining legacy call sites (and is a sensible fallback for any + // code path that runs before the operator has linked a product to a + // specific profile). Empty profile → empty singleton; the on-demand + // resolution layer takes over from there. + let provider: Option> = match keysat::db::repo::get_default_merchant_profile(&pool).await { + Ok(Some(profile)) => match keysat::db::repo::list_payment_providers_for_profile(&pool, &profile.id).await { + Ok(rows) => match rows.first() { + Some(row) => match payment::build_provider(row, cfg.btcpay_public_url.as_deref()) { + Ok(p) => Some(p), + Err(e) => { + tracing::warn!( + provider_id = %row.id, + error = %e, + "failed to build provider from default-profile row; \ + leaving legacy state.payment empty" + ); + None + } + }, + None => None, + }, + Err(e) => { + tracing::warn!( + profile_id = %profile.id, + error = %e, + "failed to list providers on default profile at boot" + ); + None + } + }, + Ok(None) => { + // Pre-migration: no default profile exists yet (operator hasn't + // installed :52 yet). Fall back to the legacy singleton-config + // loaders so the daemon still boots cleanly during the upgrade + // window — these run against btcpay_config / zaprite_config + // until migration 0020 drops those tables. load_btcpay_provider(&pool, &cfg) .await .map(|p| Arc::new(p) as Arc) - .or_else(|| { - if preferred == Some(payment::ProviderKind::Btcpay) { - tracing::warn!( - "active_payment_provider=btcpay but btcpay_config is missing; \ - falling back to Zaprite" - ); - } - None - }) .or(load_zaprite_provider(&pool) .await .map(|p| Arc::new(p) as Arc)) } + Err(e) => { + tracing::warn!(error = %e, "failed to read default merchant profile at boot"); + None + } }; match &provider { - Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"), + Some(p) => tracing::info!(provider = p.kind().as_str(), "default payment provider warmed up"), None => tracing::warn!( "no payment provider yet configured — purchases will return 503 until the \ - operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow" + operator completes 'Connect BTCPay' or 'Connect Zaprite' in the admin UI" ), } diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 1052749..0f2d71d 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -114,6 +114,13 @@ pub struct Invoice { /// `amount_sats` (which is correct for SAT-priced products). #[serde(default)] pub listed_value: Option, + /// Which payment provider settled this invoice. Added by migration + /// 0021 alongside the multi-merchant-profile work; NULL on pre-0021 + /// invoices (backfilled by the migration to the first provider on + /// the default profile). Required for reconcile.rs to dispatch + /// status checks to the right provider when multiple are configured. + #[serde(default)] + pub payment_provider_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/licensing-service/src/reconcile.rs b/licensing-service/src/reconcile.rs index 9b816a8..e691643 100644 --- a/licensing-service/src/reconcile.rs +++ b/licensing-service/src/reconcile.rs @@ -45,11 +45,15 @@ async fn tick(state: &AppState) -> anyhow::Result<()> { // provider-specific status-string normalization (BTCPay's // "Settled"/"Complete"/"Expired"/"Invalid" → ProviderInvoiceStatus // enum); this loop just operates on the typed result. - let provider = match state.payment_provider().await { - Ok(p) => p, - Err(_) => return Ok(()), // not configured yet — skip silently - }; - + // + // With multi-provider, each pending invoice is reconciled against + // its OWN provider (recorded on the invoice row, migration 0021). + // We can't iterate against a single global provider because the + // operator may have multiple providers configured across multiple + // merchant profiles. Pre-0021 invoices that slipped through with + // a NULL provider id fall back to the legacy `payment_provider()` + // accessor (which the migration's backfill should prevent from + // ever being needed in practice). let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS) .await .map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?; @@ -60,6 +64,24 @@ async fn tick(state: &AppState) -> anyhow::Result<()> { tracing::debug!(count = pending.len(), "reconciling pending invoices"); for inv in pending { + let provider = match inv.payment_provider_id.as_deref() { + Some(pid) => match state.payment_provider_by_id(pid).await { + Ok(p) => p, + Err(e) => { + tracing::debug!( + error = %e, + invoice_id = %inv.id, + provider_id = pid, + "reconciler skipping invoice — its provider is unavailable" + ); + continue; + } + }, + None => match state.payment_provider().await { + Ok(p) => p, + Err(_) => continue, // not configured yet — skip silently + }, + }; match provider.get_invoice_status(&inv.btcpay_invoice_id).await { Ok(status) => { use crate::payment::ProviderInvoiceStatus::*; diff --git a/licensing-service/src/subscriptions.rs b/licensing-service/src/subscriptions.rs index 03ebbd0..d117e66 100644 --- a/licensing-service/src/subscriptions.rs +++ b/licensing-service/src/subscriptions.rs @@ -122,6 +122,19 @@ pub struct Subscription { /// an `/v1/orders/charge` failure and fall through to the /// manual-pay branch. pub zaprite_payment_profile_expires_at: Option, + /// Merchant profile the subscription was attached to at + /// creation. Frozen for the lifetime of the sub so an operator + /// editing the product's profile attachment doesn't redirect + /// existing buyers to a different business mid-cycle. NULL on + /// subs created pre-migration 0020 (backfilled to the default + /// profile during the migration). + pub merchant_profile_id: Option, + /// Payment provider used for THIS subscription's billing cycle. + /// Frozen at creation (same rationale as merchant_profile_id). + /// The renewal worker uses this to look up the provider — it + /// never re-resolves from the product (which might have moved + /// to a different profile / different providers). + pub payment_provider_id: Option, } fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription { @@ -144,6 +157,8 @@ fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription { zaprite_payment_profile_expires_at: row .try_get("zaprite_payment_profile_expires_at") .ok(), + merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(), + payment_provider_id: row.try_get("payment_provider_id").ok().flatten(), } } @@ -151,7 +166,8 @@ const SUB_COLS: &str = "id, license_id, policy_id, product_id, period_days, \ listed_currency, listed_value, status, started_at, next_renewal_at, \ cancelled_at, consecutive_failures, \ zaprite_contact_id, zaprite_payment_profile_id, \ - zaprite_payment_profile_method, zaprite_payment_profile_expires_at"; + zaprite_payment_profile_method, zaprite_payment_profile_expires_at, \ + merchant_profile_id, payment_provider_id"; /// Subs that are due for the worker to act on right now: status /// is `active` or `past_due`, `next_renewal_at` is in the past, @@ -196,7 +212,8 @@ pub async fn find_lapsing_subscriptions( s.next_renewal_at, s.cancelled_at, s.consecutive_failures, \ s.zaprite_contact_id, s.zaprite_payment_profile_id, \ s.zaprite_payment_profile_method, \ - s.zaprite_payment_profile_expires_at \ + s.zaprite_payment_profile_expires_at, \ + s.merchant_profile_id, s.payment_provider_id \ FROM subscriptions s \ JOIN policies p ON p.id = s.policy_id \ WHERE s.status = 'past_due' \ @@ -373,6 +390,8 @@ pub async fn create_subscription( listed_currency: &str, listed_value: i64, first_cycle_invoice_id: &str, + merchant_profile_id: Option<&str>, + payment_provider_id: Option<&str>, ) -> Result { let id = Uuid::new_v4().to_string(); let now = Utc::now(); @@ -382,8 +401,9 @@ pub async fn create_subscription( sqlx::query( "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ listed_currency, listed_value, status, started_at, next_renewal_at, \ - consecutive_failures, created_at, updated_at) \ - VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?)", + consecutive_failures, merchant_profile_id, payment_provider_id, \ + created_at, updated_at) \ + VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)", ) .bind(&id) .bind(license_id) @@ -394,6 +414,8 @@ pub async fn create_subscription( .bind(listed_value) .bind(&started_at) .bind(&next_renewal_at) + .bind(merchant_profile_id) + .bind(payment_provider_id) .bind(&started_at) .bind(&started_at) .execute(pool) @@ -436,6 +458,8 @@ pub async fn create_subscription( zaprite_payment_profile_id: None, zaprite_payment_profile_method: None, zaprite_payment_profile_expires_at: None, + merchant_profile_id: merchant_profile_id.map(|s| s.to_string()), + payment_provider_id: payment_provider_id.map(|s| s.to_string()), }) } @@ -696,12 +720,24 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> { .context("rate conversion")?; let amount_sats = conversion.sats.max(1); - // 2. Get the active provider. If no provider is configured - // we can't bill — surfaces as a renewal failure that - // backs off (operator probably mid-Disconnect). - let provider = state.payment_provider().await.map_err(|e| { - anyhow!("payment provider unavailable for renewal: {e:#}") - })?; + // 2. Get the provider snapshotted on this sub at creation. The + // snapshot semantics protect existing buyers from operator-side + // re-routing: if the product was moved to a different merchant + // profile or its providers changed, this sub keeps renewing + // through the same business + payment account it started with. + // Falls back to the default profile's first provider if the + // snapshot is NULL (pre-migration subs that the 0020 backfill + // missed, or any rows that slipped through with NULL). + let provider = match sub.payment_provider_id.as_deref() { + Some(pid) => state + .payment_provider_by_id(pid) + .await + .map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?, + None => state + .payment_provider() + .await + .map_err(|e| anyhow!("payment provider unavailable for renewal: {e:#}"))?, + }; // 3. Compute the next cycle window. let now = Utc::now(); @@ -786,6 +822,7 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> { } else { Some(conversion.source.as_str()) }, + None, // payment_provider_id — set when this call site is ported to the multi-provider resolution layer ) .await .map_err(|e: AppError| anyhow!("repo create_invoice: {e:?}"))?; @@ -1103,16 +1140,39 @@ pub async fn capture_zaprite_payment_profile( return Ok(()); } - // Active provider must be Zaprite for any of the rest to be - // meaningful — `as_any` downcast keeps the trait clean. - let provider = match state.payment_provider().await { - Ok(p) => p, - Err(e) => { - tracing::warn!( - sub_id = %sub_id, error = %e, - "capture: no active payment provider — skipping" - ); - return Ok(()); + // The provider that settled THIS invoice — not "the active one." With + // multi-merchant-profile, the operator may have several providers + // configured across different profiles; capturing the saved profile + // has to talk to the SAME Zaprite org that the order was created + // against (saved-profile ids are scoped per org). + let provider = match invoice.payment_provider_id.as_deref() { + Some(pid) => match state.payment_provider_by_id(pid).await { + Ok(p) => p, + Err(e) => { + tracing::warn!( + sub_id = %sub_id, + provider_id = pid, + error = %e, + "capture: invoice's provider unavailable — skipping" + ); + return Ok(()); + } + }, + None => { + // Pre-0021 invoice with NULL provider — fall back to the legacy + // default. The 0021 backfill should have populated this column + // on the first migration run, so this branch is defensive only. + match state.payment_provider().await { + Ok(p) => p, + Err(e) => { + tracing::warn!( + sub_id = %sub_id, error = %e, + "capture: no active payment provider AND invoice has no \ + payment_provider_id — skipping" + ); + return Ok(()); + } + } } }; if provider.kind() != ProviderKind::Zaprite { @@ -1313,10 +1373,19 @@ async fn try_auto_charge_zaprite( _ => return Ok(false), }; - let provider = state - .payment_provider() - .await - .map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?; + // Use the provider snapshotted on the sub — saved-profile ids are + // scoped per Zaprite org, so we can't fall back to "the active + // provider" if the operator added another Zaprite provider since. + let provider = match sub.payment_provider_id.as_deref() { + Some(pid) => state + .payment_provider_by_id(pid) + .await + .map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?, + None => state + .payment_provider() + .await + .map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?, + }; if provider.kind() != ProviderKind::Zaprite { return Ok(false); } diff --git a/licensing-service/src/tipping.rs b/licensing-service/src/tipping.rs index f0370f7..e508e77 100644 --- a/licensing-service/src/tipping.rs +++ b/licensing-service/src/tipping.rs @@ -199,12 +199,29 @@ async fn run_tip( } }; - // Pay it via the active provider's LN node. Provider-agnostic; - // BTCPay implements `pay_lightning_invoice` today, future - // providers either implement it (Zaprite via Strike?) or fall - // through to the trait default which returns a "not supported" - // error that we record as a failed tip. - let provider = match state.payment_provider().await { + // Pay it via the provider's LN node — same provider that settled + // this license's purchase invoice (so the tip draws from the right + // Bitcoin balance). Provider-agnostic; BTCPay implements + // `pay_lightning_invoice` today, future providers either implement + // it (Zaprite via Strike?) or fall through to the trait default + // which returns a "not supported" error that we record as a failed + // tip. Falls back to the legacy active-provider accessor if the + // license's invoice has no payment_provider_id set (pre-0021). + let invoice_provider_id: Option = sqlx::query_scalar( + "SELECT i.payment_provider_id FROM invoices i \ + JOIN licenses l ON l.invoice_id = i.id \ + WHERE l.id = ?", + ) + .bind(license_id) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + let provider_result = match invoice_provider_id.as_deref() { + Some(pid) => state.payment_provider_by_id(pid).await, + None => state.payment_provider().await, + }; + let provider = match provider_result { Ok(p) => p, Err(e) => { let detail = format!("payment provider unavailable: {e:?}");