Ledger Integrity, Repost & Self-Healing Tooling
A design reference for detecting, localizing, and repairing accounting-ledger drift
1. Requirements
1.1 Functional requirements
- Check two ledger integrity invariants on a schedule, scoped per legal entity and a rolling lookback window: (1) every voucher’s Ledger Postings, summed, must have equal total debit and credit; (2) for receivable/payable accounts, the general ledger’s outstanding balance must agree with the derived Party Balance Entry ledger’s. An administrator toggles each check independently, sets the lookback in days, and scopes the run to chosen legal entities; every violation becomes its own timestamped finding, accumulating a history across runs.
- Give an operator a guided way to localize when, inside a long date range, a known discrepancy between two summary financial statements first appeared — by recursively halving the range into a tree and letting a human walk down it, rather than recomputing both statements over the full range at every step.
- Let an operator force one or more vouchers to regenerate their postings (a repost), triggered by a health finding, a reference-data correction, or an independently found discrepancy, at two granularities: a full ledger repost regenerating every Ledger Posting for named vouchers (optionally hard-deleting superseded rows instead of marking them cancelled), and a narrower balance-ledger-only repost rebuilding just the Party Balance Entries for every submitted voucher posted on/after a chosen date, without touching the general ledger.
- Let an administrator consolidate two duplicated ledger accounts into one survivor, relinking every historical reference rather than leaving orphaned postings.
1.2 Non-functional requirements
- Non-blocking at scale: a full ledger repost of more than a handful of vouchers, and every balance-ledger repost regardless of size, runs as a background job.
- Cheap investigation: the bisecting tool computes a statement pair only for the sub-range currently inspected, caching results so backtracking doesn’t recompute.
- Resumability: a partial repost or consolidation must be safely retryable — balance-ledger reposts carry an explicit status; consolidation tracks a per-row merged flag.
- No racing live traffic: account consolidation refuses to run while the ledger was actively posted to in the last few minutes.
- Repost must not be blocked by live-only rules: correcting a historical voucher must not trip validations meant only for a brand-new posting (the posting funnel is described separately).
1.3 Constraints
- The health checks cover exactly two invariants, not a general ledger-diffing engine — nothing here checks valuation, tax computation, or any derived ledger besides the receivable/payable balance ledger.
- Reposting is gated by a central allow-list of voucher types, blocked outright inside a period already closed by a submitted period-closing document, or for an invoice with a deferred-revenue/expense schedule enabled.
- The bisecting range split is driven purely by elapsed calendar days, not transaction volume, so convergence speed is unrelated to how many vouchers fall in a sub-range.
- Account consolidation requires both accounts to already agree on group/leaf status, root type, company, and currency — it merges “the same account under two names,” not a general restructuring tool.
2. High-Level Design
2.1 Component diagram
2.2 Daily monitor run (an annotated procedure, not a graph)
- The scheduled job fires once a day and loads the Integrity Monitor Profile — a single settings record, not one per entity. If disabled, nothing runs; otherwise its lookback window sets
period_start = today - windowandperiod_end = today. - Per scoped legal entity, if enabled, the debit/credit check sums debit and credit per voucher over the window and returns only vouchers where the sums disagree; each becomes a finding with
debit_credit_mismatchset. - Per scoped legal entity, if enabled, the balance-ledger check groups both ledgers by company/account/voucher/party over the window and reports vouchers with disagreeing amounts or present in only one ledger; each becomes a finding with
general_and_payment_ledger_mismatchset. - Findings are append-only. Nothing clears a prior finding once its voucher is fixed — a finding is a fact about a checked-on timestamp, not a live status (see § 6).
3. Deep Dive
3.1 Data model
Integrity Finding — one row per violation per run: voucher type/number, a debit_credit_mismatch flag, a general_and_payment_ledger_mismatch flag, and a checked-on timestamp; a voucher can accumulate several findings across runs. Integrity Monitor Profile — a single settings record: enable switch, lookback window in days, a checkbox per invariant, and a child list of scoped legal entities.
Bisect Session — also a single, global, administrator-only record: the outer from_date/to_date, a traversal-order choice (breadth-first or depth-first), the currently selected node’s id and dates, and the last-computed Profit & Loss figure, Balance Sheet figure, and their difference. Bisect Node — one search-tree node:
| Field | Purpose |
|---|---|
root / left_child / right_child |
parent and the two halves this node split into |
period_from_date / period_to_date |
the calendar-day sub-range this node covers |
profit_loss_summary / balance_sheet_summary / difference |
cached statement figures, once computed |
generated |
whether those figures have been computed yet |
General Ledger Repost Job — company, (voucher_type, voucher_no) rows, and a flag for hard-delete vs. mark-cancelled; no persisted retry/status field. Balance Ledger Repost Job — company and a cut-off posting date, an optional voucher-type filter, a manual-selection override, resolved vouchers, and an explicit status (Queued/Failed/Completed) plus an error log.
Account Consolidation Request — root type, surviving account, company, and accounts to fold in, each row with its own merged checkbox; an overall status (Pending/Success/Partial Success/Error) reflects how many rows succeeded.
3.2 Algorithm — the guided bisect
The bisect tool is a human-in-the-loop binary search over a calendar range, similar in spirit to a source-control bisect except the “signal” at each step is a pair of financial-statement numbers rather than a pass/fail verdict.
Building the tree. When an operator supplies an outer from_date/to_date and clicks “Build Tree,” every existing node in the single, global node table is discarded and a fresh tree is constructed:
build(from_date, to_date):
delete all existing nodes; root = new node(from_date, to_date); frontier = [root]
while frontier not empty:
node = pop next; span = node.to_date - node.from_date
if span == 0: continue # leaf: single day
mid = floor(span / 2)
node.left_child = new node(node.from_date, node.from_date + mid days)
node.right_child = new node(node.from_date + mid+1 day, node.to_date)
push node.left_child, node.right_child onto frontier # deque=BFS, stack=DFS
current_node = root
The BFS/DFS choice only changes the order nodes are written to storage — the split is always an exact by-calendar-days halving, so the tree shape is identical either way. Splitting continues until a node’s span is zero days, the finest granularity reached; a single day has no children, so descent stops there. Building the tree is a purely structural step — no statement figures are computed for any node except, once, the root, right after the tree is built.
Descending. bisect_left/bisect_right move the current node to its left or right child (if one exists); move_up moves back to the parent. Each calls the same follow-up step:
fetch_or_calculate(node):
if node.generated: read cached profit_loss_summary, balance_sheet_summary, difference
else:
p_l = Profit & Loss Statement report for [node.from_date, node.to_date] # net profit
b_s = Balance Sheet report for the same range # assets - liabilities + equity
difference = abs(p_l - b_s); write all three back onto node; node.generated = true
The two statement engines already used for ordinary financial reporting are re-invoked scoped to whichever sub-range is selected — but only the first time that node is visited; a later revisit is served from the cached figures instead. A heatmap widget renders the outer range as a strip of days, highlighting the currently selected sub-range so the operator can see how far the window has narrowed.
The tool supplies the numbers and the visual; it does not decide which half is “bad.” The operator reads the profit/loss figure, the balance-sheet figure, and their difference, judges which half the anomaly belongs to, and clicks accordingly — repeating until the range narrows to a single day whose vouchers can be inspected directly.
3.3 Repost semantics — what is deleted, what is regenerated, what is skipped
General Ledger Repost Job. Blocked at validation if a voucher’s type isn’t on the configured allow-list, falls before the company’s latest submitted period-closing document, or is an invoice with deferred revenue/expense enabled. A preview action re-derives each posting batch without persisting anything, rendering old and new rows side by side, differently highlighted. Batches over five vouchers background; otherwise inline. Per voucher: with the deletion flag set, existing Ledger Postings and both Party Balance Entry flavors are hard-deleted, bypassing the normal cancel-and-mark path; otherwise the voucher runs its own cancel-posting path first (rows marked cancelled, or reversed under Immutable Ledger Mode), then re-posts for fresh rows. A repost flag rides along on every posting written, suppressing a work-in-progress-account check and the “expense exceeds budget” check — both meant only for live posting — so a historically valid voucher can be reposted past a budget limit that would block a new entry.
Balance Ledger Repost Job. Narrower: unless picked manually, it discovers its own scope by querying every submitted sales invoice, purchase invoice, payment entry, and journal entry posted on/after a chosen date — the operator supplies only a company and a cut-off — and always backgrounds. Per voucher it rebuilds the in-memory posting batch, deletes existing Party Balance Entries, and regenerates them from that batch, never touching Ledger Postings: the narrow fix for the second health-monitor invariant, trusting the general ledger and rebuilding only the derived balance ledger. A mid-run failure rolls back, logs the traceback, and marks the job Failed, resubmittable. Repost tooling and the reconciliation engine (described separately) are related but distinct — a stale Party Balance Entry can throw off reconciliation’s view of what is outstanding, so a repost is sometimes a prerequisite to reconciliation, but neither invokes the other automatically.
3.4 Account consolidation, adapter contract, and error handling
An Account Consolidation Request re-validates, at merge time, that the surviving account and each account folded in agree on group/leaf status, root type, company, and currency; a mismatch throws rather than merges. If the retired account is a group and the survivor sits directly beneath it, the survivor is re-parented one level up first so it isn’t orphaned. The merge is delegated to the platform’s generic rename-with-merge primitive: every Link and Dynamic Link field system-wide referencing the retired account — including every historical Ledger Posting — is repointed at the survivor, then the retired account is deleted outright. A guard blocks the operation if a Ledger Posting was modified in the last five minutes, since the relink can’t be proven safe against a posting landing mid-operation. The batch runs as a deduplicated background job, one account at a time: each success commits immediately and marks that row merged, driving a realtime progress bar; a failure rolls back only that row, so a resubmit retries only rows not yet merged.
The illustrative surface across all three tools follows one shape: a build/step action for the bisect session (build-tree, then bisect-left/bisect-right/move-up), and a submit/start-merge action per batch job that validates its own rules and either runs inline or enqueues a background job.
Error handling follows the same split throughout: a bisect session refuses an inverted date range; closed-period, deferred-accounting, and disallowed-type checks block a General Ledger Repost Job’s whole submission; a balance-ledger repost rolls back entirely on a mid-run exception and flips to Failed (resubmittable); account consolidation rolls back only the failing row, keeps prior successes, and separately refuses outright — a hard stop, not a retry — if the ledger was written to in the last five minutes.
4. Scale and Reliability
- Health monitor cost scales with lookback window × scoped legal entities, run once daily; either check can be disabled independently.
- General ledger repost auto-backgrounds only past a five-voucher threshold, while balance-ledger repost always backgrounds — expected to run over a whole company’s post-cutoff history, rarely small.
- Concurrency guards are inconsistent. Account consolidation checks whether a job is already enqueued for the same request before starting another; the two repost jobs show no equivalent guard.
- The bisect tool is a single global instance, not one session per investigation — “Build Tree” unconditionally discards every existing node, so two people cannot investigate two ranges at once.
- Repost and consolidation are retryable, but asymmetrically: balance-ledger repost and consolidation persist enough status to resume cleanly; general ledger repost has no status field, so recovery depends on log inspection and manual resubmission.
- Monitoring: alert on a rising count of unresolved findings per legal entity, and on any balance-ledger repost left
Failed, since neither is proactively re-attempted by this tooling.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Two narrow, hardcoded integrity checks rather than a general ledger-diffing framework | Cheap and easy to reason about, but coverage is limited to debit/credit balance and the receivable/payable ledger — any other derived ledger needs its own bespoke check. |
| Findings accumulate as new rows every run, never cleared or deduplicated | Full audit trail, but nothing signals when a previously-flagged voucher was later fixed — an operator has to notice its absence in a later run. |
| Manual, human-guided bisect instead of an automated pass/fail search | Keeps the mechanism simple — two numbers, a difference, a heatmap — but there is no stopping rule; investigation quality depends entirely on the operator reading the figures correctly. |
| Bisect tree built entirely upfront to single-day leaves, figures cached lazily per node | Avoids one huge up-front computation, but the tree is a single global instance, so a new investigation wipes any cached work from the previous one. |
| Two repost granularities (full ledger vs. balance-ledger-only) instead of one | Lets an operator pick the cheaper fix when only the balance ledger drifted, but they must diagnose which situation they’re in — the wrong pick wastes work or misses a genuine imbalance. |
| Account consolidation delegates to the platform’s generic rename-with-merge primitive | Guarantees no reference is missed, at the cost of an expensive, lock-heavy operation gated behind an idle-system check, unsuitable for a busy company. |
| Repost flag suppresses a couple of live-only validations in the shared posting funnel | Makes routine correction reposts practical — a repost that would now violate this month’s budget still succeeds — but it trusts the source voucher’s own data rather than re-validating it. |
6. What to Revisit as the System Grows
- Resolution tracking for Integrity Findings. A finding is a fact about one check run, with no field marking it resolved once fixed. A status field, or a follow-up check that closes a finding once the invariant re-passes, would make the history actionable rather than purely archival.
- Status parity between the two repost jobs. The balance-ledger repost job tracks queued/failed/completed with an error log; the general ledger repost job has no equivalent, so a failed large-batch run is harder to recover from than it needs to be.
- Bisect as a per-investigation record rather than a global singleton. Giving the session and its node tree their own record per investigation, instead of one shared instance restricted to a single administrator role, would let more than one discrepancy be tracked at a time.
- Splitting the bisect range by transaction density rather than calendar days. A range with unevenly distributed posting volume converges no faster in the sparse stretches than the busy ones; splitting by voucher count (or a hybrid) could narrow in faster.
- A more general integrity-check framework. As more subsystems build their own derived ledgers, each currently needs a bespoke comparison bolted onto the monitor by hand, rather than registering against a shared “compare ledger A to derived ledger B” abstraction.