user-stories

US-04 — Admin Section with Fees

docs/user-stories/04-Admin-Fees.mdtype: user-storyupdated: 2026-08-09

US-04 — Admin Section with Fees

An admin changes a fee — either a platform-wide knob or one merchant's rate — and it applies to future transactions immediately. Every save is auditable.

Persona

Admin / opsusers.role = 'admin', gated server-side by requireAdmin.

Goal

"Drop this merchant's payout rate from 0.40% to 0.30%, and raise the flat send fee from €5 to €6 — both with a paper trail, without redeploying."

Two places fees live

The old tier model (user / silver / gold / platinum) is gone. There are now two independent surfaces:

Surface What it controls Stored in
/admin/fees Platform-wide knobs that apply to everyone platform_fees (one row per named key)
/admin/users/[id] One merchant's per-operation percentages Columns on that merchant's users row

Platform-wide knobs (/admin/fees)

Key Meaning Default
send_eur_minor Flat send fee, EUR cents 500 (€5)
invoice_open_fee_eur_minor Flat fee to open an invoice, EUR cents
buy_external_pct On-ramp reconciliation rate 1.5%
payout_max_recipients Cap on recipients per payout batch 100

Per-merchant rates (/admin/users/[id])

invoice_pct, payout_pct, api_pct, receive_pct, sell_pct, convert_pct, plus invoice_open_fee_eur_minor as a per-merchant override. Resolved at quote time by resolveMerchantOpRate.

Preconditions

  • Admin is signed in and not blocked.
  • Admin has access to the /admin nav.

Happy path

  1. Admin opens /admin/fees.
  2. The page renders one form per knob, each showing its current value and label.
  3. Admin edits a value and submits that form.
  4. The server action re-checks requireAdmin, then validates the input:
    • numeric, finite and ≥ 0 — otherwise redirect with ?error=invalid
    • percentages additionally capped at 100
    • payout_max_recipients is additionally bounded by the hard ceiling in lib/payouts/limits.ts
  5. On success the platform_fees row is updated with updated_by set to the acting admin, and an audit_log row records the previous and next value.
  6. Admin is redirected back with ?ok=1; the next quote uses the new value.

Changing one merchant's rate follows the same shape on /admin/users/[id], writing to that user's row instead.

Alternate flows

  • Inspect history — filter audit_log for fee actions to see every change with actor and before/after. There is no dedicated search UI yet; see ../10-Roadmap#Beta.
  • Feature flags — the same user detail page toggles orders_enabled, payouts_enabled and invoices_enabled, each audited separately.

Edge cases & failure modes

  • Concurrent edits — last write wins; both writes are audited, so the order stays reconstructible.
  • Invalid input (negative, non-numeric, over cap) — rejected with ?error=invalid; nothing is written.
  • Mid-quote race — an invoice already opened keeps its persisted platform_fee_minor. Fee changes are prospective only; see ../08-Risks and Drawbacks#Fees.
  • Admin self-block attempt — the console refuses to block another admin.
  • Impersonating a blocked user — refused.

Acceptance criteria

  • Only requireAdmin callers can read or write /admin/fees.
  • Every save writes an audit_log row with the actor plus previous and next values.
  • Input validation is enforced server-side, not just in the form.
  • Rate changes never retroactively affect already-frozen invoices.
  • Per-merchant rate changes on /admin/users/[id] are audited.
  • audit_log is append-only at the database level (0012_audit_log_append_only).

Out of scope

Drawbacks

Open questions

Implementing surfaces

  • UI: app/admin/fees/page.tsx, app/admin/users/[id]/page.tsx
  • Auth gate: requireAdmin (lib/auth/guards.ts)
  • Fee resolution: lib/fees/rates.tsresolveMerchantOpRate, resolveSendFeeEurMinor, resolveBuyExternalRate
  • Schema: platform_fees, per-op columns on users, audit_log
  • Domain doc: ../03-Domains#Fees, ../06-Admin Console