Skip to content

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 onlyVOID/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.items as a "what would change" table (label, field, currentValuerecomputedValue). Then offer an "Apply corrections" action that re-issues the same mutation with dryRun:false.
  • checked/drifted/applied are exact counts. items is capped at 200 — if drifted > 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/recomputedValue are 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, RecomputeDocumentsService under 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 → SupplierBalance listener, so SUPPLIER_BALANCES recomputes last and re-aggregates from scratch.
  • paid_amount/retention_amount are 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_details rather than trusting the per-line canceled_* columns. Deferred — the current recompute matches the system's own canonical re-sum.
  • Retire/repair scripts/recalculate_client_balances.py so the credit term matches this mutation. Deferred to a follow-up.