9df1908328
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.
migrations/0022_btcpay_state_profile.sql (new)
Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
the BTCPay OAuth state token can round-trip the operator's profile
pick between start_connect and the callback. Without this, multi-
profile operators couldn't authorize a SECOND BTCPay store onto a
non-default profile.
btcpay/config.rs
record_authorize_state takes merchant_profile_id; consume_authorize_state
now returns Option<String> so the callback knows which profile to
attach the new provider row to.
api/btcpay_authorize.rs (full rewrite)
start_connect accepts an optional merchant_profile_id (defaulting to
the default profile), refuses if that profile already has a BTCPay
provider attached (unique-index-friendly 409 message), and records
the profile id on the CSRF state token. The OAuth round-trip carries
the profile id back via the state token, not via a query param —
state-token-by-row is more robust than depending on BTCPay preserving
redirect-URL query params during the consent dance.
finish_connect (the callback's inner path):
- Pre-generates the payment_providers row id so it can be baked into
the BTCPay-side webhook callback URL.
- The webhook URL we register with BTCPay is now path-keyed:
/v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
isolated deliveries.
- INSERTs into payment_providers (kind='btcpay', api_key, base_url,
webhook_id, webhook_secret, store_id, attached to the chosen
profile) instead of upserting the singleton btcpay_config row.
- Populates the back-compat state.payment singleton ONLY when this
is the first provider on the default profile (so the few remaining
legacy state.payment_provider() callers still work without a
daemon restart).
disconnect accepts an optional provider_id; defaults to "the BTCPay
provider on the default profile" for back-compat with the existing
admin UI's single Disconnect button. Best-effort BTCPay-side webhook
+ API key revocation unchanged. DELETE FROM payment_providers WHERE
id = ? instead of clearing btcpay_config.
status + payment_methods report on the default-profile BTCPay row for
the legacy admin UI. Multi-profile operators will use the new
/v1/admin/merchant-profiles endpoints (part 4).
api/webhook.rs
Split into two entry points:
- handle_for_provider — the new path-keyed shape
(`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
via state.payment_provider_by_id, validates the payload against
THAT specific provider's secret, then runs the inner pipeline.
- handle — back-compat for the bare /v1/{kind}/webhook path. Routes
to whichever provider is on the default profile. Kept so any
in-flight pre-:52 webhook delivery or admin misconfiguration
doesn't silently drop on the floor.
Both share an extracted handle_inner that does the actual settle /
expire / refund processing.
api/mod.rs
Route registrations:
- Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
- Removes the legacy /v1/admin/payment-provider/activate route
(the shim function is gone).
Thank-you page provider-kind lookup ports from the deprecated
read_active_provider_preference to: invoice.payment_provider_id ->
payment_providers.kind -> ProviderKind. Falls back to the default
profile's first provider if the invoice predates migration 0021.
api/payment_provider.rs
Reduced to just the back-compat status endpoint. The activate
endpoint is removed entirely — there's no "active" preference to
flip in the merchant-profile model. Status returns the same
btcpay_configured / zaprite_configured / active shape the existing
admin UI consumes, plus a new providers[] array for callers that
want the full picture.
Build: cargo check passes. Only two warnings remaining — both
expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
branch in read_active_provider_preference that runs during the
pre-:52 upgrade window before migration 0020 has dropped the
settings row)
What's left for :52:
- New admin endpoints for merchant-profile + rail-preference CRUD
- Admin UI in web/index.html (biggest remaining chunk — Merchant
Profiles section + product picker + buy-page brand block +
rail picker)
- Tier-cap wire-up for unlimited_merchant_profiles
- Version bump + release notes + sandbox test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
515 lines
20 KiB
Rust
515 lines
20 KiB
Rust
//! BTCPay one-click authorize flow.
|
|
//!
|
|
//! Instead of making the operator generate an API key by hand and paste it
|
|
//! into a form, we use BTCPay's "authorize" redirect flow:
|
|
//!
|
|
//! 1. Operator clicks "Connect BTCPay" in StartOS — the wrapper action
|
|
//! calls `POST /v1/admin/btcpay/connect` (with the admin bearer token)
|
|
//! and gets back a BTCPay URL to open in the operator's browser.
|
|
//! 2. The operator, already logged into BTCPay on the same box, sees a
|
|
//! consent page listing the permissions this service is requesting. They
|
|
//! click **Authorize**.
|
|
//! 3. BTCPay POSTs back to our `/v1/btcpay/authorize/callback` with the
|
|
//! newly-minted API key and the store(s) it was scoped to.
|
|
//! 4. We persist the key, pick the target store, register the webhook (with
|
|
//! a freshly-generated secret), and save everything in `btcpay_config`.
|
|
//! 5. From that moment on, the `BtcpayProvider` (held as an `Arc<dyn
|
|
//! PaymentProvider>` in `AppState.payment`) is populated
|
|
//! and purchase / webhook endpoints work.
|
|
//!
|
|
//! If the callback fails for any reason, the operator is shown an error page
|
|
//! and can retry. The admin endpoint requires the admin bearer token; the
|
|
//! callback path uses the CSRF `state` token to tie a callback back to the
|
|
//! issuing operator session.
|
|
|
|
use crate::api::{admin::require_admin, AppState};
|
|
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
|
|
use crate::btcpay::config as btcpay_cfg;
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::payment::btcpay::BtcpayProvider;
|
|
use std::sync::Arc;
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{Html, IntoResponse, Redirect, Response},
|
|
Form, Json,
|
|
};
|
|
use data_encoding::BASE32_NOPAD;
|
|
use rand::RngCore;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
|
|
/// Permissions we request on the authorize page. Each is namespaced by
|
|
/// `btcpay.store.*` which means BTCPay will prompt the operator to pick
|
|
/// which store(s) to grant.
|
|
const REQUESTED_PERMISSIONS: &[&str] = &[
|
|
"btcpay.store.canviewstoresettings",
|
|
"btcpay.store.canmodifystoresettings", // to register the webhook
|
|
"btcpay.store.canviewinvoices",
|
|
"btcpay.store.cancreateinvoice",
|
|
"btcpay.store.canmodifyinvoices",
|
|
];
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ConnectResp {
|
|
/// URL the operator should open in their browser to authorize.
|
|
pub authorize_url: String,
|
|
/// CSRF state token tied to this round trip.
|
|
pub state: String,
|
|
/// Merchant profile the resulting provider row will attach to.
|
|
pub merchant_profile_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct StartConnectReq {
|
|
/// Which merchant profile to attach the BTCPay provider to. NULL =
|
|
/// the default profile (single-profile operators never see this).
|
|
#[serde(default)]
|
|
pub merchant_profile_id: Option<String>,
|
|
/// Operator-set label for the resulting payment_providers row. NULL =
|
|
/// auto-generated from the profile name.
|
|
#[serde(default)]
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
|
|
/// URL for the StartOS wrapper action to open in the operator's browser.
|
|
///
|
|
/// Accepts an optional `merchant_profile_id` so Pro/Patron operators can
|
|
/// connect multiple BTCPay stores onto different profiles side-by-side.
|
|
/// Single-profile operators (Creator tier, or anyone without an explicit
|
|
/// pick) get the default profile.
|
|
pub async fn start_connect(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
body: Option<Json<StartConnectReq>>,
|
|
) -> AppResult<Json<ConnectResp>> {
|
|
require_admin(&state, &headers)?;
|
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
|
|
|
// Resolve the target merchant profile (defaulting to the default).
|
|
let profile = match req.merchant_profile_id.as_deref() {
|
|
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
|
.await?
|
|
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))?,
|
|
None => crate::merchant_profiles::require_default(&state.db).await?,
|
|
};
|
|
|
|
// Idempotency: refuse to issue a new authorize URL if the same
|
|
// profile already has a BTCPay provider attached. Re-clicking
|
|
// Connect would otherwise INSERT-conflict at callback time (unique
|
|
// index on (merchant_profile_id, kind)) AND register a duplicate
|
|
// BTCPay webhook, producing duplicate-deliveries on every settle.
|
|
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
|
.await?;
|
|
if existing.iter().any(|p| p.kind == "btcpay") {
|
|
return Err(AppError::Conflict(format!(
|
|
"merchant profile '{}' already has a BTCPay provider attached. \
|
|
Disconnect it first if you want to re-authorize, or pick a different profile.",
|
|
profile.name
|
|
)));
|
|
}
|
|
|
|
// Random 20-byte token, base32-encoded, for the CSRF `state` parameter.
|
|
let mut raw = [0u8; 20];
|
|
rand::thread_rng().fill_bytes(&mut raw);
|
|
let state_token = BASE32_NOPAD.encode(&raw);
|
|
|
|
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
|
|
.await
|
|
.map_err(AppError::Internal)?;
|
|
|
|
// Construct the authorize URL per BTCPay's docs.
|
|
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
|
|
//
|
|
// CSRF state must travel inside the `redirect` URL itself, NOT as a
|
|
// separate query param on the outer authorize URL. Empirical
|
|
// observation against BTCPay: arbitrary query params on the
|
|
// authorize URL are NOT forwarded to the redirect target. The
|
|
// redirect URL is preserved verbatim, so any params we encode INTO
|
|
// it survive the round-trip.
|
|
let redirect = format!(
|
|
"{}/v1/btcpay/authorize/callback?state={}",
|
|
state.config.public_base_url,
|
|
urlencoding::encode(&state_token),
|
|
);
|
|
let perm_params = REQUESTED_PERMISSIONS
|
|
.iter()
|
|
.map(|p| format!("permissions={}", urlencoding::encode(p)))
|
|
.collect::<Vec<_>>()
|
|
.join("&");
|
|
|
|
// The authorize URL is followed by the operator's BROWSER, so the host
|
|
// must be reachable from outside the container. Use the explicit
|
|
// `btcpay_browser_url` if the wrapper provided it; fall back to
|
|
// `btcpay_url` only for dev/local setups (where they're the same).
|
|
let authorize_base = state
|
|
.config
|
|
.btcpay_browser_url
|
|
.as_deref()
|
|
.unwrap_or(&state.config.btcpay_url);
|
|
let authorize_url = format!(
|
|
"{}/api-keys/authorize?applicationName={}&applicationIdentifier={}&strict=true&selectiveStores=true&redirect={}&{perm_params}",
|
|
authorize_base,
|
|
urlencoding::encode("Keysat"),
|
|
urlencoding::encode("keysat"),
|
|
urlencoding::encode(&redirect),
|
|
);
|
|
|
|
let _ = req.label; // captured but not yet used — see finish_connect TODO for the future round-trip
|
|
Ok(Json(ConnectResp {
|
|
authorize_url,
|
|
state: state_token,
|
|
merchant_profile_id: profile.id,
|
|
}))
|
|
}
|
|
|
|
/// Fields BTCPay sends back on the callback. BTCPay POSTs `apiKey`,
|
|
/// `userId`, and `permissions[]` as a form body. It also preserves any
|
|
/// query-string parameters on the redirect URL — we use that for `state`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CallbackForm {
|
|
#[serde(rename = "apiKey")]
|
|
pub api_key: String,
|
|
#[serde(rename = "userId")]
|
|
pub user_id: Option<String>,
|
|
// BTCPay posts `permissions` one-per-occurrence; serde_urlencoded turns
|
|
// that into a repeated string. We don't actually need to parse them
|
|
// individually — we just re-verify via list_stores.
|
|
#[serde(default)]
|
|
pub permissions: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CallbackQuery {
|
|
pub state: String,
|
|
}
|
|
|
|
/// The real callback endpoint — POST form-encoded.
|
|
pub async fn callback(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<CallbackQuery>,
|
|
Form(form): Form<CallbackForm>,
|
|
) -> AppResult<Response> {
|
|
finish_connect(&state, &q.state, &form.api_key).await?;
|
|
Ok(success_page("BTCPay connected successfully. You can close this tab and return to Keysat."))
|
|
}
|
|
|
|
/// Some BTCPay deployments send the apiKey back as a query string on a GET.
|
|
/// Handle that too for robustness.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CallbackGetQuery {
|
|
pub state: String,
|
|
#[serde(rename = "apiKey")]
|
|
pub api_key: Option<String>,
|
|
/// Error message if BTCPay declined / operator clicked "Deny".
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
pub async fn callback_get(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<CallbackGetQuery>,
|
|
) -> Response {
|
|
if let Some(err) = q.error {
|
|
return Html(format!(
|
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
|
html_escape::encode_text(&err)
|
|
))
|
|
.into_response();
|
|
}
|
|
let Some(api_key) = q.api_key else {
|
|
// Some installs POST; in that case a bare GET with no apiKey is
|
|
// possible if the operator refreshes the tab. Redirect to root.
|
|
return Redirect::to("/").into_response();
|
|
};
|
|
match finish_connect(&state, &q.state, &api_key).await {
|
|
Ok(()) => success_page(
|
|
"BTCPay connected successfully. You can close this tab and return to Keysat.",
|
|
),
|
|
Err(e) => Html(format!(
|
|
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
|
|
html_escape::encode_text(&e.to_string())
|
|
))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Admin endpoint: list payment methods configured on the connected
|
|
/// BTCPay store. Defaults to the default-profile's BTCPay provider for
|
|
/// back-compat with the existing admin UI; the new merchant-profile
|
|
/// admin endpoint passes an explicit `provider_id` query param when
|
|
/// multiple BTCPay providers exist.
|
|
pub async fn payment_methods(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<Value>> {
|
|
require_admin(&state, &headers)?;
|
|
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
|
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
|
.await?;
|
|
let row = rows
|
|
.into_iter()
|
|
.find(|p| p.kind == "btcpay")
|
|
.ok_or(AppError::BtcpayNotConfigured)?;
|
|
let store_id = row.store_id.as_deref().unwrap_or("");
|
|
let methods = btcpay_client::list_payment_methods(&row.base_url, &row.api_key, store_id)
|
|
.await
|
|
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e:#}")))?;
|
|
let count = methods.len();
|
|
Ok(Json(json!({
|
|
"store_id": store_id,
|
|
"count": count,
|
|
"methods": methods,
|
|
})))
|
|
}
|
|
|
|
/// Admin endpoint: report BTCPay connection status for the default
|
|
/// profile (back-compat with the existing admin UI's payment-providers
|
|
/// card). Multi-profile operators use `/v1/admin/merchant-profiles` to
|
|
/// see all attached providers.
|
|
pub async fn status(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<Value>> {
|
|
require_admin(&state, &headers)?;
|
|
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
|
let row = match &default {
|
|
Some(profile) => {
|
|
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
|
.await?;
|
|
rows.into_iter().find(|p| p.kind == "btcpay")
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(Json(match row {
|
|
None => json!({ "connected": false }),
|
|
Some(p) => json!({
|
|
"connected": true,
|
|
"provider_id": p.id,
|
|
"store_id": p.store_id,
|
|
"webhook_id": p.webhook_id,
|
|
"base_url": p.base_url,
|
|
"label": p.label,
|
|
"merchant_profile_id": default.as_ref().map(|d| d.id.clone()),
|
|
"merchant_profile_name": default.as_ref().map(|d| d.name.clone()),
|
|
}),
|
|
}))
|
|
}
|
|
|
|
// --- internals ---
|
|
|
|
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
|
|
// Recovers the `merchant_profile_id` recorded when the operator
|
|
// kicked off the connect flow. NULL falls back to the default
|
|
// profile (back-compat for state tokens from pre-0022 runs).
|
|
let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
|
.await
|
|
.map_err(|_| AppError::Unauthorized)?;
|
|
let profile = match recorded_profile_id.as_deref() {
|
|
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
|
.await?
|
|
.ok_or_else(|| AppError::BadRequest(format!(
|
|
"merchant profile {id} no longer exists — the operator may have \
|
|
deleted it during the authorize round-trip. Reconnect from a \
|
|
valid profile."
|
|
)))?,
|
|
None => crate::merchant_profiles::require_default(&state.db).await?,
|
|
};
|
|
|
|
let base_url = &state.config.btcpay_url;
|
|
|
|
// Enumerate stores the key has access to. With `selectiveStores=true`
|
|
// the operator picked specific stores during authorize; we pick the
|
|
// first one that the key can see.
|
|
let stores = btcpay_client::list_stores(base_url, api_key)
|
|
.await
|
|
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e:#}")))?;
|
|
let store = stores
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| AppError::BadRequest(
|
|
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
|
|
))?;
|
|
|
|
// Generate a strong webhook secret, then register the webhook on BTCPay.
|
|
let mut raw_secret = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut raw_secret);
|
|
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
|
|
|
|
// Pre-generate the provider id so we can bake it into the webhook
|
|
// URL we register with BTCPay. The webhook router routes by this
|
|
// path-param id, isolating deliveries per-provider per-profile.
|
|
let provider_id = uuid::Uuid::new_v4().to_string();
|
|
let callback_url = format!(
|
|
"{}/v1/btcpay/webhook/{}",
|
|
state.config.public_base_url, provider_id
|
|
);
|
|
|
|
let created_webhook = btcpay_client::create_webhook(
|
|
base_url,
|
|
api_key,
|
|
&store.id,
|
|
&callback_url,
|
|
&webhook_secret,
|
|
)
|
|
.await
|
|
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e:#}")))?;
|
|
|
|
// Persist as a payment_providers row attached to the chosen profile.
|
|
let label = format!("BTCPay — {}", profile.name);
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
crate::db::repo::create_payment_provider(
|
|
&state.db,
|
|
&provider_id,
|
|
&profile.id,
|
|
"btcpay",
|
|
&label,
|
|
api_key,
|
|
base_url,
|
|
Some(&created_webhook.id),
|
|
Some(&webhook_secret),
|
|
Some(&store.id),
|
|
&now,
|
|
)
|
|
.await?;
|
|
|
|
// If this is the first provider on the default profile, also
|
|
// populate the back-compat singleton so the few remaining
|
|
// state.payment_provider() callers work without a daemon restart.
|
|
let existing = crate::db::repo::list_payment_providers_for_profile(&state.db, &profile.id)
|
|
.await?;
|
|
if profile.is_default && existing.len() == 1 {
|
|
let client = BtcpayClient::new(base_url, api_key, &store.id);
|
|
let provider = Arc::new(
|
|
BtcpayProvider::new(client, webhook_secret.clone())
|
|
.with_public_base(state.config.btcpay_public_url.clone()),
|
|
);
|
|
state.set_payment_provider(provider).await;
|
|
}
|
|
|
|
tracing::info!(
|
|
provider_id = %provider_id,
|
|
merchant_profile_id = %profile.id,
|
|
store = %store.id,
|
|
store_name = %store.name,
|
|
webhook_id = %created_webhook.id,
|
|
"BTCPay connected via authorize flow"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn success_page(msg: &str) -> Response {
|
|
let body = format!(
|
|
r#"<!doctype html><html><head><meta charset="utf-8"><title>BTCPay connected</title>
|
|
<style>body{{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:1rem;line-height:1.5}}
|
|
h2{{color:#0a7}}</style></head>
|
|
<body><h2>✓ {msg}</h2></body></html>"#,
|
|
msg = html_escape::encode_text(msg)
|
|
);
|
|
(StatusCode::OK, Html(body)).into_response()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct DisconnectReq {
|
|
/// Which provider row to disconnect. NULL = the BTCPay provider on
|
|
/// the default merchant profile (back-compat for the existing admin
|
|
/// UI's single-button Disconnect).
|
|
#[serde(default)]
|
|
pub provider_id: Option<String>,
|
|
}
|
|
|
|
/// Admin endpoint: disconnect a BTCPay provider. Best-effort revocation
|
|
/// of the webhook + API key on BTCPay's side, then unconditional delete
|
|
/// of the local payment_providers row. If BTCPay is unreachable, the
|
|
/// local state is still cleared and the operator gets a warning to
|
|
/// clean up BTCPay manually.
|
|
pub async fn disconnect(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
body: Option<Json<DisconnectReq>>,
|
|
) -> AppResult<Json<Value>> {
|
|
let actor_hash = require_admin(&state, &headers)?;
|
|
let (ip, ua) = crate::api::admin::request_context(&headers);
|
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
|
|
|
let provider_row = match req.provider_id.as_deref() {
|
|
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, pid)
|
|
.await?
|
|
.filter(|p| p.kind == "btcpay"),
|
|
None => {
|
|
// Default-profile fallback for the existing admin UI.
|
|
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
|
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
|
.await?;
|
|
rows.into_iter().find(|p| p.kind == "btcpay")
|
|
}
|
|
};
|
|
let Some(provider_row) = provider_row else {
|
|
return Ok(Json(json!({
|
|
"ok": true,
|
|
"noop": true,
|
|
"message": "no BTCPay provider connected on the named profile",
|
|
})));
|
|
};
|
|
|
|
let provider_id = provider_row.id.clone();
|
|
let store_id = provider_row.store_id.clone().unwrap_or_default();
|
|
let webhook_id = provider_row.webhook_id.clone();
|
|
|
|
// Best-effort remote cleanup. We DON'T short-circuit if either of
|
|
// these calls fails — the operator's intent is to disconnect, and
|
|
// leaving local state pointing at a remote we no longer trust is
|
|
// worse than leaving orphan state on the BTCPay side. Any failures
|
|
// are surfaced in the response so the operator can manually clean
|
|
// up on BTCPay if needed.
|
|
let mut warnings: Vec<String> = Vec::new();
|
|
if let Some(webhook_id) = webhook_id.as_deref() {
|
|
if let Err(e) = btcpay_client::delete_webhook(
|
|
&provider_row.base_url,
|
|
&provider_row.api_key,
|
|
&store_id,
|
|
webhook_id,
|
|
)
|
|
.await
|
|
{
|
|
warnings.push(format!(
|
|
"Could not delete BTCPay webhook {webhook_id}: {e}. \
|
|
You may want to manually delete it in BTCPay's store webhook settings."
|
|
));
|
|
}
|
|
}
|
|
if let Err(e) = btcpay_client::revoke_api_key(&provider_row.base_url, &provider_row.api_key).await {
|
|
warnings.push(format!(
|
|
"Could not revoke BTCPay API key: {e}. \
|
|
You may want to manually revoke it in BTCPay's account API-keys page."
|
|
));
|
|
}
|
|
|
|
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
|
|
|
|
// Clear the back-compat singleton if it was holding this one.
|
|
state.clear_payment_provider().await;
|
|
|
|
let _ = crate::db::repo::insert_audit(
|
|
&state.db,
|
|
"admin_api_key",
|
|
Some(&actor_hash),
|
|
"payment_provider.disconnect",
|
|
Some("payment_provider"),
|
|
Some(&provider_id),
|
|
ip.as_deref(),
|
|
ua.as_deref(),
|
|
&json!({ "kind": "btcpay", "store_id": store_id, "webhook_id": webhook_id }),
|
|
)
|
|
.await;
|
|
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"noop": false,
|
|
"provider_id": provider_id,
|
|
"store_id": store_id,
|
|
"webhook_id": webhook_id,
|
|
"warnings": warnings,
|
|
})))
|
|
}
|