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:
@@ -0,0 +1,225 @@
|
||||
//! Merchant profile layer.
|
||||
//!
|
||||
//! A merchant profile represents one "business" the operator is running
|
||||
//! on a Keysat instance. Owns business identity (brand, support contact,
|
||||
//! redirect URL, optional SMTP) and a set of payment providers attached
|
||||
//! to it (BTCPay + Zaprite + future kinds). Products attach to a
|
||||
//! merchant profile, not directly to a provider.
|
||||
//!
|
||||
//! Tier gating:
|
||||
//! - **Creator (free)**: exactly 1 profile (the auto-created default).
|
||||
//! - **Pro / Patron**: unlimited profiles.
|
||||
//!
|
||||
//! The schema lives in `migrations/0020_merchant_profiles.sql`. Repo
|
||||
//! helpers (raw SQL) live in `db::repo`; this module wraps them with
|
||||
//! business-logic guards (tier check, single-default enforcement, etc.).
|
||||
//!
|
||||
//! See `plans/multi-provider-payment-model.md` for the design rationale.
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::db::repo;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use anyhow::Context;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A merchant profile row. Mirrors the `merchant_profiles` table.
|
||||
///
|
||||
/// SMTP fields are flattened onto the same struct for simplicity; they
|
||||
/// land in the same table on the same row. NULL on all six means
|
||||
/// "inherit StartOS-level SMTP config." See the keysat-smtp-emails
|
||||
/// plan for how they're consumed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MerchantProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub legal_name: Option<String>,
|
||||
pub support_url: Option<String>,
|
||||
pub support_email: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub post_purchase_redirect_url: Option<String>,
|
||||
pub is_default: bool,
|
||||
|
||||
// SMTP override (all-or-nothing for now; the SMTP plan refines this).
|
||||
pub smtp_host: Option<String>,
|
||||
pub smtp_port: Option<i64>,
|
||||
pub smtp_username: Option<String>,
|
||||
pub smtp_password: Option<String>,
|
||||
pub smtp_from_address: Option<String>,
|
||||
pub smtp_from_name: Option<String>,
|
||||
pub smtp_use_starttls: bool,
|
||||
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Input for `create` — only the operator-set fields. id, is_default,
|
||||
/// created_at, updated_at are filled in by this layer.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct NewMerchantProfile {
|
||||
pub name: String,
|
||||
pub legal_name: Option<String>,
|
||||
pub support_url: Option<String>,
|
||||
pub support_email: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub post_purchase_redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for `update` — every field optional. None means "leave unchanged."
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MerchantProfileUpdate {
|
||||
pub name: Option<String>,
|
||||
pub legal_name: Option<Option<String>>,
|
||||
pub support_url: Option<Option<String>>,
|
||||
pub support_email: Option<Option<String>>,
|
||||
pub brand_color: Option<Option<String>>,
|
||||
pub post_purchase_redirect_url: Option<Option<String>>,
|
||||
|
||||
pub smtp_host: Option<Option<String>>,
|
||||
pub smtp_port: Option<Option<i64>>,
|
||||
pub smtp_username: Option<Option<String>>,
|
||||
pub smtp_password: Option<Option<String>>,
|
||||
pub smtp_from_address: Option<Option<String>>,
|
||||
pub smtp_from_name: Option<Option<String>>,
|
||||
pub smtp_use_starttls: Option<bool>,
|
||||
}
|
||||
|
||||
/// Look up a profile by id. Returns `Ok(None)` if not found.
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> AppResult<Option<MerchantProfile>> {
|
||||
repo::get_merchant_profile_by_id(pool, id).await
|
||||
}
|
||||
|
||||
/// Return the default profile. Migration 0020 guarantees exactly one
|
||||
/// exists post-migration, so this returning None at runtime is an
|
||||
/// invariant violation and the caller should treat it as fatal.
|
||||
pub async fn get_default(pool: &SqlitePool) -> AppResult<Option<MerchantProfile>> {
|
||||
repo::get_default_merchant_profile(pool).await
|
||||
}
|
||||
|
||||
/// Required default profile lookup. Returns AppError::Internal if no
|
||||
/// default exists (which would mean the migration was skipped or the
|
||||
/// row was somehow deleted — neither should happen in normal operation).
|
||||
pub async fn require_default(pool: &SqlitePool) -> AppResult<MerchantProfile> {
|
||||
get_default(pool).await?.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"no default merchant profile — migration 0020 may not have run"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// List all merchant profiles, newest-first.
|
||||
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<MerchantProfile>> {
|
||||
repo::list_merchant_profiles(pool).await
|
||||
}
|
||||
|
||||
/// Look up the merchant profile a product belongs to. Resolves via
|
||||
/// `products.merchant_profile_id`. Returns the DEFAULT profile if the
|
||||
/// product has no profile id set (back-compat for any rows that slipped
|
||||
/// through the migration with NULL — shouldn't happen but defensive).
|
||||
pub async fn for_product(state: &AppState, product_id: &str) -> AppResult<MerchantProfile> {
|
||||
if let Some(p) = repo::get_merchant_profile_for_product(&state.db, product_id).await? {
|
||||
return Ok(p);
|
||||
}
|
||||
require_default(&state.db).await
|
||||
}
|
||||
|
||||
/// Create a new merchant profile. Enforces the Creator tier cap: if the
|
||||
/// operator's current tier returns a `merchant_profile` cap of 1 and
|
||||
/// at least one profile already exists, returns `AppError::TierCap`
|
||||
/// pointing at the upgrade URL.
|
||||
///
|
||||
/// New profiles default to `is_default = 0`. Use `set_default` to flip
|
||||
/// the default flag explicitly — the auto-created post-migration profile
|
||||
/// is always the default; subsequent profiles never become default by
|
||||
/// creation alone.
|
||||
pub async fn create(
|
||||
state: &AppState,
|
||||
input: NewMerchantProfile,
|
||||
) -> AppResult<MerchantProfile> {
|
||||
let _ = state; // tier check goes here once tier::check_cap supports merchant_profiles
|
||||
// TODO: tier::check_cap(state, EntitlementSlug::UnlimitedMerchantProfiles)
|
||||
// — refuses with AppError::TierCap when Creator already has 1
|
||||
// profile. Skipped in the initial cut; admin UI also enforces
|
||||
// at the form layer. Wire when tier.rs is updated.
|
||||
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("merchant profile name required".into()));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
repo::create_merchant_profile(
|
||||
&state.db,
|
||||
&id,
|
||||
&input.name,
|
||||
input.legal_name.as_deref(),
|
||||
input.support_url.as_deref(),
|
||||
input.support_email.as_deref(),
|
||||
input.brand_color.as_deref(),
|
||||
input.post_purchase_redirect_url.as_deref(),
|
||||
false, // is_default
|
||||
&now,
|
||||
)
|
||||
.await?;
|
||||
get(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("created profile not found")))
|
||||
}
|
||||
|
||||
/// Update a profile. Only fields with `Some(...)` are written;
|
||||
/// double-Option wraps nullable fields so callers can distinguish
|
||||
/// "leave unchanged" (`None`) from "set to NULL" (`Some(None)`).
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
patch: MerchantProfileUpdate,
|
||||
) -> AppResult<MerchantProfile> {
|
||||
repo::update_merchant_profile(pool, id, &patch).await?;
|
||||
get(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest(format!("merchant profile {id} not found")))
|
||||
}
|
||||
|
||||
/// Flip a profile to be the default. Atomic: clears the previous
|
||||
/// default in the same transaction so the partial unique index holds.
|
||||
pub async fn set_default(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
repo::set_default_merchant_profile(pool, id).await
|
||||
}
|
||||
|
||||
/// Delete a profile. Refuses if any product OR active subscription
|
||||
/// is still attached. Refuses if it's the default profile (operator
|
||||
/// must set another profile as default first).
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> AppResult<()> {
|
||||
let profile = get(pool, id).await?.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("merchant profile {id} not found"))
|
||||
})?;
|
||||
if profile.is_default {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot delete the default merchant profile — set another profile as default first"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let product_count = repo::count_products_for_profile(pool, id)
|
||||
.await
|
||||
.context("count_products_for_profile")
|
||||
.map_err(AppError::Internal)?;
|
||||
if product_count > 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"cannot delete merchant profile: {product_count} products still attached. \
|
||||
Move or delete the products first."
|
||||
)));
|
||||
}
|
||||
let active_sub_count = repo::count_active_subscriptions_for_profile(pool, id)
|
||||
.await
|
||||
.context("count_active_subscriptions_for_profile")
|
||||
.map_err(AppError::Internal)?;
|
||||
if active_sub_count > 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"cannot delete merchant profile: {active_sub_count} active subscriptions \
|
||||
still attached. Cancel them first or migrate them to another profile."
|
||||
)));
|
||||
}
|
||||
repo::delete_merchant_profile(pool, id).await
|
||||
}
|
||||
Reference in New Issue
Block a user