Files
spouliot 404ab3c45d Add unit tests for LedgerService, AccountBalanceService, DepositsController, and GiftCertificatesController
181 tests total passing. Covers all 9 LedgerService transaction sources, debit/credit
balance mechanics, AR/AP movements, date range filtering, running balance computation,
and future-dated opening balance exclusion. Also covers deposit recording/deletion,
gift certificate lifecycle (issue, void, lazy expiry), and account balance recalculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:48:34 -04:00

617 lines
25 KiB
C#

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 ───────────────────────────────────────────────────────────
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!);
}
}