Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/LedgerServiceTests.cs
T
spouliot 1005be0c9e Fix Customer Deposits account mislabel and Sales Discounts recalc (audit O1, O2)
O1: account 2300 has always been used by the deposit GL code as the Customer
Deposits liability (resolved by number), but it was seeded/named "Payroll
Liabilities" for tenants the AccountingDepositsGL migration's NOT EXISTS guard
skipped — so the liability was mislabeled on the balance sheet. Rename 2300 to
"Customer Deposits" (IsSystem) and move payroll to a new 2400 account:
  - both seed paths (SeedDataService.Accounts, SeedData)
  - EnsureSystemAccountsAsync self-heal (renames only where still default-named,
    preserving user renames; ensures 2400 exists)
  - migration RenameDepositsAccountAddPayroll for existing tenants
Account number 2300 is unchanged, so the deposit posting code needs no changes.

O2: LedgerService never recomputed 4950 Sales Discounts, so "Recalculate
Balances" wiped it to JE-only and the Balance Reconciliation report showed false
drift. Add a 4950 section to GetAccountLedgerAsync and ComputePriorBalanceAsync
that reproduces the actual postings (invoice discounts DR + credit-memo issuance
DR, less the unapplied remainder of voided memos CR), matching AccountBalanceService.

Adds a LedgerService regression test for 4950. Documents both fixes plus the
remaining open findings (O3, O4) in docs/ACCOUNTING_AUDIT.md so the audit is no
longer lost. Build clean; 291 unit tests pass; migration applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:37:57 -04:00

727 lines
32 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);
}
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!);
}
}