404ab3c45d
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>
301 lines
12 KiB
C#
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!);
|
|
}
|
|
}
|