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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user