From 404ab3c45d1b788f50cf053196a9275c9379e8b1 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 26 Apr 2026 15:48:34 -0400 Subject: [PATCH 01/16] 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 --- .../AccountBalanceServiceTests.cs | 300 +++++++++ .../DepositsControllerTests.cs | 355 ++++++++++ .../GiftCertificatesControllerTests.cs | 307 +++++++++ .../LedgerServiceTests.cs | 616 ++++++++++++++++++ 4 files changed, 1578 insertions(+) create mode 100644 tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/DepositsControllerTests.cs create mode 100644 tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs create mode 100644 tests/PowderCoating.UnitTests/LedgerServiceTests.cs diff --git a/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs new file mode 100644 index 0000000..3e0ae10 --- /dev/null +++ b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs @@ -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(); + ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m }); + ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny(), It.IsAny())) + .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(); + ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny(), It.IsAny())) + .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(); + ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("simulated ledger error")); + ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny(), It.IsAny())) + .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(); + var (service, _) = CreateService(context, ledger); + + await service.RecalculateAllAsync(8); + + ledger.Verify( + l => l.GetAccountLedgerAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService( + ApplicationDbContext context, + Mock? ledger = null) + { + var unitOfWork = new UnitOfWork(context); + var service = new AccountBalanceService( + unitOfWork, + (ledger ?? new Mock()).Object, + Mock.Of>()); + 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() + .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(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/DepositsControllerTests.cs b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs new file mode 100644 index 0000000..e1b2fa2 --- /dev/null +++ b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs @@ -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(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())) + .ReturnsAsync(currentUser); + + var controller = new DepositsController( + uow, + userManager.Object, + Mock.Of>(), + 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> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + private static JsonDocument ParseJson(IActionResult result) + { + var json = Assert.IsType(result); + var serialized = JsonSerializer.Serialize(json.Value); + return JsonDocument.Parse(serialized); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .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(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs new file mode 100644 index 0000000..8b05d6f --- /dev/null +++ b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs @@ -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(result); + var dtos = Assert.IsAssignableFrom>(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(result); + var dtos = Assert.IsAssignableFrom>(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(result); + var dtos = Assert.IsAssignableFrom>(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(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(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(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(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(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())) + .ReturnsAsync(currentUser); + + var httpContext = new DefaultHttpContext(); + var controller = new GiftCertificatesController( + uow, + Mock.Of(), + Mock.Of>(), + userManager.Object, + Mock.Of()); + + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + 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> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .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(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs new file mode 100644 index 0000000..08bacd7 --- /dev/null +++ b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs @@ -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>()); + + 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() + .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(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} From 8491b308ebcf57ab7e3a30cebcd84f0fd1a788dc Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 26 Apr 2026 17:01:09 -0400 Subject: [PATCH 02/16] Add admin email wizard and logging --- .../DTOs/Notification/NotificationLogDtos.cs | 1 + .../Enums/NotificationEnums.cs | 3 +- .../Controllers/EmailBroadcastController.cs | 674 ++++++++++++++---- .../Views/EmailBroadcast/Index.cshtml | 267 +++---- .../Views/EmailBroadcast/Preview.cshtml | 135 ++++ .../EmailBroadcast/SelectCompanies.cshtml | 170 +++++ .../EmailBroadcastControllerTests.cs | 203 ++++++ 7 files changed, 1177 insertions(+), 276 deletions(-) create mode 100644 src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml create mode 100644 src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml create mode 100644 tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs diff --git a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs index 38760cd..b097146 100644 --- a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs +++ b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs @@ -10,6 +10,7 @@ public class NotificationLogDto public NotificationType NotificationType { get; set; } public string NotificationTypeDisplay => NotificationType switch { + NotificationType.AdminEmail => "Admin Email", NotificationType.QuoteSent => "Quote Sent", NotificationType.QuoteApproved => "Quote Approved", NotificationType.JobStatusChanged => "Job Status Changed", diff --git a/src/PowderCoating.Core/Enums/NotificationEnums.cs b/src/PowderCoating.Core/Enums/NotificationEnums.cs index 51e9384..50435d6 100644 --- a/src/PowderCoating.Core/Enums/NotificationEnums.cs +++ b/src/PowderCoating.Core/Enums/NotificationEnums.cs @@ -17,5 +17,6 @@ public enum NotificationType SubscriptionExpiryReminder = 10, SubscriptionExpired = 11, SmsInboundStop = 12, - SmsInboundHelp = 13 + SmsInboundHelp = 13, + AdminEmail = 14 } diff --git a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs index b66570b..c2ff3df 100644 --- a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs +++ b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs @@ -1,207 +1,597 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; -/// -/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant -/// company contacts. Emails are sent one at a time via -/// rather than bulk API because each message requires a personalised unsubscribe link -/// containing the company's unique MarketingUnsubscribeToken. -/// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class EmailBroadcastController : Controller { + private static readonly Regex ScriptRegex = new( + @"<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|meta|link)\b[^>]*>.*?<\s*/\s*\1\s*>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex CommentRegex = new( + @"", + RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex TagRegex = new( + @"<\s*(/?)\s*([a-z0-9]+)([^>]*)>", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex HrefRegex = new( + @"href\s*=\s*(['""]?)([^'"">\s]+)\1", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly HashSet AllowedTags = + [ + "a", "p", "br", "strong", "b", "em", "i", "u", + "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4" + ]; + private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; + private readonly IPlatformSettingsService _platformSettings; private readonly ILogger _logger; public EmailBroadcastController( ApplicationDbContext db, IEmailService emailService, + IPlatformSettingsService platformSettings, ILogger logger) { _db = db; _emailService = emailService; + _platformSettings = platformSettings; _logger = logger; } - /// - /// Renders the broadcast compose form, pre-populating ViewBag with plan configs - /// and the active company list so the targeting dropdowns are populated without - /// a separate AJAX call. - /// - public async Task Index() - { - await PopulateViewBag(); - return View(new BroadcastForm()); - } - - /// Returns JSON count of recipients for the current filter — used for the live preview. [HttpGet] - public async Task RecipientCount(string target, string? plan, int[]? companyIds) + public IActionResult Index() => View(new AdminEmailComposeModel()); + + [HttpPost, ValidateAntiForgeryToken] + public async Task SelectCompanies(AdminEmailComposeModel form) { - var recipients = await BuildRecipientListAsync(target, plan, companyIds); - return Json(new { count = recipients.Count }); + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", form); + + var viewModel = await BuildSelectionModelAsync(form); + return View(viewModel); } - /// - /// Sends the composed broadcast email to all recipients matching the chosen - /// targeting criteria. Aborts early (with a validation error) if no recipients - /// are found, to prevent accidental empty sends. - /// - /// Each email body is HTML-encoded (then line-breaks converted to - /// <br>) and wrapped in a branded container that appends a - /// per-company unsubscribe footer. Failures are counted and reported in the - /// success banner rather than aborting the remainder of the batch, so a single - /// bad address does not block delivery to every other recipient. - /// - /// [HttpPost, ValidateAntiForgeryToken] - public async Task Send(BroadcastForm form) + public IActionResult BackToCompose(AdminEmailComposeModel form) { - if (!ModelState.IsValid) + NormalizeComposeModel(form); + return View("Index", form); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Preview(AdminEmailSelectionModel form) + { + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", new AdminEmailComposeModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml + }); + + if (!ValidateCompanySelection(form)) + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + + var preview = await BuildPreviewModelAsync(form); + return View(preview); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task BackToSelectCompanies(AdminEmailSelectionModel form) + { + NormalizeComposeModel(form); + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Send(AdminEmailSendRequest form) + { + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", new AdminEmailComposeModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml + }); + + if (!ValidateCompanySelection(form)) + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + + var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); + var replyToEmail = await GetAdminReplyToAsync(); + const string replyToName = "Powder Coating Logix Admin"; + + var sent = 0; + var failed = 0; + var skipped = 0; + + foreach (var recipient in recipients) { - await PopulateViewBag(); - return View("Index", form); - } + var renderedSubject = RenderPlainTemplate(form.Subject, recipient); + var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient); + var plainTextBody = ConvertHtmlToPlainText(renderedBody); - var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds); - - if (recipients.Count == 0) - { - TempData["Error"] = "No recipients matched the selected criteria."; - await PopulateViewBag(); - return View("Index", form); - } - - int sent = 0, failed = 0; - var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "
"); - - foreach (var (email, name, unsubToken) in recipients) - { - var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}"; - var htmlBody = $@" -
-

{encodedBody}

-
-

- This message was sent by the Powder Coating Logix platform team.
- Unsubscribe from platform announcements -

-
"; + if (string.IsNullOrWhiteSpace(recipient.RecipientEmail)) + { + skipped++; + await WriteLogAsync(new NotificationLog + { + Channel = NotificationChannel.Email, + NotificationType = NotificationType.AdminEmail, + Status = NotificationStatus.Skipped, + RecipientName = recipient.RecipientName, + Recipient = string.Empty, + Subject = renderedSubject, + Message = plainTextBody, + ErrorMessage = "Company primary contact email is not configured.", + SentAt = DateTime.UtcNow, + CompanyId = recipient.CompanyId + }); + continue; + } + var wrappedHtml = WrapRenderedHtml(renderedBody); var (success, error) = await _emailService.SendEmailAsync( - email, name, form.Subject, form.Body, htmlBody); + recipient.RecipientEmail, + recipient.RecipientName, + renderedSubject, + plainTextBody, + wrappedHtml, + replyToEmail: replyToEmail, + replyToName: replyToEmail is null ? null : replyToName); if (success) sent++; - else + else failed++; + + await WriteLogAsync(new NotificationLog { - failed++; - _logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error); - } + Channel = NotificationChannel.Email, + NotificationType = NotificationType.AdminEmail, + Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, + RecipientName = recipient.RecipientName, + Recipient = recipient.RecipientEmail, + Subject = renderedSubject, + Message = plainTextBody, + ErrorMessage = error, + SentAt = DateTime.UtcNow, + CompanyId = recipient.CompanyId + }); } - TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}."; + TempData["Success"] = $"Admin email processed for {recipients.Count} selected compan{(recipients.Count == 1 ? "y" : "ies")}: {sent} sent, {failed} failed, {skipped} skipped."; return RedirectToAction(nameof(Index)); } - /// - /// Builds the list of (email, name, unsubscribe-token) tuples for the given - /// targeting criteria. Companies are excluded when MarketingEmailOptOut - /// is true — honouring prior unsubscribes — or when PrimaryContactEmail - /// is missing. The "specific" target requires at least one companyIds - /// entry and returns an empty list otherwise to prevent accidental all-company sends. - /// IgnoreQueryFilters() is required because this query spans companies. - /// - private async Task> BuildRecipientListAsync( - string? target, string? planFilter, int[]? companyIds) + private bool ValidateComposeModel(AdminEmailComposeModel form) { - var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail) - && !c.MarketingEmailOptOut); + if (string.IsNullOrWhiteSpace(form.Subject)) + ModelState.AddModelError(nameof(form.Subject), "Subject is required."); - target ??= "active"; + if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml))) + ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required."); - switch (target) + return ModelState.IsValid; + } + + private bool ValidateCompanySelection(AdminEmailSelectionModel form) + { + if (form.CompanyIds == null || form.CompanyIds.Length == 0) + ModelState.AddModelError(nameof(form.CompanyIds), "Select at least one company."); + + return ModelState.IsValid; + } + + private static void NormalizeComposeModel(AdminEmailComposeModel form) + { + form.Subject = (form.Subject ?? string.Empty).Trim(); + form.BodyHtml = SanitizeHtml(form.BodyHtml); + } + + private async Task BuildSelectionModelAsync(AdminEmailComposeModel form) + { + var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null + ? selection.CompanyIds + : Array.Empty(); + + return new AdminEmailSelectionModel { - case "active": - companyQuery = companyQuery.Where(c => - c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "plan": - if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt)) - companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt); - break; - case "status_grace": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "status_expired": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired); - break; - case "specific": - if (companyIds != null && companyIds.Length > 0) - companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id)); - else - return new List<(string, string, string)>(); - break; - case "all": - default: - break; + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = selectedIds, + AvailableCompanies = await LoadCompanyOptionsAsync(selectedIds) + }; + } + + private async Task BuildPreviewModelAsync(AdminEmailSelectionModel form) + { + var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); + if (recipients.Count == 0) + { + return new AdminEmailPreviewModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = form.CompanyIds, + SelectedCompanies = [], + EligibleCount = 0, + SkippedCount = 0 + }; } - var companies = await companyQuery - .Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken }) - .ToListAsync(); + var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)) + ?? recipients.First(); - return companies - .Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail)) - .Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken)) - .ToList(); + var sampleSubject = RenderPlainTemplate(form.Subject, sampleRecipient); + var sampleBody = RenderHtmlTemplate(form.BodyHtml, sampleRecipient); + + return new AdminEmailPreviewModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = form.CompanyIds, + SelectedCompanies = recipients.Select(r => new AdminEmailRecipientPreviewRow + { + CompanyId = r.CompanyId, + CompanyName = r.CompanyName, + RecipientName = r.RecipientName, + RecipientEmail = r.RecipientEmail, + CompanyAdminName = r.CompanyAdminName, + CompanyAdminEmail = r.CompanyAdminEmail, + CanSend = !string.IsNullOrWhiteSpace(r.RecipientEmail), + SkipReason = string.IsNullOrWhiteSpace(r.RecipientEmail) + ? "Missing primary contact email" + : null + }).ToList(), + EligibleCount = recipients.Count(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)), + SkippedCount = recipients.Count(r => string.IsNullOrWhiteSpace(r.RecipientEmail)), + SamplePreview = new AdminEmailRenderedPreview + { + CompanyName = sampleRecipient.CompanyName, + RecipientName = sampleRecipient.RecipientName, + RecipientEmail = sampleRecipient.RecipientEmail, + RenderedSubject = sampleSubject, + RenderedHtmlBody = WrapRenderedHtml(sampleBody) + } + }; } - /// - /// Hydrates ViewBag with the data sets needed by the broadcast compose view: - /// active subscription plan configs (for the plan-filter dropdown), - /// all non-deleted active companies (for the specific-company picker), - /// and a live count of active/grace-period companies shown in the UI summary. - /// Centralised here so it can be called from both and the - /// validation-failure branch of . - /// - private async Task PopulateViewBag() + private async Task> LoadCompanyOptionsAsync(IReadOnlyCollection selectedIds) { - ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters() - .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync(); - - ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive) + var companies = await _db.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) - .Select(c => new { c.Id, c.CompanyName }) + .Select(c => new AdminEmailCompanyOption + { + CompanyId = c.Id, + CompanyName = c.CompanyName, + PrimaryContactName = c.PrimaryContactName, + PrimaryContactEmail = c.PrimaryContactEmail, + IsActive = c.IsActive + }) .ToListAsync(); - ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .CountAsync(c => !c.IsDeleted && c.IsActive && - (c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod)); + var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.CompanyId).ToArray()); + + foreach (var company in companies) + { + company.IsSelected = selectedIds.Contains(company.CompanyId); + if (adminLookup.TryGetValue(company.CompanyId, out var admin)) + { + company.CompanyAdminName = admin.FullName; + company.CompanyAdminEmail = admin.Email; + } + } + + return companies; + } + + private async Task> LoadRecipientContextsAsync(IReadOnlyCollection companyIds) + { + var companies = await _db.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => companyIds.Contains(c.Id) && !c.IsDeleted) + .OrderBy(c => c.CompanyName) + .Select(c => new + { + c.Id, + c.CompanyName, + c.PrimaryContactName, + c.PrimaryContactEmail + }) + .ToListAsync(); + + var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.Id).ToArray()); + + return companies.Select(company => + { + adminLookup.TryGetValue(company.Id, out var admin); + + return new AdminEmailRecipientContext + { + CompanyId = company.Id, + CompanyName = company.CompanyName, + RecipientName = string.IsNullOrWhiteSpace(company.PrimaryContactName) + ? company.CompanyName + : company.PrimaryContactName, + RecipientEmail = company.PrimaryContactEmail, + FirstName = ExtractFirstName(company.PrimaryContactName, company.CompanyName), + PrimaryContactName = company.PrimaryContactName, + CompanyAdminName = admin?.FullName, + CompanyAdminEmail = admin?.Email, + CompanyAdminFirstName = ExtractFirstName(admin?.FullName, company.CompanyName) + }; + }).ToList(); + } + + private async Task> LoadCompanyAdminLookupAsync(IReadOnlyCollection companyIds) + { + var admins = await _db.Users + .AsNoTracking() + .Where(u => companyIds.Contains(u.CompanyId) + && u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin + && u.IsActive) + .OrderBy(u => u.CreatedAt) + .Select(u => new + { + u.CompanyId, + u.FirstName, + u.LastName, + u.Email + }) + .ToListAsync(); + + return admins + .GroupBy(u => u.CompanyId) + .ToDictionary( + g => g.Key, + g => + { + var admin = g.First(); + return new CompanyAdminLookup + { + FullName = $"{admin.FirstName} {admin.LastName}".Trim(), + Email = admin.Email ?? string.Empty + }; + }); + } + + private async Task GetAdminReplyToAsync() + { + var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(email => email.Contains('@')); + } + + private async Task WriteLogAsync(NotificationLog log) + { + try + { + await _db.NotificationLogs.AddAsync(log); + await _db.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write admin email notification log for company {CompanyId}", log.CompanyId); + } + } + + private static string RenderPlainTemplate(string template, AdminEmailRecipientContext recipient) + { + var rendered = template ?? string.Empty; + foreach (var replacement in BuildReplacementDictionary(recipient)) + rendered = rendered.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase); + + return rendered.Trim(); + } + + private static string RenderHtmlTemplate(string templateHtml, AdminEmailRecipientContext recipient) + { + var rendered = templateHtml ?? string.Empty; + foreach (var replacement in BuildReplacementDictionary(recipient)) + rendered = rendered.Replace( + replacement.Key, + WebUtility.HtmlEncode(replacement.Value), + StringComparison.OrdinalIgnoreCase); + + return rendered; + } + + private static Dictionary BuildReplacementDictionary(AdminEmailRecipientContext recipient) => new(StringComparer.OrdinalIgnoreCase) + { + ["{{FirstName}}"] = recipient.FirstName, + ["{{FullName}}"] = recipient.RecipientName, + ["{{CompanyName}}"] = recipient.CompanyName, + ["{{PrimaryContactName}}"] = recipient.PrimaryContactName ?? recipient.RecipientName, + ["{{PrimaryContactEmail}}"] = recipient.RecipientEmail ?? string.Empty, + ["{{CompanyAdminFirstName}}"] = recipient.CompanyAdminFirstName ?? string.Empty, + ["{{CompanyAdminName}}"] = recipient.CompanyAdminName ?? string.Empty, + ["{{CompanyAdminEmail}}"] = recipient.CompanyAdminEmail ?? string.Empty + }; + + private static string WrapRenderedHtml(string renderedHtmlBody) + { + return $""" +
+ {renderedHtmlBody} +
+ """; + } + + private static string SanitizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var sanitized = CommentRegex.Replace(html, string.Empty); + sanitized = ScriptRegex.Replace(sanitized, string.Empty); + sanitized = sanitized.Replace("", "

", StringComparison.OrdinalIgnoreCase); + + sanitized = TagRegex.Replace(sanitized, match => + { + var isClosingTag = match.Groups[1].Value == "/"; + var tagName = match.Groups[2].Value.ToLowerInvariant(); + var attributes = match.Groups[3].Value; + + if (!AllowedTags.Contains(tagName)) + return string.Empty; + + if (tagName == "br") + return "
"; + + if (tagName == "a") + { + if (isClosingTag) + return ""; + + var href = HrefRegex.Match(attributes); + var hrefValue = href.Success ? href.Groups[2].Value : string.Empty; + if (!IsSafeHref(hrefValue)) + return string.Empty; + + var encodedHref = WebUtility.HtmlEncode(hrefValue); + return $""""""; + } + + return isClosingTag ? $"" : $"<{tagName}>"; + }); + + return sanitized.Trim(); + } + + private static bool IsSafeHref(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return false; + + return href.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); + } + + private static string ConvertHtmlToPlainText(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var plain = html; + plain = Regex.Replace(plain, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*/\s*(p|h1|h2|h3|h4|blockquote)\s*>", "\n\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*li\s*>", "- ", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*/\s*li\s*>", "\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<[^>]+>", string.Empty); + plain = WebUtility.HtmlDecode(plain); + plain = Regex.Replace(plain, @"\n{3,}", "\n\n"); + return plain.Trim(); + } + + private static string ExtractFirstName(string? fullName, string fallback) + { + if (string.IsNullOrWhiteSpace(fullName)) + return fallback; + + var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length == 0 ? fallback : parts[0]; } } -public class BroadcastForm +public class AdminEmailComposeModel { - public string Target { get; set; } = "active"; - public string? PlanFilter { get; set; } - public int[]? CompanyIds { get; set; } - - [System.ComponentModel.DataAnnotations.Required] + [Required] public string Subject { get; set; } = string.Empty; - [System.ComponentModel.DataAnnotations.Required] - public string Body { get; set; } = string.Empty; + [Required] + public string BodyHtml { get; set; } = string.Empty; +} + +public class AdminEmailSelectionModel : AdminEmailComposeModel +{ + public int[]? CompanyIds { get; set; } + public List AvailableCompanies { get; set; } = []; +} + +public class AdminEmailSendRequest : AdminEmailSelectionModel; + +public class AdminEmailPreviewModel : AdminEmailSendRequest +{ + public List SelectedCompanies { get; set; } = []; + public int EligibleCount { get; set; } + public int SkippedCount { get; set; } + public AdminEmailRenderedPreview SamplePreview { get; set; } = new(); +} + +public class AdminEmailCompanyOption +{ + public int CompanyId { get; set; } + public string CompanyName { get; set; } = string.Empty; + public string? PrimaryContactName { get; set; } + public string? PrimaryContactEmail { get; set; } + public string? CompanyAdminName { get; set; } + public string? CompanyAdminEmail { get; set; } + public bool IsActive { get; set; } + public bool IsSelected { get; set; } +} + +public class AdminEmailRecipientPreviewRow +{ + public int CompanyId { get; set; } + public string CompanyName { get; set; } = string.Empty; + public string RecipientName { get; set; } = string.Empty; + public string? RecipientEmail { get; set; } + public string? CompanyAdminName { get; set; } + public string? CompanyAdminEmail { get; set; } + public bool CanSend { get; set; } + public string? SkipReason { get; set; } +} + +public class AdminEmailRenderedPreview +{ + public string CompanyName { get; set; } = string.Empty; + public string RecipientName { get; set; } = string.Empty; + public string? RecipientEmail { get; set; } + public string RenderedSubject { get; set; } = string.Empty; + public string RenderedHtmlBody { get; set; } = string.Empty; +} + +internal sealed class AdminEmailRecipientContext +{ + public int CompanyId { get; init; } + public string CompanyName { get; init; } = string.Empty; + public string RecipientName { get; init; } = string.Empty; + public string? RecipientEmail { get; init; } + public string FirstName { get; init; } = string.Empty; + public string? PrimaryContactName { get; init; } + public string? CompanyAdminFirstName { get; init; } + public string? CompanyAdminName { get; init; } + public string? CompanyAdminEmail { get; init; } +} + +internal sealed class CompanyAdminLookup +{ + public string FullName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; } diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml index 2b1cad3..2a4161b 100644 --- a/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml +++ b/src/PowderCoating.Web/Views/EmailBroadcast/Index.cshtml @@ -1,172 +1,173 @@ @using PowderCoating.Web.Controllers -@model BroadcastForm +@model AdminEmailComposeModel @{ - ViewData["Title"] = "Email Broadcast"; + ViewData["Title"] = "Admin Email"; } @section Styles { } -
-
-

Email Broadcast

+
+
+
+

Admin Email Wizard

+

Step 1 of 3: write the subject and rich-text message.

+
+
Super Admin Only
@if (TempData["Success"] != null) { -
@TempData["Success"]
- } - @if (TempData["Error"] != null) - { -
@TempData["Error"]
+
@TempData["Success"]
} -
- @Html.AntiForgeryToken() +
+
+ + @Html.AntiForgeryToken() -
- @* Left: recipients *@ -
-
-
Recipients
-
-
- - +
+ + + +
+ +
+ +
+ + + + + + + +
+
@Html.Raw(Model.BodyHtml)
+ + +
+ +
+
+
+ The email sends one company at a time to that company's Primary Contact Email. + Rich text is supported, and the preview step will render one merged sample before anything sends.
- - - - - -
- @ViewBag.ActiveCount company email(s) will receive this message. +
+
+
+
+
Available Merge Tokens
+
+ + + + + + + + +
+
Click a token to insert it into the editor.
+
-
- @* Right: compose *@ -
-
-
Compose
-
-
- - - -
-
- - - -
-
- - This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending. -
- -
+
+
-
+
- +
@section Scripts { + } diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml new file mode 100644 index 0000000..f844f99 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml @@ -0,0 +1,135 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailPreviewModel +@{ + ViewData["Title"] = "Preview Admin Email"; +} + +
+
+
+

Admin Email Wizard

+

Step 3 of 3: preview one merged sample, then send sequentially.

+
+
+ @Model.EligibleCount ready to send + @if (Model.SkippedCount > 0) + { + @Model.SkippedCount missing email + } +
+
+ +
+
+
+
Sample Preview
+
+
Recipient Sample
+
+
@Model.SamplePreview.RecipientName
+
@Model.SamplePreview.RecipientEmail
+
@Model.SamplePreview.CompanyName
+
+ +
Rendered Subject
+
@Model.SamplePreview.RenderedSubject
+ +
Rendered HTML Body
+
+ @Html.Raw(Model.SamplePreview.RenderedHtmlBody) +
+
+
+
+ +
+
+
Delivery Summary
+
+
+ The system will process each selected company one at a time. + The sample shown on the left uses the first available recipient after token replacement. +
+
+
+ +
+
Selected Companies
+
+ + + + + + + + + + + @foreach (var row in Model.SelectedCompanies) + { + + + + + + + } + +
CompanyRecipientCompany AdminReady
+
@row.CompanyName
+
#@row.CompanyId
+
+
@row.RecipientName
+
@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)
+
+
@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail)) + { +
@row.CompanyAdminEmail
+ } +
+ @if (row.CanSend) + { + Ready + } + else + { + @row.SkipReason + } +
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + @foreach (var companyId in Model.CompanyIds ?? Array.Empty()) + { + + } + +
+ + + @if (Model.EligibleCount > 0) + { + + } + else + { + + } +
+ +
diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml new file mode 100644 index 0000000..e942eb6 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml @@ -0,0 +1,170 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailSelectionModel +@{ + ViewData["Title"] = "Choose Companies"; +} + +
+
+
+

Admin Email Wizard

+

Step 2 of 3: choose which companies should receive this message.

+
+
@Model.AvailableCompanies.Count company records
+
+ +
+
+
+
+
Subject
+
@Model.Subject
+
+
+
Message Summary
+
Rich-text message prepared. Merge tokens will render on the preview step.
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + +
+
+
+
+ +
+
+ + + 0 selected +
+
+ + + +
+ + + + + + + + + + + + + @foreach (var company in Model.AvailableCompanies) + { + + + + + + + + + } + +
CompanyPrimary ContactEmailCompany AdminStatus
+ + +
@company.CompanyName
+
#@company.CompanyId
+
@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName) + @if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail)) + { + Missing + } + else + { + @company.PrimaryContactEmail + } + +
@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail)) + { +
@company.CompanyAdminEmail
+ } +
+ @if (company.IsActive) + { + Active + } + else + { + Inactive + } +
+
+ +
+ + +
+
+
+
+
+ +@section Scripts { + + +} diff --git a/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs new file mode 100644 index 0000000..3b3f06f --- /dev/null +++ b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Shared.Constants; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class EmailBroadcastControllerTests +{ + [Fact] + public async Task Preview_RendersMergedSampleAndSanitizesHtml() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 7, + CompanyId = 7, + CompanyName = "River City Powder", + PrimaryContactName = "Jamie Rivera", + PrimaryContactEmail = "jamie@example.com", + IsActive = true + }); + context.Users.Add(new ApplicationUser + { + Id = "admin-7", + CompanyId = 7, + CompanyRole = AppConstants.CompanyRoles.CompanyAdmin, + FirstName = "Alex", + LastName = "Admin", + Email = "alex@example.com", + UserName = "alex@example.com", + IsActive = true + }); + await context.SaveChangesAsync(); + + var controller = CreateController(context); + + var result = await controller.Preview(new AdminEmailSelectionModel + { + Subject = "Update for {{CompanyName}}", + BodyHtml = "

Hi {{FirstName}}, contact {{CompanyAdminName}} at {{CompanyAdminEmail}}.

", + CompanyIds = [7] + }); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + + Assert.Equal("Update for River City Powder", model.SamplePreview.RenderedSubject); + Assert.Contains("Hi Jamie", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("Alex Admin", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("alex@example.com", model.SamplePreview.RenderedHtmlBody); + Assert.DoesNotContain("(); + emailService + .Setup(x => x.SendEmailAsync( + "morgan@example.com", + "Morgan Lee", + "Notice for Summit Coatings", + It.IsAny(), + It.IsAny(), + null, + null, + null, + "admin-notify@example.com", + "Powder Coating Logix Admin")) + .ReturnsAsync((true, (string?)null)); + + var platformSettings = new Mock(); + platformSettings + .Setup(x => x.GetAsync(PlatformSettingKeys.AdminNotificationEmail)) + .ReturnsAsync("admin-notify@example.com,backup@example.com"); + + var controller = CreateController(context, emailService, platformSettings); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Notice for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}},

Thanks for using {{CompanyName}}.

", + CompanyIds = [11] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Sent, log.Status); + Assert.Equal("morgan@example.com", log.Recipient); + Assert.Equal("Notice for Summit Coatings", log.Subject); + Assert.Contains("Hello Morgan", log.Message); + emailService.VerifyAll(); + } + + [Fact] + public async Task Send_WhenPrimaryContactEmailMissing_WritesSkippedLogWithoutSending() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 13, + CompanyId = 13, + CompanyName = "No Inbox Inc", + PrimaryContactName = "Taylor Noemail", + PrimaryContactEmail = string.Empty, + IsActive = true + }); + await context.SaveChangesAsync(); + + var emailService = new Mock(); + var controller = CreateController(context, emailService); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Heads up for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}}

", + CompanyIds = [13] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Skipped, log.Status); + Assert.Equal(string.Empty, log.Recipient); + Assert.Equal("Company primary contact email is not configured.", log.ErrorMessage); + emailService.Verify( + x => x.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private static EmailBroadcastController CreateController( + ApplicationDbContext context, + Mock? emailService = null, + Mock? platformSettings = null) + { + var controller = new EmailBroadcastController( + context, + (emailService ?? new Mock()).Object, + (platformSettings ?? CreatePlatformSettings()).Object, + Mock.Of>()); + + var httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + + return controller; + } + + private static Mock CreatePlatformSettings() + { + var settings = new Mock(); + settings.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); + return settings; + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +} From 0ea192d55b47a51dfa554f931e15e8ba842be64a Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 26 Apr 2026 18:14:16 -0400 Subject: [PATCH 03/16] Harden legacy file paths and Twilio webhook validation --- .../Services/FileService.cs | 131 ++++++++++++++++-- .../Controllers/WebhooksController.cs | 17 ++- .../FileServiceTests.cs | 95 +++++++++++++ .../WebhooksControllerTests.cs | 89 ++++++++++++ 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 tests/PowderCoating.UnitTests/FileServiceTests.cs create mode 100644 tests/PowderCoating.UnitTests/WebhooksControllerTests.cs diff --git a/src/PowderCoating.Application/Services/FileService.cs b/src/PowderCoating.Application/Services/FileService.cs index 23c8de4..bd4686c 100644 --- a/src/PowderCoating.Application/Services/FileService.cs +++ b/src/PowderCoating.Application/Services/FileService.cs @@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services; /// public class FileService : IFileService { + private const string UploadsRootFolder = "uploads"; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; @@ -31,7 +32,9 @@ public class FileService : IFileService /// Validation order: null/empty check, size limit, then extension allowlist. The original file /// name is sanitised with to strip any directory components before /// prepending the GUID prefix, preventing path traversal if the browser supplies a name with - /// slashes. Returns a relative path (from wwwroot) suitable for storing in the database. + /// slashes. The target subfolder is resolved and confined under wwwroot/uploads/ before + /// any file system access occurs. Returns a relative path (from wwwroot) suitable for + /// storing in the database. /// public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync( IFormFile file, @@ -65,7 +68,11 @@ public class FileService : IFileService // Create upload directory if it doesn't exist // NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy // and should only be called for on-premises deployments. New uploads use Azure Blob. - var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder); + if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError)) + { + return (false, string.Empty, subfolderError); + } + if (!Directory.Exists(uploadPath)) { try @@ -93,7 +100,7 @@ public class FileService : IFileService } // Return relative path from wwwroot - var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/"); + var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/"); _logger.LogInformation("File saved successfully: {FilePath}", relativePath); return (true, relativePath, string.Empty); @@ -108,8 +115,8 @@ public class FileService : IFileService /// /// Deletes a file given its relative path from wwwroot. /// Returns success if the file does not exist (idempotent) so that callers do not need to check - /// existence before calling. The relative path is converted to an absolute path with - /// rather than string concatenation to prevent directory traversal. + /// existence before calling. The relative path is normalized and must remain under + /// wwwroot/uploads/; paths outside that root are rejected. /// public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath) { @@ -120,7 +127,10 @@ public class FileService : IFileService return (false, "File path is required."); } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) + { + return (false, pathError); + } if (!File.Exists(fullPath)) { @@ -142,8 +152,8 @@ public class FileService : IFileService /// /// Reads a file from disk and returns its raw bytes along with a derived MIME content type. - /// Intended for serving files that are stored outside wwwroot (or otherwise not directly - /// accessible via the static-files middleware) so controllers can stream them as file responses. + /// Intended for serving files that are stored under the legacy wwwroot/uploads/ path but + /// are otherwise not directly exposed through the static-files middleware. /// public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath) { @@ -154,7 +164,10 @@ public class FileService : IFileService return (false, Array.Empty(), string.Empty, "File path is required."); } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) + { + return (false, Array.Empty(), string.Empty, pathError); + } if (!File.Exists(fullPath)) { @@ -175,7 +188,7 @@ public class FileService : IFileService } /// - /// Checks whether a file exists at the given wwwroot-relative path without reading it. + /// Checks whether a file exists at the given wwwroot/uploads/-relative path without reading it. /// Used by views and controllers to conditionally show download links only when the file is present. /// public bool FileExists(string filePath) @@ -185,7 +198,11 @@ public class FileService : IFileService return false; } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _)) + { + return false; + } + return File.Exists(fullPath); } @@ -212,4 +229,96 @@ public class FileService : IFileService _ => "application/octet-stream" }; } + + private bool TryResolveUploadSubfolder( + string subfolder, + out string uploadPath, + out string relativeSubfolder, + out string errorMessage) + { + uploadPath = string.Empty; + relativeSubfolder = string.Empty; + errorMessage = string.Empty; + + if (string.IsNullOrWhiteSpace(subfolder)) + { + errorMessage = "Upload subfolder is required."; + return false; + } + + if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) + { + return false; + } + + var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/'); + var resolvedPath = Path.GetFullPath( + Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar))); + + if (!IsWithinDirectory(resolvedPath, uploadsRoot)) + { + errorMessage = "Invalid upload subfolder."; + _logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder); + return false; + } + + relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/"); + uploadPath = resolvedPath; + return true; + } + + private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage) + { + fullPath = string.Empty; + errorMessage = string.Empty; + + if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) + { + return false; + } + + var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/'); + if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = "Invalid file path."; + _logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath); + return false; + } + + var resolvedPath = Path.GetFullPath( + Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar))); + + if (!IsWithinDirectory(resolvedPath, uploadsRoot)) + { + errorMessage = "Invalid file path."; + _logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath); + return false; + } + + fullPath = resolvedPath; + return true; + } + + private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage) + { + uploadsRoot = string.Empty; + errorMessage = string.Empty; + + if (string.IsNullOrWhiteSpace(_environment.WebRootPath)) + { + errorMessage = "File storage is not available in this environment."; + _logger.LogWarning("WebRootPath is not configured for the legacy file service."); + return false; + } + + uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder)); + return true; + } + + private static bool IsWithinDirectory(string candidatePath, string rootPath) + { + var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/PowderCoating.Web/Controllers/WebhooksController.cs b/src/PowderCoating.Web/Controllers/WebhooksController.cs index 5ed4ce2..8b30d61 100644 --- a/src/PowderCoating.Web/Controllers/WebhooksController.cs +++ b/src/PowderCoating.Web/Controllers/WebhooksController.cs @@ -22,6 +22,7 @@ public class WebhooksController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; // CTIA-standard opt-out keywords (case-insensitive) @@ -39,10 +40,12 @@ public class WebhooksController : ControllerBase public WebhooksController( ApplicationDbContext context, IConfiguration configuration, + IWebHostEnvironment environment, ILogger logger) { _context = context; _configuration = configuration; + _environment = environment; _logger = logger; } @@ -269,16 +272,22 @@ public class WebhooksController : ControllerBase }; /// - /// Validates the incoming Twilio webhook request using HMAC-SHA1. Skips validation when the - /// auth token is unconfigured so the endpoint works in local development without real Twilio credentials. + /// Validates the incoming Twilio webhook request using HMAC-SHA1. Local development may skip + /// validation when the auth token is unconfigured, but shared environments fail closed. /// private bool ValidateTwilioRequest() { var authToken = _configuration["Twilio:AuthToken"]; if (string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-")) { - _logger.LogDebug("Twilio auth token not configured; skipping signature validation"); - return true; + if (_environment.IsDevelopment()) + { + _logger.LogDebug("Twilio auth token not configured in development; skipping signature validation"); + return true; + } + + _logger.LogError("Twilio auth token is not configured; rejecting webhook request"); + return false; } var signature = Request.Headers["X-Twilio-Signature"].FirstOrDefault() ?? string.Empty; diff --git a/tests/PowderCoating.UnitTests/FileServiceTests.cs b/tests/PowderCoating.UnitTests/FileServiceTests.cs new file mode 100644 index 0000000..b7255d6 --- /dev/null +++ b/tests/PowderCoating.UnitTests/FileServiceTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.Services; + +namespace PowderCoating.UnitTests; + +public class FileServiceTests +{ + [Fact] + public async Task DeleteFileAsync_ReturnsError_WhenPathEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.DeleteFileAsync("uploads/../secrets.txt"); + + Assert.False(result.Success); + Assert.Equal("Invalid file path.", result.ErrorMessage); + } + + [Fact] + public async Task GetFileAsync_ReturnsError_WhenPathEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.GetFileAsync("uploads/../../appsettings.json"); + + Assert.False(result.Success); + Assert.Equal("Invalid file path.", result.ErrorMessage); + Assert.Empty(result.FileContent); + } + + [Fact] + public async Task SaveFileAsync_ReturnsError_WhenSubfolderEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.SaveFileAsync( + CreateFormFile("manual.pdf"), + "../outside", + new[] { ".pdf" }, + 1024 * 1024); + + Assert.False(result.Success); + Assert.Equal("Invalid upload subfolder.", result.ErrorMessage); + } + + [Fact] + public async Task GetFileAsync_ReturnsFile_WhenPathIsUnderUploadsRoot() + { + using var harness = new FileServiceHarness(); + var uploadsPath = Path.Combine(harness.WebRootPath, "uploads", "equipment-manuals"); + Directory.CreateDirectory(uploadsPath); + + var fullPath = Path.Combine(uploadsPath, "manual.pdf"); + await File.WriteAllBytesAsync(fullPath, [1, 2, 3]); + + var result = await harness.Service.GetFileAsync("uploads/equipment-manuals/manual.pdf"); + + Assert.True(result.Success); + Assert.Equal("application/pdf", result.ContentType); + Assert.Equal(new byte[] { 1, 2, 3 }, result.FileContent); + } + + private static IFormFile CreateFormFile(string fileName) + { + var bytes = new byte[] { 1, 2, 3, 4 }; + var stream = new MemoryStream(bytes); + return new FormFile(stream, 0, bytes.Length, "file", fileName); + } + + private sealed class FileServiceHarness : IDisposable + { + public FileServiceHarness() + { + WebRootPath = Path.Combine(Path.GetTempPath(), "powdercoating-fileservice-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(WebRootPath); + + var environment = Mock.Of(x => x.WebRootPath == WebRootPath); + Service = new FileService(environment, Mock.Of>()); + } + + public string WebRootPath { get; } + public FileService Service { get; } + + public void Dispose() + { + if (Directory.Exists(WebRootPath)) + { + Directory.Delete(WebRootPath, recursive: true); + } + } + } +} diff --git a/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs b/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs new file mode 100644 index 0000000..58989df --- /dev/null +++ b/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class WebhooksControllerTests +{ + [Fact] + public async Task TwilioSms_ReturnsForbid_WhenAuthTokenMissingOutsideDevelopment() + { + await using var dbContext = CreateDbContext(); + var controller = CreateController(dbContext, Array.Empty>(), "Production"); + controller.ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext() + }; + + var result = await controller.TwilioSms(new TwilioSmsPayload { From = "+15551234567", Body = "STOP" }); + + Assert.IsType(result); + } + + [Fact] + public async Task TwilioSms_AllowsMissingAuthToken_InDevelopment() + { + await using var dbContext = CreateDbContext(); + var controller = CreateController(dbContext, Array.Empty>(), "Development"); + controller.ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext() + }; + + var result = await controller.TwilioSms(new TwilioSmsPayload()); + + var content = Assert.IsType(result); + Assert.Equal("application/xml", content.ContentType); + Assert.Equal("", content.Content); + } + + private static WebhooksController CreateController( + ApplicationDbContext dbContext, + IEnumerable> configValues, + string environmentName) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var environment = Mock.Of(x => x.EnvironmentName == environmentName); + + return new WebhooksController( + dbContext, + configuration, + environment, + Mock.Of>()); + } + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Post; + context.Request.Scheme = "https"; + context.Request.Host = new HostString("example.com"); + context.Request.Path = "/Webhooks/TwilioSms"; + context.Features.Set(new FormFeature(new FormCollection(new Dictionary + { + ["From"] = "+15551234567", + ["Body"] = "STOP" + }))); + return context; + } + + private static ApplicationDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +} From cad728ba6660c6c81c22b03930617adfa2810b2f Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Mon, 27 Apr 2026 13:32:34 -0400 Subject: [PATCH 04/16] Fix passkey login tracking, add email opt-out UI guards, and add Quick/Full quote mode toggle - PasskeyController: set LastLoginDate on passkey sign-in so Company Health and audit pages show accurate last-login times (was always showing 'Never') - Jobs/Index status modal: disable 'Notify customer' email toggle and show warning when customer has notifications turned off; CustomerNotifyByEmail added to JobListDto + JobProfile mapping + data-customer-notify attribute - Quotes/Create: disable 'Send quote via email' checkbox with 'Notifications off' badge when selected customer has email opt-out; ViewBag.CustomerEmailOptOutIds added alongside existing CustomerTaxExemptIds pattern - Quotes/Create: Quick Quote / Full Quote segmented toggle at top of form; hides non-essential fields (dates, notes, tags, oven, discount, photos) in Quick mode; selection persisted in localStorage - InvoicesController Send action: improved error logging and user-facing warning when PDF generation or email dispatch fails after status is saved - item-wizard.js: guard item restoration with try/catch; ensure writeHiddenFields always runs on form submit via capture-phase listener - Help docs and AI knowledge base updated for all new features Co-Authored-By: Claude Sonnet 4.6 --- .../DTOs/Job/JobDtos.cs | 1 + .../Mappings/JobProfile.cs | 4 +- .../Controllers/InvoicesController.cs | 9 +- .../Controllers/PasskeyController.cs | 4 + .../Controllers/QuotesController.cs | 5 + .../Helpers/HelpKnowledgeBase.cs | 15 ++- src/PowderCoating.Web/Views/Help/Jobs.cshtml | 5 + .../Views/Help/Quotes.cshtml | 32 ++++++ src/PowderCoating.Web/Views/Jobs/Index.cshtml | 14 +++ .../Views/Quotes/Create.cshtml | 101 ++++++++++++++++-- .../wwwroot/js/item-wizard.js | 22 +++- 11 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs index a1ad86c..e95a0d9 100644 --- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs +++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs @@ -99,6 +99,7 @@ public class JobListDto public string PriorityDisplayName { get; set; } = string.Empty; public string PriorityColorClass { get; set; } = "secondary"; + public bool CustomerNotifyByEmail { get; set; } = true; public DateTime? ScheduledDate { get; set; } public DateTime? DueDate { get; set; } public decimal FinalPrice { get; set; } diff --git a/src/PowderCoating.Application/Mappings/JobProfile.cs b/src/PowderCoating.Application/Mappings/JobProfile.cs index ff20718..4da668b 100644 --- a/src/PowderCoating.Application/Mappings/JobProfile.cs +++ b/src/PowderCoating.Application/Mappings/JobProfile.cs @@ -109,7 +109,9 @@ public class JobProfile : Profile .ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId)) .ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode)) .ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName)) - .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)); + .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)) + .ForMember(dest => dest.CustomerNotifyByEmail, + opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail)); // JobItem mappings CreateMap() diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 2aa586a..b43ae8b 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -893,14 +893,19 @@ public class InvoicesController : Controller if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; + bool pdfAndNotifSucceeded = false; try { var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl); + pdfAndNotifSucceeded = true; } catch (Exception notifyEx) { - _logger.LogWarning(notifyEx, "Invoice sent but notification failed for invoice {Id}", id); + _logger.LogError(notifyEx, + "Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " + + "Inner: {InnerMessage}. Invoice status was already saved as Sent.", + id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); } var notifLog = await _context.NotificationLogs @@ -911,6 +916,8 @@ public class InvoicesController : Controller this.SetNotificationResultToast(notifLog); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; + if (!pdfAndNotifSucceeded) + TempData["Warning"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) diff --git a/src/PowderCoating.Web/Controllers/PasskeyController.cs b/src/PowderCoating.Web/Controllers/PasskeyController.cs index 134cc5b..486c2a2 100644 --- a/src/PowderCoating.Web/Controllers/PasskeyController.cs +++ b/src/PowderCoating.Web/Controllers/PasskeyController.cs @@ -248,6 +248,10 @@ public class PasskeyController : Controller // Sign in — passkey satisfies both factors; no further 2FA required await _signInManager.SignInAsync(user, isPersistent: false); + // Track login date so CompanyHealth and audit pages show accurate last-login times + user.LastLoginDate = DateTime.UtcNow; + await _userManager.UpdateAsync(user); + _logger.LogInformation("User {UserId} signed in via passkey", user.Id); return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") }); diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index b325792..b271181 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -2642,6 +2642,11 @@ public class QuotesController : Controller .Where(c => c.IsTaxExempt) .Select(c => c.Id) .ToHashSet(); + // Map used by JS to disable the email checkbox when the customer has notifications turned off + ViewBag.CustomerEmailOptOutIds = customers + .Where(c => !c.NotifyByEmail) + .Select(c => c.Id) + .ToHashSet(); // Stored separately so views can restore the company default when switching away from an exempt customer // (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts) if (ViewBag.CompanyTaxPercent == null && customers.Any()) diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index f16f8f9..aae9a06 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -184,12 +184,15 @@ public static class HelpKnowledgeBase - *Expired* — validity period passed - *Converted* — converted into a job + **Quick Quote vs Full Quote mode:** The New Quote form has a toggle at the top — "Quick Quote" hides non-essential fields (dates, notes, tags, oven settings, discounts, photos) so you can get a price in seconds. "Full Quote" shows the complete form. Your selection is remembered automatically. Both modes use the same pricing engine — hidden fields just use defaults. + **How to create a quote:** 1. Go to [Quotes](/Quotes) → "New Quote" - 2. Select existing customer OR enter prospect info (name, email, phone) - 3. Add line items using the item wizard (3 item types below) - 4. Review the pricing breakdown - 5. Save as Draft or Send immediately + 2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top + 3. Select existing customer OR enter prospect info (name, email, phone) + 4. Add line items using the item wizard (3 item types below) + 5. Review the pricing breakdown + 6. Save as Draft or Send immediately **Three item types in the quote wizard:** 1. *Calculated* — you enter dimensions; system calculates surface area and price from operating costs @@ -220,7 +223,7 @@ public static class HelpKnowledgeBase **Prospect conversion:** If a quote was for a prospect (no existing customer), you can convert them to a customer from the Quote Details page after the quote is approved. - **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. + **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. If the customer has email notifications turned off, the send checkbox on the Create page and the Send button on Details are both disabled — a "Notifications off" warning is shown instead. **Customer approval portal:** Customers can approve/reject quotes via a public link (/QuoteApproval) — no login required. @@ -284,6 +287,8 @@ public static class HelpKnowledgeBase **Assigning workers:** Select an assigned shop worker on the Create or Edit page. Worker appears on the Details and Index views. + **Quick status change:** On the Jobs list, click any status badge to open a status-change modal without leaving the page. The modal includes a "Notify customer via email" toggle. If the customer has email notifications turned off, that toggle is automatically disabled and a warning is shown — no email will be sent. + **Job Notes:** Add internal notes on the Job Details page. Notes are private. **Time Entries:** Track labor time on a job from the Details page. diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index c7b28b5..e604772 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -77,6 +77,11 @@ (waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that will not be completed.

+

+ On the Jobs list, click any status badge to open a quick-change modal. The modal includes a + Notify customer via email toggle. If the customer has email notifications turned off, + that toggle is automatically disabled and a warning note is shown — no email will be sent regardless. +

diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 5408b88..3f69008 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -40,6 +40,28 @@

Creating a Quote

+ +

Quick Quote vs Full Quote

+

+ The quote form offers two modes, selectable via the Quick Quote / Full Quote toggle at the + top of the page. Your selection is remembered automatically for next time. +

+
    +
  • Quick Quote — shows only the essentials: customer picker (or walk-in info) and + the item wizard. Dates, notes, tags, oven settings, discounts, and photos are hidden. Use this for + fast phone or counter estimates where you just need a price.
  • +
  • Full Quote — shows the complete form with all fields. Use this for formal + quotes where you want to capture notes, set an expiry date, apply a discount, or add photos.
  • +
+ +

To create a new quote:

  1. Go to Operations › Quotes and click New Quote.
  2. @@ -251,6 +273,16 @@
  3. Click Send Quote. The status changes from Draft to Sent.
  4. If email notifications are configured for your company, the customer will automatically receive an email with the quote details.
+

You can also manually mark a quote as Approved or Rejected when you hear back from the customer verbally or by phone, without going through a formal email send. diff --git a/src/PowderCoating.Web/Views/Jobs/Index.cshtml b/src/PowderCoating.Web/Views/Jobs/Index.cshtml index fae92e3..129e429 100644 --- a/src/PowderCoating.Web/Views/Jobs/Index.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Index.cshtml @@ -195,6 +195,7 @@ data-job-number="@job.JobNumber" data-status-id="@job.JobStatusId" data-status-name="@job.StatusDisplayName" + data-customer-notify="@job.CustomerNotifyByEmail.ToString().ToLower()" title="Click to change status"> @job.StatusDisplayName @@ -511,6 +512,9 @@ Notify customer via email +