Admin Recompute From Source of Truth¶
Date: 2026-06-02
Problem / motivation¶
Cached/derived accounting values are maintained incrementally — every
invoice, receipt, payment and credit adjusts a running balance by a delta, and
entity statuses are re-derived on each mutation. When a bug, a partial failure
or a bad import corrupts one of those incremental updates, the cached value
silently drifts from the underlying source rows and there is no in-app way to
repair it. The only existing tool was the ops-only CLI
scripts/recalculate_client_balances.py, which is also incomplete — it omits
credit notes from the client-balance formula.
This feature gives a tenant administrator a GraphQL mutation that recomputes these values from their source-of-truth rows, with a dry-run preview (report drift, write nothing) and an apply mode. Whole-tenant sweeps only.
GraphQL surface¶
recomputeBalances(
targets: [RecomputeTarget!] = null # empty/null => all targets
dryRun: Boolean! = true # default is the safe preview
): [RecomputeResult!]!
enum RecomputeTarget {
CLIENT_BALANCES
SUPPLIER_BALANCES
SALES_INVOICES
SUPPLIER_INVOICES
ORDERS
QUOTES
CREDITS
}
type RecomputeResult {
target: RecomputeTarget!
dryRun: Boolean!
checked: Int! # entities examined
drifted: Int! # entities with at least one wrong field
applied: Int! # entities corrected (0 when dryRun)
items: [RecomputeDrift!]! # per-field detail, capped at 200
}
type RecomputeDrift {
entityId: ID!
label: String # invoice/credit number, order/quote number; null for balances
field: String! # "balance", "total_amount", "paid_amount", "invoice_status", ...
currentValue: String! # stringified decimal or enum name
recomputedValue: String!
}
Auth: restricted to Role.ADMINISTRATOR via RolePermissionAccess. Any other
role gets a 403 RolePermissionError. There is no new Path/menu permission —
the role gate is the control.
What each target recomputes (source of truth)¶
| Target | Recompute |
|---|---|
CLIENT_BALANCES |
SUM(invoice totals) − SUM(receipts) − SUM(active credits) per client. Active credits only — VOID/CANCELED are excluded (their effect was already reverted). This is the bug the legacy script had. |
SUPPLIER_BALANCES |
SUM(supplier-invoice totals) − SUM(payments) per supplier. |
SALES_INVOICES |
Base amounts + cancellations re-summed from line details; paid_amount re-summed from receipt + prepayment-application details; retention_amount preserved; invoice_status re-derived. |
SUPPLIER_INVOICES |
Base amounts + cancellations from line details; paid_amount from payment details; status re-derived. |
ORDERS |
Order balance re-summed from order details; order_status re-derived from the order's invoice balance (only when the order has been invoiced). |
QUOTES |
Quote balance re-summed from quote details. Quote status is workflow-managed and left untouched. |
CREDITS |
Credit balance re-summed from credit details; credit_status = APPLIED if linked to an invoice else UNAPPLIED, with VOID/CANCELED preserved. |
When multiple targets are requested they always run, and their results are
returned, in this canonical order: SALES_INVOICES, SUPPLIER_INVOICES, CREDITS,
ORDERS, QUOTES, CLIENT_BALANCES, SUPPLIER_BALANCES. Master balances run last
so they re-aggregate from the freshly-corrected child documents.
Frontend contract¶
- Surface this as an admin maintenance tool (e.g. under Settings → Admin), visible only to administrators.
- Always dry-run first. Show
RecomputeResult.itemsas a "what would change" table (label,field,currentValue→recomputedValue). Then offer an "Apply corrections" action that re-issues the same mutation withdryRun:false. checked/drifted/appliedare exact counts.itemsis capped at 200 — ifdrifted > items.length, tell the user the list was truncated but the apply still corrects everything.- A non-administrator gets
403 RolePermissionError; hide the tool for them. currentValue/recomputedValueare strings (decimals like"125.40", or enum names like"PARTIALLY_PAID"); render verbatim.
What shipped¶
New module app/graphql/recompute/:
- Strawberry types — recompute_target.py, recompute_response.py.
- Services —
RecomputeService(orchestrator/dispatch),RecomputeBalancesService,RecomputeInvoicesService,RecomputeDocumentsServiceunder services/. - Mutation —
RecomputeMutations.recompute_balances(recompute_mutations.py). - Tests —
tests/graphql/recompute/(status-mirror unit tests, DB-backed balance recompute incl. the credit-term regression, and an all-targets smoke test).
No new tables, migrations, Path/Resource, or backfill — it operates on
existing data and is role-gated.
Caveats / pitfalls (carried from the design)¶
- Apply ordering matters. Writing a supplier-invoice balance fires the
SupplierInvoiceBalance → SupplierBalancelistener, soSUPPLIER_BALANCESrecomputes last and re-aggregates from scratch. paid_amount/retention_amountare never wiped — the service deliberately avoids the balance repos'recalculate_balances, which reset them to 0.- Status logic is mirrored in pure helpers (so dry-run never dirties the session); they are unit-tested to match the repository status functions.
- Dry-run is pure — only SELECTs run; no rows are written.
Future additions¶
- Targeted single-record recompute (pass one
clientId/invoiceId) for fixing a single reported case without a full sweep. Deferred — the whole-tenant sweep covers the "a bug corrupted everything" case the feature was built for. - Deeper invoice cancellation recompute from
credit_detailsrather than trusting the per-linecanceled_*columns. Deferred — the current recompute matches the system's own canonical re-sum. - Retire/repair
scripts/recalculate_client_balances.pyso the credit term matches this mutation. Deferred to a follow-up.