Files
PowderCoatingLogix/docs/ACCOUNTING_LEDGER_REFACTOR.md
T
spouliot 57ec3ed127 Record O9 decision: expense materials at purchase (periodic inventory)
Owner decision: this is a service business that uses materials to deliver a service
rather than selling inventory, so powder/consumables are expensed at purchase (bill
to a COGS/expense account) and inventory is not capitalized on the Balance Sheet.

No code change required — the Balance Sheet already behaves this way, and the
perpetual consumption-COGS path (O6) is opt-in via item account mappings (set only
via CSV import) and stays dormant under this policy. Documents the double-count
footgun (do not both expense at purchase and map item COGS/Inventory accounts) and
locks the periodic choice into the ledger-refactor plan's Phase 5.

All accounting audit findings O1-O9 now resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:37:43 -04:00

7.1 KiB
Raw Blame History

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 O1O8 the current books are correct.


Why

Today every financial event's GL effect is encoded in three places:

  1. Account.CurrentBalance — updated live at each posting site via AccountBalanceService.DebitAsync/CreditAsync.
  2. LedgerService — re-derives each account's balance by reading source documents (Payments, Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). Drives RecalculateAllAsync and the Balance Reconciliation report.
  3. FinancialReportServiceindependently 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 from JournalEntryLine only (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

  1. One posting service: PostAsync(date, description, lines[]) — validates balanced, assigns the JE-YYMM-#### number, marks Posted, updates CurrentBalance from the lines. AccountBalanceService becomes a thin wrapper over it.
  2. 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.WriteOff already posts a JE — copy that model everywhere.
  3. Edits/voids become reversing entries (post an opposite JE), never mutate in place.
  4. Gut the re-derivation: LedgerService → JE-only; FinancialReportService TB/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 CurrentBalance as 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 LedgerService to JE-only; confirm RecalculateAllAsync reproduces 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 5O9 is already decided: expense at purchase (periodic). Materials are used to deliver a service, not sold, so they are expensed when purchased (bill → COGS/expense account); inventory is not capitalized on the Balance Sheet. In the refactor, post material purchases as DR COGS / CR AP (expense immediately) and do not emit a separate consumption JE. The opt-in perpetual path (item CogsAccountId + InventoryAccountId) stays unused under this policy — keep those mappings empty to avoid double-counting. No perpetual inventory asset/relief postings.

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 O1O8 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 01 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.