From 89f1b8970502d864cc65ca91b711e0563d1d1680 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 3 Jun 2026 22:48:54 -0500 Subject: [PATCH] =?UTF-8?q?WIP=20=E2=80=94=20merchant=20profile=20CRUD=20e?= =?UTF-8?q?ndpoints=20+=20tier-cap=20wire-up=20(part=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/api/merchant_profiles.rs | 341 ++++++++++++++++++ licensing-service/src/api/mod.rs | 23 +- licensing-service/src/api/tier.rs | 33 ++ licensing-service/src/merchant_profiles.rs | 10 +- 4 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 licensing-service/src/api/merchant_profiles.rs diff --git a/licensing-service/src/api/merchant_profiles.rs b/licensing-service/src/api/merchant_profiles.rs new file mode 100644 index 0000000..042d0ee --- /dev/null +++ b/licensing-service/src/api/merchant_profiles.rs @@ -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 = 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, + headers: HeaderMap, +) -> AppResult> { + require_admin(&state, &headers)?; + let profiles = merchant_profiles::list(&state.db).await?; + let mut out: Vec = 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, + headers: HeaderMap, + Path(id): Path, +) -> AppResult> { + 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::>()); + 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, + headers: HeaderMap, + Json(req): Json, +) -> AppResult> { + 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, + headers: HeaderMap, + Path(id): Path, + Json(patch): Json, +) -> AppResult> { + 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, + headers: HeaderMap, + Path(id): Path, +) -> AppResult> { + 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, + headers: HeaderMap, + Path(id): Path, +) -> AppResult> { + 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, + headers: HeaderMap, + Path((profile_id, rail)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + 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, + headers: HeaderMap, + Path((profile_id, rail)): Path<(String, String)>, +) -> AppResult> { + 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(), + }))) +} diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 7ef51da..d9c32e6 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -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 diff --git a/licensing-service/src/api/tier.rs b/licensing-service/src/api/tier.rs index 09cd0e3..ce45ee7 100644 --- a/licensing-service/src/api/tier.rs +++ b/licensing-service/src/api/tier.rs @@ -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 diff --git a/licensing-service/src/merchant_profiles.rs b/licensing-service/src/merchant_profiles.rs index 30be9cb..365f230 100644 --- a/licensing-service/src/merchant_profiles.rs +++ b/licensing-service/src/merchant_profiles.rs @@ -138,11 +138,11 @@ pub async fn create( state: &AppState, input: NewMerchantProfile, ) -> AppResult { - 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()));