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:
Grant
2026-06-03 22:45:43 -05:00
parent cf251fc63f
commit 9df1908328
6 changed files with 361 additions and 260 deletions
+52 -16
View File
@@ -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 \