O6: inventory consumed on jobs posts DR COGS / CR Inventory, but neither recompute
engine reflected it — so reports understated COGS / overstated inventory and a
"Recalculate Balances" wiped the effect. The COGS posting fires only for JobUsage
and Waste transaction types, which are created only at the two COGS-posting sites,
so the consumption is exactly identifiable from InventoryTransaction:
- both posting sites now record consumption at the effective (weighted-average)
unit cost so TotalCost equals the COGS posted (the recompute reads TotalCost)
- LedgerService: new section (dated rows + prior balance) crediting Inventory /
debiting COGS from JobUsage/Waste rows on items with both accounts mapped
- FinancialReportService: Trial Balance + accrual P&L include consumption COGS
This reads existing transactions, so historical data is covered with no backfill.
The Balance Sheet inventory line is intentionally left alone — it does not track
inventory purchases either (periodic), so relieving it for consumption alone would
unbalance it; tracked as O9 (inventory capitalization policy).
O8: the write-off already creates a balanced posted JournalEntry (both engines read
it via their JE-line sections). The real defect was 4 "Status != WrittenOff" filters
in FinancialReportService that excluded pre-write-off payments from AR credits and
bank debits — leaving the paid portion dangling as open AR and understating the bank.
Removed those filters; AR now nets to zero for written-off invoices and the trial
balance balances. No backfill needed.
Adds a LedgerService regression test for inventory consumption. Build clean; 293
unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ApplyGiftCertificate posts DR 2500 Gift Certificate Liability / CR AR, but the AR
recompute only subtracted payments and credit-memo applications — so the redemption's
2500 debit was recomputed while its AR credit was not, leaving the Trial Balance out
of balance by the total gift-certificate amount redeemed and overstating AR on the
Balance Sheet.
Subtract GC redemptions from AR in both recompute engines:
- FinancialReportService: Balance Sheet (gcRedeemedBs) and Trial Balance (gcRedeemedTb)
- LedgerService: AR section (dated rows) and ComputePriorBalanceAsync (prior balance)
AR Aging was already correct (uses BalanceDue, which includes GiftCertificateRedeemed).
Adds a LedgerService regression test. Build clean; 292 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the read-path defense-in-depth pass flagged in the accounting audit:
every Accounts lookup in a controller now carries an explicit CompanyId predicate,
matching the standing rule in CLAUDE.md ("every FindAsync/GetAllAsync must include
an explicit CompanyId"). ~19 lookups across 12 controllers:
- Tier 1 (write-path): AccountsController duplicate account-number check (Create/Edit)
- Tier 2 (dropdowns/lists): Accounts (Index/year-end/parent), BankReconciliations,
Bills (bank list + receipt scan + suggest), Budgets, CatalogItems, Expenses,
FixedAssets, Inventory, JournalEntries chart dropdown, Vendors
- Tier 3 (accountIds.Contains display maps): JournalEntries/Reports/VendorCredits
detail views, scoped via the in-scope entity's CompanyId for uniformity
companyId source per controller: _tenantContext where available, else the in-scope
entity's CompanyId, else the current user. Build clean; 291 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Account 2500 is resolved by number as the GC liability (GiftCertificatesController),
but the per-tenant seeder never created it — so tenants onboarded after the
AccountingGapsPhase2 migration had no GC liability account and gift-certificate GL
postings silently no-op'd. The default-company seeder also created 2500 as
"Long-Term Loan", mislabeling that company's GC obligations.
- SeedDataService.Accounts: seed 2500 "Gift Certificate Liability" (IsSystem)
- SeedData: seed 2500 as GC liability; move long-term loan to 2900
- EnsureSystemAccountsAsync: self-heal — rename a 2500 still named "Long-Term Loan"
(preserving user renames) and ensure a 2500 exists
- migration FixGiftCertificateLiabilityAccount: move long-term loan to 2900 where a
2500="Long-Term Loan" exists without a 2900, relabel the mislabeled 2500, and
safety-net insert a 2500 for any company lacking one
Non-destructive: no account Id/number/balance is changed (same pattern as O1).
Verified on dev: existing GC-liability rows preserved, no spurious accounts added.
All audit findings O1-O5 resolved. Build clean; 291 unit tests pass; migration applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
O3: defense-in-depth on the write/posting path. Finding #7 scoped the report
(read) path; this scopes every GL posting-path account lookup that determines
where money lands, so a SuperAdmin acting in a company context can never post to
another tenant's account:
- InvoicesController: all account-resolver helpers (checking, customer deposits,
sales returns, customer credits, AR, bad debt, sales tax, sales discount, GC
liability) plus the bank-account and write-off expense dropdowns
- CreditMemosController: Create/Apply/Void GL lookups (scoped via the in-scope
customer/invoice/memo)
- GiftCertificatesController: Create/BulkCreate/Void GL lookups + GC liability helper
- BillsController: AP/expense account resolution that pre-fills APAccountId
DepositsController and JournalEntriesController.SalesTaxPayment were already scoped.
O4: SalesTaxPayment now rejects a remittance greater than the outstanding Sales
Tax Payable balance (0.005 rounding tolerance), so a typo can no longer drive
2200 into an abnormal debit balance.
Remaining pure read-path dropdown lookups (app-wide, lower risk) are documented
in docs/ACCOUNTING_AUDIT.md as a separate follow-up. All audit findings O1-O4 are
now resolved. Build clean; 291 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
O1: account 2300 has always been used by the deposit GL code as the Customer
Deposits liability (resolved by number), but it was seeded/named "Payroll
Liabilities" for tenants the AccountingDepositsGL migration's NOT EXISTS guard
skipped — so the liability was mislabeled on the balance sheet. Rename 2300 to
"Customer Deposits" (IsSystem) and move payroll to a new 2400 account:
- both seed paths (SeedDataService.Accounts, SeedData)
- EnsureSystemAccountsAsync self-heal (renames only where still default-named,
preserving user renames; ensures 2400 exists)
- migration RenameDepositsAccountAddPayroll for existing tenants
Account number 2300 is unchanged, so the deposit posting code needs no changes.
O2: LedgerService never recomputed 4950 Sales Discounts, so "Recalculate
Balances" wiped it to JE-only and the Balance Reconciliation report showed false
drift. Add a 4950 section to GetAccountLedgerAsync and ComputePriorBalanceAsync
that reproduces the actual postings (invoice discounts DR + credit-memo issuance
DR, less the unapplied remainder of voided memos CR), matching AccountBalanceService.
Adds a LedgerService regression test for 4950. Documents both fixes plus the
remaining open findings (O3, O4) in docs/ACCOUNTING_AUDIT.md so the audit is no
longer lost. Build clean; 291 unit tests pass; migration applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>