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:
@@ -352,6 +352,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/v1/machines/heartbeat", post(machines::heartbeat))
|
||||
.route("/v1/machines/deactivate", post(machines::deactivate))
|
||||
.route("/v1/btcpay/webhook", post(webhook::handle))
|
||||
.route("/v1/btcpay/webhook/:provider_id", post(webhook::handle_for_provider))
|
||||
.route(
|
||||
"/v1/admin/btcpay/connect",
|
||||
post(btcpay_authorize::start_connect),
|
||||
@@ -389,22 +390,23 @@ pub fn router(state: AppState) -> Router {
|
||||
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.
|
||||
// Back-compat snapshot of the default profile's providers. The
|
||||
// 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(
|
||||
"/v1/admin/payment-provider/status",
|
||||
get(payment_provider::status),
|
||||
)
|
||||
.route(
|
||||
"/v1/admin/payment-provider/activate",
|
||||
post(payment_provider::activate),
|
||||
)
|
||||
// Zaprite webhook landing — operator points Zaprite's
|
||||
// webhook setting at this URL. Same handler as
|
||||
// /v1/btcpay/webhook because the underlying validate_webhook
|
||||
// is on the trait surface and the active provider self-
|
||||
// identifies its event shape.
|
||||
.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/:id",
|
||||
@@ -713,17 +715,51 @@ async fn thank_you(
|
||||
|
||||
// Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning
|
||||
// + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus
|
||||
// Bitcoin. The lede and the polling-status copy should reflect which
|
||||
// payment rails are actually in play so a buyer who paid by card
|
||||
// doesn't see "your Bitcoin payment was received" while their Stripe
|
||||
// transaction shows up in the operator's dashboard.
|
||||
// Bitcoin. The lede and the polling-status copy reflect which payment
|
||||
// rails actually settled THIS invoice, not "the currently active
|
||||
// provider" (which is meaningless in the multi-provider model).
|
||||
//
|
||||
// Today this reads `SETTING_ACTIVE_PROVIDER` (the singleton model).
|
||||
// When the multi-provider work lands, swap this for a lookup of the
|
||||
// invoice's own `payment_provider_id` so the copy matches the rail
|
||||
// that actually settled THIS purchase, not whatever's currently
|
||||
// active on the daemon.
|
||||
let provider_kind = crate::payment::read_active_provider_preference(&state.db).await;
|
||||
// Look up the invoice's own `payment_provider_id` (recorded by
|
||||
// migration 0021) → resolve to its kind via payment_providers. Falls
|
||||
// back to whichever provider is attached to the default profile if
|
||||
// the invoice predates 0021, then to BTCPay if even THAT can't be
|
||||
// resolved (operator visited /thank-you with no providers connected
|
||||
// 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 {
|
||||
Some(crate::payment::ProviderKind::Zaprite) => (
|
||||
"Your payment was received. We\u{2019}re waiting for it to settle and \
|
||||
|
||||
Reference in New Issue
Block a user