v0.2.0:3 — durable payment-provider switching (Option B)

Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).

What changed:

**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.

**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.

**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
  successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
  successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
  provider being disconnected — but leave it alone if it pointed
  at the OTHER provider (different operator intent).

**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
  current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
  "zaprite" } — flips the active provider and the flag together,
  without going through the full Connect flow. 400 if the named
  provider isn't configured (operator must run Connect first).

**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.

**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.

Test count: 42 (was 41; +1).

Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
This commit is contained in:
Grant
2026-05-08 16:51:15 -05:00
parent 0a76c9d121
commit ec2b21d8f7
9 changed files with 519 additions and 14 deletions
@@ -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",
+12
View File
@@ -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
@@ -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<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
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<AppState>,
headers: HeaderMap,
Json(req): Json<ActivateReq>,
) -> AppResult<Json<Value>> {
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(),
})))
}
@@ -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,
+47 -14
View File
@@ -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<Arc<dyn payment::PaymentProvider>> = {
if let Some(p) = load_btcpay_provider(&pool, &cfg).await {
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
Some(arc)
} else if let Some(p) = load_zaprite_provider(&pool).await {
let arc: Arc<dyn payment::PaymentProvider> = 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<Arc<dyn payment::PaymentProvider>> = 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<dyn payment::PaymentProvider>)
.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<dyn payment::PaymentProvider>))
}
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<dyn payment::PaymentProvider>)
.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<dyn payment::PaymentProvider>))
}
};
match &provider {
+40
View File
@@ -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<ProviderKind> {
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 <provider>" 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 {