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()),
|
.with_public_base(state.config.btcpay_public_url.clone()),
|
||||||
);
|
);
|
||||||
state.set_payment_provider(provider).await;
|
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!(
|
tracing::info!(
|
||||||
store = %store.id,
|
store = %store.id,
|
||||||
@@ -392,6 +405,21 @@ pub async fn disconnect(
|
|||||||
// attempts return BtcpayNotConfigured cleanly.
|
// attempts return BtcpayNotConfigured cleanly.
|
||||||
state.clear_payment_provider().await;
|
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(
|
let _ = crate::db::repo::insert_audit(
|
||||||
&state.db,
|
&state.db,
|
||||||
"admin_api_key",
|
"admin_api_key",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ pub mod tier;
|
|||||||
pub mod validate;
|
pub mod validate;
|
||||||
pub mod community;
|
pub mod community;
|
||||||
pub mod db_info;
|
pub mod db_info;
|
||||||
|
pub mod payment_provider;
|
||||||
pub mod rates_admin;
|
pub mod rates_admin;
|
||||||
pub mod recover;
|
pub mod recover;
|
||||||
pub mod zaprite_authorize;
|
pub mod zaprite_authorize;
|
||||||
@@ -245,6 +246,17 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/v1/admin/zaprite/status",
|
"/v1/admin/zaprite/status",
|
||||||
get(zaprite_authorize::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
|
// Zaprite webhook landing — operator points Zaprite's
|
||||||
// webhook setting at this URL. Same handler as
|
// webhook setting at this URL. Same handler as
|
||||||
// /v1/btcpay/webhook because the underlying validate_webhook
|
// /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
|
state
|
||||||
.set_payment_provider(Arc::new(provider))
|
.set_payment_provider(Arc::new(provider))
|
||||||
.await;
|
.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(
|
let _ = crate::db::repo::insert_audit(
|
||||||
&state.db,
|
&state.db,
|
||||||
@@ -146,6 +155,21 @@ pub async fn disconnect(
|
|||||||
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
|
AppError::Internal(anyhow::anyhow!("clear zaprite_config: {e:#}"))
|
||||||
})?;
|
})?;
|
||||||
state.clear_payment_provider().await;
|
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(
|
let _ = crate::db::repo::insert_audit(
|
||||||
&state.db,
|
&state.db,
|
||||||
|
|||||||
@@ -58,20 +58,53 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- payment provider (may be None until operator connects) ---
|
// --- payment provider (may be None until operator connects) ---
|
||||||
// Resolution order: BTCPay first (the original / default), then
|
// Resolution order:
|
||||||
// Zaprite. If both are configured, BTCPay wins — operators with
|
// 1. operator's explicit preference from the
|
||||||
// both connected get sat-priced flows through BTCPay; the
|
// active_payment_provider setting (set by the most recent
|
||||||
// future v0.3 multi-provider routing will let policies pick
|
// Connect or Activate action),
|
||||||
// which provider handles which payment rail.
|
// 2. fallback for legacy installs without the setting:
|
||||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = {
|
// BTCPay first, Zaprite second. Once we ship v0.3 with the
|
||||||
if let Some(p) = load_btcpay_provider(&pool, &cfg).await {
|
// multi-provider routing layer this fallback retires.
|
||||||
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
|
let preferred = payment::read_active_provider_preference(&pool).await;
|
||||||
Some(arc)
|
let provider: Option<Arc<dyn payment::PaymentProvider>> = match preferred {
|
||||||
} else if let Some(p) = load_zaprite_provider(&pool).await {
|
Some(payment::ProviderKind::Zaprite) => {
|
||||||
let arc: Arc<dyn payment::PaymentProvider> = Arc::new(p);
|
// Operator explicitly chose Zaprite. Try Zaprite; if it
|
||||||
Some(arc)
|
// can't be loaded (e.g., the row was deleted out from
|
||||||
} else {
|
// 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
|
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 {
|
match &provider {
|
||||||
|
|||||||
@@ -40,6 +40,46 @@ use std::any::Any;
|
|||||||
pub mod btcpay;
|
pub mod btcpay;
|
||||||
pub mod zaprite;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ProviderKind {
|
pub enum ProviderKind {
|
||||||
|
|||||||
@@ -1204,6 +1204,143 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() {
|
|||||||
assert_eq!(row.4, 98_000);
|
assert_eq!(row.4, 98_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Active-provider preference round-trip. Pins the contract that
|
||||||
|
/// `Activate <provider>` 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 webhook authentication contract.
|
||||||
///
|
///
|
||||||
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
|
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
import { sdk } from '../sdk'
|
import { sdk } from '../sdk'
|
||||||
import { activateLicense, showLicenseStatus } from './activateLicense'
|
import { activateLicense, showLicenseStatus } from './activateLicense'
|
||||||
|
import { activateBtcpay, activateZaprite } from './activatePaymentProvider'
|
||||||
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
|
||||||
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite'
|
import { configureZaprite, disconnectZaprite, zapriteStatus } from './configureZaprite'
|
||||||
import { setOperatorName } from './setOperatorName'
|
import { setOperatorName } from './setOperatorName'
|
||||||
@@ -33,10 +34,12 @@ export const actions = sdk.Actions.of()
|
|||||||
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
|
// BTCPay setup (Bitcoin-only payments via your own BTCPay Server)
|
||||||
.addAction(configureBtcpay)
|
.addAction(configureBtcpay)
|
||||||
.addAction(btcpayStatus)
|
.addAction(btcpayStatus)
|
||||||
|
.addAction(activateBtcpay)
|
||||||
.addAction(disconnectBtcpay)
|
.addAction(disconnectBtcpay)
|
||||||
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
|
// Zaprite setup (Bitcoin + fiat-card payments via Zaprite's broker)
|
||||||
.addAction(configureZaprite)
|
.addAction(configureZaprite)
|
||||||
.addAction(zapriteStatus)
|
.addAction(zapriteStatus)
|
||||||
|
.addAction(activateZaprite)
|
||||||
.addAction(disconnectZaprite)
|
.addAction(disconnectZaprite)
|
||||||
// Keysat self-license (Keysat-licenses-Keysat)
|
// Keysat self-license (Keysat-licenses-Keysat)
|
||||||
.addAction(activateLicense)
|
.addAction(activateLicense)
|
||||||
|
|||||||
Reference in New Issue
Block a user