System Accounts: treasury view + gated fund movement (bankowner/funder/dealer)#50
Merged
Conversation
added 4 commits
July 11, 2026 12:51
…ted transfers New desk page /app/system-accounts giving operators the treasury handle that cashout testing (and the parked mikeyy consolidation) needs. View (require_financial — System Manager + Accounts Manager): - pulse tiles: BankOwner Float, Outstanding Payables (USD-equiv of Pending/Draft/In Progress cashouts), Free Float (float − payables, red when negative), Funder Float - per-account cards for every mongo role account (bankowner/funder/ dealer) plus an optional site_config `system_watchlist` of view-only ops accounts (e.g. FLASH FLOAT, the parked mikeyy account): live IBEX balance per wallet + expandable recent activity reusing the census tx-type labels/signed amounts Control (System Manager only — Move Funds): - transfer between role wallets via the IBEX add-invoice-on-receiver / pay-from-sender primitive the flash cutover code uses; review → frappe.confirm → execute - per-transfer cap (site_config `system_transfer_cap_usd`, default $100) - watchlist accounts are view-only, never transfer endpoints - every attempt recorded in a new append-only System Transfer Log doctype (in_create, no write/create/delete perms) BEFORE the IBEX call; status Draft → Paid/Failed; audit_log on success Money-flow basis (verified in the flash backend): wallet.id IS the IBEX account id; a cashout is the user paying an invoice on bankowner, so bankowner float grows with cashouts and "coverage" is float minus unpaid fiat liability. IbexClient gains exactly two writes (add_invoice, pay_invoice) behind _post; a contract test pins the write surface to those two endpoints. Also fixes a latent UX bug in the shared handle_api_errors: deliberate frappe.throw() (ValidationError/PermissionError) was masked as "An internal error occurred" for EVERY admin endpoint; now re-raised so the message (e.g. the transfer cap) reaches the operator. New test_system_accounts_page_contract.py (9 tests): gate stack, System-Manager-only transfer + cap + role-wallet guard, log-before-move ordering, activity membership revalidation, IBEX write-surface lock, CSS scoping, registration, append-only doctype. Suite: 53 passed. Verified live against IBEX sandbox: seeded three role accounts, executed a real funder→bankowner $0.25 transfer (IBEX SUCCEEDED, balances moved $2.00→$2.25 / $1.14→$0.89, logged STL-2026-00009 Paid), and confirmed the cap + role-wallet guards reject.
… opt-in
Answers "can I add to the watchlist from the UI, and move funds in/out of
those accounts?" — yes to both, with a deliberate safety default.
- New System Watchlist doctype (System-Manager CRUD, Accounts-Manager
read) backs a UI-managed list; the legacy site_config `system_watchlist`
still works but stays view-only and non-managed.
- Page gains a "Watch Account" action (validates the ref resolves in
mongo) and, on each managed watch card, a remove ✕ and an "Enable/
Disable transfers" toggle. A "view-only" / "transfers on" chip shows
state at a glance.
- Per-account opt-in: watchlist accounts are view-only until you flip
allow_transfers. Only then do they count as a transfer endpoint and
appear in the Move Funds picker. Role accounts (bankowner/funder/
dealer) stay always-transferable. Enabling is confirm-gated; every
add/remove/toggle is audit_logged.
- transfer guard reworded ("transfer-enabled system wallet") and now
admits opted-in watch accounts; cap + System-Manager gate + System
Transfer Log unchanged.
Three new endpoints (add/remove/set_watchlist_transfers), all System
Manager only. +1 contract test (opt-in semantics + endpoint gating);
suite 54 passed.
Verified live against IBEX sandbox: watched sandbox_user_2, enabled
transfers (confirm dialog), it appeared in the picker, moved bankowner→
watch $0.10 (SUCCEEDED, STL-2026-00010); disabling opt-in then rejected
the identical move ("not a transfer-enabled system wallet").
Fixes the confirmed findings from the money-safety review, most-severe
first. All verified live against IBEX sandbox.
CRITICAL — double-spend on retry: pay_invoice's HTTP call timing out was
recorded as "Failed", but a Lightning payment can settle server-side
after the HTTP timeout — the operator would see failure and retry,
moving the funds twice. Now:
- a requests timeout/connection error marks the transfer "Pending"
(new status), never "Failed", and tells the operator the outcome is
unknown — verify balances before retrying;
- an idempotency guard refuses a new transfer from a wallet that has
any unresolved (Draft/Pending) prior transfer, so a retry can't
double-spend.
MAJOR — false success: the pay_invoice result was never inspected, so a
200-with-FAILED/PENDING body was logged "Paid". New _payment_settled()
gates "Paid" on an affirmative SUCCEEDED (status name or statusId 2);
an affirmative failure marks "Failed"; anything ambiguous stays
"Pending". Fail-safe: an unknown response shape returns None (never
success). Unit-tested across success/fail/ambiguous/malformed shapes.
MAJOR — dropped balances + BTC leak: the currency fallback returned
title-case "Usdt"/"Btc" (as mongo actually stores it), which failed the
uppercase USD/USDT float check and the BTC exclusion — silently zeroing
balances from the totals and leaking BTC wallets into the transfer
picker as dollars. Currency is now normalized to uppercase at the source.
MAJOR — dead watchlist buttons: toggle/remove sent username||account_id,
but id-added entries are stored under the id, so the buttons no-op'd.
The resolved account now carries watch_ref (the exact stored ref) and
the buttons use it.
MINOR — NaN bypass: float("nan") slipped past `amount > cap` (NaN
comparisons are always False). math.isfinite() now gates the amount.
MINOR — preimage leak: the raw pay_invoice response (incl. LN preimage)
was returned to the browser; the endpoint now returns only {success,
log, status}.
MINOR — config masks doctype: a legacy site_config watch entry could
mask a doctype transfers-on row for the same account; resolution now
prefers the managed (doctype) entry.
+6 contract tests pinning: cap enforcement (was unpinned — could be
deleted green), Paid-only-on-settlement, Pending-on-timeout, the
idempotency guard, the NaN guard, and no-raw-response-leak. Suite: 60
passed / 3 known-stale.
Live-verified: happy-path transfer still marks Paid with no preimage in
the response; NaN rejected; the dedup guard blocks a retry while a
Pending row exists; balances/totals correct with normalized currencies;
a mongo-id-added watch entry resolves its watch_ref to the id.
The container's ruff formatted the multiline assert differently from the pinned ruff-pre-commit v0.8.1 that CI runs. Reformatted to match.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a System Accounts desk page (
/app/system-accounts) giving operators the treasury handle that cashout testing needs — live bankowner/funder/dealer balances, cashout-coverage math, and a gated way to move funds between system wallets. This is the admin handle called for before consolidating the parked ops accounts (ENG-492).What it does
View (
require_financial— System Manager + Accounts Manager):Move funds (
System Manageronly):site_config(system_transfer_cap_usd, default $100)UI-managed watchlist with per-account transfer opt-in: watch any ops account from the page (validated against mongo); it's view-only until you explicitly enable transfers on it — only then is it a valid transfer endpoint. This is the safe path to consolidate parked accounts (watch → enable → move → disable).
Money-flow basis (verified in the flash backend)
wallets.idIS the IBEX account id (system accounts too); a cashout is the user paying an invoice on bankowner, so bankowner float grows with cashouts and the fiat owed is the liability. Role accounts are identified by mongoaccounts.role.New backend surface
api/system_accounts.py— resolve accounts, payables, transfer, watchlist managementapi/ibex_client.pygains exactly two writes (add_invoice,pay_invoice) behind_post; a contract test pins the write surface to those two endpointshandle_api_errorsnow re-raisesfrappe.ValidationError/PermissionErrorso deliberatethrow()messages (e.g. the cap) reach the operator instead of being masked as "internal error" — this was a latent bug affecting every admin endpointAdversarial money-safety review (multi-agent) + fixes
An 8-dimension + completeness-critic review confirmed 14 findings; all fixed in
fb73981:pay_invoiceHTTP timeout was logged "Failed", but a Lightning payment can settle after the HTTP call times out → operator retries → funds move twice. Fixed: a timeout marks a distinct Pending status (never Failed), and an idempotency guard refuses a new transfer from a wallet with any unresolved (Draft/Pending) prior.FAILEDbody logged "Paid". New_payment_settled()gates Paid on an affirmative SUCCEEDED, fail-safe to Pending on any ambiguous shape."Usdt"/"Btc"), failing the uppercase checks → balances silently zeroed and BTC leaked into the picker as dollars. Normalized to uppercase at the source.watch_ref.math.isfiniteguard); the raw IBEX response incl. the LN preimage was returned to the browser (now returns only{success, log, status}); config-masks-doctype watchlist edge.Tests
test_system_accounts_page_contract.py(16 tests) pins the money-safety invariants: log-before-move ordering, System-Manager-only transfer, cap enforcement (was unpinned — could have been deleted green), Paid-only-on-settlement, Pending-on-timeout, the idempotency guard, the NaN guard, no-raw-response-leak, IBEX write-surface lock, activity membership revalidation, append-only doctype, per-account opt-in. Suite: 60 passed; the 3 remaining failures are the pre-existing staletest_account_hub_search_jstests.Verification
Every path verified live against IBEX sandbox: seeded role + watchlist accounts, ran real transfers (happy-path marks Paid with no preimage leak; funder→bankowner and bankowner→watchlist both settled and logged), and confirmed every guard rejects — cap, non-finite amount, non-transfer-enabled wallet, and the dedup guard blocking a retry while a Pending row exists. The
_payment_settledfail-safe logic is unit-tested across success/failure/ambiguous/malformed shapes.Deploy notes
system_transfer_cap_usdon TEST and prod (per-transfer cap; default $100 if unset). Same mechanism as the IBEX creds:bench --site <site> set-config system_transfer_cap_usd <n>.bench clear-cacheafter the image bump.setup.pysync_pages + the workspace fixture.system_transfer_cap_usdcap lives in site_config precisely so a web-only System Manager can't raise their own limit — changing it needs shell/bench access.🤖 Generated with Claude Code