Paradaux

PAR-209

0

Admin firm-management tool (disband / rename) in the economy explorer

DoneUnassignedEconomy ExplorerFeature

Goal

A staff-only firm administration tool in the economy explorer to disband and rename a firm from the UI, faithfully reproducing the business-rian plugin's control flow (balance sweep, account archival, firm archive, name rules). It must be a separate, deliberately-gated tool (not a button bolted onto the public firm page) because disband is destructive and moves money, and every action must be audited.

Why a separate tool

Disband sweeps balances and archives Treasury accounts — high blast radius. Keep it off the public/firm drilldown to avoid accidental/casual invocation: a dedicated /admin/firms surface, admin-role gated, with an explicit confirm step (mirroring the plugin's 60s confirmation token for the user path). Rename can live there too.

The plugin control flow to replicate (business-rian — ground truth)

Disband — FirmServiceImpl.disbandInternal() (FirmServiceImpl.java:169–198), single @Transactional:

  1. Resolve the proprietor's PERSONAL account — treasury.resolveOrCreatePersonal(proprietorUuid) (creates if missing).
  2. List firm accounts — firm_accounts WHERE firm_id=? AND removed_at IS NULL.
  3. For each account:
    • read balance (getBalanceByAccountId); if > 0, transfer the full balance → proprietor's personal account via TreasuryApi.transfer(TransferRequest) with reason "Firm disbanded", requester=authorizer=proprietor, source "BusinessPlugin";
    • archive the Treasury account (treasury.archiveAccount(accountId));
    • soft-delete the firm↔account link (UPDATE firm_accounts SET removed_at = CURRENT_TIMESTAMP …).
  4. Archive the firmUPDATE firm SET is_archived = 1, default_account_id = NULL, updated_at=….
  • Guards: firm exists & not archived; user path requires proprietor + a valid confirmation token (admin-forced path skips both). Notifications: proprietor + online employees + server broadcast (not relevant to the web tool).
  • NOT touched: firm_employees / firm_role / firm_role_permission / firm_invites / firm_transfer_requests / firm_properties (the firm is archived, not hard-deleted, so nothing cascades). No ChestShop handling.

Rename — FirmServiceImpl.renameFirm() (FirmServiceImpl.java:270–286), @Transactional:

  • Validate name: length 2–32, regex [A-Za-z0-9 _.\-]{2,32}, no leading digit (NameValidator/validateFirmName); reject MiniMessage/&<> (chat-injection safe).
  • Case-insensitive uniqueness: reject if a different firm already has the name (allow case-only self-rename, e.g. AcmeACME).
  • UPDATE firm SET display_name=? WHERE firm_id=?. No Treasury calls, no side effects. Note: Treasury account display names ("<name> Corporate Account") are not updated by the plugin either — they intentionally drift. ChestShops bind by account id (B:<base36>), not name, so rename is shop-safe.

⚠️ Ledger-authority constraint

All money movement and Treasury-account archival must go through the Treasury authoritative path — never raw SQL against accounts/balances. The explorer (Node/Kysely) cannot call the Java TreasuryApi. So the money parts have to be delegated to the Treasury service tier.

How it could be done (recommended)

Option A — orchestrate in treasury-rest-api, drive from the explorer (recommended). treasury-rest-api already has DB access to both the Treasury accounts (AccountMapper, incl. resolve-personal-by-player) and the business firm/firm_accounts tables (FirmMapper), plus the idempotent POST /api/v1/transfers and PATCH /firms/me (display-name). Add admin-scoped endpoints there that replicate the plugin faithfully in Java, within proper Treasury transaction/idempotency boundaries:

  • POST /api/v1/admin/firms/{firmId}/disband → resolve-or-create proprietor personal, per-account transfer (idempotency-keyed) + archive + firm_accounts soft-delete, then firm archive. Returns a per-account result list for the audit log.
  • POST /api/v1/admin/firms/{firmId}/rename (or reuse the firm-update path) with the 2–32 / regex / no-leading-digit / case-insensitive-uniqueness rules.
  • Needs a new admin/service credential scope (today's VerifiedToken write endpoints are firm-scoped me; transfer is "from the token's account"). This is a sibling Treasury API issue dependency. The explorer provides /admin/firms UI + a thin server action that calls these endpoints (server-side, admin-credentialed) and records explorer_audit. This keeps sensitive money logic in one Java tier instead of reimplementing it in TypeScript.

Option B — orchestrate in the explorer backend (fallback). Explorer does the business-schema writes directly (it already writes MariaDB for groups/rate-limits/webhooks): firm_accounts soft-delete + firm archive + rename/validation; and calls treasury-rest-api per account for transfer + (new) archive + resolve-personal. More moving parts and a two-system partial-failure surface; only viable once rest-api exposes an admin transfer-from-arbitrary-account + archive.

Partial-failure / idempotency (applies to both): Treasury calls are external IPC and can't share a DB transaction with the firm-table writes — the plugin documents this exact orphan risk. The tool must therefore: process accounts one-by-one, use idempotency keys on every transfer (so a retry can't double-pay), be resumable (re-running disband on a partially-disbanded firm skips already-archived/removed accounts), and log every step so an interrupted run is reconcilable. Order matters: transfer → archive account → soft-delete link → (after all accounts) archive firm.

Auditing & gating (explorer)

  • Gate on viewer.role === 'admin' (requireRole/requireAdmin), no anonymous/player variant.
  • Record an explorer_audit row per operation via audit()/auditView() (lib/audit.ts): target_type:'firm', target_id: firm id, method POST, path /admin/firms/{disband|rename}, actor + outcome. For disband, also log the per-account transfer/archive results (amount swept, destination personal account, idempotency key) so the money movement is fully traceable.
  • Explicit confirmation step in the UI before disband (type firm name to confirm), mirroring the plugin's confirmation token.

Tables touched (reference)

  • firm: is_archived→1, default_account_id→NULL, display_name (rename), updated_at.
  • firm_accounts: removed_at (soft-delete per link).
  • Treasury accounts: archived (via Treasury service); balances moved via transfer only.
  • explorer_audit: one+ rows per action.

Acceptance

  • /admin/firms (admin-only) can rename a firm with the exact plugin validation/uniqueness rules; non-admins get 403; case-only rename allowed.
  • Disband, via the tool, sweeps each account's balance to the proprietor's personal account through the Treasury ledger path (idempotent, no double-pay on retry), archives those Treasury accounts, soft-deletes the firm_accounts links, and archives the firm — matching the plugin outcome.
  • Re-running disband on an already/partially-disbanded firm is safe (idempotent/resumable).
  • Every disband/rename writes a complete explorer_audit trail (incl. per-account money movement for disband).
  • No raw SQL touches balances; ledger authority preserved.

Cross-project dependency

Option A needs a Treasury API (treasury-rest-api) issue to add the admin-scoped disband/rename endpoints + an admin credential scope. File that as a sibling and link it.

Resources

Comments

tesks · Jun 16, 2026, 8:18 PM

Backend dependency filed: PAR-210 — admin-scoped firm disband/rename endpoints + service credential in treasury-rest-api (the ledger-authoritative tier for Option A). PAR-209 is the explorer UI + orchestration + explorer_audit.

tesks · Jun 17, 2026, 11:25 AM

Dev built + verified end-to-end. economy-explorer 565fbb3: new /admin/firms admin-gated tool (search → rename + type-to-confirm disband) calling the ledger-authoritative treasury admin API via a server-only client, each action audited (explorer_audit, target_type firm). tsc/lint/unit/build green. gitops: wired TREASURY_API_BASE_URL/TREASURY_ADMIN_TOKEN (from economy-explorer-treasury-admin) into the dev deployment (base overlay). Dev deploy rolled to 565fbb3 with the env. Verified from the dev explorer pod's own runtime: disband of a seeded test firm → HTTP 200, swept 320.00 firm→proprietor, account+firm archived (DB confirmed), cleaned up. Shipping to prod next (no prod e2e per request).

tesks · Jun 17, 2026, 11:38 AM

Shipped to prod. Release economy-explorer #24 merged → main e665fbd. Both prod instances rolled to production-sha-e665fbd (ArgoCD economy-explorer-production Healthy, 2/2 each) with the treasury admin credential wired (gitops: per-network TREASURY_API_BASE_URL/TREASURY_ADMIN_TOKEN from economy-explorer-{democracycraft,statecraft}-treasury-admin; dev wired via the base overlay). Per request, no e2e test in prod — verified deployment + env wiring + health only. Dev was fully e2e-verified (disband via the explorer's own credential → 200, ledger sweep confirmed). /admin/firms is now live (admin-gated) on dev + both prod networks.

Activity

  • tesks commented
  • ParadauxIO linked a commit — Commit e665fbd — Merge pull request #24 from MCCitiesNetwork/develop
  • ParadauxIO linked a commit — Commit 565fbb3 — Admin firm-management tool: disband / rename (PAR-209)
  • ParadauxIO changed status to Status → Done
  • ParadauxIO linked a pull request — PR #24 merged — Release develop → main: admin firm-management tool (PAR-209)
  • ParadauxIO linked a pull request — PR #24 open — Release develop → main: admin firm-management tool (PAR-209)
  • tesks commented
  • ParadauxIO linked a commit — Commit 565fbb3 — Admin firm-management tool: disband / rename (PAR-209)
  • tesks changed status to Status → In Progress
  • tesks commented
  • tesks created the issue