diff --git a/licensing-service/migrations/0022_btcpay_state_profile.sql b/licensing-service/migrations/0022_btcpay_state_profile.sql new file mode 100644 index 0000000..0b1ef69 --- /dev/null +++ b/licensing-service/migrations/0022_btcpay_state_profile.sql @@ -0,0 +1,24 @@ +-- Carry merchant_profile_id through the BTCPay OAuth round trip. +-- +-- Operator hits POST /v1/admin/btcpay/connect with a merchant_profile_id, +-- daemon generates a CSRF state token and stores it; operator opens +-- BTCPay's authorize URL in their browser; BTCPay POSTs back to our +-- callback with the apiKey + the state token; daemon consumes the state +-- token and uses it to look up which merchant profile the new provider +-- row should attach to. +-- +-- Pre-multi-provider, `btcpay_authorize_state` was a singleton-ish +-- pattern (one in-flight authorize at a time) and the resulting provider +-- always attached to "the singleton btcpay_config row." With multi- +-- profile, the operator might want to authorize a SECOND BTCPay store +-- onto a different profile (Pro/Patron); the state token has to +-- remember which profile they kicked off the flow from. +-- +-- Additive: nullable column, NULL = "attach to the default profile" +-- (back-compat for any pre-:52 state tokens that survived a daemon +-- restart mid-flow, though the table is also pruned by timestamp). + +PRAGMA foreign_keys = ON; + +ALTER TABLE btcpay_authorize_state + ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id); diff --git a/licensing-service/src/api/btcpay_authorize.rs b/licensing-service/src/api/btcpay_authorize.rs index e245c77..8c63697 100644 --- a/licensing-service/src/api/btcpay_authorize.rs +++ b/licensing-service/src/api/btcpay_authorize.rs @@ -56,25 +56,57 @@ pub struct ConnectResp { pub authorize_url: String, /// CSRF state token tied to this round trip. pub state: String, + /// Merchant profile the resulting provider row will attach to. + pub merchant_profile_id: String, +} + +#[derive(Debug, Deserialize, Default)] +pub struct StartConnectReq { + /// Which merchant profile to attach the BTCPay provider to. NULL = + /// the default profile (single-profile operators never see this). + #[serde(default)] + pub merchant_profile_id: Option, + /// Operator-set label for the resulting payment_providers row. NULL = + /// auto-generated from the profile name. + #[serde(default)] + pub label: Option, } /// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize /// URL for the StartOS wrapper action to open in the operator's browser. +/// +/// Accepts an optional `merchant_profile_id` so Pro/Patron operators can +/// connect multiple BTCPay stores onto different profiles side-by-side. +/// Single-profile operators (Creator tier, or anyone without an explicit +/// pick) get the default profile. pub async fn start_connect( State(state): State, headers: HeaderMap, + body: Option>, ) -> AppResult> { require_admin(&state, &headers)?; + let req = body.map(|Json(b)| b).unwrap_or_default(); - // Idempotency: if BTCPay is already connected, refuse to issue a new - // authorize URL. Re-clicking Connect today produces a duplicate - // webhook subscription on BTCPay, which results in every payment - // event being delivered to Keysat twice. Make the operator go - // through Disconnect first if they really want to re-authorize. - if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await { + // Resolve the target merchant profile (defaulting to the default). + let profile = match req.merchant_profile_id.as_deref() { + Some(id) => crate::merchant_profiles::get(&state.db, id) + .await? + .ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))?, + None => crate::merchant_profiles::require_default(&state.db).await?, + }; + + // Idempotency: refuse to issue a new authorize URL if the same + // profile already has a BTCPay provider attached. Re-clicking + // Connect would otherwise INSERT-conflict at callback time (unique + // index on (merchant_profile_id, kind)) AND register a duplicate + // BTCPay webhook, producing duplicate-deliveries on every settle. + let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id) + .await?; + if existing.iter().any(|p| p.kind == "btcpay") { return Err(AppError::Conflict(format!( - "BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.", - existing.store_id, + "merchant profile '{}' already has a BTCPay provider attached. \ + Disconnect it first if you want to re-authorize, or pick a different profile.", + profile.name ))); } @@ -83,7 +115,7 @@ pub async fn start_connect( rand::thread_rng().fill_bytes(&mut raw); let state_token = BASE32_NOPAD.encode(&raw); - btcpay_cfg::record_authorize_state(&state.db, &state_token) + btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id)) .await .map_err(AppError::Internal)?; @@ -124,9 +156,11 @@ pub async fn start_connect( urlencoding::encode(&redirect), ); + let _ = req.label; // captured but not yet used — see finish_connect TODO for the future round-trip Ok(Json(ConnectResp { authorize_url, state: state_token, + merchant_profile_id: profile.id, })) } @@ -201,47 +235,63 @@ pub async fn callback_get( } /// Admin endpoint: list payment methods configured on the connected -/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`. -/// Used by the wrapper / future web UI to surface a "no wallet -/// configured" state. +/// BTCPay store. Defaults to the default-profile's BTCPay provider for +/// back-compat with the existing admin UI; the new merchant-profile +/// admin endpoint passes an explicit `provider_id` query param when +/// multiple BTCPay providers exist. pub async fn payment_methods( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; - let cfg = btcpay_cfg::load(&state.db) - .await - .map_err(AppError::Internal)? + let default = crate::merchant_profiles::require_default(&state.db).await?; + let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id) + .await?; + let row = rows + .into_iter() + .find(|p| p.kind == "btcpay") .ok_or(AppError::BtcpayNotConfigured)?; - let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id) + let store_id = row.store_id.as_deref().unwrap_or(""); + let methods = btcpay_client::list_payment_methods(&row.base_url, &row.api_key, store_id) .await - .map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?; - - // Return both the raw array for callers that want detail, and a - // boolean summary for the common "is anything configured?" check. + .map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e:#}")))?; let count = methods.len(); Ok(Json(json!({ - "store_id": cfg.store_id, + "store_id": store_id, "count": count, "methods": methods, }))) } -/// Admin endpoint: report current BTCPay connection status. +/// Admin endpoint: report BTCPay connection status for the default +/// profile (back-compat with the existing admin UI's payment-providers +/// card). Multi-profile operators use `/v1/admin/merchant-profiles` to +/// see all attached providers. pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; - - let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?; - Ok(Json(match cfg { + let default = crate::merchant_profiles::get_default(&state.db).await?; + let row = match &default { + Some(profile) => { + let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id) + .await?; + rows.into_iter().find(|p| p.kind == "btcpay") + } + None => None, + }; + Ok(Json(match row { None => json!({ "connected": false }), - Some(c) => json!({ + Some(p) => json!({ "connected": true, - "store_id": c.store_id, - "webhook_id": c.webhook_id, - "base_url": c.base_url, + "provider_id": p.id, + "store_id": p.store_id, + "webhook_id": p.webhook_id, + "base_url": p.base_url, + "label": p.label, + "merchant_profile_id": default.as_ref().map(|d| d.id.clone()), + "merchant_profile_name": default.as_ref().map(|d| d.name.clone()), }), })) } @@ -249,9 +299,22 @@ pub async fn status( // --- internals --- async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> { - btcpay_cfg::consume_authorize_state(&state.db, state_token) + // Recovers the `merchant_profile_id` recorded when the operator + // kicked off the connect flow. NULL falls back to the default + // profile (back-compat for state tokens from pre-0022 runs). + let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token) .await .map_err(|_| AppError::Unauthorized)?; + let profile = match recorded_profile_id.as_deref() { + Some(id) => crate::merchant_profiles::get(&state.db, id) + .await? + .ok_or_else(|| AppError::BadRequest(format!( + "merchant profile {id} no longer exists — the operator may have \ + deleted it during the authorize round-trip. Reconnect from a \ + valid profile." + )))?, + None => crate::merchant_profiles::require_default(&state.db).await?, + }; let base_url = &state.config.btcpay_url; @@ -260,7 +323,7 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A // first one that the key can see. let stores = btcpay_client::list_stores(base_url, api_key) .await - .map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?; + .map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e:#}")))?; let store = stores .into_iter() .next() @@ -273,7 +336,14 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A rand::thread_rng().fill_bytes(&mut raw_secret); let webhook_secret = BASE32_NOPAD.encode(&raw_secret); - let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url); + // Pre-generate the provider id so we can bake it into the webhook + // URL we register with BTCPay. The webhook router routes by this + // path-param id, isolating deliveries per-provider per-profile. + let provider_id = uuid::Uuid::new_v4().to_string(); + let callback_url = format!( + "{}/v1/btcpay/webhook/{}", + state.config.public_base_url, provider_id + ); let created_webhook = btcpay_client::create_webhook( base_url, @@ -283,46 +353,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A &webhook_secret, ) .await - .map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?; + .map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e:#}")))?; - // Persist. - let cfg = btcpay_cfg::BtcpayConfig { - base_url: base_url.clone(), - api_key: api_key.to_string(), - store_id: store.id.clone(), - webhook_id: Some(created_webhook.id.clone()), - webhook_secret: webhook_secret.clone(), - }; - btcpay_cfg::save(&state.db, &cfg) - .await - .map_err(AppError::Internal)?; - - // Swap runtime — wrap a fresh BtcpayProvider into the - // PaymentProvider trait object held by AppState. Pass the - // public-facing BTCPay URL too so that checkout URLs returned to - // buyers get rewritten from the internal Docker hostname to a - // browser-reachable host. - let client = BtcpayClient::new(base_url, api_key, &store.id); - let provider = Arc::new( - BtcpayProvider::new(client, webhook_secret) - .with_public_base(state.config.btcpay_public_url.clone()), - ); - state.set_payment_provider(provider).await; - // Persist active-provider preference so the boot-time loader - // picks BTCPay on next restart even if Zaprite's config row - // is also still in the DB. Failure here is non-fatal (BTCPay - // is the historical default, so the fallback loader picks it - // anyway) but logged. - if let Err(e) = crate::payment::write_active_provider_preference( + // Persist as a payment_providers row attached to the chosen profile. + let label = format!("BTCPay — {}", profile.name); + let now = chrono::Utc::now().to_rfc3339(); + crate::db::repo::create_payment_provider( &state.db, - crate::payment::ProviderKind::Btcpay, + &provider_id, + &profile.id, + "btcpay", + &label, + api_key, + base_url, + Some(&created_webhook.id), + Some(&webhook_secret), + Some(&store.id), + &now, ) - .await - { - tracing::warn!(error = %e, "failed to record BTCPay as active payment provider"); + .await?; + + // If this is the first provider on the default profile, also + // populate the back-compat singleton so the few remaining + // state.payment_provider() callers work without a daemon restart. + let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id) + .await?; + if profile.is_default && existing.len() == 1 { + let client = BtcpayClient::new(base_url, api_key, &store.id); + let provider = Arc::new( + BtcpayProvider::new(client, webhook_secret.clone()) + .with_public_base(state.config.btcpay_public_url.clone()), + ); + state.set_payment_provider(provider).await; } tracing::info!( + provider_id = %provider_id, + merchant_profile_id = %profile.id, store = %store.id, store_name = %store.name, webhook_id = %created_webhook.id, @@ -342,31 +409,52 @@ h2{{color:#0a7}} (StatusCode::OK, Html(body)).into_response() } -/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the -/// webhook + API key on BTCPay's side, then unconditional clear of the -/// local config row. If BTCPay is unreachable, the local state is still -/// cleared and the operator gets a warning to clean up BTCPay manually. +#[derive(Debug, Deserialize, Default)] +pub struct DisconnectReq { + /// Which provider row to disconnect. NULL = the BTCPay provider on + /// the default merchant profile (back-compat for the existing admin + /// UI's single-button Disconnect). + #[serde(default)] + pub provider_id: Option, +} + +/// Admin endpoint: disconnect a BTCPay provider. Best-effort revocation +/// of the webhook + API key on BTCPay's side, then unconditional delete +/// of the local payment_providers row. If BTCPay is unreachable, the +/// local state is still cleared and the operator gets a warning to +/// clean up BTCPay manually. pub async fn disconnect( State(state): State, headers: HeaderMap, + body: Option>, ) -> AppResult> { let actor_hash = require_admin(&state, &headers)?; let (ip, ua) = crate::api::admin::request_context(&headers); + let req = body.map(|Json(b)| b).unwrap_or_default(); - let cfg = btcpay_cfg::load(&state.db) - .await - .map_err(AppError::Internal)?; - let Some(cfg) = cfg else { + let provider_row = match req.provider_id.as_deref() { + Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, pid) + .await? + .filter(|p| p.kind == "btcpay"), + None => { + // Default-profile fallback for the existing admin UI. + let default = crate::merchant_profiles::require_default(&state.db).await?; + let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id) + .await?; + rows.into_iter().find(|p| p.kind == "btcpay") + } + }; + let Some(provider_row) = provider_row else { return Ok(Json(json!({ "ok": true, "noop": true, - "message": "BTCPay was not connected; nothing to do.", + "message": "no BTCPay provider connected on the named profile", }))); }; - // Capture metadata for the response BEFORE we clear local state. - let store_id = cfg.store_id.clone(); - let webhook_id = cfg.webhook_id.clone(); + let provider_id = provider_row.id.clone(); + let store_id = provider_row.store_id.clone().unwrap_or_default(); + let webhook_id = provider_row.webhook_id.clone(); // Best-effort remote cleanup. We DON'T short-circuit if either of // these calls fails — the operator's intent is to disconnect, and @@ -377,9 +465,9 @@ pub async fn disconnect( let mut warnings: Vec = Vec::new(); if let Some(webhook_id) = webhook_id.as_deref() { if let Err(e) = btcpay_client::delete_webhook( - &cfg.base_url, - &cfg.api_key, - &cfg.store_id, + &provider_row.base_url, + &provider_row.api_key, + &store_id, webhook_id, ) .await @@ -390,52 +478,35 @@ pub async fn disconnect( )); } } - if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await { + if let Err(e) = btcpay_client::revoke_api_key(&provider_row.base_url, &provider_row.api_key).await { warnings.push(format!( "Could not revoke BTCPay API key: {e}. \ You may want to manually revoke it in BTCPay's account API-keys page." )); } - btcpay_cfg::clear(&state.db) - .await - .map_err(AppError::Internal)?; + crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?; - // Replace the runtime payment provider so subsequent purchase - // attempts return BtcpayNotConfigured cleanly. + // Clear the back-compat singleton if it was holding this one. state.clear_payment_provider().await; - // If BTCPay was the recorded active-provider preference, clear - // it. Don't blindly clear if it was Zaprite — different operator - // intent. - if matches!( - crate::payment::read_active_provider_preference(&state.db).await, - Some(crate::payment::ProviderKind::Btcpay) - ) { - let _ = crate::db::repo::settings_set( - &state.db, - crate::payment::SETTING_ACTIVE_PROVIDER, - None, - ) - .await; - } - let _ = crate::db::repo::insert_audit( &state.db, "admin_api_key", Some(&actor_hash), - "btcpay.disconnect", - Some("btcpay_config"), - None, + "payment_provider.disconnect", + Some("payment_provider"), + Some(&provider_id), ip.as_deref(), ua.as_deref(), - &json!({ "store_id": store_id, "webhook_id": webhook_id }), + &json!({ "kind": "btcpay", "store_id": store_id, "webhook_id": webhook_id }), ) .await; Ok(Json(json!({ "ok": true, "noop": false, + "provider_id": provider_id, "store_id": store_id, "webhook_id": webhook_id, "warnings": warnings, diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index b2b62a8..7ef51da 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -352,6 +352,7 @@ pub fn router(state: AppState) -> Router { .route("/v1/machines/heartbeat", post(machines::heartbeat)) .route("/v1/machines/deactivate", post(machines::deactivate)) .route("/v1/btcpay/webhook", post(webhook::handle)) + .route("/v1/btcpay/webhook/:provider_id", post(webhook::handle_for_provider)) .route( "/v1/admin/btcpay/connect", post(btcpay_authorize::start_connect), @@ -389,22 +390,23 @@ pub fn router(state: AppState) -> Router { get(zaprite_authorize::status), ) // Provider-agnostic active-payment-provider control. - // Operators with both BTCPay and Zaprite configured can flip - // the active one without re-running Connect. + // Back-compat snapshot of the default profile's providers. The + // legacy `activate` endpoint is removed — in the merchant-profile + // model providers attach to profiles and products pick a profile + // at resolution time; there's no singleton "active" preference to + // flip. Multi-profile operators should use the new + // /v1/admin/merchant-profiles endpoints instead. .route( "/v1/admin/payment-provider/status", get(payment_provider::status), ) - .route( - "/v1/admin/payment-provider/activate", - post(payment_provider::activate), - ) // Zaprite webhook landing — operator points Zaprite's // webhook setting at this URL. Same handler as // /v1/btcpay/webhook because the underlying validate_webhook // is on the trait surface and the active provider self- // identifies its event shape. .route("/v1/zaprite/webhook", post(webhook::handle)) + .route("/v1/zaprite/webhook/:provider_id", post(webhook::handle_for_provider)) .route("/v1/admin/products", post(admin::create_product)) .route( "/v1/admin/products/:id", @@ -713,17 +715,51 @@ async fn thank_you( // Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning // + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus - // Bitcoin. The lede and the polling-status copy should reflect which - // payment rails are actually in play so a buyer who paid by card - // doesn't see "your Bitcoin payment was received" while their Stripe - // transaction shows up in the operator's dashboard. + // Bitcoin. The lede and the polling-status copy reflect which payment + // rails actually settled THIS invoice, not "the currently active + // provider" (which is meaningless in the multi-provider model). // - // Today this reads `SETTING_ACTIVE_PROVIDER` (the singleton model). - // When the multi-provider work lands, swap this for a lookup of the - // invoice's own `payment_provider_id` so the copy matches the rail - // that actually settled THIS purchase, not whatever's currently - // active on the daemon. - let provider_kind = crate::payment::read_active_provider_preference(&state.db).await; + // Look up the invoice's own `payment_provider_id` (recorded by + // migration 0021) → resolve to its kind via payment_providers. Falls + // back to whichever provider is attached to the default profile if + // the invoice predates 0021, then to BTCPay if even THAT can't be + // resolved (operator visited /thank-you with no providers connected + // at all — rare). + let invoice_provider_kind: Option = if !invoice_id.is_empty() { + let row: Option<(Option,)> = sqlx::query_as( + "SELECT i.payment_provider_id FROM invoices i WHERE i.id = ? LIMIT 1", + ) + .bind(&invoice_id) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + match row.and_then(|(pid,)| pid) { + Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, &pid) + .await + .ok() + .flatten() + .and_then(|p| crate::payment::ProviderKind::parse(&p.kind)), + None => None, + } + } else { + None + }; + let provider_kind = match invoice_provider_kind { + Some(k) => Some(k), + None => { + // Fall back to the default profile's first provider. + let default = crate::merchant_profiles::get_default(&state.db).await.ok().flatten(); + match default { + Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id) + .await + .ok() + .and_then(|rows| rows.into_iter().next()) + .and_then(|row| crate::payment::ProviderKind::parse(&row.kind)), + None => None, + } + } + }; let (lede_text, provider_kind_str) = match provider_kind { Some(crate::payment::ProviderKind::Zaprite) => ( "Your payment was received. We\u{2019}re waiting for it to settle and \ diff --git a/licensing-service/src/api/payment_provider.rs b/licensing-service/src/api/payment_provider.rs index d9e6d7b..f36ae73 100644 --- a/licensing-service/src/api/payment_provider.rs +++ b/licensing-service/src/api/payment_provider.rs @@ -1,140 +1,64 @@ -//! Active-provider swap endpoint. +//! Payment-provider status endpoint (multi-merchant-profile model). //! -//! When an operator has both BTCPay AND Zaprite configured (i.e., -//! they ran Connect on both at some point), this lets them flip -//! the active one without re-authorizing. The Connect flows are -//! still where credentials live; this endpoint only changes which -//! credentials the daemon currently routes through. +//! Pre-:52 this module held two endpoints: +//! - `GET /v1/admin/payment-provider/status` — which provider was +//! active, plus configured flags for BTCPay + Zaprite. +//! - `POST /v1/admin/payment-provider/activate` — flip the singleton +//! active-provider preference between two configured ones. +//! +//! Both became meaningless in the merchant-profile model — providers +//! aren't "active," they attach to profiles, and products pick a profile +//! at the resolution layer. The activate endpoint is removed. The status +//! endpoint stays as a back-compat shim so the existing admin UI's +//! payment-providers card keeps rendering until the new Merchant +//! Profiles UI replaces it: it now reports against the DEFAULT profile +//! (single-profile operators see no change). Multi-profile operators +//! should use the new `/v1/admin/merchant-profiles` endpoints to see +//! all providers across all profiles. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::require_admin; use crate::api::AppState; -use crate::error::{AppError, AppResult}; -use crate::payment::{ - self, btcpay::BtcpayProvider, zaprite::ZapriteProvider, ProviderKind, -}; +use crate::error::AppResult; use axum::{extract::State, http::HeaderMap, Json}; -use serde::Deserialize; use serde_json::{json, Value}; -use std::sync::Arc; -#[derive(Debug, Deserialize)] -pub struct ActivateReq { - /// `'btcpay'` or `'zaprite'`. Other values → 400. - pub provider: String, -} - -/// `GET /v1/admin/payment-provider/status` — both providers' -/// configuration state at a glance, plus the active preference. -/// Lets the SPA render a "BTCPay [active] / Zaprite [configured, -/// not active]" header without two separate fetches. +/// `GET /v1/admin/payment-provider/status` — back-compat snapshot of +/// providers attached to the default merchant profile. Returns the same +/// shape as pre-:52 with `btcpay_configured` / `zaprite_configured` / +/// `active` for compatibility with the existing admin UI; new code +/// should use `/v1/admin/merchant-profiles/{id}` instead. pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; - let btcpay_configured = crate::btcpay::config::load(&state.db) - .await - .map(|o| o.is_some()) - .unwrap_or(false); - let zaprite_configured = payment::zaprite::config::load(&state.db) - .await - .map(|o| o.is_some()) - .unwrap_or(false); - let preference = payment::read_active_provider_preference(&state.db).await; - let active_runtime = match state.payment.read().await.as_ref() { - Some(p) => Some(p.kind().as_str().to_string()), - None => None, + let default = crate::merchant_profiles::get_default(&state.db).await?; + let providers = match &default { + Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?, + None => Vec::new(), }; + let btcpay_row = providers.iter().find(|p| p.kind == "btcpay").cloned(); + let zaprite_row = providers.iter().find(|p| p.kind == "zaprite").cloned(); + // "active" used to mean "the singleton active-provider preference." + // In the new model there isn't one. For back-compat we report the + // FIRST provider on the default profile (which is what the legacy + // boot-loader semantics would have picked) so the existing admin UI + // shows a sensible active badge. Multi-rail operators get the full + // picture from the new merchant-profile endpoints. + let active_runtime = providers.first().map(|p| p.kind.clone()); Ok(Json(json!({ - "btcpay_configured": btcpay_configured, - "zaprite_configured": zaprite_configured, - "preferred": preference.map(|k| k.as_str().to_string()), + "btcpay_configured": btcpay_row.is_some(), + "zaprite_configured": zaprite_row.is_some(), + "preferred": active_runtime.clone(), "active": active_runtime, - }))) -} - -/// `POST /v1/admin/payment-provider/activate` — swap the active -/// provider to whichever already-configured one the operator -/// names. 400 if the named provider isn't configured (run Connect -/// first). -pub async fn activate( - State(state): State, - headers: HeaderMap, - Json(req): Json, -) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; - let (ip, ua) = request_context(&headers); - - let kind = match req.provider.to_lowercase().as_str() { - "btcpay" => ProviderKind::Btcpay, - "zaprite" => ProviderKind::Zaprite, - other => { - return Err(AppError::BadRequest(format!( - "unknown provider '{other}'; accepted: btcpay, zaprite" - ))) - } - }; - - // Build the provider from its persisted config. Refuse if the - // config row isn't there — operator has to run Connect first. - match kind { - ProviderKind::Btcpay => { - let cfg = crate::btcpay::config::load(&state.db) - .await - .map_err(AppError::Internal)? - .ok_or_else(|| { - AppError::BadRequest( - "BTCPay not configured. Run Connect BTCPay first.".into(), - ) - })?; - let client = crate::btcpay::client::BtcpayClient::new( - &cfg.base_url, - &cfg.api_key, - &cfg.store_id, - ); - let provider = Arc::new( - BtcpayProvider::new(client, cfg.webhook_secret) - .with_public_base(state.config.btcpay_public_url.clone()), - ); - state.set_payment_provider(provider).await; - } - ProviderKind::Zaprite => { - crate::api::tier::enforce_zaprite_feature(&state).await?; - let cfg = payment::zaprite::config::load(&state.db) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}")))? - .ok_or_else(|| { - AppError::BadRequest( - "Zaprite not configured. Run Connect Zaprite first.".into(), - ) - })?; - let client = payment::zaprite::ZapriteClient::new(&cfg.base_url, &cfg.api_key); - let provider = Arc::new(ZapriteProvider::new(client)); - state.set_payment_provider(provider).await; - } - } - - // Persist the preference so the boot-time loader picks the - // same one on next restart. - payment::write_active_provider_preference(&state.db, kind) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("write preference: {e:#}")))?; - - let _ = crate::db::repo::insert_audit( - &state.db, - "admin_api_key", - Some(&actor_hash), - "payment_provider.activate", - Some("payment_provider"), - Some(kind.as_str()), - ip.as_deref(), - ua.as_deref(), - &json!({ "provider": kind.as_str() }), - ) - .await; - - Ok(Json(json!({ - "ok": true, - "active": kind.as_str(), + "merchant_profile_id": default.as_ref().map(|p| p.id.clone()), + "merchant_profile_name": default.as_ref().map(|p| p.name.clone()), + "providers": providers.iter().map(|p| json!({ + "id": p.id, + "kind": p.kind, + "label": p.label, + "base_url": p.base_url, + "store_id": p.store_id, + })).collect::>(), }))) } diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs index 11786a3..e63916c 100644 --- a/licensing-service/src/api/webhook.rs +++ b/licensing-service/src/api/webhook.rs @@ -23,20 +23,51 @@ use crate::error::{AppError, AppResult}; use crate::payment::ProviderWebhookEvent; use axum::{ body::Bytes, - extract::State, + extract::{Path, State}, http::{HeaderMap, StatusCode}, }; use chrono::Utc; +/// Multi-provider webhook landing: `/v1/{kind}/webhook/:provider_id`. +/// The provider id picks WHICH provider's secret validates this delivery. +/// Without that, an operator with two BTCPay providers across two merchant +/// profiles would have indistinguishable webhook URLs and BTCPay payloads +/// would round-robin to whoever happened to be "the active provider" at +/// request time. The path-param resolution ensures every delivery is +/// validated against the secret it was created with. +pub async fn handle_for_provider( + State(state): State, + Path(provider_id): Path, + headers: HeaderMap, + body: Bytes, +) -> AppResult { + let provider = state.payment_provider_by_id(&provider_id).await?; + handle_inner(state, provider, headers, body).await +} + +/// Back-compat landing for the pre-:52 URL shape. Routes to whichever +/// provider is on the default merchant profile. New webhooks registered +/// against `:52`+ use the path-keyed shape above; this exists so any +/// in-flight pre-:52 delivery (or operator misconfiguration) doesn't +/// silently drop on the floor. pub async fn handle( State(state): State, headers: HeaderMap, body: Bytes, ) -> AppResult { - // Active provider validates its own webhooks (each provider has a - // different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig, - // Zaprite's TBD). On any verification failure we 401. let provider = state.payment_provider().await?; + handle_inner(state, provider, headers, body).await +} + +async fn handle_inner( + state: AppState, + provider: std::sync::Arc, + headers: HeaderMap, + body: Bytes, +) -> AppResult { + // The resolved provider validates its own webhooks (each provider has + // a different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig, + // Zaprite's externalUniqId round-trip). On verification failure: 401. let event = provider .validate_webhook(&headers, &body) .map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?; diff --git a/licensing-service/src/btcpay/config.rs b/licensing-service/src/btcpay/config.rs index 9f1099e..a0e4881 100644 --- a/licensing-service/src/btcpay/config.rs +++ b/licensing-service/src/btcpay/config.rs @@ -79,14 +79,22 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> { Ok(()) } -/// Record a new in-flight authorize state token. The caller has already -/// generated a cryptographically-random token. -pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> { +/// Record a new in-flight authorize state token. `merchant_profile_id` +/// (multi-provider model, migration 0022) names which merchant profile +/// the resulting provider row should attach to when the callback fires +/// — None falls back to "the default profile" at consume-time. +pub async fn record_authorize_state( + pool: &SqlitePool, + token: &str, + merchant_profile_id: Option<&str>, +) -> Result<()> { let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)", + "INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \ + VALUES (?, ?, ?)", ) .bind(token) + .bind(merchant_profile_id) .bind(&now) .execute(pool) .await @@ -101,11 +109,17 @@ pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<() } /// Validate that `token` was issued recently and has not been consumed. -/// Consumes (deletes) the token on success so a replay fails. -pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> { +/// Consumes (deletes) the token on success so a replay fails, and +/// returns the `merchant_profile_id` recorded at start-connect time so +/// the callback knows which profile to attach the new provider to. +pub async fn consume_authorize_state( + pool: &SqlitePool, + token: &str, +) -> Result> { + use sqlx::Row; let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339(); let row = sqlx::query( - "SELECT state_token FROM btcpay_authorize_state \ + "SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \ WHERE state_token = ? AND created_at >= ?", ) .bind(token) @@ -113,13 +127,14 @@ pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<( .fetch_optional(pool) .await?; - if row.is_none() { + let Some(row) = row else { return Err(anyhow!("unknown or expired authorize state token")); - } + }; + let merchant_profile_id: Option = row.try_get("merchant_profile_id").ok().flatten(); sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?") .bind(token) .execute(pool) .await?; - Ok(()) + Ok(merchant_profile_id) }