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
+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 {