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
@@ -655,6 +655,43 @@ public class LedgerServiceTests
Assert.Equal(150m, ledger.ClosingBalance); // debit-normal AR: 200 invoiced 50 redeemed
}
// ── Inventory consumption posts DR COGS / CR Inventory; both sides must be in the ──
// ── recompute so a recalc reproduces them and the trial balance stays balanced (O6). ──
[Fact]
public async Task GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory()
{
await using var context = CreateContext();
// COGS account (id 1) and Inventory asset account (id 2)
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "5100", Name = "Powder & Materials", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsActive = true });
context.Accounts.Add(new Account { Id = 2, CompanyId = 1, AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsActive = true });
context.InventoryItems.Add(new InventoryItem
{
Id = 7, CompanyId = 1, Name = "White Powder", SKU = "WP-1",
CogsAccountId = 1, InventoryAccountId = 2
});
context.InventoryTransactions.Add(new InventoryTransaction
{
Id = 1, CompanyId = 1, InventoryItemId = 7,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -4m, UnitCost = 10m, TotalCost = 40m,
TransactionDate = InPeriod, Reference = "Job 100"
});
await context.SaveChangesAsync();
var svc = CreateService(context);
var cogs = await svc.GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var cogsEntry = Assert.Single(cogs!.Entries, e => e.Source == "Inventory Usage");
Assert.Equal(40m, cogsEntry.Debit);
Assert.Equal(40m, cogs.ClosingBalance); // debit-normal COGS
var inv = await svc.GetAccountLedgerAsync(2, PeriodStart, PeriodEnd);
var invEntry = Assert.Single(inv!.Entries, e => e.Source == "Inventory Usage");
Assert.Equal(40m, invEntry.Credit);
Assert.Equal(-40m, inv.ClosingBalance); // debit-normal asset relieved by a credit
}
private static LedgerService CreateService(ApplicationDbContext context)
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());