89f1b89705
Backend is now feature-complete for :52. Admin UI still has to consume
these endpoints (part 5) but every operation the UI needs has a
working API surface behind it.
api/merchant_profiles.rs (new module)
Axum handlers wrapping the merchant_profiles::* business-logic helpers
and the rail-preference repo helpers. Each endpoint writes an audit
entry so the operator can see every profile/rail-preference change
in the audit log.
GET /v1/admin/merchant-profiles list + summarize
POST /v1/admin/merchant-profiles create (tier-gated)
GET /v1/admin/merchant-profiles/:id detail + providers + rail prefs + counts
PATCH /v1/admin/merchant-profiles/:id partial update
DELETE /v1/admin/merchant-profiles/:id refuses if attached
POST /v1/admin/merchant-profiles/:id/set-default transactional flip
PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail validates + persists
DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail clears the override
set_rail_preference validates THREE things before persisting: rail
name is one of lightning/onchain/card; the provider exists; the
provider is attached to THIS profile; AND it serves this rail. So
the operator can't pin "Card" to a BTCPay row, and can't pin a
provider that belongs to a different profile.
list/get redact SMTP password (smtp_configured: bool is enough for
the UI to render "configured/not configured" status; the actual
password stays write-only). The edit form submits a new password
only when the operator explicitly rotates it.
api/tier.rs
New enforce_merchant_profile_cap helper. Refuses with HTTP 402
AppError::PaymentRequired when a Creator-tier operator already has
one profile (the default) and the self-license lacks the new
`unlimited_merchant_profiles` entitlement. Same shape as the
existing enforce_product_cap / enforce_policy_cap helpers — the
admin UI's existing tier-cap modal renders the upgrade CTA from
the upgrade_url field.
Note: master Keysat's Pro and Patron policies need
`unlimited_merchant_profiles` added to their entitlement JSON as a
separate admin action on the master keysat.xyz instance — purely
data, no code change. Master operator self-license must be re-
issued (or naturally renewed) to pick up the new entitlement.
merchant_profiles.rs
create() now calls enforce_merchant_profile_cap before INSERT.
Replaces the TODO comment from part 1.
api/mod.rs
Registers the merchant_profiles module and wires the routes above.
Build: cargo check passes. Two warnings remaining — both expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim's own pre-migration
fallback branch
Backend status: every multi-provider story (purchase routing,
subscription snapshot, webhook delivery, connect/disconnect, profile
CRUD, tier gating) is now wired to the new schema. Only the admin UI
+ a version bump remain.
What's left for :52:
- Admin UI in web/index.html — Merchant Profiles section, product
picker, buy-page brand block + rail picker. Roughly 600-1000 lines
of HTML/CSS/JS consuming the new endpoints. Largest single
remaining piece.
- Version bump to :52 + release notes flagging the one-way migration
+ the post-migration manual Zaprite-webhook-URL update.
- End-to-end sandbox test against two profiles + two Zaprite orgs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
8.7 KiB
Rust
226 lines
8.7 KiB
Rust
//! 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> {
|
|
// Tier gate: Creator gets 1 profile (the auto-created default).
|
|
// Pro / Patron with `unlimited_merchant_profiles` get N. Returns
|
|
// AppError::PaymentRequired (HTTP 402) with the upgrade URL so the
|
|
// admin UI can render the existing tier-cap modal.
|
|
crate::api::tier::enforce_merchant_profile_cap(state).await?;
|
|
|
|
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
|
|
}
|