WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
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>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
-- Carry merchant_profile_id through the BTCPay OAuth round trip.
|
||||||
|
--
|
||||||
|
-- Operator hits POST /v1/admin/btcpay/connect with a merchant_profile_id,
|
||||||
|
-- daemon generates a CSRF state token and stores it; operator opens
|
||||||
|
-- BTCPay's authorize URL in their browser; BTCPay POSTs back to our
|
||||||
|
-- callback with the apiKey + the state token; daemon consumes the state
|
||||||
|
-- token and uses it to look up which merchant profile the new provider
|
||||||
|
-- row should attach to.
|
||||||
|
--
|
||||||
|
-- Pre-multi-provider, `btcpay_authorize_state` was a singleton-ish
|
||||||
|
-- pattern (one in-flight authorize at a time) and the resulting provider
|
||||||
|
-- always attached to "the singleton btcpay_config row." With multi-
|
||||||
|
-- profile, the operator might want to authorize a SECOND BTCPay store
|
||||||
|
-- onto a different profile (Pro/Patron); the state token has to
|
||||||
|
-- remember which profile they kicked off the flow from.
|
||||||
|
--
|
||||||
|
-- Additive: nullable column, NULL = "attach to the default profile"
|
||||||
|
-- (back-compat for any pre-:52 state tokens that survived a daemon
|
||||||
|
-- restart mid-flow, though the table is also pruned by timestamp).
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
ALTER TABLE btcpay_authorize_state
|
||||||
|
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
||||||
@@ -56,25 +56,57 @@ pub struct ConnectResp {
|
|||||||
pub authorize_url: String,
|
pub authorize_url: String,
|
||||||
/// CSRF state token tied to this round trip.
|
/// CSRF state token tied to this round trip.
|
||||||
pub state: String,
|
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
|
/// Admin endpoint: starts a connect round trip. Returns the BTCPay authorize
|
||||||
/// URL for the StartOS wrapper action to open in the operator's browser.
|
/// 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(
|
pub async fn start_connect(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
body: Option<Json<StartConnectReq>>,
|
||||||
) -> AppResult<Json<ConnectResp>> {
|
) -> AppResult<Json<ConnectResp>> {
|
||||||
require_admin(&state, &headers)?;
|
require_admin(&state, &headers)?;
|
||||||
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||||
|
|
||||||
// Idempotency: if BTCPay is already connected, refuse to issue a new
|
// Resolve the target merchant profile (defaulting to the default).
|
||||||
// authorize URL. Re-clicking Connect today produces a duplicate
|
let profile = match req.merchant_profile_id.as_deref() {
|
||||||
// webhook subscription on BTCPay, which results in every payment
|
Some(id) => crate::merchant_profiles::get(&state.db, id)
|
||||||
// event being delivered to Keysat twice. Make the operator go
|
.await?
|
||||||
// through Disconnect first if they really want to re-authorize.
|
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))?,
|
||||||
if let Ok(Some(existing)) = btcpay_cfg::load(&state.db).await {
|
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!(
|
return Err(AppError::Conflict(format!(
|
||||||
"BTCPay is already connected (store {}). Run 'Disconnect BTCPay' first if you need to re-authorize.",
|
"merchant profile '{}' already has a BTCPay provider attached. \
|
||||||
existing.store_id,
|
Disconnect it first if you want to re-authorize, or pick a different profile.",
|
||||||
|
profile.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +115,7 @@ pub async fn start_connect(
|
|||||||
rand::thread_rng().fill_bytes(&mut raw);
|
rand::thread_rng().fill_bytes(&mut raw);
|
||||||
let state_token = BASE32_NOPAD.encode(&raw);
|
let state_token = BASE32_NOPAD.encode(&raw);
|
||||||
|
|
||||||
btcpay_cfg::record_authorize_state(&state.db, &state_token)
|
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
|
|
||||||
@@ -124,9 +156,11 @@ pub async fn start_connect(
|
|||||||
urlencoding::encode(&redirect),
|
urlencoding::encode(&redirect),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let _ = req.label; // captured but not yet used — see finish_connect TODO for the future round-trip
|
||||||
Ok(Json(ConnectResp {
|
Ok(Json(ConnectResp {
|
||||||
authorize_url,
|
authorize_url,
|
||||||
state: state_token,
|
state: state_token,
|
||||||
|
merchant_profile_id: profile.id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,47 +235,63 @@ pub async fn callback_get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Admin endpoint: list payment methods configured on the connected
|
/// Admin endpoint: list payment methods configured on the connected
|
||||||
/// BTCPay store. Proxies to BTCPay's `/api/v1/stores/{id}/payment-methods`.
|
/// BTCPay store. Defaults to the default-profile's BTCPay provider for
|
||||||
/// Used by the wrapper / future web UI to surface a "no wallet
|
/// back-compat with the existing admin UI; the new merchant-profile
|
||||||
/// configured" state.
|
/// admin endpoint passes an explicit `provider_id` query param when
|
||||||
|
/// multiple BTCPay providers exist.
|
||||||
pub async fn payment_methods(
|
pub async fn payment_methods(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
require_admin(&state, &headers)?;
|
require_admin(&state, &headers)?;
|
||||||
let cfg = btcpay_cfg::load(&state.db)
|
let default = crate::merchant_profiles::require_default(&state.db).await?;
|
||||||
.await
|
let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id)
|
||||||
.map_err(AppError::Internal)?
|
.await?;
|
||||||
|
let row = rows
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.kind == "btcpay")
|
||||||
.ok_or(AppError::BtcpayNotConfigured)?;
|
.ok_or(AppError::BtcpayNotConfigured)?;
|
||||||
let methods = btcpay_client::list_payment_methods(&cfg.base_url, &cfg.api_key, &cfg.store_id)
|
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
|
.await
|
||||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e}")))?;
|
.map_err(|e| AppError::Upstream(format!("BTCPay list-payment-methods: {e:#}")))?;
|
||||||
|
|
||||||
// Return both the raw array for callers that want detail, and a
|
|
||||||
// boolean summary for the common "is anything configured?" check.
|
|
||||||
let count = methods.len();
|
let count = methods.len();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"store_id": cfg.store_id,
|
"store_id": store_id,
|
||||||
"count": count,
|
"count": count,
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin endpoint: report current BTCPay connection status.
|
/// 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(
|
pub async fn status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
require_admin(&state, &headers)?;
|
require_admin(&state, &headers)?;
|
||||||
|
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
||||||
let cfg = btcpay_cfg::load(&state.db).await.map_err(AppError::Internal)?;
|
let row = match &default {
|
||||||
Ok(Json(match cfg {
|
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 }),
|
None => json!({ "connected": false }),
|
||||||
Some(c) => json!({
|
Some(p) => json!({
|
||||||
"connected": true,
|
"connected": true,
|
||||||
"store_id": c.store_id,
|
"provider_id": p.id,
|
||||||
"webhook_id": c.webhook_id,
|
"store_id": p.store_id,
|
||||||
"base_url": c.base_url,
|
"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()),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -249,9 +299,22 @@ pub async fn status(
|
|||||||
// --- internals ---
|
// --- internals ---
|
||||||
|
|
||||||
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
|
async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> AppResult<()> {
|
||||||
btcpay_cfg::consume_authorize_state(&state.db, state_token)
|
// 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
|
.await
|
||||||
.map_err(|_| AppError::Unauthorized)?;
|
.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;
|
let base_url = &state.config.btcpay_url;
|
||||||
|
|
||||||
@@ -260,7 +323,7 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
// first one that the key can see.
|
// first one that the key can see.
|
||||||
let stores = btcpay_client::list_stores(base_url, api_key)
|
let stores = btcpay_client::list_stores(base_url, api_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e}")))?;
|
.map_err(|e| AppError::Upstream(format!("BTCPay list-stores: {e:#}")))?;
|
||||||
let store = stores
|
let store = stores
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
@@ -273,7 +336,14 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
rand::thread_rng().fill_bytes(&mut raw_secret);
|
rand::thread_rng().fill_bytes(&mut raw_secret);
|
||||||
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
|
let webhook_secret = BASE32_NOPAD.encode(&raw_secret);
|
||||||
|
|
||||||
let callback_url = format!("{}/v1/btcpay/webhook", state.config.public_base_url);
|
// 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(
|
let created_webhook = btcpay_client::create_webhook(
|
||||||
base_url,
|
base_url,
|
||||||
@@ -283,46 +353,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
|
|||||||
&webhook_secret,
|
&webhook_secret,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e}")))?;
|
.map_err(|e| AppError::Upstream(format!("BTCPay create-webhook: {e:#}")))?;
|
||||||
|
|
||||||
// Persist.
|
// Persist as a payment_providers row attached to the chosen profile.
|
||||||
let cfg = btcpay_cfg::BtcpayConfig {
|
let label = format!("BTCPay — {}", profile.name);
|
||||||
base_url: base_url.clone(),
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
api_key: api_key.to_string(),
|
crate::db::repo::create_payment_provider(
|
||||||
store_id: store.id.clone(),
|
|
||||||
webhook_id: Some(created_webhook.id.clone()),
|
|
||||||
webhook_secret: webhook_secret.clone(),
|
|
||||||
};
|
|
||||||
btcpay_cfg::save(&state.db, &cfg)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Internal)?;
|
|
||||||
|
|
||||||
// Swap runtime — wrap a fresh BtcpayProvider into the
|
|
||||||
// PaymentProvider trait object held by AppState. Pass the
|
|
||||||
// public-facing BTCPay URL too so that checkout URLs returned to
|
|
||||||
// buyers get rewritten from the internal Docker hostname to a
|
|
||||||
// browser-reachable host.
|
|
||||||
let client = BtcpayClient::new(base_url, api_key, &store.id);
|
|
||||||
let provider = Arc::new(
|
|
||||||
BtcpayProvider::new(client, webhook_secret)
|
|
||||||
.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,
|
&state.db,
|
||||||
crate::payment::ProviderKind::Btcpay,
|
&provider_id,
|
||||||
|
&profile.id,
|
||||||
|
"btcpay",
|
||||||
|
&label,
|
||||||
|
api_key,
|
||||||
|
base_url,
|
||||||
|
Some(&created_webhook.id),
|
||||||
|
Some(&webhook_secret),
|
||||||
|
Some(&store.id),
|
||||||
|
&now,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to record BTCPay as active payment provider");
|
// 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!(
|
tracing::info!(
|
||||||
|
provider_id = %provider_id,
|
||||||
|
merchant_profile_id = %profile.id,
|
||||||
store = %store.id,
|
store = %store.id,
|
||||||
store_name = %store.name,
|
store_name = %store.name,
|
||||||
webhook_id = %created_webhook.id,
|
webhook_id = %created_webhook.id,
|
||||||
@@ -342,31 +409,52 @@ h2{{color:#0a7}}</style></head>
|
|||||||
(StatusCode::OK, Html(body)).into_response()
|
(StatusCode::OK, Html(body)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin endpoint: disconnect BTCPay. Best-effort revocation of the
|
#[derive(Debug, Deserialize, Default)]
|
||||||
/// webhook + API key on BTCPay's side, then unconditional clear of the
|
pub struct DisconnectReq {
|
||||||
/// local config row. If BTCPay is unreachable, the local state is still
|
/// Which provider row to disconnect. NULL = the BTCPay provider on
|
||||||
/// cleared and the operator gets a warning to clean up BTCPay manually.
|
/// 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(
|
pub async fn disconnect(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
body: Option<Json<DisconnectReq>>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let actor_hash = require_admin(&state, &headers)?;
|
let actor_hash = require_admin(&state, &headers)?;
|
||||||
let (ip, ua) = crate::api::admin::request_context(&headers);
|
let (ip, ua) = crate::api::admin::request_context(&headers);
|
||||||
|
let req = body.map(|Json(b)| b).unwrap_or_default();
|
||||||
|
|
||||||
let cfg = btcpay_cfg::load(&state.db)
|
let provider_row = match req.provider_id.as_deref() {
|
||||||
.await
|
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, pid)
|
||||||
.map_err(AppError::Internal)?;
|
.await?
|
||||||
let Some(cfg) = cfg else {
|
.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!({
|
return Ok(Json(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"noop": true,
|
"noop": true,
|
||||||
"message": "BTCPay was not connected; nothing to do.",
|
"message": "no BTCPay provider connected on the named profile",
|
||||||
})));
|
})));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture metadata for the response BEFORE we clear local state.
|
let provider_id = provider_row.id.clone();
|
||||||
let store_id = cfg.store_id.clone();
|
let store_id = provider_row.store_id.clone().unwrap_or_default();
|
||||||
let webhook_id = cfg.webhook_id.clone();
|
let webhook_id = provider_row.webhook_id.clone();
|
||||||
|
|
||||||
// Best-effort remote cleanup. We DON'T short-circuit if either of
|
// Best-effort remote cleanup. We DON'T short-circuit if either of
|
||||||
// these calls fails — the operator's intent is to disconnect, and
|
// these calls fails — the operator's intent is to disconnect, and
|
||||||
@@ -377,9 +465,9 @@ pub async fn disconnect(
|
|||||||
let mut warnings: Vec<String> = Vec::new();
|
let mut warnings: Vec<String> = Vec::new();
|
||||||
if let Some(webhook_id) = webhook_id.as_deref() {
|
if let Some(webhook_id) = webhook_id.as_deref() {
|
||||||
if let Err(e) = btcpay_client::delete_webhook(
|
if let Err(e) = btcpay_client::delete_webhook(
|
||||||
&cfg.base_url,
|
&provider_row.base_url,
|
||||||
&cfg.api_key,
|
&provider_row.api_key,
|
||||||
&cfg.store_id,
|
&store_id,
|
||||||
webhook_id,
|
webhook_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -390,52 +478,35 @@ pub async fn disconnect(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = btcpay_client::revoke_api_key(&cfg.base_url, &cfg.api_key).await {
|
if let Err(e) = btcpay_client::revoke_api_key(&provider_row.base_url, &provider_row.api_key).await {
|
||||||
warnings.push(format!(
|
warnings.push(format!(
|
||||||
"Could not revoke BTCPay API key: {e}. \
|
"Could not revoke BTCPay API key: {e}. \
|
||||||
You may want to manually revoke it in BTCPay's account API-keys page."
|
You may want to manually revoke it in BTCPay's account API-keys page."
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
btcpay_cfg::clear(&state.db)
|
crate::db::repo::delete_payment_provider(&state.db, &provider_id).await?;
|
||||||
.await
|
|
||||||
.map_err(AppError::Internal)?;
|
|
||||||
|
|
||||||
// Replace the runtime payment provider so subsequent purchase
|
// Clear the back-compat singleton if it was holding this one.
|
||||||
// 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",
|
||||||
Some(&actor_hash),
|
Some(&actor_hash),
|
||||||
"btcpay.disconnect",
|
"payment_provider.disconnect",
|
||||||
Some("btcpay_config"),
|
Some("payment_provider"),
|
||||||
None,
|
Some(&provider_id),
|
||||||
ip.as_deref(),
|
ip.as_deref(),
|
||||||
ua.as_deref(),
|
ua.as_deref(),
|
||||||
&json!({ "store_id": store_id, "webhook_id": webhook_id }),
|
&json!({ "kind": "btcpay", "store_id": store_id, "webhook_id": webhook_id }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"noop": false,
|
"noop": false,
|
||||||
|
"provider_id": provider_id,
|
||||||
"store_id": store_id,
|
"store_id": store_id,
|
||||||
"webhook_id": webhook_id,
|
"webhook_id": webhook_id,
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
||||||
.route("/v1/machines/deactivate", post(machines::deactivate))
|
.route("/v1/machines/deactivate", post(machines::deactivate))
|
||||||
.route("/v1/btcpay/webhook", post(webhook::handle))
|
.route("/v1/btcpay/webhook", post(webhook::handle))
|
||||||
|
.route("/v1/btcpay/webhook/:provider_id", post(webhook::handle_for_provider))
|
||||||
.route(
|
.route(
|
||||||
"/v1/admin/btcpay/connect",
|
"/v1/admin/btcpay/connect",
|
||||||
post(btcpay_authorize::start_connect),
|
post(btcpay_authorize::start_connect),
|
||||||
@@ -389,22 +390,23 @@ pub fn router(state: AppState) -> Router {
|
|||||||
get(zaprite_authorize::status),
|
get(zaprite_authorize::status),
|
||||||
)
|
)
|
||||||
// Provider-agnostic active-payment-provider control.
|
// Provider-agnostic active-payment-provider control.
|
||||||
// Operators with both BTCPay and Zaprite configured can flip
|
// Back-compat snapshot of the default profile's providers. The
|
||||||
// the active one without re-running Connect.
|
// legacy `activate` endpoint is removed — in the merchant-profile
|
||||||
|
// model providers attach to profiles and products pick a profile
|
||||||
|
// at resolution time; there's no singleton "active" preference to
|
||||||
|
// flip. Multi-profile operators should use the new
|
||||||
|
// /v1/admin/merchant-profiles endpoints instead.
|
||||||
.route(
|
.route(
|
||||||
"/v1/admin/payment-provider/status",
|
"/v1/admin/payment-provider/status",
|
||||||
get(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
|
||||||
// is on the trait surface and the active provider self-
|
// is on the trait surface and the active provider self-
|
||||||
// identifies its event shape.
|
// identifies its event shape.
|
||||||
.route("/v1/zaprite/webhook", post(webhook::handle))
|
.route("/v1/zaprite/webhook", post(webhook::handle))
|
||||||
|
.route("/v1/zaprite/webhook/:provider_id", post(webhook::handle_for_provider))
|
||||||
.route("/v1/admin/products", post(admin::create_product))
|
.route("/v1/admin/products", post(admin::create_product))
|
||||||
.route(
|
.route(
|
||||||
"/v1/admin/products/:id",
|
"/v1/admin/products/:id",
|
||||||
@@ -713,17 +715,51 @@ async fn thank_you(
|
|||||||
|
|
||||||
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
|
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
|
||||||
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
|
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
|
||||||
// Bitcoin. The lede and the polling-status copy should reflect which
|
// Bitcoin. The lede and the polling-status copy reflect which payment
|
||||||
// payment rails are actually in play so a buyer who paid by card
|
// rails actually settled THIS invoice, not "the currently active
|
||||||
// doesn't see "your Bitcoin payment was received" while their Stripe
|
// provider" (which is meaningless in the multi-provider model).
|
||||||
// transaction shows up in the operator's dashboard.
|
|
||||||
//
|
//
|
||||||
// Today this reads `SETTING_ACTIVE_PROVIDER` (the singleton model).
|
// Look up the invoice's own `payment_provider_id` (recorded by
|
||||||
// When the multi-provider work lands, swap this for a lookup of the
|
// migration 0021) → resolve to its kind via payment_providers. Falls
|
||||||
// invoice's own `payment_provider_id` so the copy matches the rail
|
// back to whichever provider is attached to the default profile if
|
||||||
// that actually settled THIS purchase, not whatever's currently
|
// the invoice predates 0021, then to BTCPay if even THAT can't be
|
||||||
// active on the daemon.
|
// resolved (operator visited /thank-you with no providers connected
|
||||||
let provider_kind = crate::payment::read_active_provider_preference(&state.db).await;
|
// at all — rare).
|
||||||
|
let invoice_provider_kind: Option<crate::payment::ProviderKind> = if !invoice_id.is_empty() {
|
||||||
|
let row: Option<(Option<String>,)> = sqlx::query_as(
|
||||||
|
"SELECT i.payment_provider_id FROM invoices i WHERE i.id = ? LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&invoice_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
match row.and_then(|(pid,)| pid) {
|
||||||
|
Some(pid) => crate::db::repo::get_payment_provider_by_id(&state.db, &pid)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|p| crate::payment::ProviderKind::parse(&p.kind)),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let provider_kind = match invoice_provider_kind {
|
||||||
|
Some(k) => Some(k),
|
||||||
|
None => {
|
||||||
|
// Fall back to the default profile's first provider.
|
||||||
|
let default = crate::merchant_profiles::get_default(&state.db).await.ok().flatten();
|
||||||
|
match default {
|
||||||
|
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|rows| rows.into_iter().next())
|
||||||
|
.and_then(|row| crate::payment::ProviderKind::parse(&row.kind)),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
let (lede_text, provider_kind_str) = match provider_kind {
|
let (lede_text, provider_kind_str) = match provider_kind {
|
||||||
Some(crate::payment::ProviderKind::Zaprite) => (
|
Some(crate::payment::ProviderKind::Zaprite) => (
|
||||||
"Your payment was received. We\u{2019}re waiting for it to settle and \
|
"Your payment was received. We\u{2019}re waiting for it to settle and \
|
||||||
|
|||||||
@@ -1,140 +1,64 @@
|
|||||||
//! Active-provider swap endpoint.
|
//! Payment-provider status endpoint (multi-merchant-profile model).
|
||||||
//!
|
//!
|
||||||
//! When an operator has both BTCPay AND Zaprite configured (i.e.,
|
//! Pre-:52 this module held two endpoints:
|
||||||
//! they ran Connect on both at some point), this lets them flip
|
//! - `GET /v1/admin/payment-provider/status` — which provider was
|
||||||
//! the active one without re-authorizing. The Connect flows are
|
//! active, plus configured flags for BTCPay + Zaprite.
|
||||||
//! still where credentials live; this endpoint only changes which
|
//! - `POST /v1/admin/payment-provider/activate` — flip the singleton
|
||||||
//! credentials the daemon currently routes through.
|
//! active-provider preference between two configured ones.
|
||||||
|
//!
|
||||||
|
//! Both became meaningless in the merchant-profile model — providers
|
||||||
|
//! aren't "active," they attach to profiles, and products pick a profile
|
||||||
|
//! at the resolution layer. The activate endpoint is removed. The status
|
||||||
|
//! endpoint stays as a back-compat shim so the existing admin UI's
|
||||||
|
//! payment-providers card keeps rendering until the new Merchant
|
||||||
|
//! Profiles UI replaces it: it now reports against the DEFAULT profile
|
||||||
|
//! (single-profile operators see no change). Multi-profile operators
|
||||||
|
//! should use the new `/v1/admin/merchant-profiles` endpoints to see
|
||||||
|
//! all providers across all profiles.
|
||||||
|
|
||||||
use crate::api::admin::{request_context, require_admin};
|
use crate::api::admin::require_admin;
|
||||||
use crate::api::AppState;
|
use crate::api::AppState;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::AppResult;
|
||||||
use crate::payment::{
|
|
||||||
self, btcpay::BtcpayProvider, zaprite::ZapriteProvider, ProviderKind,
|
|
||||||
};
|
|
||||||
use axum::{extract::State, http::HeaderMap, Json};
|
use axum::{extract::State, http::HeaderMap, Json};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
/// `GET /v1/admin/payment-provider/status` — back-compat snapshot of
|
||||||
pub struct ActivateReq {
|
/// providers attached to the default merchant profile. Returns the same
|
||||||
/// `'btcpay'` or `'zaprite'`. Other values → 400.
|
/// shape as pre-:52 with `btcpay_configured` / `zaprite_configured` /
|
||||||
pub provider: String,
|
/// `active` for compatibility with the existing admin UI; new code
|
||||||
}
|
/// should use `/v1/admin/merchant-profiles/{id}` instead.
|
||||||
|
|
||||||
/// `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(
|
pub async fn status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
require_admin(&state, &headers)?;
|
require_admin(&state, &headers)?;
|
||||||
let btcpay_configured = crate::btcpay::config::load(&state.db)
|
let default = crate::merchant_profiles::get_default(&state.db).await?;
|
||||||
.await
|
let providers = match &default {
|
||||||
.map(|o| o.is_some())
|
Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?,
|
||||||
.unwrap_or(false);
|
None => Vec::new(),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
let btcpay_row = providers.iter().find(|p| p.kind == "btcpay").cloned();
|
||||||
|
let zaprite_row = providers.iter().find(|p| p.kind == "zaprite").cloned();
|
||||||
|
// "active" used to mean "the singleton active-provider preference."
|
||||||
|
// In the new model there isn't one. For back-compat we report the
|
||||||
|
// FIRST provider on the default profile (which is what the legacy
|
||||||
|
// boot-loader semantics would have picked) so the existing admin UI
|
||||||
|
// shows a sensible active badge. Multi-rail operators get the full
|
||||||
|
// picture from the new merchant-profile endpoints.
|
||||||
|
let active_runtime = providers.first().map(|p| p.kind.clone());
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"btcpay_configured": btcpay_configured,
|
"btcpay_configured": btcpay_row.is_some(),
|
||||||
"zaprite_configured": zaprite_configured,
|
"zaprite_configured": zaprite_row.is_some(),
|
||||||
"preferred": preference.map(|k| k.as_str().to_string()),
|
"preferred": active_runtime.clone(),
|
||||||
"active": active_runtime,
|
"active": active_runtime,
|
||||||
})))
|
"merchant_profile_id": default.as_ref().map(|p| p.id.clone()),
|
||||||
}
|
"merchant_profile_name": default.as_ref().map(|p| p.name.clone()),
|
||||||
|
"providers": providers.iter().map(|p| json!({
|
||||||
/// `POST /v1/admin/payment-provider/activate` — swap the active
|
"id": p.id,
|
||||||
/// provider to whichever already-configured one the operator
|
"kind": p.kind,
|
||||||
/// names. 400 if the named provider isn't configured (run Connect
|
"label": p.label,
|
||||||
/// first).
|
"base_url": p.base_url,
|
||||||
pub async fn activate(
|
"store_id": p.store_id,
|
||||||
State(state): State<AppState>,
|
})).collect::<Vec<_>>(),
|
||||||
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 => {
|
|
||||||
crate::api::tier::enforce_zaprite_feature(&state).await?;
|
|
||||||
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(),
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,20 +23,51 @@ use crate::error::{AppError, AppResult};
|
|||||||
use crate::payment::ProviderWebhookEvent;
|
use crate::payment::ProviderWebhookEvent;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
|
/// Multi-provider webhook landing: `/v1/{kind}/webhook/:provider_id`.
|
||||||
|
/// The provider id picks WHICH provider's secret validates this delivery.
|
||||||
|
/// Without that, an operator with two BTCPay providers across two merchant
|
||||||
|
/// profiles would have indistinguishable webhook URLs and BTCPay payloads
|
||||||
|
/// would round-robin to whoever happened to be "the active provider" at
|
||||||
|
/// request time. The path-param resolution ensures every delivery is
|
||||||
|
/// validated against the secret it was created with.
|
||||||
|
pub async fn handle_for_provider(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(provider_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
let provider = state.payment_provider_by_id(&provider_id).await?;
|
||||||
|
handle_inner(state, provider, headers, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat landing for the pre-:52 URL shape. Routes to whichever
|
||||||
|
/// provider is on the default merchant profile. New webhooks registered
|
||||||
|
/// against `:52`+ use the path-keyed shape above; this exists so any
|
||||||
|
/// in-flight pre-:52 delivery (or operator misconfiguration) doesn't
|
||||||
|
/// silently drop on the floor.
|
||||||
pub async fn handle(
|
pub async fn handle(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
// Active provider validates its own webhooks (each provider has a
|
|
||||||
// different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
|
|
||||||
// Zaprite's TBD). On any verification failure we 401.
|
|
||||||
let provider = state.payment_provider().await?;
|
let provider = state.payment_provider().await?;
|
||||||
|
handle_inner(state, provider, headers, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_inner(
|
||||||
|
state: AppState,
|
||||||
|
provider: std::sync::Arc<dyn crate::payment::PaymentProvider>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
// The resolved provider validates its own webhooks (each provider has
|
||||||
|
// a different signature scheme — BTCPay's HMAC-SHA256 in BTCPay-Sig,
|
||||||
|
// Zaprite's externalUniqId round-trip). On verification failure: 401.
|
||||||
let event = provider
|
let event = provider
|
||||||
.validate_webhook(&headers, &body)
|
.validate_webhook(&headers, &body)
|
||||||
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
|
.map_err(|e| AppError::Unauthorized.tap_log(format!("webhook validation: {e:#}")))?;
|
||||||
|
|||||||
@@ -79,14 +79,22 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a new in-flight authorize state token. The caller has already
|
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
||||||
/// generated a cryptographically-random token.
|
/// (multi-provider model, migration 0022) names which merchant profile
|
||||||
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
/// the resulting provider row should attach to when the callback fires
|
||||||
|
/// — None falls back to "the default profile" at consume-time.
|
||||||
|
pub async fn record_authorize_state(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
token: &str,
|
||||||
|
merchant_profile_id: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
|
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
|
||||||
|
VALUES (?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
|
.bind(merchant_profile_id)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
@@ -101,11 +109,17 @@ pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that `token` was issued recently and has not been consumed.
|
/// Validate that `token` was issued recently and has not been consumed.
|
||||||
/// Consumes (deletes) the token on success so a replay fails.
|
/// Consumes (deletes) the token on success so a replay fails, and
|
||||||
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
/// returns the `merchant_profile_id` recorded at start-connect time so
|
||||||
|
/// the callback knows which profile to attach the new provider to.
|
||||||
|
pub async fn consume_authorize_state(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
use sqlx::Row;
|
||||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT state_token FROM btcpay_authorize_state \
|
"SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \
|
||||||
WHERE state_token = ? AND created_at >= ?",
|
WHERE state_token = ? AND created_at >= ?",
|
||||||
)
|
)
|
||||||
.bind(token)
|
.bind(token)
|
||||||
@@ -113,13 +127,14 @@ pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<(
|
|||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if row.is_none() {
|
let Some(row) = row else {
|
||||||
return Err(anyhow!("unknown or expired authorize state token"));
|
return Err(anyhow!("unknown or expired authorize state token"));
|
||||||
}
|
};
|
||||||
|
let merchant_profile_id: Option<String> = row.try_get("merchant_profile_id").ok().flatten();
|
||||||
|
|
||||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(merchant_profile_id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user