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>
This commit is contained in:
2026-04-26 15:48:34 -04:00
parent a4b8ae611a
commit 404ab3c45d
4 changed files with 1578 additions and 0 deletions
@@ -0,0 +1,300 @@
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!);
}
}
@@ -0,0 +1,355 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class DepositsControllerTests
{
// ── Record — input validation (no user resolution needed) ─────────────
[Fact]
public async Task Record_WhenAmountIsZero_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 0m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("greater than zero", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenAmountIsNegative_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: -50m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
[Fact]
public async Task Record_WhenNeitherJobNorQuoteProvided_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: null, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("Job or Quote must be specified", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenInvalidPaymentMethod_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "BitcoinPizza",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("Invalid payment method", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenUserNotFound_ReturnsUnauthorized()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: null);
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
Assert.IsType<UnauthorizedResult>(result);
}
// ── Record — success paths ────────────────────────────────────────────
[Fact]
public async Task Record_WithJobId_CreatesDepositAndReturnsSuccessJson()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var receivedDate = new DateTime(2026, 4, 15);
var result = await controller.Record(jobId: 42, quoteId: null, customerId: 7,
amount: 250m, paymentMethod: "Check",
receivedDate: receivedDate, reference: "REF-001", notes: "Half up front");
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(250m, doc.RootElement.GetProperty("amount").GetDecimal());
Assert.Equal("Check", doc.RootElement.GetProperty("paymentMethod").GetString());
Assert.Equal("04/15/2026", doc.RootElement.GetProperty("receivedDate").GetString());
Assert.Equal("REF-001", doc.RootElement.GetProperty("reference").GetString());
Assert.Equal("Half up front", doc.RootElement.GetProperty("notes").GetString());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
Assert.Equal(42, deposit.JobId);
Assert.Null(deposit.QuoteId);
Assert.Equal(7, deposit.CustomerId);
Assert.Equal(250m, deposit.Amount);
Assert.Equal(PaymentMethod.Check, deposit.PaymentMethod);
}
[Fact]
public async Task Record_WithQuoteId_CreatesDepositLinkedToQuote()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var result = await controller.Record(jobId: null, quoteId: 99, customerId: 5,
amount: 500m, paymentMethod: "CreditDebitCard",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("Credit / Debit Card", doc.RootElement.GetProperty("paymentMethod").GetString());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
Assert.Equal(99, deposit.QuoteId);
Assert.Null(deposit.JobId);
}
[Fact]
public async Task Record_GeneratesReceiptNumberInExpectedFormat()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
// Format: DEP-YYMM-#### (e.g. DEP-2604-0001)
Assert.Matches(@"^DEP-\d{4}-\d{4}$", receiptNumber);
Assert.EndsWith("-0001", receiptNumber);
}
[Fact]
public async Task Record_SecondDepositInSameMonth_IncrementsSequenceNumber()
{
await using var context = CreateContext();
var user = CreateUser(companyId: 1);
var controller = CreateController(context, currentUser: user);
// First deposit
await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
// Second deposit
var result = await controller.Record(jobId: 2, quoteId: null, customerId: 1,
amount: 200m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
Assert.EndsWith("-0002", receiptNumber);
}
[Fact]
public async Task Record_PaymentMethodDisplayStrings_AreHumanReadable()
{
await using var context = CreateContext();
var user = CreateUser(companyId: 1);
var cases = new[]
{
("Cash", "Cash"),
("Check", "Check"),
("CreditDebitCard", "Credit / Debit Card"),
("BankTransferACH", "Bank Transfer / ACH"),
("DigitalPayment", "Digital Payment"),
};
foreach (var (method, expected) in cases)
{
var controller = CreateController(context, currentUser: user);
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 10m, paymentMethod: method,
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
if (doc.RootElement.GetProperty("success").GetBoolean())
Assert.Equal(expected, doc.RootElement.GetProperty("paymentMethod").GetString());
}
}
// ── Delete ────────────────────────────────────────────────────────────
[Fact]
public async Task Delete_WhenDepositNotFound_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 999, returnUrl: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("not found", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Delete_WhenDepositAlreadyAppliedToInvoice_ReturnsJsonError()
{
await using var context = CreateContext();
context.Deposits.Add(new Deposit
{
Id = 1,
CompanyId = 1,
ReceiptNumber = "DEP-2604-0001",
CustomerId = 1,
JobId = 1,
Amount = 100m,
PaymentMethod = PaymentMethod.Cash,
ReceivedDate = DateTime.UtcNow,
AppliedToInvoiceId = 55 // already applied
});
await context.SaveChangesAsync();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 1, returnUrl: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("already been applied", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Delete_WhenUnappliedDeposit_SoftDeletesAndReturnsSuccess()
{
await using var context = CreateContext();
context.Deposits.Add(new Deposit
{
Id = 2,
CompanyId = 1,
ReceiptNumber = "DEP-2604-0001",
CustomerId = 1,
JobId = 1,
Amount = 75m,
PaymentMethod = PaymentMethod.Cash,
ReceivedDate = DateTime.UtcNow,
AppliedToInvoiceId = null // not yet applied
});
await context.SaveChangesAsync();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 2, returnUrl: null);
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(d => d.Id == 2);
Assert.True(deposit.IsDeleted);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static DepositsController CreateController(
ApplicationDbContext context,
ApplicationUser? currentUser)
{
var uow = new UnitOfWork(context);
var userManager = CreateUserManagerMock();
userManager
.Setup(m => m.GetUserAsync(It.IsAny<ClaimsPrincipal>()))
.ReturnsAsync(currentUser);
var controller = new DepositsController(
uow,
userManager.Object,
Mock.Of<ILogger<DepositsController>>(),
context);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private static ApplicationUser CreateUser(int companyId) => new()
{
Id = "test-user",
CompanyId = companyId,
UserName = "test@example.com",
Email = "test@example.com",
FirstName = "Test",
LastName = "User"
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
private static JsonDocument ParseJson(IActionResult result)
{
var json = Assert.IsType<JsonResult>(result);
var serialized = JsonSerializer.Serialize(json.Value);
return JsonDocument.Parse(serialized);
}
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!);
}
}
@@ -0,0 +1,307 @@
using System.Security.Claims;
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.GiftCertificate;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class GiftCertificatesControllerTests
{
// ── Index — lazy expiration ───────────────────────────────────────────
[Fact]
public async Task Index_LazilySetsExpiredStatus_ForActiveCertsPastExpiryDate()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2603-0001",
OriginalAmount = 100m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow.AddMonths(-2),
ExpiryDate = DateTime.UtcNow.AddDays(-1) // past expiry
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index(null, null);
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
Assert.Single(dtos);
Assert.Equal(GiftCertificateStatus.Expired, dtos[0].Status);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Expired, dbCert.Status);
}
[Fact]
public async Task Index_DoesNotExpire_ActiveCertsWithFutureExpiryDate()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2604-0001",
OriginalAmount = 50m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow,
ExpiryDate = DateTime.UtcNow.AddDays(30) // future expiry
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
await controller.Index(null, null);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Active, dbCert.Status);
}
// ── Index — filtering ─────────────────────────────────────────────────
[Fact]
public async Task Index_StatusFilter_ReturnsOnlyMatchingStatus()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 200m, Status = GiftCertificateStatus.Voided, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index(null, nameof(GiftCertificateStatus.Active));
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
var dto = Assert.Single(dtos);
Assert.Equal(GiftCertificateStatus.Active, dto.Status);
}
[Fact]
public async Task Index_SearchTerm_FiltersByCertificateCode()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index("0001", null);
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
var dto = Assert.Single(dtos);
Assert.Equal("GC-2604-0001", dto.CertificateCode);
}
[Fact]
public async Task Index_ViewBag_TotalValueIncludesOnlyActiveAndPartiallyRedeemed()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-A", OriginalAmount = 100m, RedeemedAmount = 0, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-P", OriginalAmount = 80m, RedeemedAmount = 30m, Status = GiftCertificateStatus.PartiallyRedeemed, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 3, CompanyId = 1, CertificateCode = "GC-V", OriginalAmount = 200m, RedeemedAmount = 200m, Status = GiftCertificateStatus.FullyRedeemed, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
await controller.Index(null, null);
// Active: RemainingBalance=100, PartiallyRedeemed: 80-30=50, FullyRedeemed: excluded
Assert.Equal(150m, controller.ViewBag.TotalValue);
Assert.Equal(2, controller.ViewBag.TotalActive);
}
// ── Create — success paths ────────────────────────────────────────────
[Fact]
public async Task Create_WithSoldReason_SetsPurchasePriceAndGeneratesCode()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Create(new CreateGiftCertificateDto
{
Amount = 100m,
IssuedReason = GiftCertificateIssuedReason.Sold,
PurchasePrice = 80m,
PurchasingCustomerId = null,
RecipientName = "Jane Doe"
});
Assert.IsType<RedirectToActionResult>(result);
var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(80m, cert.PurchasePrice);
Assert.Equal(GiftCertificateStatus.Active, cert.Status);
Assert.Matches(@"^GC-\d{4}-\d{4}$", cert.CertificateCode);
Assert.EndsWith("-0001", cert.CertificateCode);
}
[Fact]
public async Task Create_WithNonSoldReason_NullsPurchasePriceAndPurchasingCustomerId()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Create(new CreateGiftCertificateDto
{
Amount = 50m,
IssuedReason = GiftCertificateIssuedReason.Promotional,
PurchasePrice = 50m, // Provided but should be nulled
PurchasingCustomerId = 7 // Provided but should be nulled
});
Assert.IsType<RedirectToActionResult>(result);
var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Null(cert.PurchasePrice);
Assert.Null(cert.PurchasingCustomerId);
}
// ── Void ─────────────────────────────────────────────────────────────
[Fact]
public async Task Void_ActiveCert_ChangesStatusToVoidedAndRedirectsToIndex()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2604-0001",
OriginalAmount = 100m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 1);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Voided, dbCert.Status);
}
[Fact]
public async Task Void_FullyRedeemedCert_BlocksWithTempDataErrorAndRedirectsToDetails()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 2, CompanyId = 1,
CertificateCode = "GC-2604-0002",
OriginalAmount = 100m, RedeemedAmount = 100m,
Status = GiftCertificateStatus.FullyRedeemed,
IssueDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 2);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Details", redirect.ActionName);
Assert.Contains("fully redeemed", controller.TempData["Error"]?.ToString());
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.FullyRedeemed, dbCert.Status); // unchanged
}
[Fact]
public async Task Void_NonExistentCert_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 999);
Assert.IsType<NotFoundResult>(result);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static GiftCertificatesController CreateController(
ApplicationDbContext context,
ApplicationUser? currentUser)
{
var uow = new UnitOfWork(context);
var userManager = CreateUserManagerMock();
userManager
.Setup(m => m.GetUserAsync(It.IsAny<ClaimsPrincipal>()))
.ReturnsAsync(currentUser);
var httpContext = new DefaultHttpContext();
var controller = new GiftCertificatesController(
uow,
Mock.Of<IMapper>(),
Mock.Of<ILogger<GiftCertificatesController>>(),
userManager.Object,
Mock.Of<IPdfService>());
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
}
private static ApplicationUser CreateUser(int companyId) => new()
{
Id = "test-user",
CompanyId = companyId,
UserName = "test@example.com",
Email = "test@example.com",
FirstName = "Test",
LastName = "User"
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
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!);
}
}
@@ -0,0 +1,616 @@
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!);
}
}