WIP — merchant profile foundation (multi-provider payment model, part 1)
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,143 @@ impl AppState {
|
||||
let mut guard = self.payment.write().await;
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Merchant-profile-aware resolution layer (migration 0020+)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// The legacy `payment_provider()` / `set_payment_provider()` accessors
|
||||
// above continue to work as a "default provider for the default
|
||||
// profile" compatibility shim during the multi-provider transition.
|
||||
// New call sites should use one of the methods below instead.
|
||||
|
||||
/// Look up a payment provider by its row id. Reads the row from the
|
||||
/// DB, instantiates a typed `PaymentProvider` impl via
|
||||
/// `payment::build_provider`. Not cached today — the caller is
|
||||
/// usually invoking it once per request lifecycle so the cost is
|
||||
/// nil. Add a cache here when profiling says we need one.
|
||||
pub async fn payment_provider_by_id(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
|
||||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("payment provider {provider_id}"))
|
||||
})?;
|
||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||||
.map_err(AppError::Internal)
|
||||
}
|
||||
|
||||
/// Resolve the merchant profile a product belongs to. Falls back to
|
||||
/// the default profile if the product has no `merchant_profile_id`
|
||||
/// set (defensive — shouldn't happen post-migration, but handles
|
||||
/// any rows that slip through).
|
||||
pub async fn merchant_profile_for_product(
|
||||
&self,
|
||||
product_id: &str,
|
||||
) -> AppResult<crate::merchant_profiles::MerchantProfile> {
|
||||
crate::merchant_profiles::for_product(self, product_id).await
|
||||
}
|
||||
|
||||
/// Pick the provider on `profile_id` that serves the given `rail`.
|
||||
/// Resolution order:
|
||||
/// 1. Honor `merchant_profile_rail_preferences` if the operator
|
||||
/// set an explicit preference for this (profile, rail) pair.
|
||||
/// 2. If exactly one attached provider serves the rail, use it.
|
||||
/// 3. If multiple serve the rail and no preference is set, use
|
||||
/// the earliest-`connected_at` one (deterministic) and log a
|
||||
/// warning so the operator knows to set an explicit preference.
|
||||
/// 4. If no attached provider serves the rail, return
|
||||
/// `AppError::BadRequest` — caller should treat this as
|
||||
/// "buyer's pick isn't available for this merchant."
|
||||
pub async fn resolve_provider_for_profile_rail(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
rail: crate::payment::Rail,
|
||||
) -> AppResult<(crate::db::repo::PaymentProviderRow, Arc<dyn crate::payment::PaymentProvider>)> {
|
||||
// 1. Check the explicit preference table first.
|
||||
let preferences = crate::db::repo::list_rail_preferences_for_profile(&self.db, profile_id).await?;
|
||||
if let Some(pref) = preferences.iter().find(|p| p.rail == rail.as_str()) {
|
||||
let row = crate::db::repo::get_payment_provider_by_id(&self.db, &pref.payment_provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"rail preference points at missing provider {}",
|
||||
pref.payment_provider_id
|
||||
))
|
||||
})?;
|
||||
let provider =
|
||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||||
.map_err(AppError::Internal)?;
|
||||
return Ok((row, provider));
|
||||
}
|
||||
|
||||
// 2. + 3. No explicit preference — find providers on this profile
|
||||
// whose kind serves the requested rail.
|
||||
let providers = crate::db::repo::list_payment_providers_for_profile(&self.db, profile_id).await?;
|
||||
let mut candidates: Vec<&crate::db::repo::PaymentProviderRow> = providers
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
crate::payment::ProviderKind::parse(&row.kind)
|
||||
.map(|kind| crate::payment::rails_for_kind(kind).contains(&rail))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
// Earliest-connected-first is already the order from list_payment_providers_for_profile
|
||||
// (ORDER BY connected_at ASC), but be explicit for clarity.
|
||||
candidates.sort_by(|a, b| a.connected_at.cmp(&b.connected_at));
|
||||
|
||||
match candidates.as_slice() {
|
||||
[] => Err(AppError::BadRequest(format!(
|
||||
"merchant profile {profile_id} has no provider that serves the '{}' rail. \
|
||||
Connect one in the admin UI's Merchant Profiles page, or set a rail \
|
||||
preference if multiple providers serve this rail.",
|
||||
rail.as_str()
|
||||
))),
|
||||
[only] => {
|
||||
let row = (*only).clone();
|
||||
let provider =
|
||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||||
.map_err(AppError::Internal)?;
|
||||
Ok((row, provider))
|
||||
}
|
||||
[first, ..] => {
|
||||
tracing::warn!(
|
||||
profile_id = %profile_id,
|
||||
rail = rail.as_str(),
|
||||
chosen = %first.id,
|
||||
candidate_count = candidates.len(),
|
||||
"multiple providers serve this rail on the profile; using earliest-connected \
|
||||
deterministically. Set an explicit rail preference in the admin UI to silence \
|
||||
this warning."
|
||||
);
|
||||
let row = (*first).clone();
|
||||
let provider =
|
||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
||||
.map_err(AppError::Internal)?;
|
||||
Ok((row, provider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for the most common purchase-flow case: given a
|
||||
/// product id and a buyer-picked rail, resolve to (profile, provider
|
||||
/// row, provider impl). Used by `purchase.rs` and `subscriptions.rs`
|
||||
/// renewals.
|
||||
pub async fn resolve_provider_for_product_rail(
|
||||
&self,
|
||||
product_id: &str,
|
||||
rail: crate::payment::Rail,
|
||||
) -> AppResult<(
|
||||
crate::merchant_profiles::MerchantProfile,
|
||||
crate::db::repo::PaymentProviderRow,
|
||||
Arc<dyn crate::payment::PaymentProvider>,
|
||||
)> {
|
||||
let profile = self.merchant_profile_for_product(product_id).await?;
|
||||
let (row, provider) = self.resolve_provider_for_profile_rail(&profile.id, rail).await?;
|
||||
Ok((profile, row, provider))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for SqlitePool {
|
||||
|
||||
Reference in New Issue
Block a user