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:
+48
-30
@@ -142,16 +142,25 @@ are **not** mirrored, so: (a) "Recalculate Balances" corrupts those accounts, (b
|
|||||||
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
|
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
|
||||||
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
|
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
|
||||||
|
|
||||||
### O6 — Inventory consumption COGS not in either recompute — **MEDIUM**
|
### O6 — Inventory consumption COGS not in the recompute — **RESOLVED (recompute approach)**
|
||||||
- `JobsController:3019` and `InventoryController:1855` post **DR COGS / CR Inventory** when an inventory
|
- `JobsController` and `InventoryController` post **DR COGS / CR Inventory** when an item with both
|
||||||
item with both `CogsAccountId` and `InventoryAccountId` is consumed on a job. Neither `LedgerService`
|
`CogsAccountId` and `InventoryAccountId` is consumed. The COGS posting fires only for `JobUsage` and
|
||||||
nor `FinancialReportService` reads this (no JobUsage/InventoryTransaction COGS section).
|
`Waste` transaction types, and those types are created **only** at the two COGS-posting sites — so the
|
||||||
- **Impact:** COGS understated (profit overstated) and Inventory asset overstated on P&L/Balance Sheet
|
consumption is exactly identifiable from `InventoryTransaction`. (This is why the recompute approach was
|
||||||
relative to the posted `CurrentBalance`; a recalc wipes the effect; reconciliation flags drift. TB still
|
used instead of the originally-planned JE+backfill: it reads existing transactions, so historical data is
|
||||||
*balances* (both sides omitted). Only active for items with both account mappings set.
|
covered automatically with no fuzzy backfill.)
|
||||||
- **Fix direction:** add a COGS/Inventory section to both recompute engines driven by `InventoryTransaction`
|
- **Fix applied:**
|
||||||
consumption rows (needs the posted cost captured on the transaction, e.g. `TotalCost`), or — better —
|
- Both posting sites now record the consumption at the effective (weighted-average) unit cost, so the
|
||||||
post these via real `JournalEntry` lines so the JE recompute path already covers them.
|
transaction's `TotalCost` equals the COGS actually posted (the recompute reads `TotalCost`).
|
||||||
|
- `LedgerService` (dated rows + prior balance): new section 12d — for a COGS or Inventory account, sum
|
||||||
|
`TotalCost` of JobUsage/Waste transactions on items with both accounts (DR COGS / CR Inventory).
|
||||||
|
- `FinancialReportService` **Trial Balance** (`cogsConsumptionByAcct` DR / `invConsumptionByAcct` CR) and
|
||||||
|
**P&L** (accrual COGS) include consumption. Cash-basis P&L excludes it (cash recognises cost at purchase).
|
||||||
|
- Regression test `GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory`.
|
||||||
|
- **Deliberately NOT changed — see O9:** the **Balance Sheet** inventory-asset line is left as-is because it
|
||||||
|
already does not track inventory *purchases* (it expenses them through retained earnings), so relieving it
|
||||||
|
for consumption alone would drive inventory negative or unbalance the sheet. That's a separate inventory
|
||||||
|
*capitalization* policy decision (O9). TB and recalc are now correct and balanced; build clean; 293 tests pass.
|
||||||
|
|
||||||
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
|
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
|
||||||
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
|
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
|
||||||
@@ -166,28 +175,37 @@ O2 (Sales Discounts 4950) was the first instance found and fixed; these are the
|
|||||||
balance. Mirrors the `cmApplied` treatment. Regression test
|
balance. Mirrors the `cmApplied` treatment. Regression test
|
||||||
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
|
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
|
||||||
|
|
||||||
### O8 — Written-off invoices misstated in AR recompute — **MEDIUM**
|
### O8 — Written-off invoices misstated in AR recompute — **RESOLVED**
|
||||||
- `InvoicesController.WriteOff:1689` posts **DR Bad Debt / CR AR** for the balance due and marks the invoice
|
- `InvoicesController.WriteOff` already posts **DR Bad Debt / CR AR** *and creates a balanced posted
|
||||||
`WrittenOff`. In the recompute, written-off invoices' `Total` is still counted as an AR **debit**
|
JournalEntry* — so both recompute engines already pick the entry up via their JE-line sections. The real
|
||||||
(`arDebits` only excludes Draft/Voided), while `FinancialReportService` **excludes** their payments from AR
|
defect was narrower: `FinancialReportService` **excluded payments on written-off invoices** from AR credits
|
||||||
credits (Status != WrittenOff filter) and neither engine models the write-off CR — so a written-off invoice
|
and bank debits (4× `Status != InvoiceStatus.WrittenOff` filters). Because the write-off JE only credits the
|
||||||
shows its full `Total` as open AR on recompute. `LedgerService` and `FinancialReportService` also disagree
|
*unpaid balance*, excluding the earlier payments left the **paid** portion dangling as open AR (and
|
||||||
(Ledger does not apply the WrittenOff payment filter).
|
understated the bank). `LedgerService` had no such filter, so the two engines disagreed.
|
||||||
- **Impact:** AR overstated by written-off balances on recompute/reports; recalc corrupts AR; the two engines
|
- **Fix applied:** removed all four `WrittenOff` exclusion filters in `FinancialReportService` (Balance Sheet
|
||||||
disagree. Active when invoices are written off.
|
+ Trial Balance, both the bank-deposit and AR-credit queries). Now: invoice `Total` (debit) − payments
|
||||||
- **Fix direction:** treat `WrittenOff` consistently — exclude written-off invoices' `Total` from `arDebits`
|
(credit) − write-off JE (credit) nets AR to zero, the payment counts in the bank, and bad debt is the JE
|
||||||
(or model the write-off CR) and align the two engines.
|
debit — trial balance balances, and the two engines agree. No backfill needed (the write-off JE already
|
||||||
|
exists for historical write-offs). AR Aging was already correct.
|
||||||
|
|
||||||
**Architectural note:** these keep recurring because reports re-derive balances from source documents in
|
**Architectural note:** O2/O6/O7/O8 all stem from reports re-deriving balances from source documents in
|
||||||
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
|
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
|
||||||
lines and drive all reports/recompute from those lines alone (single source of truth), retiring the
|
lines and drive all reports/recompute from those lines alone (single source of truth) — O8's write-off already
|
||||||
per-document re-derivation. Worth considering before the ledger grows further.
|
does exactly this, which is why it was the cleanest. Worth considering before the ledger grows further.
|
||||||
|
|
||||||
|
### O9 — Balance Sheet does not capitalize/relieve inventory (periodic vs perpetual) — **OPEN, needs policy decision**
|
||||||
|
- Surfaced while fixing O6. The **Trial Balance** computes an inventory asset as opening + purchase bill-lines
|
||||||
|
− consumption (perpetual), but the **Balance Sheet** `ComputeBalance` does **not** add inventory purchase
|
||||||
|
bill-lines to the asset and instead expenses all bill costs through retained earnings (periodic). So the BS
|
||||||
|
inventory line ≈ opening balance only, and BS vs TB disagree on inventory. O6's consumption relief was
|
||||||
|
therefore intentionally **not** applied to the BS (doing so alone would drive inventory negative / unbalance
|
||||||
|
the sheet).
|
||||||
|
- **Impact:** Balance Sheet inventory asset is understated and COGS timing differs between BS (periodic) and
|
||||||
|
P&L/TB (now perpetual for consumption). Pre-existing; not a trial-balance imbalance.
|
||||||
|
- **Fix direction:** decide on an inventory accounting policy (perpetual vs periodic) and make the Balance
|
||||||
|
Sheet consistent with the Trial Balance / P&L. Best done as part of the JournalEntry single-source refactor.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
Findings **O1–O5, O7, + the read-path sweep are resolved** on `dev`. **O6 and O8 remain OPEN** — both need the
|
Findings **O1–O8 + the read-path sweep are resolved** on `dev`. **O9 is newly identified and OPEN** (inventory
|
||||||
JournalEntry approach (post a balanced JE at consumption/write-off time) plus a one-time historical backfill,
|
capitalization policy — needs a decision, recommend folding into the JournalEntry single-source refactor).
|
||||||
because neither event leaves a reliably recompute-able marker (O6: no flag distinguishing COGS-posting
|
|
||||||
reductions, and posted cost uses AverageCost while the transaction stores UnitCost; O8: the write-off's
|
|
||||||
bad-debt account and amount aren't stored as a ledger record, so mirroring only the AR side would re-break the
|
|
||||||
trial balance).
|
|
||||||
Original audit numbering #1–3/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
|
Original audit numbering #1–3/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
|
||||||
|
|||||||
@@ -263,6 +263,19 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
foreach (var b in accrualBillLines)
|
foreach (var b in accrualBillLines)
|
||||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
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
|
var expAccounts = await _context.Accounts
|
||||||
@@ -307,8 +320,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
var depositsByAcct = await _context.Payments
|
var depositsByAcct = await _context.Payments
|
||||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.GroupBy(p => p.DepositAccountId!.Value)
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
@@ -357,8 +369,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||||
var arCredits = await _context.Payments
|
var arCredits = await _context.Payments
|
||||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||||
var cmAppliedBs = await _context.CreditMemoApplications
|
var cmAppliedBs = await _context.CreditMemoApplications
|
||||||
@@ -985,8 +996,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// Bank/cash: customer payments deposited here (DR)
|
// Bank/cash: customer payments deposited here (DR)
|
||||||
var depositsByAcct = await _context.Payments
|
var depositsByAcct = await _context.Payments
|
||||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.GroupBy(p => p.DepositAccountId!.Value)
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.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) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.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).
|
// 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.
|
// 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.
|
// 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;
|
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||||
var arTotalCredits = await _context.Payments
|
var arTotalCredits = await _context.Payments
|
||||||
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||||
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
|
// 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 += expenseByAcct.GetValueOrDefault(a.Id);
|
||||||
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||||
debits += discountsByAcct.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
|
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
|
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 ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
var jeLines = await _context.JournalEntryLines
|
var jeLines = await _context.JournalEntryLines
|
||||||
.Include(l => l.JournalEntry)
|
.Include(l => l.JournalEntry)
|
||||||
@@ -855,6 +890,26 @@ public class LedgerService : ILedgerService
|
|||||||
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
|
.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)
|
// 10. Posted journal entry lines touching this account (prior to period)
|
||||||
debits += await _context.JournalEntryLines
|
debits += await _context.JournalEntryLines
|
||||||
.Where(l => l.AccountId == accountId
|
.Where(l => l.AccountId == accountId
|
||||||
|
|||||||
@@ -1831,13 +1831,16 @@ public class InventoryController : Controller
|
|||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
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
|
var txn = new InventoryTransaction
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
InventoryItemId = item.Id,
|
||||||
TransactionType = transactionType,
|
TransactionType = transactionType,
|
||||||
Quantity = -quantityUsed,
|
Quantity = -quantityUsed,
|
||||||
UnitCost = item.UnitCost,
|
UnitCost = effectiveUnitCost,
|
||||||
TotalCost = quantityUsed * item.UnitCost,
|
TotalCost = quantityUsed * effectiveUnitCost,
|
||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
BalanceAfter = item.QuantityOnHand,
|
BalanceAfter = item.QuantityOnHand,
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
@@ -1851,7 +1854,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
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.DebitAsync(item.CogsAccountId, cost);
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2997,13 +2997,17 @@ public class JobsController : Controller
|
|||||||
inventoryItem.QuantityOnHand -= deductNow;
|
inventoryItem.QuantityOnHand -= deductNow;
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
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
|
var transaction = new InventoryTransaction
|
||||||
{
|
{
|
||||||
InventoryItemId = inventoryItem.Id,
|
InventoryItemId = inventoryItem.Id,
|
||||||
TransactionType = InventoryTransactionType.JobUsage,
|
TransactionType = InventoryTransactionType.JobUsage,
|
||||||
Quantity = -deductNow,
|
Quantity = -deductNow,
|
||||||
UnitCost = inventoryItem.UnitCost,
|
UnitCost = effectiveUnitCost,
|
||||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
TotalCost = effectiveUnitCost * deductNow,
|
||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
Reference = job.JobNumber,
|
Reference = job.JobNumber,
|
||||||
@@ -3015,7 +3019,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
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.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -655,6 +655,43 @@ public class LedgerServiceTests
|
|||||||
Assert.Equal(150m, ledger.ClosingBalance); // debit-normal AR: 200 invoiced − 50 redeemed
|
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)
|
private static LedgerService CreateService(ApplicationDbContext context)
|
||||||
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());
|
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user