diff --git a/licensing-service/src/api/btcpay_authorize.rs b/licensing-service/src/api/btcpay_authorize.rs index b0859fd..fb7fd16 100644 --- a/licensing-service/src/api/btcpay_authorize.rs +++ b/licensing-service/src/api/btcpay_authorize.rs @@ -308,6 +308,19 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A .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( + &state.db, + crate::payment::ProviderKind::Btcpay, + ) + .await + { + tracing::warn!(error = %e, "failed to record BTCPay as active payment provider"); + } tracing::info!( store = %store.id, @@ -392,6 +405,21 @@ pub async fn disconnect( // attempts return BtcpayNotConfigured cleanly. 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", diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 8fb4d32..f112c50 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -71,6 +71,7 @@ pub mod tier; pub mod validate; pub mod community; pub mod db_info; +pub mod payment_provider; pub mod rates_admin; pub mod recover; pub mod zaprite_authorize; @@ -245,6 +246,17 @@ pub fn router(state: AppState) -> Router { "/v1/admin/zaprite/status", 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. + .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 diff --git a/licensing-service/src/api/payment_provider.rs b/licensing-service/src/api/payment_provider.rs new file mode 100644 index 0000000..5389084 --- /dev/null +++ b/licensing-service/src/api/payment_provider.rs @@ -0,0 +1,139 @@ +//! Active-provider swap endpoint. +//! +//! 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. + +use crate::api::admin::{request_context, require_admin}; +use crate::api::AppState; +use crate::error::{AppError, AppResult}; +use crate::payment::{ + self, btcpay::BtcpayProvider, zaprite::ZapriteProvider, ProviderKind, +}; +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. +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, + }; + Ok(Json(json!({ + "btcpay_configured": btcpay_configured, + "zaprite_configured": zaprite_configured, + "preferred": preference.map(|k| k.as_str().to_string()), + "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 => { + 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(), + }))) +} diff --git a/licensing-service/src/api/zaprite_authorize.rs b/licensing-service/src/api/zaprite_authorize.rs index 0ae8693..cee288e 100644 --- a/licensing-service/src/api/zaprite_authorize.rs +++ b/licensing-service/src/api/zaprite_authorize.rs @@ -95,6 +95,15 @@ pub async fn connect( state .set_payment_provider(Arc::new(provider)) .await; + // Persist the operator's preference so the boot-time loader + // picks Zaprite on next restart, even if BTCPay's config row + // is also still in the DB. + crate::payment::write_active_provider_preference( + &state.db, + crate::payment::ProviderKind::Zaprite, + ) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("write provider preference: {e:#}")))?; let _ = crate::db::repo::insert_audit( &state.db, @@ -146,6 +155,21 @@ pub async fn disconnect( AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}")) })?; state.clear_payment_provider().await; + // If the active-provider preference was Zaprite, clear it. + // Don't blindly clear if it was BTCPay — that's a different + // operator's choice we shouldn't undo just because they ran + // Disconnect Zaprite. + if matches!( + crate::payment::read_active_provider_preference(&state.db).await, + Some(crate::payment::ProviderKind::Zaprite) + ) { + let _ = crate::db::repo::settings_set( + &state.db, + crate::payment::SETTING_ACTIVE_PROVIDER, + None, + ) + .await; + } let _ = crate::db::repo::insert_audit( &state.db, diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 723f56f..1393a4e 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -58,20 +58,53 @@ async fn main() -> anyhow::Result<()> { ); // --- payment provider (may be None until operator connects) --- - // Resolution order: BTCPay first (the original / default), then - // Zaprite. If both are configured, BTCPay wins — operators with - // both connected get sat-priced flows through BTCPay; the - // future v0.3 multi-provider routing will let policies pick - // which provider handles which payment rail. - let provider: Option> = { - if let Some(p) = load_btcpay_provider(&pool, &cfg).await { - let arc: Arc = Arc::new(p); - Some(arc) - } else if let Some(p) = load_zaprite_provider(&pool).await { - let arc: Arc = Arc::new(p); - Some(arc) - } else { - None + // 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. + 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)) } }; match &provider { diff --git a/licensing-service/src/payment/mod.rs b/licensing-service/src/payment/mod.rs index d7963ec..c0319c5 100644 --- a/licensing-service/src/payment/mod.rs +++ b/licensing-service/src/payment/mod.rs @@ -40,6 +40,46 @@ use std::any::Any; pub mod btcpay; pub mod zaprite; +/// Settings-table key that records which provider the operator +/// last activated. Used by the boot-time loader to pick which +/// provider to load when both `btcpay_config` and `zaprite_config` +/// are populated. Values: `'btcpay'` | `'zaprite'`. Absent means +/// "use whichever single provider is configured" (back-compat +/// for installs that pre-date this setting). +pub const SETTING_ACTIVE_PROVIDER: &str = "active_payment_provider"; + +/// Convenience getter for the active-provider setting. Returns +/// `Some(ProviderKind)` if the operator has explicitly chosen +/// one, `None` if they haven't (caller falls back to the +/// load-order heuristic). +pub async fn read_active_provider_preference( + pool: &sqlx::SqlitePool, +) -> Option { + match crate::db::repo::settings_get(pool, SETTING_ACTIVE_PROVIDER).await { + Ok(Some(s)) => match s.as_str() { + "btcpay" => Some(ProviderKind::Btcpay), + "zaprite" => Some(ProviderKind::Zaprite), + _ => None, + }, + _ => None, + } +} + +/// Persist the operator's active-provider preference. Called by +/// the connect endpoints (Connect BTCPay, Connect Zaprite) and +/// by the new "Activate " endpoint that flips between +/// already-configured providers without re-authorizing. +pub async fn write_active_provider_preference( + pool: &sqlx::SqlitePool, + kind: ProviderKind, +) -> anyhow::Result<()> { + let value = kind.as_str(); + crate::db::repo::settings_set(pool, SETTING_ACTIVE_PROVIDER, Some(value)) + .await + .map_err(|e| anyhow::anyhow!("write active provider preference: {e:#}"))?; + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 4acf264..41e8c5d 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1204,6 +1204,143 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() { assert_eq!(row.4, 98_000); } +/// Active-provider preference round-trip. Pins the contract that +/// `Activate ` flips both the in-memory provider AND the +/// persisted preference so the next daemon boot picks the same one. +/// +/// Simulates the operator's lifecycle: +/// 1. Configure both BTCPay and Zaprite (both rows in DB) +/// 2. Activate Zaprite → preference flag = "zaprite" +/// 3. Activate BTCPay → preference flag = "btcpay" +/// 4. Disconnect BTCPay → preference flag cleared (because it +/// pointed at the wiped config) +/// 5. Disconnect Zaprite while preference was already "btcpay" +/// → preference NOT cleared (stays at "btcpay" because it +/// was pointing at a different provider) +#[tokio::test] +async fn payment_provider_preference_round_trip() { + use keysat::payment::{self, ProviderKind}; + + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Pre-seed both configs as if the operator had run Connect on + // each at some point. We bypass the actual Connect endpoints + // because they call out to BTCPay / Zaprite to validate the + // credentials, which we don't want to do in unit tests. + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO btcpay_config(id, base_url, api_key, store_id, webhook_id, \ + webhook_secret, connected_at) \ + VALUES(1, 'http://btcpay.test', 'btcpay-key', 'store-1', 'wh-1', \ + '0123456789abcdef', ?)", + ) + .bind(&now) + .execute(&state.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \ + VALUES(1, 'zaprite-key', 'https://api.zaprite.test', NULL, ?, ?)", + ) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await + .unwrap(); + + // Step 1: no preference recorded yet. + let pref = payment::read_active_provider_preference(&state.db).await; + assert_eq!(pref, None); + + // Step 2: GET status surfaces both as configured, no active yet. + let req = build_request( + "GET", + "/v1/admin/payment-provider/status", + &[("authorization", &auth)], + None, + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["btcpay_configured"], true); + assert_eq!(body["zaprite_configured"], true); + assert!(body["preferred"].is_null()); + + // Step 3: Activate Zaprite. The endpoint reads the saved + // zaprite_config to build the provider — the saved key + // 'zaprite-key' won't talk to a real API but the activate + // path doesn't ping; that's only on Connect. + let req = build_request( + "POST", + "/v1/admin/payment-provider/activate", + &[("authorization", &auth)], + Some(json!({"provider": "zaprite"})), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "activate zaprite should succeed when zaprite_config is present" + ); + let pref = payment::read_active_provider_preference(&state.db).await; + assert_eq!(pref, Some(ProviderKind::Zaprite)); + + // Step 4: Activate BTCPay. Preference flips. + let req = build_request( + "POST", + "/v1/admin/payment-provider/activate", + &[("authorization", &auth)], + Some(json!({"provider": "btcpay"})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let pref = payment::read_active_provider_preference(&state.db).await; + assert_eq!(pref, Some(ProviderKind::Btcpay)); + + // Step 5: Activate something that's not configured. Should 400. + sqlx::query("DELETE FROM zaprite_config WHERE id = 1") + .execute(&state.db) + .await + .unwrap(); + let req = build_request( + "POST", + "/v1/admin/payment-provider/activate", + &[("authorization", &auth)], + Some(json!({"provider": "zaprite"})), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "activating an unconfigured provider must 400 with 'run Connect first'" + ); + + // Step 6: Bad provider name → 400. + let req = build_request( + "POST", + "/v1/admin/payment-provider/activate", + &[("authorization", &auth)], + Some(json!({"provider": "stripe"})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Step 7: write_active_provider_preference invariant — + // explicit setting survives a re-read (durability across the + // simulated restart that the boot-time loader cares about). + payment::write_active_provider_preference(&state.db, ProviderKind::Btcpay) + .await + .unwrap(); + let pref = payment::read_active_provider_preference(&state.db).await; + assert_eq!(pref, Some(ProviderKind::Btcpay)); + payment::write_active_provider_preference(&state.db, ProviderKind::Zaprite) + .await + .unwrap(); + let pref = payment::read_active_provider_preference(&state.db).await; + assert_eq!(pref, Some(ProviderKind::Zaprite)); +} + /// Zaprite webhook authentication contract. /// /// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC, diff --git a/startos/actions/activatePaymentProvider.ts b/startos/actions/activatePaymentProvider.ts new file mode 100644 index 0000000..eb2b70e --- /dev/null +++ b/startos/actions/activatePaymentProvider.ts @@ -0,0 +1,89 @@ +// Switch the active payment provider WITHOUT re-running Connect. +// Use case: operator has both BTCPay and Zaprite configured (i.e., +// they ran Connect on both at some point) and wants to flip which +// one currently handles purchases. Two convenience actions — +// "Activate BTCPay" / "Activate Zaprite" — each POSTs to the +// daemon's /v1/admin/payment-provider/activate endpoint. +// +// If the named provider isn't yet configured, the daemon returns +// 400 with a "Run Connect first" message; we surface that to the +// operator unchanged. + +import { sdk } from '../sdk' +import { store } from '../fileModels/store' +import { adminCall, LICENSING_URL } from '../utils' + +async function activate(provider: 'btcpay' | 'zaprite') { + const storeData = await store.read().once() + if (!storeData) throw new Error('Store not initialized — restart the service.') + const resp = await adminCall( + LICENSING_URL, + storeData.admin_api_key, + '/v1/admin/payment-provider/activate', + { + method: 'POST', + body: JSON.stringify({ provider }), + }, + ) + if (!resp.ok) { + throw new Error(`Activate failed: HTTP ${resp.status} — ${await resp.text()}`) + } + const body = (await resp.json()) as { ok: true; active: string } + return body +} + +export const activateBtcpay = sdk.Action.withoutInput( + 'activate-btcpay', + async () => ({ + name: 'Activate BTCPay', + description: + 'Switch the active payment provider to BTCPay. Use this if both ' + + 'BTCPay and Zaprite are already connected and you want to flip ' + + "which one handles new purchases. Existing license keys aren't " + + 'affected by the swap.', + warning: null, + allowedStatuses: 'only-running', + group: 'BTCPay', + visibility: 'enabled', + }), + async () => { + const body = await activate('btcpay') + return { + version: '1', + title: 'BTCPay is now the active provider', + message: + `Active payment provider is now ${body.active}. New purchases ` + + `route through BTCPay. Zaprite remains configured but inactive ` + + `until you run "Activate Zaprite" or "Disconnect Zaprite".`, + result: null, + } + }, +) + +export const activateZaprite = sdk.Action.withoutInput( + 'activate-zaprite', + async () => ({ + name: 'Activate Zaprite', + description: + 'Switch the active payment provider to Zaprite. Use this if both ' + + 'BTCPay and Zaprite are already connected and you want to flip ' + + "which one handles new purchases. Existing license keys aren't " + + 'affected by the swap.', + warning: null, + allowedStatuses: 'only-running', + group: 'Zaprite', + visibility: 'enabled', + }), + async () => { + const body = await activate('zaprite') + return { + version: '1', + title: 'Zaprite is now the active provider', + message: + `Active payment provider is now ${body.active}. New purchases ` + + `route through Zaprite. BTCPay remains configured but inactive ` + + `until you run "Activate BTCPay" or "Disconnect BTCPay".`, + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index cf4179d..fe06293 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -20,6 +20,7 @@ import { sdk } from '../sdk' import { activateLicense, showLicenseStatus } from './activateLicense' +import { activateBtcpay, activateZaprite } from './activatePaymentProvider' import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay' import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite' import { setOperatorName } from './setOperatorName' @@ -33,10 +34,12 @@ export const actions = sdk.Actions.of() // BTCPay setup (Bitcoin-only payments via your own BTCPay Server) .addAction(configureBtcpay) .addAction(btcpayStatus) + .addAction(activateBtcpay) .addAction(disconnectBtcpay) // Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker) .addAction(configureZaprite) .addAction(zapriteStatus) + .addAction(activateZaprite) .addAction(disconnectZaprite) // Keysat self-license (Keysat-licenses-Keysat) .addAction(activateLicense)