27bfd4db4d
- 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>
310 lines
13 KiB
C#
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!);
|
|
}
|
|
}
|