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:
@@ -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!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user