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>
11 KiB
Accounting System Audit
Living record of the accounting/GL audit and its remediation status. Keep this file updated whenever an accounting finding is opened or closed — the original audit was lost once because it was never written down. Don't let that happen again.
Last reconciled against code: 2026-06-19 (branch dev, commits through 9532812).
Verification at that point: dotnet build clean (0 errors); dotnet test tests/PowderCoating.UnitTests → 290 passed, 0 failed.
How the GL is modeled (context for the findings)
Balances are computed two different ways, and they must agree:
- At posting time — controllers call
AccountBalanceService.DebitAsync/CreditAsync, which mutate the denormalizedAccount.CurrentBalance. - At report time —
FinancialReportService(Trial Balance, Balance Sheet, P&L, AR/AP aging, statements) andLedgerService(per-account ledger +RecalculateBalances) recompute balances from source documents.
LedgerService also backs the Balance Reconciliation report (the detective control added in
c2cd19e), which compares stored CurrentBalance vs the ledger-recomputed balance. Any account
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
and (b) produce misleading output in the reconciliation report.
Resolved findings (this audit batch)
| # | Finding | Fix | Commit | Verified |
|---|---|---|---|---|
| #4 | Sales Tax Payable (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). |
JournalEntries/SalesTaxPayment GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. |
0c921ba |
Posting reviewed JournalEntriesController:235-296. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
| #7 | Most report queries relied on the global tenant filter, which is bypassed for SuperAdmin → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit CompanyId == companyId predicate added to every query in FinancialReportService. |
9532812 |
Confirmed: every query in FinancialReportService.cs carries an explicit CompanyId predicate. |
| #9a | A cash refund posted DR AR (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now reverses the sale: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in Core/Accounting/RefundAllocation.Split. |
2a82a1d |
Posting (InvoicesController.IssueRefund/CancelRefund) and both recompute paths (LedgerService §5b, FinancialReportService TB/BS/P&L) all use RefundAllocation.Split — they agree by construction. |
| #9b | Store-credit refunds & credit memos posted nothing to the GL on issue (only a CreditMemo + Customer.CreditBalance) → outstanding store credit invisible on the balance sheet. |
New 2350 Customer Credits liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | 9ce3612 |
Posting sites confirmed (CreditMemosController 180-185/262-270/314-320; InvoicesController store-credit path). Reporting cmContraRevenue/cmIssuedNonVoided math reviewed. |
| — | No detective control to catch denormalized-balance drift. | Balance Reconciliation report (Reports/Reconciliation): per-account stored vs recomputed, plus AR/AP control-vs-subledger. |
c2cd19e |
Reviewed FinancialReportService.GetBalanceReconciliationAsync. NOTE: its usefulness is limited by O2 below. |
Original numbering gap
The commits reference findings #4, #7, #9a, #9b — implying a list of at least #1–#9. The original audit document was never persisted and is unrecoverable. Findings #1, #2, #3, #5, #6, #8 cannot be matched to commits and their status is unknown. If the original list resurfaces, reconcile it here.
Resolved findings (2026-06-19 remediation)
O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — RESOLVED
- Root cause refined: there is no payroll feature posting to 2300; the deposit GL code has always used
2300 (resolved by number) as the Customer Deposits liability. Migration
AccountingDepositsGLalready renamed it to "Customer Deposits" for tenants that lacked a 2300, but itsNOT EXISTSguard skipped pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the wrong name. - Fix (chosen approach: rename 2300, move payroll to a new 2400):
- Seed files now create 2300 = "Customer Deposits" (IsSystem) and a separate 2400 = "Payroll
Liabilities" —
SeedDataService.Accounts.cs,SeedData.cs. EnsureSystemAccountsAsyncself-heals: renames any 2300 still named "Payroll Liabilities" → "Customer Deposits" (preserving user renames) and ensures 2400 exists.- Migration
20260619233108_RenameDepositsAccountAddPayrolldoes the same for existing tenants (rename only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes only empty 2400s; does not re-introduce the mislabel).
- Seed files now create 2300 = "Customer Deposits" (IsSystem) and a separate 2400 = "Payroll
Liabilities" —
- Account number 2300 is unchanged, so the deposit posting code (
DepositsController,InvoicesController) needed no changes — its lookups are by"2300".
O2 — LedgerService never recomputed 4950 Sales Discounts → recalc corrupted it — RESOLVED
- Fix: added a 4950 section to both
LedgerService.GetAccountLedgerAsyncandComputePriorBalanceAsyncthat reproduces the actual postings so a balance recalc matches the stored balance: invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) − the unapplied remainder of voided memos (CR at void). This mirrors whatAccountBalanceServiceposts atInvoicesController:849/1108/1629andCreditMemosController, so the Balance Reconciliation report no longer shows false drift on 4950. - Note / micro-discrepancy: the ledger uses
memo.AmountAppliedfor the voided remainder (matching the true posting), whereasFinancialReportServiceTB/BS derive the voided portion from applications against non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug. - Regression test:
LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue.
Verification of O1+O2: dotnet build clean; dotnet test tests/PowderCoating.UnitTests → 291 passed;
migration applied to the dev database successfully.
O3 — Write-path account lookups omitted explicit CompanyId — RESOLVED
- Added
CompanyIdto every GL posting-path account lookup that determines where money posts:InvoicesController— all account-resolver helpers (GetCheckingAccountIdAsync,GetCustomerDepositsAccountIdAsync,GetSalesReturnsAccountIdAsync,GetCustomerCreditsAccountIdAsync,GetArAccountIdAsync,GetBadDebtAccountIdAsync,ResolveSalesTaxAccountIdAsync,GetSalesDiscountAccountIdAsync,GetGcLiabilityAccountIdAsync) plus the bank-account and write-off expense dropdowns (scoped via_tenantContext).CreditMemosController— Create/Apply/Void GL lookups (scoped via the in-scopecustomer/invoice/memo).GiftCertificatesController— Create, BulkCreate, Void GL lookups +GetGcLiabilityAccountIdAsync.BillsController— AP/expense account resolution that pre-fillsAPAccountId(Create + CreateFromPO).
DepositsControllerandJournalEntriesController.SalesTaxPaymentalready scoped correctly.
O4 — Sales-tax remittance could over-remit (drive 2200 negative) — RESOLVED
JournalEntriesController.SalesTaxPayment(POST) now rejects any amount exceedingtaxAcct.CurrentBalance(0.005 rounding tolerance), so a typo can no longer push Sales Tax Payable into an abnormal debit balance.
Verification of O3+O4: dotnet build clean; dotnet test tests/PowderCoating.UnitTests → 291 passed.
O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — RESOLVED
- Root cause (same shape as O1): 2500 is resolved by number as the GC liability
(
GiftCertificatesController). TheAccountingGapsPhase2migration seeded it for tenants existing at deploy, but (a) the per-tenant seederSeedDataService.Accounts.csnever created a 2500, so tenants onboarded afterward had no GC liability account and GC GL postings silently no-op'd; and (b) the default-company seederSeedData.cscreated 2500 as "Long-Term Loan", so that company's GC obligations were mislabeled (and the migration'sNOT EXISTSguard skipped it). - Fix:
SeedDataService.Accounts.csnow seeds 2500 "Gift Certificate Liability" (IsSystem).SeedData.csnow seeds 2500 as GC liability and moves the long-term loan to 2900.EnsureSystemAccountsAsyncself-heals: renames any 2500 still named "Long-Term Loan" → "Gift Certificate Liability" (preserving user renames) and ensures a 2500 exists.- Migration
20260620002950_FixGiftCertificateLiabilityAccount: moves long-term loan to 2900 where a 2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a 2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build clean; migration applied; 291 unit tests pass.
Read-path account lookup sweep (Tier 1–3) — RESOLVED
Completed the app-wide defense-in-depth pass: every Accounts lookup in a controller now carries an
explicit CompanyId predicate, matching the standing rule in CLAUDE.md. ~19 lookups across 12 controllers:
- Tier 1 (write-path validation):
AccountsControllerduplicate account-number check on Create/Edit. - Tier 2 (dropdowns/lists):
AccountsController(Index/year-end/parent dropdown),BankReconciliations,Bills(bank list + receipt-scan + suggest),Budgets,CatalogItems,Expenses,FixedAssets,Inventory,JournalEntries(chart dropdown),Vendors. - Tier 3 (
accountIds.Containsdisplay maps):JournalEntriesDetails,Reportsbudget vs actual,VendorCreditsDetails — scoped via the in-scope entity'sCompanyIdfor uniformity.companyIdsource per controller:_tenantContext.GetCurrentCompanyId()where available, else the in-scope entity'sCompanyId, else_userManagercurrent user. Build clean; 291 unit tests pass.
Status
All audit findings O1–O4 are resolved on dev. Original audit numbering #1–3/#5/#6/#8 remains
unrecoverable (see top). The accounting batch has not been merged to master/production yet.