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>
This commit is contained in:
Grant
2026-06-03 22:48:54 -05:00
parent 9df1908328
commit 89f1b89705
4 changed files with 401 additions and 6 deletions
@@ -0,0 +1,341 @@
//! Admin CRUD endpoints for merchant profiles + rail preferences.
//!
//! Thin Axum handlers wrapping the business-logic helpers in
//! `crate::merchant_profiles` and the rail-preference repo helpers.
//! Consumed by the new Merchant Profiles section of the admin UI.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::merchant_profiles::{
self, MerchantProfile, MerchantProfileUpdate, NewMerchantProfile,
};
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
fn profile_to_json(p: &MerchantProfile, with_providers: Option<&[crate::db::repo::PaymentProviderRow]>) -> Value {
let mut obj = json!({
"id": p.id,
"name": p.name,
"legal_name": p.legal_name,
"support_url": p.support_url,
"support_email": p.support_email,
"brand_color": p.brand_color,
"post_purchase_redirect_url": p.post_purchase_redirect_url,
"is_default": p.is_default,
// SMTP credentials are redacted in list/get responses — operators
// see whether they're set, not the password itself. The edit
// form submits new credentials only when the operator explicitly
// wants to rotate them.
"smtp_configured": p.smtp_host.is_some(),
"smtp_host": p.smtp_host,
"smtp_port": p.smtp_port,
"smtp_username": p.smtp_username,
"smtp_from_address": p.smtp_from_address,
"smtp_from_name": p.smtp_from_name,
"smtp_use_starttls": p.smtp_use_starttls,
"created_at": p.created_at,
"updated_at": p.updated_at,
});
if let Some(providers) = with_providers {
let arr: Vec<Value> = providers
.iter()
.map(|row| {
let rails: Vec<&'static str> = crate::payment::ProviderKind::parse(&row.kind)
.map(|kind| {
crate::payment::rails_for_kind(kind)
.into_iter()
.map(|r| r.as_str())
.collect()
})
.unwrap_or_default();
json!({
"id": row.id,
"kind": row.kind,
"label": row.label,
"base_url": row.base_url,
"store_id": row.store_id,
"webhook_id": row.webhook_id,
"connected_at": row.connected_at,
"served_rails": rails,
})
})
.collect();
obj["providers"] = json!(arr);
}
obj
}
/// `GET /v1/admin/merchant-profiles` — list every profile + a brief
/// summary of attached providers per profile.
pub async fn list(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let profiles = merchant_profiles::list(&state.db).await?;
let mut out: Vec<Value> = Vec::with_capacity(profiles.len());
for p in &profiles {
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?;
out.push(profile_to_json(p, Some(&providers)));
}
Ok(Json(json!({ "profiles": out })))
}
/// `GET /v1/admin/merchant-profiles/:id` — full detail for a profile,
/// including providers + rail preferences.
pub async fn get(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let profile = merchant_profiles::get(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("merchant profile {id}")))?;
let providers = crate::db::repo::list_payment_providers_for_profile(&state.db, &id).await?;
let rail_prefs = crate::db::repo::list_rail_preferences_for_profile(&state.db, &id).await?;
let mut obj = profile_to_json(&profile, Some(&providers));
obj["rail_preferences"] = json!(rail_prefs
.into_iter()
.map(|p| json!({ "rail": p.rail, "payment_provider_id": p.payment_provider_id }))
.collect::<Vec<_>>());
let product_count =
crate::db::repo::count_products_for_profile(&state.db, &id)
.await
.map_err(AppError::Internal)?;
let active_subscription_count =
crate::db::repo::count_active_subscriptions_for_profile(&state.db, &id)
.await
.map_err(AppError::Internal)?;
obj["product_count"] = json!(product_count);
obj["active_subscription_count"] = json!(active_subscription_count);
Ok(Json(obj))
}
/// `POST /v1/admin/merchant-profiles` — create a new profile.
/// Tier-gated: Creator hits cap on the second profile.
pub async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<NewMerchantProfile>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let created = merchant_profiles::create(&state, req).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.create",
Some("merchant_profile"),
Some(&created.id),
ip.as_deref(),
ua.as_deref(),
&json!({ "name": created.name }),
)
.await;
Ok(Json(profile_to_json(&created, None)))
}
/// `PATCH /v1/admin/merchant-profiles/:id` — partial update.
pub async fn update(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(patch): Json<MerchantProfileUpdate>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let updated = merchant_profiles::update(&state.db, &id, patch).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.update",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({ "name": updated.name }),
)
.await;
Ok(Json(profile_to_json(&updated, None)))
}
/// `DELETE /v1/admin/merchant-profiles/:id` — delete a non-default
/// profile with no attached products or active subscriptions.
pub async fn delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
merchant_profiles::delete(&state.db, &id).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.delete",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "id": id })))
}
/// `POST /v1/admin/merchant-profiles/:id/set-default` — flip the
/// default-profile flag to this id.
pub async fn set_default(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
merchant_profiles::set_default(&state.db, &id).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.set_default",
Some("merchant_profile"),
Some(&id),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
Ok(Json(json!({ "ok": true, "id": id })))
}
// ---------------------------------------------------------------------
// Rail preferences
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SetRailPreferenceReq {
pub payment_provider_id: String,
}
/// `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
/// pin the provider that should serve this rail on this profile.
/// Validates that the provider belongs to the profile AND serves
/// the requested rail before persisting.
pub async fn set_rail_preference(
State(state): State<AppState>,
headers: HeaderMap,
Path((profile_id, rail)): Path<(String, String)>,
Json(req): Json<SetRailPreferenceReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
// Validate the rail name.
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
AppError::BadRequest(format!(
"unknown rail '{rail}'; accepted: lightning, onchain, card"
))
})?;
// Validate the provider exists, belongs to THIS profile, and serves
// THIS rail.
let provider_row = crate::db::repo::get_payment_provider_by_id(
&state.db,
&req.payment_provider_id,
)
.await?
.ok_or_else(|| {
AppError::BadRequest(format!("payment provider {} not found", req.payment_provider_id))
})?;
if provider_row.merchant_profile_id != profile_id {
return Err(AppError::BadRequest(format!(
"payment provider {} is not attached to merchant profile {profile_id}",
req.payment_provider_id
)));
}
let served = crate::payment::ProviderKind::parse(&provider_row.kind)
.map(crate::payment::rails_for_kind)
.unwrap_or_default();
if !served.contains(&parsed_rail) {
return Err(AppError::BadRequest(format!(
"payment provider {} (kind={}) does not serve the '{rail}' rail; \
pick a provider that does, or remove this preference",
req.payment_provider_id, provider_row.kind
)));
}
crate::db::repo::set_rail_preference(
&state.db,
&profile_id,
parsed_rail.as_str(),
&req.payment_provider_id,
)
.await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.rail_preference.set",
Some("merchant_profile"),
Some(&profile_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "rail": parsed_rail.as_str(), "payment_provider_id": req.payment_provider_id }),
)
.await;
Ok(Json(json!({
"ok": true,
"merchant_profile_id": profile_id,
"rail": parsed_rail.as_str(),
"payment_provider_id": req.payment_provider_id,
})))
}
/// `DELETE /v1/admin/merchant-profiles/:id/rail-preferences/:rail` —
/// clear a rail preference, letting the deterministic-earliest-connected
/// fallback take over.
pub async fn clear_rail_preference(
State(state): State<AppState>,
headers: HeaderMap,
Path((profile_id, rail)): Path<(String, String)>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| {
AppError::BadRequest(format!(
"unknown rail '{rail}'; accepted: lightning, onchain, card"
))
})?;
crate::db::repo::clear_rail_preference(&state.db, &profile_id, parsed_rail.as_str()).await?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"merchant_profile.rail_preference.clear",
Some("merchant_profile"),
Some(&profile_id),
ip.as_deref(),
ua.as_deref(),
&json!({ "rail": parsed_rail.as_str() }),
)
.await;
Ok(Json(json!({
"ok": true,
"merchant_profile_id": profile_id,
"rail": parsed_rail.as_str(),
})))
}
+22 -1
View File
@@ -75,6 +75,7 @@ pub mod tier;
pub mod validate;
pub mod community;
pub mod db_info;
pub mod merchant_profiles;
pub mod payment_provider;
pub mod rates_admin;
pub mod recover;
@@ -395,11 +396,31 @@ pub fn router(state: AppState) -> Router {
// model providers attach to profiles and products pick a profile
// at resolution time; there's no singleton "active" preference to
// flip. Multi-profile operators should use the new
// /v1/admin/merchant-profiles endpoints instead.
// /v1/admin/merchant-profiles endpoints below.
.route(
"/v1/admin/payment-provider/status",
get(payment_provider::status),
)
// Merchant profile CRUD + rail preferences.
.route(
"/v1/admin/merchant-profiles",
get(merchant_profiles::list).post(merchant_profiles::create),
)
.route(
"/v1/admin/merchant-profiles/:id",
get(merchant_profiles::get)
.patch(merchant_profiles::update)
.delete(merchant_profiles::delete),
)
.route(
"/v1/admin/merchant-profiles/:id/set-default",
post(merchant_profiles::set_default),
)
.route(
"/v1/admin/merchant-profiles/:id/rail-preferences/:rail",
axum::routing::put(merchant_profiles::set_rail_preference)
.delete(merchant_profiles::clear_rail_preference),
)
// Zaprite webhook landing — operator points Zaprite's
// webhook setting at this URL. Same handler as
// /v1/btcpay/webhook because the underlying validate_webhook
+33
View File
@@ -221,6 +221,39 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
Ok(())
}
/// Refuse a new merchant profile if the operator is at the Creator-tier
/// merchant-profile cap (= 1) and lacks `unlimited_merchant_profiles`.
/// Counts every profile including the auto-created default. So Creator
/// operators have the default profile (auto-created by migration 0020)
/// and can't add more; Pro and Patron operators are unlimited.
///
/// The `unlimited_merchant_profiles` entitlement needs to be added to
/// the master Keysat's Pro and Patron policies as a separate admin
/// action — see plans/multi-provider-payment-model.md "Tier gating"
/// section.
pub async fn enforce_merchant_profile_cap(state: &AppState) -> AppResult<()> {
let tier = current(state).await;
if tier.has("unlimited_merchant_profiles") {
return Ok(());
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM merchant_profiles")
.fetch_one(&state.db)
.await?;
// Creator gets 1 (the default profile).
if count >= 1 {
return Err(AppError::PaymentRequired {
message: format!(
"Your {} tier allows a single merchant profile (the default). \
You're at {}. Upgrade to Pro to run multiple businesses \
from one Keysat instance.",
tier.display_name, count
),
upgrade_url: UPGRADE_URL_PRO.to_string(),
});
}
Ok(())
}
/// Refuse to mark a policy as recurring unless the operator's self-tier
/// carries the `recurring_billing` entitlement. Pro and Patron tiers
/// have it; Creator does not. Called from both create-policy and
+5 -5
View File
@@ -138,11 +138,11 @@ 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.
// 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()));