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:
@@ -40,43 +40,63 @@ use std::any::Any;
|
||||
pub mod btcpay;
|
||||
pub mod zaprite;
|
||||
|
||||
/// Settings-table key that records which provider the operator
|
||||
/// last activated. Used by the boot-time loader to pick which
|
||||
/// provider to load when both `btcpay_config` and `zaprite_config`
|
||||
/// are populated. Values: `'btcpay'` | `'zaprite'`. Absent means
|
||||
/// "use whichever single provider is configured" (back-compat
|
||||
/// for installs that pre-date this setting).
|
||||
// =========================================================================
|
||||
// Legacy compatibility shims — DEPRECATED, will be removed once all call
|
||||
// sites migrate to the merchant-profile-aware resolution layer.
|
||||
// =========================================================================
|
||||
//
|
||||
// During the multi-provider transition the singleton-config-and-active-
|
||||
// provider-preference helpers stay callable so the existing connect flows
|
||||
// (`btcpay_authorize.rs`, `zaprite_authorize.rs`) and the boot loader in
|
||||
// `main.rs` keep working. Each shim wraps the new schema with the old
|
||||
// semantics: `read_active_provider_preference` looks up the first provider
|
||||
// attached to the default merchant profile and returns its kind;
|
||||
// `write_active_provider_preference` is a no-op (the new model doesn't
|
||||
// track an "active provider" preference — providers attach to profiles,
|
||||
// profiles attach to products).
|
||||
|
||||
#[deprecated(
|
||||
note = "use merchant-profile-aware resolution: \
|
||||
state.payment_provider_for(product_id, rail)"
|
||||
)]
|
||||
pub const SETTING_ACTIVE_PROVIDER: &str = "active_payment_provider";
|
||||
|
||||
/// Convenience getter for the active-provider setting. Returns
|
||||
/// `Some(ProviderKind)` if the operator has explicitly chosen
|
||||
/// one, `None` if they haven't (caller falls back to the
|
||||
/// load-order heuristic).
|
||||
#[deprecated(
|
||||
note = "look up providers via list_payment_providers_for_profile or \
|
||||
payment_provider_by_id on AppState"
|
||||
)]
|
||||
pub async fn read_active_provider_preference(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Option<ProviderKind> {
|
||||
// Post-migration: derive from the first provider attached to the
|
||||
// default merchant profile (deterministic by connected_at ASC).
|
||||
// Pre-migration (if the migration hasn't run yet on this DB):
|
||||
// fall back to the legacy settings-table read.
|
||||
let default_profile = crate::db::repo::get_default_merchant_profile(pool).await.ok().flatten();
|
||||
if let Some(profile) = default_profile {
|
||||
if let Ok(rows) = crate::db::repo::list_payment_providers_for_profile(pool, &profile.id).await {
|
||||
if let Some(first) = rows.first() {
|
||||
return ProviderKind::parse(&first.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy fallback for the pre-migration window.
|
||||
match crate::db::repo::settings_get(pool, SETTING_ACTIVE_PROVIDER).await {
|
||||
Ok(Some(s)) => match s.as_str() {
|
||||
"btcpay" => Some(ProviderKind::Btcpay),
|
||||
"zaprite" => Some(ProviderKind::Zaprite),
|
||||
_ => None,
|
||||
},
|
||||
Ok(Some(s)) => ProviderKind::parse(&s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the operator's active-provider preference. Called by
|
||||
/// the connect endpoints (Connect BTCPay, Connect Zaprite) and
|
||||
/// by the new "Activate <provider>" endpoint that flips between
|
||||
/// already-configured providers without re-authorizing.
|
||||
#[deprecated(
|
||||
note = "providers are now attached to merchant profiles, not implicitly active. \
|
||||
This shim is a no-op; remove the call."
|
||||
)]
|
||||
pub async fn write_active_provider_preference(
|
||||
pool: &sqlx::SqlitePool,
|
||||
kind: ProviderKind,
|
||||
_pool: &sqlx::SqlitePool,
|
||||
_kind: ProviderKind,
|
||||
) -> anyhow::Result<()> {
|
||||
let value = kind.as_str();
|
||||
crate::db::repo::settings_set(pool, SETTING_ACTIVE_PROVIDER, Some(value))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("write active provider preference: {e:#}"))?;
|
||||
// No-op. In the multi-provider model there's no "active" preference
|
||||
// to write — providers are looked up by id (per-product) or by profile.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,6 +114,95 @@ impl ProviderKind {
|
||||
ProviderKind::Zaprite => "zaprite",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"btcpay" => Some(Self::Btcpay),
|
||||
"zaprite" => Some(Self::Zaprite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Buyer-facing payment method. The buy page renders a picker over these
|
||||
/// (when a merchant profile exposes more than one); the routing layer maps
|
||||
/// the buyer's pick to a specific provider via the profile's attached
|
||||
/// providers + optional `merchant_profile_rail_preferences` tie-breakers.
|
||||
///
|
||||
/// Rails-per-provider-kind are **inherent** (declared by each provider
|
||||
/// impl's `served_rails()` trait method), not configurable per provider
|
||||
/// row. BTCPay serves Lightning + OnChain. Zaprite serves Card +
|
||||
/// Lightning + OnChain.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Rail {
|
||||
Lightning,
|
||||
Onchain,
|
||||
Card,
|
||||
}
|
||||
|
||||
impl Rail {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Rail::Lightning => "lightning",
|
||||
Rail::Onchain => "onchain",
|
||||
Rail::Card => "card",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"lightning" => Some(Self::Lightning),
|
||||
"onchain" | "on-chain" | "on_chain" => Some(Self::Onchain),
|
||||
"card" => Some(Self::Card),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static rails served by a provider kind. Returned by
|
||||
/// `PaymentProvider::served_rails()`; centralized here so callers that
|
||||
/// just want to know "what does kind X support" (e.g., the admin UI's
|
||||
/// connect-flow guidance) don't have to instantiate a provider.
|
||||
pub fn rails_for_kind(kind: ProviderKind) -> Vec<Rail> {
|
||||
match kind {
|
||||
ProviderKind::Btcpay => vec![Rail::Lightning, Rail::Onchain],
|
||||
ProviderKind::Zaprite => vec![Rail::Card, Rail::Lightning, Rail::Onchain],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a typed `PaymentProvider` trait object from a `payment_providers`
|
||||
/// row. Dispatch on `kind`. Used by the AppState provider cache when
|
||||
/// resolving by provider id.
|
||||
pub fn build_provider(
|
||||
row: &crate::db::repo::PaymentProviderRow,
|
||||
public_base_url: Option<&str>,
|
||||
) -> anyhow::Result<std::sync::Arc<dyn PaymentProvider>> {
|
||||
use crate::btcpay::client::BtcpayClient;
|
||||
use crate::payment::btcpay::BtcpayProvider;
|
||||
use crate::payment::zaprite::{ZapriteClient, ZapriteProvider};
|
||||
|
||||
match ProviderKind::parse(&row.kind) {
|
||||
Some(ProviderKind::Btcpay) => {
|
||||
let store_id = row.store_id.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("BTCPay provider row {} missing store_id", row.id)
|
||||
})?;
|
||||
let webhook_secret = row.webhook_secret.clone().unwrap_or_default();
|
||||
let client = BtcpayClient::new(&row.base_url, &row.api_key, store_id);
|
||||
let provider = BtcpayProvider::new(client, webhook_secret)
|
||||
.with_public_base(public_base_url.map(|s| s.to_string()));
|
||||
Ok(std::sync::Arc::new(provider))
|
||||
}
|
||||
Some(ProviderKind::Zaprite) => {
|
||||
let client = ZapriteClient::new(row.base_url.clone(), row.api_key.clone());
|
||||
Ok(std::sync::Arc::new(ZapriteProvider::new(client)))
|
||||
}
|
||||
None => Err(anyhow::anyhow!(
|
||||
"unknown payment provider kind {:?} on row {}",
|
||||
row.kind,
|
||||
row.id
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A monetary amount + the unit it's denominated in.
|
||||
@@ -230,6 +339,14 @@ pub struct PaymentReceipt {
|
||||
pub trait PaymentProvider: Send + Sync + Any {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
|
||||
/// Payment rails this provider can settle. Default impl uses the
|
||||
/// static `rails_for_kind()` mapping; impls only override if they
|
||||
/// expose a non-default set (e.g., a degraded BTCPay configured
|
||||
/// without Lightning support — not currently a Keysat concern).
|
||||
fn served_rails(&self) -> Vec<Rail> {
|
||||
rails_for_kind(self.kind())
|
||||
}
|
||||
|
||||
async fn create_invoice(
|
||||
&self,
|
||||
params: CreateInvoiceParams<'_>,
|
||||
|
||||
Reference in New Issue
Block a user