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

119 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. **`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 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 5** — **O9 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.