PAR-209
Admin firm-management tool (disband / rename) in the economy explorer
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:
- Resolve the proprietor's PERSONAL account —
treasury.resolveOrCreatePersonal(proprietorUuid)(creates if missing). - List firm accounts —
firm_accounts WHERE firm_id=? AND removed_at IS NULL. - For each account:
- read balance (
getBalanceByAccountId); if> 0, transfer the full balance → proprietor's personal account viaTreasuryApi.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 …).
- read balance (
- Archive the firm —
UPDATE 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.
Acme→ACME). 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_accountssoft-delete, thenfirmarchive. 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
VerifiedTokenwrite endpoints are firm-scopedme; transfer is "from the token's account"). This is a sibling Treasury API issue dependency. The explorer provides/admin/firmsUI + a thin server action that calls these endpoints (server-side, admin-credentialed) and recordsexplorer_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_auditrow per operation viaaudit()/auditView()(lib/audit.ts):target_type:'firm',target_id:firm id, methodPOST, 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 viatransferonly. 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_accountslinks, 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_audittrail (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
- commite665fbd Merge pull request #24 from MCCitiesNetwork/developMCCitiesNetwork/economy-explorer
- commit565fbb3 Admin firm-management tool: disband / rename (PAR-209)MCCitiesNetwork/economy-explorer
- PR · merged#24 Release develop → main: admin firm-management tool (PAR-209)MCCitiesNetwork/economy-explorer
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