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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user