Files
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

310 lines
13 KiB
C#

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>(),
Mock.Of<ICompanyLogoService>(),
Mock.Of<IAccountBalanceService>());
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!);
}
}