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
@@ -263,6 +263,19 @@ public class FinancialReportService : IFinancialReportService
.ToListAsync();
foreach (var b in accrualBillLines)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
// Inventory consumed on jobs posts DR COGS / CR Inventory — recognise the COGS in the period.
// (Cash basis recognises inventory cost when purchased, so this applies to accrual only.)
var consumptionCogs = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate >= from && t.TransactionDate <= toEnd)
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(t => t.TotalCost) })
.ToListAsync();
foreach (var c in consumptionCogs)
expenseAmounts[c.AccountId] = expenseAmounts.GetValueOrDefault(c.AccountId) + c.Amount;
}
var expAccounts = await _context.Accounts
@@ -307,8 +320,7 @@ public class FinancialReportService : IFinancialReportService
var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
&& p.Invoice.Status != InvoiceStatus.Voided)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
@@ -357,8 +369,7 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
&& p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
var cmAppliedBs = await _context.CreditMemoApplications
@@ -985,8 +996,7 @@ public class FinancialReportService : IFinancialReportService
// Bank/cash: customer payments deposited here (DR)
var depositsByAcct = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
&& p.Invoice.Status != InvoiceStatus.Voided)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
@@ -1063,6 +1073,25 @@ public class FinancialReportService : IFinancialReportService
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Inventory consumption: COGS account (DR) and Inventory asset account (CR) for JobUsage/Waste
// transactions on items with both accounts mapped — mirrors the DR COGS / CR Inventory posting.
var cogsConsumptionByAcct = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate <= asOfEnd)
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
var invConsumptionByAcct = await _context.InventoryTransactions
.Where(t => t.CompanyId == companyId
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
&& t.TransactionDate <= asOfEnd)
.GroupBy(t => t.InventoryItem.InventoryAccountId!.Value)
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
.ToDictionaryAsync(g => g.Id, g => g.Amt);
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
// Credit memo applications are also added to AR credits below so the double-entry balances.
@@ -1135,8 +1164,7 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(i => (decimal?)i.Total) ?? 0m;
var arTotalCredits = await _context.Payments
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
&& p.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
@@ -1251,6 +1279,8 @@ public class FinancialReportService : IFinancialReportService
debits += expenseByAcct.GetValueOrDefault(a.Id);
debits += billLinesByAcct.GetValueOrDefault(a.Id);
debits += discountsByAcct.GetValueOrDefault(a.Id);
debits += cogsConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → DR COGS
credits += invConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → CR Inventory
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
@@ -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
@@ -1831,13 +1831,16 @@ public class InventoryController : Controller
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
// Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
// posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
UnitCost = effectiveUnitCost,
TotalCost = quantityUsed * effectiveUnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
@@ -1851,7 +1854,7 @@ public class InventoryController : Controller
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
var cost = txn.TotalCost;
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
@@ -2997,13 +2997,17 @@ public class JobsController : Controller
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// Record the consumption at the effective (weighted-average) unit cost so the
// transaction's TotalCost equals the COGS actually posted — the GL recompute
// reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost;
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
UnitCost = effectiveUnitCost,
TotalCost = effectiveUnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
@@ -3015,7 +3019,7 @@ public class JobsController : Controller
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
var cost = transaction.TotalCost;
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}