Captures the phased plan to make JournalEntry lines the single source of truth for all GL balances/reports, retiring the parallel re-derivation in LedgerService and FinancialReportService. Resolves the O2/O6/O7/O8 bug class structurally and folds in O9 (inventory capitalization) at Phase 5. Proposed only — not started. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.9 KiB
Accounting Ledger Refactor — Single Source of Truth
Status: PROPOSED / not started. This is a captured plan, not in-progress work. Companion to
docs/ACCOUNTING_AUDIT.md. Resolves the O2/O6/O7/O8 bug class and the open finding O9 (inventory capitalization). Do this when there's runway + a staging environment — it is not an emergency; after audit fixes O1–O8 the current books are correct.
Why
Today every financial event's GL effect is encoded in three places:
Account.CurrentBalance— updated live at each posting site viaAccountBalanceService.DebitAsync/CreditAsync.LedgerService— re-derives each account's balance by reading source documents (Payments, Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). DrivesRecalculateAllAsyncand the Balance Reconciliation report.FinancialReportService— independently re-derives P&L, Balance Sheet, and Trial Balance from the same documents, with its own per-report logic (largely duplicated across the three reports).
Adding a transaction type means hand-mirroring its debit and credit into all three engines, in the right direction, in every report method. Missing one spot produced every finding in the audit:
| Finding | Missed mirror |
|---|---|
| O2 | 4950 Sales Discounts absent from LedgerService → recalc wiped it |
| O6 | inventory-consumption COGS absent from both recompute engines |
| O7 | GC redemption's AR credit absent → Trial Balance did not balance |
| O8 | written-off payments filtered inconsistently between the two engines |
These are one design flaw with four faces: reports re-interpret documents instead of reading a ledger.
Target
The journal becomes the only representation of GL effect. The tables already exist
(JournalEntry + JournalEntryLine, with a Posted status).
- Every financial event posts a balanced
JournalEntry(DR == CR enforced once, centrally). Account.CurrentBalance, Trial Balance, Balance Sheet, P&L, and the recompute all derive fromJournalEntryLineonly (plus each account's opening balance).- Invoices/Payments/Bills remain operational records; they stop carrying implicit GL meaning that every report must re-decode.
Result: LedgerService.GetAccountLedgerAsync (~800 lines of per-document sections) collapses to
"sum the JE lines for this account in range." Trial Balance / Balance Sheet become straight per-account JE
aggregations. The special cases — gcRedeemed, cmApplied, refund splits, consumption COGS, discount
contra-revenue — all disappear because they are simply journal lines.
What changes in the code
- One posting service:
PostAsync(date, description, lines[])— validates balanced, assigns theJE-YYMM-####number, marksPosted, updatesCurrentBalancefrom the lines.AccountBalanceServicebecomes a thin wrapper over it. - Convert every posting site to build JE lines instead of ad-hoc Debit/Credit pairs: invoice create,
payment, deposit record/apply, refund, credit memo issue/apply/void, GC issue/redeem/void, bill, bill
payment, vendor credit, inventory consumption, write-off, sales-tax remittance, year-end close,
depreciation, Stripe. Many already call
DebitAsync; mechanical but touches every accounting controller.InvoicesController.WriteOffalready posts a JE — copy that model everywhere. - Edits/voids become reversing entries (post an opposite JE), never mutate in place.
- Gut the re-derivation:
LedgerService→ JE-only;FinancialReportServiceTB/BS/P&L → JE-only. AR/AP aging and customer/vendor statements stay document-driven (they answer "which documents are open," not "what is the GL balance"). The AR control account then auto-reconciles to the subledger because both come from the same postings.
Migration of historical data
- (a) Replay — re-run every historical document through the new posting logic to emit JEs. Most faithful; must handle edits/voids in chronological order. Complex, higher risk.
- (b) Conversion balances (recommended) — snapshot each account's current
CurrentBalanceas a single dated "opening balance" JE at a cutover date; only new events post JEs after that. This is how real systems migrate. With ~7 companies it is the pragmatic, low-risk path: pre-cutover per-transaction GL granularity is lost, but every balance is preserved exactly.
Phased plan (verifiable, not big-bang)
- Phase 0 — build
PostAsync+ balanced-entry invariant + unit tests. - Phase 1 — route all posting sites through it, creating JEs alongside existing behavior (no report changes yet). The Balance Reconciliation report is the regression harness — it should show near-zero drift everywhere once every event posts a JE.
- Phase 2 — switch
LedgerServiceto JE-only; confirmRecalculateAllAsyncreproduces balances. - Phase 3 — switch
FinancialReportService(TB/BS/P&L) to JE-only; reconcile each report per company against the old numbers using the snapshot harness below. - Phase 4 — post conversion-balance JEs at the cutover date; delete the dead re-derivation code (~1,000+ lines).
- Phase 5 — decide O9 (perpetual vs periodic inventory) and implement it as JE postings. Perpetual falls out naturally: purchases DR Inventory, consumption DR COGS / CR Inventory (O6 already posts the consumption half). This makes the Balance Sheet inventory line consistent with the Trial Balance / P&L.
Prerequisites before starting
- A report-snapshot/diff harness to compare each company's P&L / Balance Sheet / Trial Balance before vs after each phase (the safety net for "numbers shifting").
- A staging copy of production data to run the cutover against first.
- A decision on O9 (the inventory policy) — see
docs/ACCOUNTING_AUDIT.md.
Honest assessment
- Effort: the single biggest change in the accounting area — weeks, not hours, with careful per-company verification.
- Risk: broad blast radius (every accounting controller); report numbers will shift slightly as they become consistent (the point), so it needs per-company sign-off.
- Optional: after O1–O8 the current system is correct. The refactor buys prevention of this bug class, a real audit trail, and ~1,500 fewer lines of parallel logic. Higher ROI the more accounting features you keep adding.
- Safe stopping point: Phases 0–1 alone (post JEs everywhere, watch the reconciliation report) are low-risk and immediately valuable. You can stop there and continue later.
What it fixes
- Eliminates the O2/O6/O7/O8 bug class (one place to get each event right).
- Trial Balance balances by construction.
- Every balance is traceable to a journal entry (audit trail).
- Resolves O9 as part of Phase 5.
- Turns the Balance Reconciliation report into a true integrity check rather than a drift detector.