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

301 lines
12 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class AccountBalanceServiceTests
{
// ── DebitAsync ────────────────────────────────────────────────────────
[Fact]
public async Task DebitAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(null, 50m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task DebitAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, 0m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task DebitAsync_WhenAccountNotFound_DoesNotThrow()
{
await using var context = CreateContext();
var (service, _) = CreateService(context);
// Account 999 does not exist — service must silently no-op
await service.DebitAsync(999, 50m);
}
[Theory]
[InlineData(AccountSubType.Checking, 100, 50, 150)] // Asset — debit-normal
[InlineData(AccountSubType.Savings, 200, 75, 275)] // Asset — debit-normal
[InlineData(AccountSubType.AccountsReceivable, 0, 100, 100)] // Asset — debit-normal
[InlineData(AccountSubType.CostOfGoodsSold, 50, 25, 75)] // COGS — debit-normal
[InlineData(AccountSubType.Advertising, 0, 100, 100)] // Expense (≥50) — debit-normal
[InlineData(AccountSubType.Payroll, 40, 60, 100)] // Expense (≥50) — debit-normal
public async Task DebitAsync_OnDebitNormalAccount_IncreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.AccountsPayable, 100, 50, 50)] // Liability — credit-normal
[InlineData(AccountSubType.Sales, 200, 75, 125)] // Revenue — credit-normal
[InlineData(AccountSubType.OwnersEquity, 500, 100, 400)] // Equity — credit-normal
[InlineData(AccountSubType.RetainedEarnings, 300, 50, 250)] // Equity — credit-normal
public async Task DebitAsync_OnCreditNormalAccount_DecreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
// ── CreditAsync ───────────────────────────────────────────────────────
[Fact]
public async Task CreditAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(null, 50m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task CreditAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, 0m);
Assert.Equal(100m, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.Checking, 200, 50, 150)] // Asset — debit-normal → credit decreases
[InlineData(AccountSubType.CostOfGoodsSold, 100, 30, 70)] // COGS — debit-normal → credit decreases
[InlineData(AccountSubType.SuppliesMaterials, 80, 20, 60)] // Expense (≥50) — debit-normal → credit decreases
public async Task CreditAsync_OnDebitNormalAccount_DecreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.AccountsPayable, 100, 50, 150)] // Liability — credit-normal → credit increases
[InlineData(AccountSubType.Sales, 0, 200, 200)] // Revenue — credit-normal → credit increases
[InlineData(AccountSubType.RetainedEarnings, 300, 100, 400)] // Equity — credit-normal → credit increases
public async Task CreditAsync_OnCreditNormalAccount_IncreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
// ── RecalculateAllAsync ───────────────────────────────────────────────
[Fact]
public async Task RecalculateAllAsync_UpdatesEachActiveAccountFromLedgerClosingBalance()
{
await using var context = CreateContext();
SeedAccount(context, 1, AccountSubType.Checking, 0m, companyId: 5);
SeedAccount(context, 2, AccountSubType.Sales, 0m, companyId: 5);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m });
ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 1200m });
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(5);
var accounts = await context.Accounts.IgnoreQueryFilters()
.Where(a => a.CompanyId == 5).ToListAsync();
Assert.Equal(500m, accounts.Single(a => a.Id == 1).CurrentBalance);
Assert.Equal(1200m, accounts.Single(a => a.Id == 2).CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_WhenLedgerReturnsNull_SkipsAccountBalance()
{
await using var context = CreateContext();
SeedAccount(context, 10, AccountSubType.Checking, 999m, companyId: 6);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync((AccountLedgerDto?)null);
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(6);
var account = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 10);
Assert.Equal(999m, account.CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_WhenOneAccountThrows_StillUpdatesRemainingAccounts()
{
await using var context = CreateContext();
SeedAccount(context, 20, AccountSubType.Checking, 0m, companyId: 7);
SeedAccount(context, 21, AccountSubType.Sales, 0m, companyId: 7);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ThrowsAsync(new InvalidOperationException("simulated ledger error"));
ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 750m });
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(7);
var account21 = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 21);
Assert.Equal(750m, account21.CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_ExcludesInactiveAccounts()
{
await using var context = CreateContext();
SeedAccount(context, 30, AccountSubType.Checking, 0m, companyId: 8, isActive: false);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(8);
ledger.Verify(
l => l.GetAccountLedgerAsync(It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()),
Times.Never);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService(
ApplicationDbContext context,
Mock<ILedgerService>? ledger = null)
{
var unitOfWork = new UnitOfWork(context);
var service = new AccountBalanceService(
unitOfWork,
(ledger ?? new Mock<ILedgerService>()).Object,
Mock.Of<ILogger<AccountBalanceService>>());
return (service, unitOfWork);
}
private static Account SeedAccount(
ApplicationDbContext context,
int id,
AccountSubType subType,
decimal balance,
int companyId = 1,
bool isActive = true)
{
var account = new Account
{
Id = id,
CompanyId = companyId,
AccountNumber = $"ACCT-{id}",
Name = $"Account {id}",
AccountSubType = subType,
CurrentBalance = balance,
IsActive = isActive
};
context.Accounts.Add(account);
return account;
}
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!);
}
}