Files
keysat/licensing-service/src/merchant_profiles.rs
T
Grant 89f1b89705 WIP — merchant profile CRUD endpoints + tier-cap wire-up (part 4)
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>
2026-06-03 22:48:54 -05:00

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
}