Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/LedgerServiceTests.cs
T
spouliot 7834d67432 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>
2026-06-19 21:27:20 -04:00

800 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class LedgerServiceTests
{
private static readonly DateTime PeriodStart = new DateTime(2026, 4, 1);
private static readonly DateTime PeriodEnd = new DateTime(2026, 4, 30);
private static readonly DateTime InPeriod = new DateTime(2026, 4, 15);
// ── Returns null for unknown account ─────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_WhenAccountDoesNotExist_ReturnsNull()
{
await using var context = CreateContext();
var service = CreateService(context);
var result = await service.GetAccountLedgerAsync(999, PeriodStart, PeriodEnd);
Assert.Null(result);
}
// ── Source 1: customer payment deposited (DEBIT) ─────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source1_CustomerPaymentDeposited_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedInvoice(context, id: 99);
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 99,
DepositAccountId = 1,
Amount = 250m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Customer Payment", entry.Source);
Assert.Equal(250m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
// ── Source 2: expense paid FROM account (CREDIT) ──────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source2_ExpensePaidFromAccount_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-001",
PaymentAccountId = 1, // Checking account pays the expense
ExpenseAccountId = 999, // Different account — so Source 6 does not also fire
Amount = 80m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Expense", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(80m, entry.Credit);
}
// ── Source 3: bill payment made FROM account (CREDIT) ─────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source3_BillPaymentFromAccount_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedBill(context, id: 99); // Include(bp => bp.Bill) requires the Bill to exist
context.BillPayments.Add(new BillPayment
{
Id = 1, CompanyId = 1,
PaymentNumber = "BP-001",
BillId = 99,
VendorId = 1,
BankAccountId = 1, // Checking account used to pay the bill
Amount = 150m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Bill Payment", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(150m, entry.Credit);
}
// ── Source 4: invoice revenue line items (CREDIT) ─────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source4_InvoiceLineItem_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Sales);
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-001",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 500m
});
context.InvoiceItems.Add(new InvoiceItem
{
Id = 1, CompanyId = 1,
InvoiceId = 10,
RevenueAccountId = 1,
Description = "Powder Coating",
Quantity = 1, UnitPrice = 500m, TotalPrice = 500m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Invoice", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(500m, entry.Credit);
}
[Fact]
public async Task GetAccountLedgerAsync_Source4_DraftAndVoidedInvoicesExcluded()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Sales);
context.Invoices.AddRange(
new Invoice { Id = 10, CompanyId = 1, InvoiceNumber = "INV-DRAFT", CustomerId = 1, Status = InvoiceStatus.Draft, InvoiceDate = InPeriod },
new Invoice { Id = 11, CompanyId = 1, InvoiceNumber = "INV-VOID", CustomerId = 1, Status = InvoiceStatus.Voided, InvoiceDate = InPeriod });
context.InvoiceItems.AddRange(
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 11, RevenueAccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Empty(ledger!.Entries);
}
// ── Source 5: sales tax collected (CREDIT) ────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source5_SalesTaxCollected_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.OtherCurrentLiability);
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-TAX",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
TaxAmount = 42m,
SalesTaxAccountId = 1,
Total = 542m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Sales Tax", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(42m, entry.Credit);
}
// ── Source 6: expense categorized to account (DEBIT) ──────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source6_ExpenseCategorizedToAccount_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Advertising);
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-002",
ExpenseAccountId = 1, // Advertising account receives the expense
PaymentAccountId = 999, // Different account — so Source 2 does not also fire
Amount = 300m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Expense", entry.Source);
Assert.Equal(300m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
// ── Source 7: bill line items to expense account (DEBIT) ──────────────
[Fact]
public async Task GetAccountLedgerAsync_Source7_BillLineItem_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
context.Bills.Add(new Bill
{
Id = 10, CompanyId = 1,
BillNumber = "BILL-001",
VendorId = 1,
APAccountId = 999, // AP account different from test account
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 200m
});
context.BillLineItems.Add(new BillLineItem
{
Id = 1, CompanyId = 1,
BillId = 10,
AccountId = 1,
Description = "Powder supplies",
Quantity = 1, UnitPrice = 200m, Amount = 200m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Bill", entry.Source);
Assert.Equal(200m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
[Fact]
public async Task GetAccountLedgerAsync_Source7_DraftAndVoidedBillsExcluded()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
context.Bills.AddRange(
new Bill { Id = 10, CompanyId = 1, BillNumber = "B-DRAFT", VendorId = 1, APAccountId = 999, Status = BillStatus.Draft, BillDate = InPeriod, Total = 100m },
new Bill { Id = 11, CompanyId = 1, BillNumber = "B-VOID", VendorId = 1, APAccountId = 999, Status = BillStatus.Voided, BillDate = InPeriod, Total = 100m });
context.BillLineItems.AddRange(
new BillLineItem { Id = 1, CompanyId = 1, BillId = 10, AccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, Amount = 100m },
new BillLineItem { Id = 2, CompanyId = 1, BillId = 11, AccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, Amount = 100m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Empty(ledger!.Entries);
}
// ── Source 8: Accounts Receivable ─────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source8_AR_InvoicesDebitPaymentsCredit()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.AccountsReceivable);
SeedCustomer(context, id: 1); // Include(i => i.Customer) requires the Customer to exist
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-AR",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 400m
});
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10,
Amount = 100m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
// No DepositAccountId — so Source 1 does not also fire for this account
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(2, ledger!.Entries.Count);
var invoiceEntry = ledger.Entries.Single(e => e.Source == "Invoice");
var paymentEntry = ledger.Entries.Single(e => e.Source == "Invoice Payment");
Assert.Equal(400m, invoiceEntry.Debit);
Assert.Equal(0m, invoiceEntry.Credit);
Assert.Equal(0m, paymentEntry.Debit);
Assert.Equal(100m, paymentEntry.Credit);
Assert.Equal(300m, ledger.ClosingBalance); // debit-normal: 400 - 100
}
// ── Source 9: Accounts Payable ────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source9_AP_BillsCreditBillPaymentsDebit()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.AccountsPayable);
SeedVendor(context, id: 1); // Include(b => b.Vendor) requires the Vendor to exist
context.Bills.Add(new Bill
{
Id = 10, CompanyId = 1,
BillNumber = "BILL-AP",
VendorId = 1,
APAccountId = 1, // This is the AP account under test
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 400m
});
// Bill must be seeded because the WHERE for BillPayments navigates through Bill.APAccountId
context.BillPayments.Add(new BillPayment
{
Id = 1, CompanyId = 1,
PaymentNumber = "BP-AP",
BillId = 10,
VendorId = 1,
BankAccountId = 999, // Different account — so Source 3 does not also fire
Amount = 150m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(2, ledger!.Entries.Count);
var billEntry = ledger.Entries.Single(e => e.Source == "Bill");
var paymentEntry = ledger.Entries.Single(e => e.Source == "Bill Payment");
Assert.Equal(0m, billEntry.Debit);
Assert.Equal(400m, billEntry.Credit);
Assert.Equal(150m, paymentEntry.Debit);
Assert.Equal(0m, paymentEntry.Credit);
Assert.Equal(250m, ledger.ClosingBalance); // credit-normal: 400 - 150
}
// ── Date range filtering ──────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_DateRangeFiltering_ExcludesEntriesOutsideRange()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
var dates = new[]
{
new DateTime(2026, 3, 15), // before period — excluded from entries, goes to priorBalance
new DateTime(2026, 4, 15), // in period — included
new DateTime(2026, 5, 15), // after period — excluded
};
var i = 0;
foreach (var date in dates)
{
context.Expenses.Add(new Expense
{
Id = ++i, CompanyId = 1,
ExpenseNumber = $"EXP-{i:D3}",
PaymentAccountId = 1,
ExpenseAccountId = 999,
Amount = 100m,
Date = date,
PaymentMethod = PaymentMethod.Cash
});
}
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal(InPeriod.Date, entry.Date.Date);
}
// ── Running balance ───────────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_RunningBalance_DebitNormalAccount_CorrectlyComputed()
{
await using var context = CreateContext();
// Opening balance of 100 on Checking (debit-normal); null date means always applied
SeedAccount(context, id: 1, AccountSubType.Checking, openingBalance: 100m, openingBalanceDate: null);
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
context.Payments.AddRange(
new Payment { Id = 1, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 50m, PaymentDate = new DateTime(2026, 4, 10), PaymentMethod = PaymentMethod.Cash },
new Payment { Id = 2, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 75m, PaymentDate = new DateTime(2026, 4, 20), PaymentMethod = PaymentMethod.Cash });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(100m, ledger!.OpeningBalance);
Assert.Equal(2, ledger.Entries.Count);
Assert.Equal(150m, ledger.Entries[0].RunningBalance); // 100 + 50
Assert.Equal(225m, ledger.Entries[1].RunningBalance); // 150 + 75
Assert.Equal(225m, ledger.ClosingBalance);
}
[Fact]
public async Task GetAccountLedgerAsync_RunningBalance_CreditNormalAccount_CorrectlyComputed()
{
await using var context = CreateContext();
// Opening balance of 200 on Sales (credit-normal); null date means always applied
SeedAccount(context, id: 1, AccountSubType.Sales, openingBalance: 200m, openingBalanceDate: null);
// Source 4: InvoiceItems must have the Invoice seeded because the WHERE navigates through Invoice
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-001",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 150m
});
context.InvoiceItems.AddRange(
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "A", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "B", Quantity = 1, UnitPrice = 50m, TotalPrice = 50m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(200m, ledger!.OpeningBalance);
Assert.Equal(2, ledger.Entries.Count);
// Credit-normal: runningBalance += Credit - Debit
// After entry 1 (Credit=100): 200 + 100 = 300
// After entry 2 (Credit=50): 300 + 50 = 350
Assert.Equal(350m, ledger.ClosingBalance);
}
// ── Opening balance — future-dated excluded ───────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_FutureDatedOpeningBalance_ExcludedFromPriorBalance()
{
await using var context = CreateContext();
// Opening balance dated 2027-01-01 — later than PeriodEnd (2026-04-30), so excluded
SeedAccount(context, id: 1, AccountSubType.Checking,
openingBalance: 500m, openingBalanceDate: new DateTime(2027, 1, 1));
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10, DepositAccountId = 1,
Amount = 100m, PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
// OpeningBalance (prior balance) = 0, not 500 — future-dated opening balance excluded
Assert.Equal(0m, ledger!.OpeningBalance);
Assert.Equal(100m, ledger.ClosingBalance); // 0 + 100
}
// ── Period totals ─────────────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_PeriodTotals_CorrectlySumDebitsAndCredits()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
// Source 1: payment deposited → DEBIT 200
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10, DepositAccountId = 1,
Amount = 200m, PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
// Source 2: expense paid from account → CREDIT 50
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-001",
PaymentAccountId = 1, // Checking pays
ExpenseAccountId = 999, // Expense account (different) — Source 6 does not also fire
Amount = 50m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(200m, ledger!.PeriodDebits);
Assert.Equal(50m, ledger.PeriodCredits);
Assert.Equal(150m, ledger.ClosingBalance); // debit-normal: 200 - 50
}
// ── Helpers ───────────────────────────────────────────────────────────
// ── Cash refund reverses the sale: Sales Returns + Sales Tax debited, AR untouched ──
[Fact]
public async Task GetAccountLedgerAsync_CashRefund_ReversesTheSale_DebitsReturnsAndTax_NotAr()
{
await using var context = CreateContext();
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "1000", Name = "Checking", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsActive = true });
context.Accounts.Add(new Account { Id = 2, CompanyId = 1, AccountNumber = "1100", Name = "AR", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsActive = true });
context.Accounts.Add(new Account { Id = 3, CompanyId = 1, AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsActive = true });
context.Accounts.Add(new Account { Id = 4, CompanyId = 1, AccountNumber = "4960", Name = "Sales Returns", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsActive = true });
// Invoice $108 incl. $8 tax, tax posted to account 3.
context.Invoices.Add(new Invoice
{
Id = 99, CompanyId = 1, InvoiceNumber = "INV-0099", CustomerId = 1,
Status = InvoiceStatus.Sent, InvoiceDate = InPeriod,
Total = 108m, TaxAmount = 8m, SalesTaxAccountId = 3
});
// Full cash refund of $108 paid from Checking.
context.Refunds.Add(new Refund
{
Id = 1, CompanyId = 1, InvoiceId = 99, Amount = 108m,
RefundDate = InPeriod, RefundMethod = PaymentMethod.Check, DepositAccountId = 1
});
await context.SaveChangesAsync();
var svc = CreateService(context);
// Sales Returns (4960): revenue portion debited ($100).
var returns = await svc.GetAccountLedgerAsync(4, PeriodStart, PeriodEnd);
var returnsEntry = Assert.Single(returns!.Entries, e => e.Source == "Refund");
Assert.Equal(100m, returnsEntry.Debit);
Assert.Equal(0m, returnsEntry.Credit);
// Sales Tax Payable: tax portion debited ($8), relieving the liability.
var tax = await svc.GetAccountLedgerAsync(3, PeriodStart, PeriodEnd);
var taxEntry = Assert.Single(tax!.Entries, e => e.Source == "Refund");
Assert.Equal(8m, taxEntry.Debit);
// Bank: full $108 credited (cash leaves).
var bank = await svc.GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var bankEntry = Assert.Single(bank!.Entries, e => e.Source == "Refund");
Assert.Equal(108m, bankEntry.Credit);
// AR is no longer touched by refunds under the "reverse the sale" model.
var ar = await svc.GetAccountLedgerAsync(2, PeriodStart, PeriodEnd);
Assert.DoesNotContain(ar!.Entries, e => e.Source == "Refund");
}
// ── Credit memo: Customer Credits (2350) tracks outstanding store credit, excludes voided ──
[Fact]
public async Task GetAccountLedgerAsync_CustomerCredits2350_TracksOutstandingStoreCredit_ExcludesVoided()
{
await using var context = CreateContext();
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsActive = true });
SeedInvoice(context, id: 99);
// Active memo: $100 issued, $40 applied → $60 outstanding liability.
context.CreditMemos.Add(new CreditMemo { Id = 1, CompanyId = 1, MemoNumber = "CM-1", CustomerId = 1, Amount = 100m, AmountApplied = 40m, IssueDate = InPeriod, Status = CreditMemoStatus.PartiallyApplied });
context.CreditMemoApplications.Add(new CreditMemoApplication { Id = 1, CompanyId = 1, CreditMemoId = 1, InvoiceId = 99, AmountApplied = 40m, AppliedDate = InPeriod });
// Voided memo: must contribute nothing to the liability.
context.CreditMemos.Add(new CreditMemo { Id = 2, CompanyId = 1, MemoNumber = "CM-2", CustomerId = 1, Amount = 50m, AmountApplied = 0m, IssueDate = InPeriod, Status = CreditMemoStatus.Voided });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
// CR 100 (issue) + DR 40 (apply) on the active memo; the voided memo is excluded.
Assert.Equal(2, ledger!.Entries.Count);
Assert.Equal(60m, ledger.ClosingBalance); // credit-normal liability: 100 issued 40 applied
}
// ── Sales Discounts (4950): recompute mirrors the actual postings so a balance ──
// ── recalc reproduces the stored balance instead of wiping it to JE-only (O2). ──
[Fact]
public async Task GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue()
{
await using var context = CreateContext();
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsActive = true });
// Invoice with a $30 discount → DR 4950 at invoice date.
context.Invoices.Add(new Invoice
{
Id = 99, CompanyId = 1, InvoiceNumber = "INV-0099", CustomerId = 1,
Status = InvoiceStatus.Sent, InvoiceDate = InPeriod, Total = 270m, DiscountAmount = 30m
});
// Active memo $100 → DR 4950 = full amount at issue (the applied portion stays as contra-revenue).
context.CreditMemos.Add(new CreditMemo { Id = 1, CompanyId = 1, MemoNumber = "CM-1", CustomerId = 1, Amount = 100m, AmountApplied = 40m, IssueDate = InPeriod, Status = CreditMemoStatus.PartiallyApplied });
// Voided memo $50 with $20 applied → DR 50 at issue, CR (5020)=30 unapplied remainder at void.
context.CreditMemos.Add(new CreditMemo { Id = 2, CompanyId = 1, MemoNumber = "CM-2", CustomerId = 1, Amount = 50m, AmountApplied = 20m, IssueDate = InPeriod, Status = CreditMemoStatus.Voided, UpdatedAt = InPeriod });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
// Entries: invoice discount + two memo issuances + one void reversal.
Assert.Equal(4, ledger!.Entries.Count);
Assert.Equal(180m, ledger.PeriodDebits); // 30 + 100 + 50
Assert.Equal(30m, ledger.PeriodCredits); // voided unapplied remainder
// Credit-normal contra-revenue with a net debit balance → 150 (shows in the TB debit column).
Assert.Equal(-150m, ledger.ClosingBalance);
}
// ── Gift-certificate redemptions credit AR (DR 2500 / CR AR) — must appear in the AR ──
// ── recompute or the trial balance is left out of balance by the redeemed amount (O7). ──
[Fact]
public async Task GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.AccountsReceivable);
SeedCustomer(context, id: 1);
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1, InvoiceNumber = "INV-GC", CustomerId = 1,
Status = InvoiceStatus.Sent, InvoiceDate = InPeriod, Total = 200m
});
context.GiftCertificates.Add(new GiftCertificate
{
Id = 5, CompanyId = 1, CertificateCode = "GC-5", OriginalAmount = 50m, RedeemedAmount = 50m,
IssueDate = InPeriod, Status = GiftCertificateStatus.FullyRedeemed
});
context.GiftCertificateRedemptions.Add(new GiftCertificateRedemption
{
Id = 1, CompanyId = 1, GiftCertificateId = 5, InvoiceId = 10,
AmountRedeemed = 50m, RedeemedDate = InPeriod
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var invoiceEntry = ledger!.Entries.Single(e => e.Source == "Invoice");
var gcEntry = ledger.Entries.Single(e => e.Source == "Gift Certificate");
Assert.Equal(200m, invoiceEntry.Debit);
Assert.Equal(50m, gcEntry.Credit);
Assert.Equal(0m, gcEntry.Debit);
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>>());
private static Account SeedAccount(
ApplicationDbContext context,
int id,
AccountSubType subType,
int companyId = 1,
decimal openingBalance = 0m,
DateTime? openingBalanceDate = null)
{
var account = new Account
{
Id = id,
CompanyId = companyId,
AccountNumber = $"ACCT-{id:D4}",
Name = $"Account {id}",
AccountSubType = subType,
OpeningBalance = openingBalance,
OpeningBalanceDate = openingBalanceDate,
IsActive = true
};
context.Accounts.Add(account);
return account;
}
private static Invoice SeedInvoice(ApplicationDbContext context, int id, int companyId = 1)
{
var invoice = new Invoice
{
Id = id,
CompanyId = companyId,
InvoiceNumber = $"INV-{id:D4}",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 0m
};
context.Invoices.Add(invoice);
return invoice;
}
private static void SeedVendor(ApplicationDbContext context, int id, int companyId = 1)
{
context.Vendors.Add(new Vendor
{
Id = id,
CompanyId = companyId,
CompanyName = $"Vendor {id}"
});
}
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId = 1)
{
context.Customers.Add(new Customer
{
Id = id,
CompanyId = companyId,
CompanyName = $"Customer {id}"
});
}
private static void SeedBill(ApplicationDbContext context, int id, int companyId = 1)
{
context.Bills.Add(new Bill
{
Id = id,
CompanyId = companyId,
BillNumber = $"BILL-{id:D4}",
VendorId = 999,
APAccountId = 999,
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 0m
});
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}