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
+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