Recompute inventory-consumption COGS and fix written-off AR (audit O6, O8)

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>
This commit is contained in:
2026-06-19 21:27:20 -04:00
parent 91ed19c2b1
commit 7834d67432
6 changed files with 191 additions and 44 deletions
@@ -600,6 +600,41 @@ public class LedgerService : ILedgerService
});
}
// ── 12d. Inventory consumption COGS (DR COGS / CR Inventory) ──────────
// When an item with both a COGS and an Inventory account is consumed (JobUsage/Waste — the only
// two transaction types created at the COGS-posting sites), JobsController/InventoryController post
// DR COGS / CR Inventory at the transaction's TotalCost. Reproduce it here so a balance recompute
// matches the posting and the trial balance stays balanced. TotalCost is stored positive.
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
{
var consumption = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
&& t.TransactionDate >= fromDate && t.TransactionDate <= toDate)
.ToListAsync();
foreach (var t in consumption)
{
var amount = Math.Abs(t.TotalCost);
if (t.InventoryItem.CogsAccountId == accountId)
entries.Add(new LedgerEntryDto
{
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
Source = "Inventory Usage", Description = $"COGS — {t.InventoryItem.Name}",
Debit = amount, Credit = 0, LinkController = "Inventory", LinkId = t.InventoryItemId
});
if (t.InventoryItem.InventoryAccountId == accountId)
entries.Add(new LedgerEntryDto
{
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
Source = "Inventory Usage", Description = $"Inventory relieved — {t.InventoryItem.Name}",
Debit = 0, Credit = amount, LinkController = "Inventory", LinkId = t.InventoryItemId
});
}
}
// ── 10. Journal Entry lines touching this account ──────────────────
var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry)
@@ -855,6 +890,26 @@ public class LedgerService : ILedgerService
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
}
// 12d. Inventory consumption COGS (DR COGS / CR Inventory). Mirrors section 12d in
// GetAccountLedgerAsync so the prior-period opening balance matches the posting.
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
{
var priorConsumption = await _context.InventoryTransactions
.Include(t => t.InventoryItem)
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
&& t.TransactionDate < beforeDate)
.ToListAsync();
foreach (var t in priorConsumption)
{
var amount = Math.Abs(t.TotalCost);
if (t.InventoryItem.CogsAccountId == accountId) debits += amount;
if (t.InventoryItem.InventoryAccountId == accountId) credits += amount;
}
}
// 10. Posted journal entry lines touching this account (prior to period)
debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId