Compare commits

...

5 Commits

Author SHA1 Message Date
spouliot cad728ba66 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 <noreply@anthropic.com>
2026-04-27 13:32:34 -04:00
spouliot 0ea192d55b Harden legacy file paths and Twilio webhook validation 2026-04-26 18:14:16 -04:00
spouliot 8491b308eb Add admin email wizard and logging 2026-04-26 17:01:09 -04:00
spouliot 404ab3c45d Add unit tests for LedgerService, AccountBalanceService, DepositsController, and GiftCertificatesController
181 tests total passing. Covers all 9 LedgerService transaction sources, debit/credit
balance mechanics, AR/AP movements, date range filtering, running balance computation,
and future-dated opening balance exclusion. Also covers deposit recording/deletion,
gift certificate lifecycle (issue, void, lazy expiry), and account balance recalculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:48:34 -04:00
spouliot a4b8ae611a Add passkey prompt dismissal and consolidate company admin navigation
- Add "Don't ask me again" to passkey enrollment prompt (PasskeyPromptDismissed
  field on ApplicationUser; DismissPrompt POST action; migration applied)
- Add Subscription & Features button to Companies/Index btn-group and
  Companies/Edit header for direct navigation to SubscriptionManagement/Manage
- Add Edit Company back-link on SubscriptionManagement/Manage
- Remove duplicate AI Features section from Companies/Edit (managed exclusively
  via Subscription & Features page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:34:50 -04:00
34 changed files with 12692 additions and 348 deletions
@@ -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; }
@@ -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",
@@ -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<JobItem, JobItemDto>()
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
/// </summary>
public class FileService : IFileService
{
private const string UploadsRootFolder = "uploads";
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileService> _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 <see cref="Path.GetFileName"/> 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 <c>wwwroot</c>) suitable for storing in the database.
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
/// storing in the database.
/// </summary>
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
/// <summary>
/// Deletes a file given its relative path from <c>wwwroot</c>.
/// 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
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
/// existence before calling. The relative path is normalized and must remain under
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
/// </summary>
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
/// <summary>
/// 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 <c>wwwroot</c> (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 <c>wwwroot/uploads/</c> path but
/// are otherwise not directly exposed through the static-files middleware.
/// </summary>
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<byte>(), 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<byte>(), string.Empty, pathError);
}
if (!File.Exists(fullPath))
{
@@ -175,7 +188,7 @@ public class FileService : IFileService
}
/// <summary>
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
/// Used by views and controllers to conditionally show download links only when the file is present.
/// </summary>
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);
}
}
@@ -61,6 +61,9 @@ public class ApplicationUser : IdentityUser
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
// Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false;
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
@@ -17,5 +17,6 @@ public enum NotificationType
SubscriptionExpiryReminder = 10,
SubscriptionExpired = 11,
SmsInboundStop = 12,
SmsInboundHelp = 13
SmsInboundHelp = 13,
AdminEmail = 14
}
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPasskeyPromptDismissed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "PasskeyPromptDismissed",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasskeyPromptDismissed",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020));
}
}
}
@@ -555,6 +555,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PasskeyPromptDismissed")
.HasColumnType("bit");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
@@ -5836,7 +5839,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5847,7 +5850,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5858,7 +5861,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020),
CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -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;
/// <summary>
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
/// rather than bulk API because each message requires a personalised unsubscribe link
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
/// </summary>
[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<string> 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<EmailBroadcastController> _logger;
public EmailBroadcastController(
ApplicationDbContext db,
IEmailService emailService,
IPlatformSettingsService platformSettings,
ILogger<EmailBroadcastController> logger)
{
_db = db;
_emailService = emailService;
_platformSettings = platformSettings;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> Index()
{
await PopulateViewBag();
return View(new BroadcastForm());
}
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
[HttpGet]
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
public IActionResult Index() => View(new AdminEmailComposeModel());
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// <para>
/// Each email body is HTML-encoded (then line-breaks converted to
/// <c>&lt;br&gt;</c>) 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.
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(BroadcastForm form)
public IActionResult BackToCompose(AdminEmailComposeModel form)
{
if (!ModelState.IsValid)
NormalizeComposeModel(form);
return View("Index", form);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> BackToSelectCompanies(AdminEmailSelectionModel form)
{
NormalizeComposeModel(form);
return View("SelectCompanies", await BuildSelectionModelAsync(form));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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", "<br>");
foreach (var (email, name, unsubToken) in recipients)
{
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
var htmlBody = $@"
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
<p>{encodedBody}</p>
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
<p style=""font-size:12px;color:#999"">
This message was sent by the Powder Coating Logix platform team.<br>
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
</p>
</div>";
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));
}
/// <summary>
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
/// is missing. The "specific" target requires at least one <c>companyIds</c>
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
/// </summary>
private async Task<List<(string Email, string Name, string UnsubToken)>> 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<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
{
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
? selection.CompanyIds
: Array.Empty<int>();
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<AdminEmailPreviewModel> 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)
}
};
}
/// <summary>
/// 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 <see cref="Index"/> and the
/// validation-failure branch of <see cref="Send"/>.
/// </summary>
private async Task PopulateViewBag()
private async Task<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> 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<List<AdminEmailRecipientContext>> LoadRecipientContextsAsync(IReadOnlyCollection<int> 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<Dictionary<int, CompanyAdminLookup>> LoadCompanyAdminLookupAsync(IReadOnlyCollection<int> 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<string?> 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<string, string> 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 $"""
<div style="font-family:Arial,Helvetica,sans-serif;max-width:700px;margin:0 auto;color:#1f2937;line-height:1.6;">
{renderedHtmlBody}
</div>
""";
}
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("<div", "<p", StringComparison.OrdinalIgnoreCase)
.Replace("</div>", "</p>", 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 "<br>";
if (tagName == "a")
{
if (isClosingTag)
return "</a>";
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 $"""<a href="{encodedHref}" target="_blank" rel="noopener noreferrer">""";
}
return isClosingTag ? $"</{tagName}>" : $"<{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<AdminEmailCompanyOption> AvailableCompanies { get; set; } = [];
}
public class AdminEmailSendRequest : AdminEmailSelectionModel;
public class AdminEmailPreviewModel : AdminEmailSendRequest
{
public List<AdminEmailRecipientPreviewRow> 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;
}
@@ -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)
@@ -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") });
@@ -258,8 +262,8 @@ public class PasskeyController : Controller
// ─── Post-login enrollment prompt ─────────────────────────────────────────
/// <summary>
/// Shown immediately after password login. If the user already has a passkey,
/// redirects straight to returnUrl. Otherwise presents the "Enable Face ID" page.
/// Shown immediately after password login. Skips to returnUrl if the user already
/// has a passkey or has previously dismissed the prompt.
/// </summary>
[Authorize]
[HttpGet("/Passkey/EnrollPrompt")]
@@ -268,15 +272,32 @@ public class PasskeyController : Controller
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
// Skip prompt for users who already have at least one passkey
var hasPasskey = await _db.UserPasskeys.AnyAsync(p => p.UserId == user.Id);
if (hasPasskey)
if (hasPasskey || user.PasskeyPromptDismissed)
return Redirect(returnUrl ?? "/");
ViewBag.ReturnUrl = returnUrl ?? "/";
return View();
}
/// <summary>
/// Permanently dismisses the passkey enrollment prompt for this user. They can
/// re-enable it from Profile → Security at any time.
/// </summary>
[Authorize]
[HttpPost("/Passkey/DismissPrompt")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DismissPrompt(string? returnUrl)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
user.PasskeyPromptDismissed = true;
await _userManager.UpdateAsync(user);
return Redirect(returnUrl ?? "/");
}
/// <summary>Shows all passkeys registered by the current user.</summary>
[Authorize]
[HttpGet("/Passkey/Manage")]
@@ -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())
@@ -22,6 +22,7 @@ public class WebhooksController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<WebhooksController> _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<WebhooksController> logger)
{
_context = context;
_configuration = configuration;
_environment = environment;
_logger = logger;
}
@@ -269,16 +272,22 @@ public class WebhooksController : ControllerBase
};
/// <summary>
/// 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.
/// </summary>
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;
@@ -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.
@@ -10,10 +10,14 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<div class="d-flex justify-content-between mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@Model.Id"
class="btn btn-outline-info">
<i class="bi bi-credit-card me-1"></i>Subscription &amp; Features
</a>
</div>
<div class="card shadow-sm">
@@ -145,37 +149,6 @@
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">AI Features</h5>
<p class="text-muted small mb-3">Control which AI-powered features are available to this company and set monthly usage limits.</p>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiPhotoQuotesEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiPhotoQuotesEnabled" class="form-check-label fw-medium">AI Photo Quotes</label>
</div>
<div class="form-text">Allow this company to use photo-based AI quoting.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiInventoryAssistEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiInventoryAssistEnabled" class="form-check-label fw-medium">AI Inventory Assist</label>
</div>
<div class="form-text">Allow this company to use AI lookup on inventory items.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiCatalogPriceCheckEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiCatalogPriceCheckEnabled" class="form-check-label fw-medium">AI Catalog Price Check</label>
</div>
<div class="form-text">Override: grants access regardless of plan tier.</div>
</div>
<div class="col-md-4">
<label asp-for="MaxAiPhotoQuotesPerMonthOverride" class="form-label">Monthly AI Quote Limit Override</label>
<input asp-for="MaxAiPhotoQuotesPerMonthOverride" type="number" class="form-control" min="-1" placeholder="Leave blank to use plan default" />
<div class="form-text">-1 = unlimited. 0 = disabled. Blank = use subscription plan default.</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
@@ -184,6 +184,10 @@
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@company.Id"
class="btn btn-outline-info" title="Manage Subscription & Features">
<i class="bi bi-credit-card"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@company.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
@@ -1,172 +1,173 @@
@using PowderCoating.Web.Controllers
@model BroadcastForm
@model AdminEmailComposeModel
@{
ViewData["Title"] = "Email Broadcast";
ViewData["Title"] = "Admin Email";
}
@section Styles {
<style>
[data-bs-theme="dark"] .card {
border-color: var(--bs-border-color) !important;
.admin-email-shell {
max-width: 1100px;
}
[data-bs-theme="dark"] .card-header {
background-color: var(--bs-secondary-bg) !important;
border-color: var(--bs-border-color) !important;
color: var(--bs-body-color);
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: .5rem;
padding: .75rem;
border: 1px solid var(--bs-border-color);
border-bottom: 0;
border-top-left-radius: .75rem;
border-top-right-radius: .75rem;
background: linear-gradient(135deg, rgba(13,110,253,.08), rgba(25,135,84,.08));
}
[data-bs-theme="dark"] .alert-info {
background-color: rgba(13,202,240,.1);
border-color: rgba(13,202,240,.3);
color: var(--bs-body-color);
.editor-surface {
min-height: 320px;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-bottom-left-radius: .75rem;
border-bottom-right-radius: .75rem;
background: var(--bs-body-bg);
overflow: auto;
}
[data-bs-theme="dark"] .alert-warning {
background-color: rgba(255,193,7,.1);
border-color: rgba(255,193,7,.3);
color: var(--bs-body-color);
.editor-surface:focus {
outline: 0;
box-shadow: inset 0 0 0 1px rgba(13,110,253,.45);
}
.token-pill {
display: inline-flex;
align-items: center;
padding: .35rem .6rem;
border-radius: 999px;
background: rgba(13,110,253,.08);
color: var(--bs-primary);
font-size: .875rem;
font-family: var(--bs-font-monospace);
cursor: pointer;
}
</style>
}
<div class="container-fluid py-3" style="max-width:860px">
<div class="d-flex align-items-center gap-3 mb-3">
<h4 class="mb-0"><i class="bi bi-broadcast me-2 text-primary"></i>Email Broadcast</h4>
<div class="container-fluid py-4 admin-email-shell">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-envelope-paper me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 1 of 3: write the subject and rich-text message.</p>
</div>
<div class="badge text-bg-primary-subtle border border-primary-subtle px-3 py-2">Super Admin Only</div>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-3">@TempData["Success"]</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-3">@TempData["Error"]</div>
<div class="alert alert-success alert-permanent mb-4">@TempData["Success"]</div>
}
<form method="post" asp-action="Send" id="broadcast-form">
@Html.AntiForgeryToken()
<div class="card shadow-sm border-0">
<div class="card-body p-4 p-lg-5">
<form method="post" asp-action="SelectCompanies" id="compose-form">
@Html.AntiForgeryToken()
<div class="row g-3">
@* Left: recipients *@
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold py-2">Recipients</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-medium">Send to</label>
<select name="Target" id="target-select" class="form-select" onchange="onTargetChange()">
<option value="active" selected="@(Model.Target == "active")">All active companies</option>
<option value="all" selected="@(Model.Target == "all")">All companies (incl. expired)</option>
<option value="status_grace" selected="@(Model.Target == "status_grace")">Grace period companies</option>
<option value="status_expired" selected="@(Model.Target == "status_expired")">Expired companies</option>
<option value="plan" selected="@(Model.Target == "plan")">By subscription plan</option>
<option value="specific" selected="@(Model.Target == "specific")">Specific companies</option>
</select>
<div class="mb-4">
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
<input asp-for="Subject" class="form-control form-control-lg" placeholder="Service update for {{CompanyName}}" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Message</label>
<div class="editor-toolbar">
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="bold"><i class="bi bi-type-bold"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="italic"><i class="bi bi-type-italic"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="underline"><i class="bi bi-type-underline"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertUnorderedList"><i class="bi bi-list-ul"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="insertOrderedList"><i class="bi bi-list-ol"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="add-link-btn"><i class="bi bi-link-45deg"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-editor-command="removeFormat">Clear Formatting</button>
</div>
<div id="message-editor" class="editor-surface" contenteditable="true">@Html.Raw(Model.BodyHtml)</div>
<textarea asp-for="BodyHtml" class="d-none"></textarea>
<span asp-validation-for="BodyHtml" class="text-danger small"></span>
</div>
<div class="row g-4 align-items-start mb-4">
<div class="col-lg-8">
<div class="alert alert-info mb-0">
The email sends one company at a time to that company's <strong>Primary Contact Email</strong>.
Rich text is supported, and the preview step will render one merged sample before anything sends.
</div>
<div id="plan-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Plan</label>
<select name="PlanFilter" class="form-select">
@foreach (var p in (IEnumerable<dynamic>)ViewBag.PlanConfigs)
{
<option value="@p.Plan">@p.DisplayName</option>
}
</select>
</div>
<div id="specific-filter" class="mb-3" style="display:none">
<label class="form-label fw-medium">Companies</label>
<select name="CompanyIds" multiple class="form-select" style="height:160px">
@foreach (var c in (IEnumerable<dynamic>)ViewBag.Companies)
{
<option value="@c.Id">@c.CompanyName</option>
}
</select>
<div class="form-text">Hold Ctrl / Cmd to select multiple.</div>
</div>
<div class="alert alert-info py-2 small mb-0" id="recipient-preview">
<span id="recipient-count">@ViewBag.ActiveCount</span> company email(s) will receive this message.
</div>
<div class="col-lg-4">
<div class="card border-0 bg-light h-100">
<div class="card-body">
<div class="fw-semibold mb-2">Available Merge Tokens</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="token-pill border-0" data-token="{{FirstName}}">{{FirstName}}</button>
<button type="button" class="token-pill border-0" data-token="{{FullName}}">{{FullName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyName}}">{{CompanyName}}</button>
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactName}}">{{PrimaryContactName}}</button>
<button type="button" class="token-pill border-0" data-token="{{PrimaryContactEmail}}">{{PrimaryContactEmail}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminFirstName}}">{{CompanyAdminFirstName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminName}}">{{CompanyAdminName}}</button>
<button type="button" class="token-pill border-0" data-token="{{CompanyAdminEmail}}">{{CompanyAdminEmail}}</button>
</div>
<div class="small text-muted mt-2">Click a token to insert it into the editor.</div>
</div>
</div>
</div>
</div>
</div>
@* Right: compose *@
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header fw-semibold py-2">Compose</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Subject" class="form-label fw-medium">Subject</label>
<input asp-for="Subject" class="form-control" placeholder="e.g. Scheduled maintenance this Saturday" />
<span asp-validation-for="Subject" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Body" class="form-label fw-medium">Message</label>
<textarea asp-for="Body" class="form-control" rows="12"
placeholder="Write your message here. Plain text — line breaks will be preserved."></textarea>
<span asp-validation-for="Body" class="text-danger small"></span>
</div>
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
This will send a real email to the primary contact address of each matching company. Double-check your recipient selection before sending.
</div>
<button type="submit" class="btn btn-primary" id="send-btn">
<i class="bi bi-send me-1"></i>Send Broadcast
</button>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary btn-lg">
Next: Choose Companies <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
</form>
</div>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
const targetSelect = document.getElementById('target-select');
const planFilter = document.getElementById('plan-filter');
const specificFilter = document.getElementById('specific-filter');
const countEl = document.getElementById('recipient-count');
(function () {
const form = document.getElementById('compose-form');
const editor = document.getElementById('message-editor');
const hiddenBody = document.getElementById('BodyHtml');
function onTargetChange() {
const val = targetSelect.value;
planFilter.style.display = val === 'plan' ? '' : 'none';
specificFilter.style.display = val === 'specific' ? '' : 'none';
updateCount();
}
async function updateCount() {
const val = targetSelect.value;
const planSel = document.querySelector('[name="PlanFilter"]');
const companySel = document.querySelector('[name="CompanyIds"]');
const params = new URLSearchParams({ target: val });
if (val === 'plan' && planSel) params.set('plan', planSel.value);
if (val === 'specific' && companySel) {
Array.from(companySel.selectedOptions).forEach(o => params.append('companyIds', o.value));
function syncEditor() {
hiddenBody.value = editor.innerHTML.trim();
}
try {
const resp = await fetch('@Url.Action("RecipientCount")?' + params.toString());
const data = await resp.json();
countEl.textContent = data.count;
} catch { countEl.textContent = '?'; }
}
document.querySelectorAll('[data-editor-command]').forEach(button => {
button.addEventListener('click', () => {
document.execCommand(button.dataset.editorCommand, false);
editor.focus();
syncEditor();
});
});
window.onTargetChange = onTargetChange;
document.getElementById('add-link-btn').addEventListener('click', () => {
const url = prompt('Enter a full URL starting with https:// or mailto:');
if (!url) return;
document.execCommand('createLink', false, url);
editor.focus();
syncEditor();
});
// Wire up change events for sub-filters
document.querySelector('[name="PlanFilter"]')?.addEventListener('change', updateCount);
document.querySelector('[name="CompanyIds"]')?.addEventListener('change', updateCount);
document.querySelectorAll('[data-token]').forEach(button => {
button.addEventListener('click', () => {
editor.focus();
document.execCommand('insertText', false, button.dataset.token);
syncEditor();
});
});
// Init visibility
onTargetChange();
// Confirm before send
document.getElementById('send-btn').addEventListener('click', function (e) {
const count = countEl.textContent;
if (!confirm(`Send this email to ${count} company recipient(s)?`)) e.preventDefault();
});
})();
editor.addEventListener('input', syncEditor);
form.addEventListener('submit', syncEditor);
syncEditor();
})();
</script>
}
@@ -0,0 +1,135 @@
@using PowderCoating.Web.Controllers
@model AdminEmailPreviewModel
@{
ViewData["Title"] = "Preview Admin Email";
}
<div class="container-fluid py-4" style="max-width:1150px">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-eye me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 3 of 3: preview one merged sample, then send sequentially.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge text-bg-success">@Model.EligibleCount ready to send</span>
@if (Model.SkippedCount > 0)
{
<span class="badge text-bg-warning">@Model.SkippedCount missing email</span>
}
</div>
</div>
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-transparent fw-semibold py-3">Sample Preview</div>
<div class="card-body">
<div class="small text-muted text-uppercase fw-semibold mb-1">Recipient Sample</div>
<div class="mb-3">
<div class="fw-semibold">@Model.SamplePreview.RecipientName</div>
<div class="text-muted">@Model.SamplePreview.RecipientEmail</div>
<div class="small text-muted">@Model.SamplePreview.CompanyName</div>
</div>
<div class="small text-muted text-uppercase fw-semibold mb-1">Rendered Subject</div>
<div class="fw-semibold mb-3">@Model.SamplePreview.RenderedSubject</div>
<div class="small text-muted text-uppercase fw-semibold mb-2">Rendered HTML Body</div>
<div class="border rounded-3 p-3 bg-light-subtle" style="min-height:320px">
@Html.Raw(Model.SamplePreview.RenderedHtmlBody)
</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-transparent fw-semibold py-3">Delivery Summary</div>
<div class="card-body">
<div class="alert alert-info mb-0">
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.
</div>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-transparent fw-semibold py-3">Selected Companies</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light">
<tr>
<th>Company</th>
<th>Recipient</th>
<th>Company Admin</th>
<th>Ready</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.SelectedCompanies)
{
<tr>
<td>
<div class="fw-semibold">@row.CompanyName</div>
<div class="small text-muted">#@row.CompanyId</div>
</td>
<td>
<div>@row.RecipientName</div>
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
</td>
<td>
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
{
<div class="small text-muted">@row.CompanyAdminEmail</div>
}
</td>
<td>
@if (row.CanSend)
{
<span class="badge text-bg-success">Ready</span>
}
else
{
<span class="badge text-bg-warning">@row.SkipReason</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<form method="post" asp-action="Send" class="mt-4">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Subject" />
<input type="hidden" asp-for="BodyHtml" />
@foreach (var companyId in Model.CompanyIds ?? Array.Empty<int>())
{
<input type="hidden" name="CompanyIds" value="@companyId" />
}
<div class="d-flex flex-wrap justify-content-between gap-3">
<button type="submit"
formaction="@Url.Action("BackToSelectCompanies")"
class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Companies
</button>
@if (Model.EligibleCount > 0)
{
<button type="submit" class="btn btn-primary"
onclick="return confirm('Send this message one company at a time to @Model.EligibleCount ready recipient(s)?');">
<i class="bi bi-send me-2"></i>Send Admin Email
</button>
}
else
{
<button type="button" class="btn btn-secondary" disabled>No deliverable recipients selected</button>
}
</div>
</form>
</div>
@@ -0,0 +1,170 @@
@using PowderCoating.Web.Controllers
@model AdminEmailSelectionModel
@{
ViewData["Title"] = "Choose Companies";
}
<div class="container-fluid py-4" style="max-width:1100px">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h3 class="mb-1"><i class="bi bi-building-check me-2 text-primary"></i>Admin Email Wizard</h3>
<p class="text-muted mb-0">Step 2 of 3: choose which companies should receive this message.</p>
</div>
<div class="badge text-bg-secondary px-3 py-2">@Model.AvailableCompanies.Count company records</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-lg-6">
<div class="small text-muted text-uppercase fw-semibold mb-1">Subject</div>
<div class="fw-semibold">@Model.Subject</div>
</div>
<div class="col-lg-6">
<div class="small text-muted text-uppercase fw-semibold mb-1">Message Summary</div>
<div class="text-muted">Rich-text message prepared. Merge tokens will render on the preview step.</div>
</div>
</div>
</div>
</div>
<form method="post" asp-action="Preview" id="company-select-form">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Subject" />
<input type="hidden" asp-for="BodyHtml" />
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<div class="row g-3 align-items-center mb-3">
<div class="col-lg-5">
<input type="search" class="form-control" id="company-filter" placeholder="Search company, contact, or email" />
</div>
<div class="col-lg-7 d-flex flex-wrap gap-2 justify-content-lg-end">
<button type="button" class="btn btn-outline-secondary btn-sm" id="select-all-btn">Select All Visible</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="clear-all-btn">Clear Visible</button>
<span class="badge text-bg-primary" id="selected-count">0 selected</span>
</div>
</div>
<span asp-validation-for="CompanyIds" class="text-danger small d-block mb-3"></span>
<div class="table-responsive">
<table class="table align-middle">
<thead class="table-light">
<tr>
<th style="width:56px"></th>
<th>Company</th>
<th>Primary Contact</th>
<th>Email</th>
<th>Company Admin</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var company in Model.AvailableCompanies)
{
<tr class="company-row">
<td>
<input class="form-check-input company-checkbox"
type="checkbox"
name="CompanyIds"
value="@company.CompanyId"
@(company.IsSelected ? "checked" : null) />
</td>
<td>
<div class="fw-semibold">@company.CompanyName</div>
<div class="small text-muted">#@company.CompanyId</div>
</td>
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName)</td>
<td>
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
{
<span class="badge text-bg-warning">Missing</span>
}
else
{
<span>@company.PrimaryContactEmail</span>
}
</td>
<td>
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
{
<div class="small text-muted">@company.CompanyAdminEmail</div>
}
</td>
<td>
@if (company.IsActive)
{
<span class="badge text-bg-success">Active</span>
}
else
{
<span class="badge text-bg-secondary">Inactive</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between gap-3 mt-4">
<button type="submit"
formaction="@Url.Action("BackToCompose")"
class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Compose
</button>
<button type="submit" class="btn btn-primary">
Next: Preview <i class="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
</div>
</form>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(function () {
const filterInput = document.getElementById('company-filter');
const rows = Array.from(document.querySelectorAll('.company-row'));
const checkboxes = Array.from(document.querySelectorAll('.company-checkbox'));
const selectedCount = document.getElementById('selected-count');
function updateSelectedCount() {
const total = checkboxes.filter(cb => cb.checked).length;
selectedCount.textContent = `${total} selected`;
}
function applyFilter() {
const term = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const visible = row.textContent.toLowerCase().includes(term);
row.style.display = visible ? '' : 'none';
});
}
document.getElementById('select-all-btn').addEventListener('click', () => {
rows.forEach(row => {
if (row.style.display === 'none') return;
row.querySelector('.company-checkbox').checked = true;
});
updateSelectedCount();
});
document.getElementById('clear-all-btn').addEventListener('click', () => {
rows.forEach(row => {
if (row.style.display === 'none') return;
row.querySelector('.company-checkbox').checked = false;
});
updateSelectedCount();
});
filterInput.addEventListener('input', applyFilter);
checkboxes.forEach(cb => cb.addEventListener('change', updateSelectedCount));
updateSelectedCount();
})();
</script>
}
@@ -77,6 +77,11 @@
(waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that
will not be completed.
</p>
<p>
On the Jobs list, click any status badge to open a quick-change modal. The modal includes a
<strong>Notify customer via email</strong> 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.
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
@@ -40,6 +40,28 @@
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-plus-circle text-primary me-2"></i>Creating a Quote
</h2>
<h3 class="h6 fw-semibold mt-0 mb-2"><i class="bi bi-lightning me-1 text-primary"></i>Quick Quote vs Full Quote</h3>
<p>
The quote form offers two modes, selectable via the <strong>Quick Quote / Full Quote</strong> toggle at the
top of the page. Your selection is remembered automatically for next time.
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Quick Quote</strong> — 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.</li>
<li class="mb-1"><strong>Full Quote</strong> — 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.</li>
</ul>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Switching to Quick Quote does not change how the quote saves — all pricing calculations and the item
wizard work exactly the same. Hidden fields use their default values (no rush fee, no discount, company
default tax rate).
</div>
</div>
<p>To create a new quote:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Quotes</strong> and click <strong>New Quote</strong>.</li>
@@ -251,6 +273,16 @@
<li class="mb-2">Click <strong>Send Quote</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured for your company, the customer will automatically receive an email with the quote details.</li>
</ol>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
<i class="bi bi-bell-slash flex-shrink-0 mt-1"></i>
<div>
<strong>Customer notifications off:</strong> If a customer has email notifications turned off, the
<strong>Send quote via email</strong> checkbox on the Create page is automatically disabled and marked
with a <em>Notifications off</em> badge. The Send button on the Details page is also disabled.
To re-enable emails for this customer, open their record and turn on <strong>Notify by Email</strong>
under their contact settings.
</div>
</div>
<p>
You can also manually mark a quote as <strong>Approved</strong> or <strong>Rejected</strong> when
you hear back from the customer verbally or by phone, without going through a formal email send.
@@ -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">
<span class="pcl-chip-dot"></span>@job.StatusDisplayName
</span>
@@ -511,6 +512,9 @@
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
<div id="statusModalEmailOptOutNote" class="alert alert-warning alert-permanent py-1 px-2 mt-2 small" style="display:none;">
<i class="bi bi-bell-slash me-1"></i>This customer has email notifications turned off.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -745,12 +749,22 @@
currentJobStatusId = this.getAttribute('data-status-id');
const jobNumber = this.getAttribute('data-job-number');
const statusName = this.getAttribute('data-status-name');
const customerNotify = this.getAttribute('data-customer-notify') !== 'false';
// Update modal content
document.getElementById('modalStatusJobNumber').textContent = jobNumber;
document.getElementById('modalCurrentStatus').textContent = statusName;
document.getElementById('statusSelect').value = currentJobStatusId;
// Reflect customer email opt-out preference
const emailCheckbox = document.getElementById('statusModalSendEmail');
const emailOptOutNote = document.getElementById('statusModalEmailOptOutNote');
if (emailCheckbox) {
emailCheckbox.disabled = !customerNotify;
if (!customerNotify) emailCheckbox.checked = false;
}
if (emailOptOutNote) emailOptOutNote.style.display = customerNotify ? 'none' : 'block';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('statusModal'));
modal.show();
@@ -121,6 +121,13 @@
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
Maybe later
</a>
<form method="post" action="/Passkey/DismissPrompt">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="@returnUrl" />
<button type="submit" class="btn btn-link text-muted w-100" style="font-size:0.85rem;">
Don't ask me again
</button>
</form>
</div>
</div>
@@ -20,6 +20,21 @@
@Html.AntiForgeryToken()
<input type="hidden" asp-for="TaxPercent" />
<!-- Mode Toggle -->
<div class="d-flex align-items-center gap-2 mb-3">
<div class="quote-mode-toggle" role="group" aria-label="Quote mode">
<label class="quote-mode-opt">
<input type="radio" name="quoteMode" id="quoteModeQuick" value="simple" autocomplete="off">
<span><i class="bi bi-lightning-fill me-1"></i>Quick Quote</span>
</label>
<label class="quote-mode-opt">
<input type="radio" name="quoteMode" id="quoteModeFull" value="advanced" autocomplete="off">
<span><i class="bi bi-sliders me-1"></i>Full Quote</span>
</label>
</div>
<small class="text-muted fst-italic" id="quoteModeHint"></small>
</div>
<!-- Section 1: Customer / Prospect/Walk-In -->
<div class="card mb-4">
<div class="card-header">
@@ -80,7 +95,7 @@
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 quote-advanced-only">
<div class="col-md-6">
<label asp-for="ProspectAddress" class="form-label"></label>
<input asp-for="ProspectAddress" class="form-control" />
@@ -103,7 +118,7 @@
</div>
<!-- Section 2: Quote Information -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Quote Information
<a tabindex="0" class="help-icon" role="button"
@@ -167,7 +182,7 @@
</div>
<!-- Section 3: Oven & Batch Pricing -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-fire me-2"></i>Oven &amp; Batch Pricing
<a tabindex="0" class="help-icon" role="button"
@@ -242,7 +257,7 @@
</div>
<!-- Section 4: Discount -->
<div class="card mb-4">
<div class="card mb-4 quote-advanced-only">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-tag me-2"></i>Discount <small class="text-muted fw-normal">(optional)</small>
<a tabindex="0" class="help-icon" role="button"
@@ -329,7 +344,7 @@
<!-- Section 6: Quote Photos -->
@if (ViewBag.QuotePhotosEnabled is bool qpe && qpe)
{
<div class="card mb-4" id="quotePhotosCard">
<div class="card mb-4 quote-advanced-only" id="quotePhotosCard">
<div class="card-header bg-white d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="bi bi-images me-2 text-secondary"></i>Photos <span class="badge bg-secondary ms-1" id="stagedPhotoCount">0</span></h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="document.getElementById('quotePhotoStagingInput').click()">
@@ -360,6 +375,10 @@
<i class="bi bi-envelope me-1"></i>Send quote via email
</label>
</div>
<span id="emailOptOutNote" class="badge bg-warning text-dark ms-1" style="display:none;"
title="This customer has email notifications turned off">
<i class="bi bi-bell-slash me-1"></i>Notifications off
</span>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="top"
data-bs-title="Send via Email"
@@ -538,6 +557,7 @@
"taxPercent": @Model.TaxPercent,
"companyTaxPercent": @((ViewBag.CompanyTaxPercent ?? Model.TaxPercent).ToString()),
"taxExemptCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerTaxExemptIds ?? new System.Collections.Generic.HashSet<int>())),
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
@@ -556,6 +576,36 @@
@section Styles {
<link rel="stylesheet" href="~/lib/tom-select/css/tom-select.bootstrap5.min.css">
<style>
/* Quick / Full quote mode toggle */
#quoteForm.quote-simple-mode .quote-advanced-only { display: none !important; }
.quote-mode-toggle {
display: inline-flex;
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 999px;
padding: 3px;
gap: 2px;
}
.quote-mode-opt { margin: 0; }
.quote-mode-opt input { display: none; }
.quote-mode-opt span {
display: block;
padding: 4px 16px;
border-radius: 999px;
cursor: pointer;
font-size: .85rem;
font-weight: 600;
color: var(--bs-secondary-color);
transition: background .15s, color .15s, box-shadow .15s;
user-select: none;
}
.quote-mode-opt input:checked + span {
background: #0d6efd;
color: #fff;
box-shadow: 0 1px 4px rgba(13,110,253,.35);
}
.quote-mode-opt span:hover { color: var(--bs-body-color); }
.quote-mode-opt input:checked + span:hover { color: #fff; }
/* Wizard step indicator */
.wizard-step-dot {
width: 22px; height: 22px; border-radius: 50%;
@@ -604,6 +654,34 @@
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
// ── Quick / Full quote mode toggle ──────────────────────────────────
(function () {
const STORAGE_KEY = 'pcl_quote_mode';
const form = document.getElementById('quoteForm');
const hint = document.getElementById('quoteModeHint');
function applyMode(mode) {
if (mode === 'simple') {
form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden — switch to Full Quote to see them.';
} else {
form.classList.remove('quote-simple-mode');
hint.textContent = '';
}
}
const saved = localStorage.getItem(STORAGE_KEY) || 'advanced';
document.getElementById(saved === 'simple' ? 'quoteModeQuick' : 'quoteModeFull').checked = true;
applyMode(saved);
document.querySelectorAll('input[name="quoteMode"]').forEach(function (radio) {
radio.addEventListener('change', function () {
localStorage.setItem(STORAGE_KEY, this.value);
applyMode(this.value);
});
});
})();
document.addEventListener('DOMContentLoaded', function () {
initTagInput('quoteTags', 'quoteTagsContainer');
new TomSelect('#customerSelect', {
@@ -616,15 +694,26 @@
});
});
// Update tax rate when customer changes to/from a tax-exempt customer
// Update tax rate and email opt-out state when customer changes
function onQuoteCustomerChanged(select) {
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
const exemptIds = new Set(meta.taxExemptCustomerIds || []);
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
const customerId = parseInt(select.value) || 0;
const taxField = document.querySelector('[name="TaxPercent"]');
if (taxField) {
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
}
const emailCheckbox = document.getElementById('SendEmailToCustomer');
const emailNote = document.getElementById('emailOptOutNote');
if (emailCheckbox) {
const optedOut = optOutIds.has(customerId);
emailCheckbox.disabled = optedOut;
if (optedOut) emailCheckbox.checked = false;
if (emailNote) emailNote.style.display = optedOut ? 'inline' : 'none';
}
}
// Toggle customer / prospect sections
@@ -25,6 +25,10 @@
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-building me-1"></i>Edit Company
</a>
<h4 class="mb-0">
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
</h4>
@@ -65,11 +65,23 @@ document.addEventListener('DOMContentLoaded', () => {
// Restore items from server round-trip (validation failure re-render)
const existingEl = document.getElementById('existingItemsData');
if (existingEl) {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
try {
quoteItems = JSON.parse(existingEl.textContent);
renderAllCards();
writeHiddenFields();
window.scrollTo({ top: 0, behavior: 'instant' });
scheduleAutoPricing();
} catch (err) {
console.error('item-wizard: failed to restore items from server model:', err);
}
}
// Guarantee hidden fields are always written on form submission, even if the wizard
// was never interacted with (e.g. validation round-trip with pre-existing items).
const hfc = document.getElementById('hiddenFieldsContainer');
const ownerForm = hfc?.closest('form');
if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
}
// Close any open powder combobox dropdown when clicking outside it
@@ -0,0 +1,300 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class AccountBalanceServiceTests
{
// ── DebitAsync ────────────────────────────────────────────────────────
[Fact]
public async Task DebitAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(null, 50m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task DebitAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, 0m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task DebitAsync_WhenAccountNotFound_DoesNotThrow()
{
await using var context = CreateContext();
var (service, _) = CreateService(context);
// Account 999 does not exist — service must silently no-op
await service.DebitAsync(999, 50m);
}
[Theory]
[InlineData(AccountSubType.Checking, 100, 50, 150)] // Asset — debit-normal
[InlineData(AccountSubType.Savings, 200, 75, 275)] // Asset — debit-normal
[InlineData(AccountSubType.AccountsReceivable, 0, 100, 100)] // Asset — debit-normal
[InlineData(AccountSubType.CostOfGoodsSold, 50, 25, 75)] // COGS — debit-normal
[InlineData(AccountSubType.Advertising, 0, 100, 100)] // Expense (≥50) — debit-normal
[InlineData(AccountSubType.Payroll, 40, 60, 100)] // Expense (≥50) — debit-normal
public async Task DebitAsync_OnDebitNormalAccount_IncreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.AccountsPayable, 100, 50, 50)] // Liability — credit-normal
[InlineData(AccountSubType.Sales, 200, 75, 125)] // Revenue — credit-normal
[InlineData(AccountSubType.OwnersEquity, 500, 100, 400)] // Equity — credit-normal
[InlineData(AccountSubType.RetainedEarnings, 300, 50, 250)] // Equity — credit-normal
public async Task DebitAsync_OnCreditNormalAccount_DecreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.DebitAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
// ── CreditAsync ───────────────────────────────────────────────────────
[Fact]
public async Task CreditAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(null, 50m);
Assert.Equal(100m, account.CurrentBalance);
}
[Fact]
public async Task CreditAsync_WhenAmountIsZero_LeavesBalanceUnchanged()
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, AccountSubType.Checking, 100m);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, 0m);
Assert.Equal(100m, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.Checking, 200, 50, 150)] // Asset — debit-normal → credit decreases
[InlineData(AccountSubType.CostOfGoodsSold, 100, 30, 70)] // COGS — debit-normal → credit decreases
[InlineData(AccountSubType.SuppliesMaterials, 80, 20, 60)] // Expense (≥50) — debit-normal → credit decreases
public async Task CreditAsync_OnDebitNormalAccount_DecreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
[Theory]
[InlineData(AccountSubType.AccountsPayable, 100, 50, 150)] // Liability — credit-normal → credit increases
[InlineData(AccountSubType.Sales, 0, 200, 200)] // Revenue — credit-normal → credit increases
[InlineData(AccountSubType.RetainedEarnings, 300, 100, 400)] // Equity — credit-normal → credit increases
public async Task CreditAsync_OnCreditNormalAccount_IncreasesBalance(
AccountSubType subType, decimal start, decimal amount, decimal expected)
{
await using var context = CreateContext();
var account = SeedAccount(context, 1, subType, start);
await context.SaveChangesAsync();
var (service, _) = CreateService(context);
await service.CreditAsync(1, amount);
Assert.Equal(expected, account.CurrentBalance);
}
// ── RecalculateAllAsync ───────────────────────────────────────────────
[Fact]
public async Task RecalculateAllAsync_UpdatesEachActiveAccountFromLedgerClosingBalance()
{
await using var context = CreateContext();
SeedAccount(context, 1, AccountSubType.Checking, 0m, companyId: 5);
SeedAccount(context, 2, AccountSubType.Sales, 0m, companyId: 5);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m });
ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 1200m });
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(5);
var accounts = await context.Accounts.IgnoreQueryFilters()
.Where(a => a.CompanyId == 5).ToListAsync();
Assert.Equal(500m, accounts.Single(a => a.Id == 1).CurrentBalance);
Assert.Equal(1200m, accounts.Single(a => a.Id == 2).CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_WhenLedgerReturnsNull_SkipsAccountBalance()
{
await using var context = CreateContext();
SeedAccount(context, 10, AccountSubType.Checking, 999m, companyId: 6);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync((AccountLedgerDto?)null);
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(6);
var account = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 10);
Assert.Equal(999m, account.CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_WhenOneAccountThrows_StillUpdatesRemainingAccounts()
{
await using var context = CreateContext();
SeedAccount(context, 20, AccountSubType.Checking, 0m, companyId: 7);
SeedAccount(context, 21, AccountSubType.Sales, 0m, companyId: 7);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ThrowsAsync(new InvalidOperationException("simulated ledger error"));
ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.ReturnsAsync(new AccountLedgerDto { ClosingBalance = 750m });
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(7);
var account21 = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 21);
Assert.Equal(750m, account21.CurrentBalance);
}
[Fact]
public async Task RecalculateAllAsync_ExcludesInactiveAccounts()
{
await using var context = CreateContext();
SeedAccount(context, 30, AccountSubType.Checking, 0m, companyId: 8, isActive: false);
await context.SaveChangesAsync();
var ledger = new Mock<ILedgerService>();
var (service, _) = CreateService(context, ledger);
await service.RecalculateAllAsync(8);
ledger.Verify(
l => l.GetAccountLedgerAsync(It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()),
Times.Never);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService(
ApplicationDbContext context,
Mock<ILedgerService>? ledger = null)
{
var unitOfWork = new UnitOfWork(context);
var service = new AccountBalanceService(
unitOfWork,
(ledger ?? new Mock<ILedgerService>()).Object,
Mock.Of<ILogger<AccountBalanceService>>());
return (service, unitOfWork);
}
private static Account SeedAccount(
ApplicationDbContext context,
int id,
AccountSubType subType,
decimal balance,
int companyId = 1,
bool isActive = true)
{
var account = new Account
{
Id = id,
CompanyId = companyId,
AccountNumber = $"ACCT-{id}",
Name = $"Account {id}",
AccountSubType = subType,
CurrentBalance = balance,
IsActive = isActive
};
context.Accounts.Add(account);
return account;
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}
@@ -0,0 +1,355 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class DepositsControllerTests
{
// ── Record — input validation (no user resolution needed) ─────────────
[Fact]
public async Task Record_WhenAmountIsZero_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 0m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("greater than zero", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenAmountIsNegative_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: -50m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
[Fact]
public async Task Record_WhenNeitherJobNorQuoteProvided_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: null, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("Job or Quote must be specified", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenInvalidPaymentMethod_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "BitcoinPizza",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("Invalid payment method", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Record_WhenUserNotFound_ReturnsUnauthorized()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: null);
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
Assert.IsType<UnauthorizedResult>(result);
}
// ── Record — success paths ────────────────────────────────────────────
[Fact]
public async Task Record_WithJobId_CreatesDepositAndReturnsSuccessJson()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var receivedDate = new DateTime(2026, 4, 15);
var result = await controller.Record(jobId: 42, quoteId: null, customerId: 7,
amount: 250m, paymentMethod: "Check",
receivedDate: receivedDate, reference: "REF-001", notes: "Half up front");
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(250m, doc.RootElement.GetProperty("amount").GetDecimal());
Assert.Equal("Check", doc.RootElement.GetProperty("paymentMethod").GetString());
Assert.Equal("04/15/2026", doc.RootElement.GetProperty("receivedDate").GetString());
Assert.Equal("REF-001", doc.RootElement.GetProperty("reference").GetString());
Assert.Equal("Half up front", doc.RootElement.GetProperty("notes").GetString());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
Assert.Equal(42, deposit.JobId);
Assert.Null(deposit.QuoteId);
Assert.Equal(7, deposit.CustomerId);
Assert.Equal(250m, deposit.Amount);
Assert.Equal(PaymentMethod.Check, deposit.PaymentMethod);
}
[Fact]
public async Task Record_WithQuoteId_CreatesDepositLinkedToQuote()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var result = await controller.Record(jobId: null, quoteId: 99, customerId: 5,
amount: 500m, paymentMethod: "CreditDebitCard",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Equal("Credit / Debit Card", doc.RootElement.GetProperty("paymentMethod").GetString());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync();
Assert.Equal(99, deposit.QuoteId);
Assert.Null(deposit.JobId);
}
[Fact]
public async Task Record_GeneratesReceiptNumberInExpectedFormat()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(companyId: 1));
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
// Format: DEP-YYMM-#### (e.g. DEP-2604-0001)
Assert.Matches(@"^DEP-\d{4}-\d{4}$", receiptNumber);
Assert.EndsWith("-0001", receiptNumber);
}
[Fact]
public async Task Record_SecondDepositInSameMonth_IncrementsSequenceNumber()
{
await using var context = CreateContext();
var user = CreateUser(companyId: 1);
var controller = CreateController(context, currentUser: user);
// First deposit
await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 100m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
// Second deposit
var result = await controller.Record(jobId: 2, quoteId: null, customerId: 1,
amount: 200m, paymentMethod: "Cash",
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!;
Assert.EndsWith("-0002", receiptNumber);
}
[Fact]
public async Task Record_PaymentMethodDisplayStrings_AreHumanReadable()
{
await using var context = CreateContext();
var user = CreateUser(companyId: 1);
var cases = new[]
{
("Cash", "Cash"),
("Check", "Check"),
("CreditDebitCard", "Credit / Debit Card"),
("BankTransferACH", "Bank Transfer / ACH"),
("DigitalPayment", "Digital Payment"),
};
foreach (var (method, expected) in cases)
{
var controller = CreateController(context, currentUser: user);
var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1,
amount: 10m, paymentMethod: method,
receivedDate: DateTime.UtcNow, reference: null, notes: null);
using var doc = ParseJson(result);
if (doc.RootElement.GetProperty("success").GetBoolean())
Assert.Equal(expected, doc.RootElement.GetProperty("paymentMethod").GetString());
}
}
// ── Delete ────────────────────────────────────────────────────────────
[Fact]
public async Task Delete_WhenDepositNotFound_ReturnsJsonError()
{
await using var context = CreateContext();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 999, returnUrl: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("not found", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Delete_WhenDepositAlreadyAppliedToInvoice_ReturnsJsonError()
{
await using var context = CreateContext();
context.Deposits.Add(new Deposit
{
Id = 1,
CompanyId = 1,
ReceiptNumber = "DEP-2604-0001",
CustomerId = 1,
JobId = 1,
Amount = 100m,
PaymentMethod = PaymentMethod.Cash,
ReceivedDate = DateTime.UtcNow,
AppliedToInvoiceId = 55 // already applied
});
await context.SaveChangesAsync();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 1, returnUrl: null);
using var doc = ParseJson(result);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("already been applied", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task Delete_WhenUnappliedDeposit_SoftDeletesAndReturnsSuccess()
{
await using var context = CreateContext();
context.Deposits.Add(new Deposit
{
Id = 2,
CompanyId = 1,
ReceiptNumber = "DEP-2604-0001",
CustomerId = 1,
JobId = 1,
Amount = 75m,
PaymentMethod = PaymentMethod.Cash,
ReceivedDate = DateTime.UtcNow,
AppliedToInvoiceId = null // not yet applied
});
await context.SaveChangesAsync();
var controller = CreateController(context, currentUser: CreateUser(1));
var result = await controller.Delete(id: 2, returnUrl: null);
using var doc = ParseJson(result);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(d => d.Id == 2);
Assert.True(deposit.IsDeleted);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static DepositsController CreateController(
ApplicationDbContext context,
ApplicationUser? currentUser)
{
var uow = new UnitOfWork(context);
var userManager = CreateUserManagerMock();
userManager
.Setup(m => m.GetUserAsync(It.IsAny<ClaimsPrincipal>()))
.ReturnsAsync(currentUser);
var controller = new DepositsController(
uow,
userManager.Object,
Mock.Of<ILogger<DepositsController>>(),
context);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private static ApplicationUser CreateUser(int companyId) => new()
{
Id = "test-user",
CompanyId = companyId,
UserName = "test@example.com",
Email = "test@example.com",
FirstName = "Test",
LastName = "User"
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
private static JsonDocument ParseJson(IActionResult result)
{
var json = Assert.IsType<JsonResult>(result);
var serialized = JsonSerializer.Serialize(json.Value);
return JsonDocument.Parse(serialized);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}
@@ -0,0 +1,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 = "<p>Hi {{FirstName}}, contact {{CompanyAdminName}} at {{CompanyAdminEmail}}.</p><script>alert('x')</script>",
CompanyIds = [7]
});
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<AdminEmailPreviewModel>(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("<script", model.SamplePreview.RenderedHtmlBody, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Send_WhenEmailSucceeds_WritesAdminEmailLogAndUsesPlatformReplyTo()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 11,
CompanyId = 11,
CompanyName = "Summit Coatings",
PrimaryContactName = "Morgan Lee",
PrimaryContactEmail = "morgan@example.com",
IsActive = true
});
await context.SaveChangesAsync();
var emailService = new Mock<IEmailService>();
emailService
.Setup(x => x.SendEmailAsync(
"morgan@example.com",
"Morgan Lee",
"Notice for Summit Coatings",
It.IsAny<string>(),
It.IsAny<string>(),
null,
null,
null,
"admin-notify@example.com",
"Powder Coating Logix Admin"))
.ReturnsAsync((true, (string?)null));
var platformSettings = new Mock<IPlatformSettingsService>();
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 = "<p>Hello {{FirstName}},</p><p>Thanks for using {{CompanyName}}.</p>",
CompanyIds = [11]
});
var redirect = Assert.IsType<RedirectToActionResult>(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<IEmailService>();
var controller = CreateController(context, emailService);
var result = await controller.Send(new AdminEmailSendRequest
{
Subject = "Heads up for {{CompanyName}}",
BodyHtml = "<p>Hello {{FirstName}}</p>",
CompanyIds = [13]
});
var redirect = Assert.IsType<RedirectToActionResult>(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<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string?>(),
It.IsAny<byte[]?>(),
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<string?>()),
Times.Never);
}
private static EmailBroadcastController CreateController(
ApplicationDbContext context,
Mock<IEmailService>? emailService = null,
Mock<IPlatformSettingsService>? platformSettings = null)
{
var controller = new EmailBroadcastController(
context,
(emailService ?? new Mock<IEmailService>()).Object,
(platformSettings ?? CreatePlatformSettings()).Object,
Mock.Of<ILogger<EmailBroadcastController>>());
var httpContext = new DefaultHttpContext();
controller.ControllerContext = new ControllerContext
{
HttpContext = httpContext
};
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
}
private static Mock<IPlatformSettingsService> CreatePlatformSettings()
{
var settings = new Mock<IPlatformSettingsService>();
settings.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
return settings;
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -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<IWebHostEnvironment>(x => x.WebRootPath == WebRootPath);
Service = new FileService(environment, Mock.Of<ILogger<FileService>>());
}
public string WebRootPath { get; }
public FileService Service { get; }
public void Dispose()
{
if (Directory.Exists(WebRootPath))
{
Directory.Delete(WebRootPath, recursive: true);
}
}
}
}
@@ -0,0 +1,307 @@
using System.Security.Claims;
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.GiftCertificate;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class GiftCertificatesControllerTests
{
// ── Index — lazy expiration ───────────────────────────────────────────
[Fact]
public async Task Index_LazilySetsExpiredStatus_ForActiveCertsPastExpiryDate()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2603-0001",
OriginalAmount = 100m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow.AddMonths(-2),
ExpiryDate = DateTime.UtcNow.AddDays(-1) // past expiry
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index(null, null);
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
Assert.Single(dtos);
Assert.Equal(GiftCertificateStatus.Expired, dtos[0].Status);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Expired, dbCert.Status);
}
[Fact]
public async Task Index_DoesNotExpire_ActiveCertsWithFutureExpiryDate()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2604-0001",
OriginalAmount = 50m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow,
ExpiryDate = DateTime.UtcNow.AddDays(30) // future expiry
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
await controller.Index(null, null);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Active, dbCert.Status);
}
// ── Index — filtering ─────────────────────────────────────────────────
[Fact]
public async Task Index_StatusFilter_ReturnsOnlyMatchingStatus()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 200m, Status = GiftCertificateStatus.Voided, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index(null, nameof(GiftCertificateStatus.Active));
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
var dto = Assert.Single(dtos);
Assert.Equal(GiftCertificateStatus.Active, dto.Status);
}
[Fact]
public async Task Index_SearchTerm_FiltersByCertificateCode()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Index("0001", null);
var view = Assert.IsType<ViewResult>(result);
var dtos = Assert.IsAssignableFrom<List<GiftCertificateListDto>>(view.Model);
var dto = Assert.Single(dtos);
Assert.Equal("GC-2604-0001", dto.CertificateCode);
}
[Fact]
public async Task Index_ViewBag_TotalValueIncludesOnlyActiveAndPartiallyRedeemed()
{
await using var context = CreateContext();
context.GiftCertificates.AddRange(
new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-A", OriginalAmount = 100m, RedeemedAmount = 0, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-P", OriginalAmount = 80m, RedeemedAmount = 30m, Status = GiftCertificateStatus.PartiallyRedeemed, IssueDate = DateTime.UtcNow },
new GiftCertificate { Id = 3, CompanyId = 1, CertificateCode = "GC-V", OriginalAmount = 200m, RedeemedAmount = 200m, Status = GiftCertificateStatus.FullyRedeemed, IssueDate = DateTime.UtcNow });
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
await controller.Index(null, null);
// Active: RemainingBalance=100, PartiallyRedeemed: 80-30=50, FullyRedeemed: excluded
Assert.Equal(150m, controller.ViewBag.TotalValue);
Assert.Equal(2, controller.ViewBag.TotalActive);
}
// ── Create — success paths ────────────────────────────────────────────
[Fact]
public async Task Create_WithSoldReason_SetsPurchasePriceAndGeneratesCode()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Create(new CreateGiftCertificateDto
{
Amount = 100m,
IssuedReason = GiftCertificateIssuedReason.Sold,
PurchasePrice = 80m,
PurchasingCustomerId = null,
RecipientName = "Jane Doe"
});
Assert.IsType<RedirectToActionResult>(result);
var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(80m, cert.PurchasePrice);
Assert.Equal(GiftCertificateStatus.Active, cert.Status);
Assert.Matches(@"^GC-\d{4}-\d{4}$", cert.CertificateCode);
Assert.EndsWith("-0001", cert.CertificateCode);
}
[Fact]
public async Task Create_WithNonSoldReason_NullsPurchasePriceAndPurchasingCustomerId()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Create(new CreateGiftCertificateDto
{
Amount = 50m,
IssuedReason = GiftCertificateIssuedReason.Promotional,
PurchasePrice = 50m, // Provided but should be nulled
PurchasingCustomerId = 7 // Provided but should be nulled
});
Assert.IsType<RedirectToActionResult>(result);
var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Null(cert.PurchasePrice);
Assert.Null(cert.PurchasingCustomerId);
}
// ── Void ─────────────────────────────────────────────────────────────
[Fact]
public async Task Void_ActiveCert_ChangesStatusToVoidedAndRedirectsToIndex()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 1, CompanyId = 1,
CertificateCode = "GC-2604-0001",
OriginalAmount = 100m, RedeemedAmount = 0,
Status = GiftCertificateStatus.Active,
IssueDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 1);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.Voided, dbCert.Status);
}
[Fact]
public async Task Void_FullyRedeemedCert_BlocksWithTempDataErrorAndRedirectsToDetails()
{
await using var context = CreateContext();
context.GiftCertificates.Add(new GiftCertificate
{
Id = 2, CompanyId = 1,
CertificateCode = "GC-2604-0002",
OriginalAmount = 100m, RedeemedAmount = 100m,
Status = GiftCertificateStatus.FullyRedeemed,
IssueDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 2);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Details", redirect.ActionName);
Assert.Contains("fully redeemed", controller.TempData["Error"]?.ToString());
var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync();
Assert.Equal(GiftCertificateStatus.FullyRedeemed, dbCert.Status); // unchanged
}
[Fact]
public async Task Void_NonExistentCert_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = CreateController(context, CreateUser(companyId: 1));
var result = await controller.Void(id: 999);
Assert.IsType<NotFoundResult>(result);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static GiftCertificatesController CreateController(
ApplicationDbContext context,
ApplicationUser? currentUser)
{
var uow = new UnitOfWork(context);
var userManager = CreateUserManagerMock();
userManager
.Setup(m => m.GetUserAsync(It.IsAny<ClaimsPrincipal>()))
.ReturnsAsync(currentUser);
var httpContext = new DefaultHttpContext();
var controller = new GiftCertificatesController(
uow,
Mock.Of<IMapper>(),
Mock.Of<ILogger<GiftCertificatesController>>(),
userManager.Object,
Mock.Of<IPdfService>());
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
}
private static ApplicationUser CreateUser(int companyId) => new()
{
Id = "test-user",
CompanyId = companyId,
UserName = "test@example.com",
Email = "test@example.com",
FirstName = "Test",
LastName = "User"
};
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}
@@ -0,0 +1,616 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class LedgerServiceTests
{
private static readonly DateTime PeriodStart = new DateTime(2026, 4, 1);
private static readonly DateTime PeriodEnd = new DateTime(2026, 4, 30);
private static readonly DateTime InPeriod = new DateTime(2026, 4, 15);
// ── Returns null for unknown account ─────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_WhenAccountDoesNotExist_ReturnsNull()
{
await using var context = CreateContext();
var service = CreateService(context);
var result = await service.GetAccountLedgerAsync(999, PeriodStart, PeriodEnd);
Assert.Null(result);
}
// ── Source 1: customer payment deposited (DEBIT) ─────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source1_CustomerPaymentDeposited_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedInvoice(context, id: 99);
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 99,
DepositAccountId = 1,
Amount = 250m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Customer Payment", entry.Source);
Assert.Equal(250m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
// ── Source 2: expense paid FROM account (CREDIT) ──────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source2_ExpensePaidFromAccount_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-001",
PaymentAccountId = 1, // Checking account pays the expense
ExpenseAccountId = 999, // Different account — so Source 6 does not also fire
Amount = 80m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Expense", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(80m, entry.Credit);
}
// ── Source 3: bill payment made FROM account (CREDIT) ─────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source3_BillPaymentFromAccount_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedBill(context, id: 99); // Include(bp => bp.Bill) requires the Bill to exist
context.BillPayments.Add(new BillPayment
{
Id = 1, CompanyId = 1,
PaymentNumber = "BP-001",
BillId = 99,
VendorId = 1,
BankAccountId = 1, // Checking account used to pay the bill
Amount = 150m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Bill Payment", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(150m, entry.Credit);
}
// ── Source 4: invoice revenue line items (CREDIT) ─────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source4_InvoiceLineItem_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Sales);
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-001",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 500m
});
context.InvoiceItems.Add(new InvoiceItem
{
Id = 1, CompanyId = 1,
InvoiceId = 10,
RevenueAccountId = 1,
Description = "Powder Coating",
Quantity = 1, UnitPrice = 500m, TotalPrice = 500m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Invoice", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(500m, entry.Credit);
}
[Fact]
public async Task GetAccountLedgerAsync_Source4_DraftAndVoidedInvoicesExcluded()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Sales);
context.Invoices.AddRange(
new Invoice { Id = 10, CompanyId = 1, InvoiceNumber = "INV-DRAFT", CustomerId = 1, Status = InvoiceStatus.Draft, InvoiceDate = InPeriod },
new Invoice { Id = 11, CompanyId = 1, InvoiceNumber = "INV-VOID", CustomerId = 1, Status = InvoiceStatus.Voided, InvoiceDate = InPeriod });
context.InvoiceItems.AddRange(
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 11, RevenueAccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Empty(ledger!.Entries);
}
// ── Source 5: sales tax collected (CREDIT) ────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source5_SalesTaxCollected_CreatesCreditEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.OtherCurrentLiability);
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-TAX",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
TaxAmount = 42m,
SalesTaxAccountId = 1,
Total = 542m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Sales Tax", entry.Source);
Assert.Equal(0m, entry.Debit);
Assert.Equal(42m, entry.Credit);
}
// ── Source 6: expense categorized to account (DEBIT) ──────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source6_ExpenseCategorizedToAccount_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Advertising);
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-002",
ExpenseAccountId = 1, // Advertising account receives the expense
PaymentAccountId = 999, // Different account — so Source 2 does not also fire
Amount = 300m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Expense", entry.Source);
Assert.Equal(300m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
// ── Source 7: bill line items to expense account (DEBIT) ──────────────
[Fact]
public async Task GetAccountLedgerAsync_Source7_BillLineItem_CreatesDebitEntry()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
context.Bills.Add(new Bill
{
Id = 10, CompanyId = 1,
BillNumber = "BILL-001",
VendorId = 1,
APAccountId = 999, // AP account different from test account
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 200m
});
context.BillLineItems.Add(new BillLineItem
{
Id = 1, CompanyId = 1,
BillId = 10,
AccountId = 1,
Description = "Powder supplies",
Quantity = 1, UnitPrice = 200m, Amount = 200m
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal("Bill", entry.Source);
Assert.Equal(200m, entry.Debit);
Assert.Equal(0m, entry.Credit);
}
[Fact]
public async Task GetAccountLedgerAsync_Source7_DraftAndVoidedBillsExcluded()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
context.Bills.AddRange(
new Bill { Id = 10, CompanyId = 1, BillNumber = "B-DRAFT", VendorId = 1, APAccountId = 999, Status = BillStatus.Draft, BillDate = InPeriod, Total = 100m },
new Bill { Id = 11, CompanyId = 1, BillNumber = "B-VOID", VendorId = 1, APAccountId = 999, Status = BillStatus.Voided, BillDate = InPeriod, Total = 100m });
context.BillLineItems.AddRange(
new BillLineItem { Id = 1, CompanyId = 1, BillId = 10, AccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, Amount = 100m },
new BillLineItem { Id = 2, CompanyId = 1, BillId = 11, AccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, Amount = 100m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Empty(ledger!.Entries);
}
// ── Source 8: Accounts Receivable ─────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source8_AR_InvoicesDebitPaymentsCredit()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.AccountsReceivable);
SeedCustomer(context, id: 1); // Include(i => i.Customer) requires the Customer to exist
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-AR",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 400m
});
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10,
Amount = 100m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
// No DepositAccountId — so Source 1 does not also fire for this account
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(2, ledger!.Entries.Count);
var invoiceEntry = ledger.Entries.Single(e => e.Source == "Invoice");
var paymentEntry = ledger.Entries.Single(e => e.Source == "Invoice Payment");
Assert.Equal(400m, invoiceEntry.Debit);
Assert.Equal(0m, invoiceEntry.Credit);
Assert.Equal(0m, paymentEntry.Debit);
Assert.Equal(100m, paymentEntry.Credit);
Assert.Equal(300m, ledger.ClosingBalance); // debit-normal: 400 - 100
}
// ── Source 9: Accounts Payable ────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_Source9_AP_BillsCreditBillPaymentsDebit()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.AccountsPayable);
SeedVendor(context, id: 1); // Include(b => b.Vendor) requires the Vendor to exist
context.Bills.Add(new Bill
{
Id = 10, CompanyId = 1,
BillNumber = "BILL-AP",
VendorId = 1,
APAccountId = 1, // This is the AP account under test
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 400m
});
// Bill must be seeded because the WHERE for BillPayments navigates through Bill.APAccountId
context.BillPayments.Add(new BillPayment
{
Id = 1, CompanyId = 1,
PaymentNumber = "BP-AP",
BillId = 10,
VendorId = 1,
BankAccountId = 999, // Different account — so Source 3 does not also fire
Amount = 150m,
PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Check
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(2, ledger!.Entries.Count);
var billEntry = ledger.Entries.Single(e => e.Source == "Bill");
var paymentEntry = ledger.Entries.Single(e => e.Source == "Bill Payment");
Assert.Equal(0m, billEntry.Debit);
Assert.Equal(400m, billEntry.Credit);
Assert.Equal(150m, paymentEntry.Debit);
Assert.Equal(0m, paymentEntry.Credit);
Assert.Equal(250m, ledger.ClosingBalance); // credit-normal: 400 - 150
}
// ── Date range filtering ──────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_DateRangeFiltering_ExcludesEntriesOutsideRange()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
var dates = new[]
{
new DateTime(2026, 3, 15), // before period — excluded from entries, goes to priorBalance
new DateTime(2026, 4, 15), // in period — included
new DateTime(2026, 5, 15), // after period — excluded
};
var i = 0;
foreach (var date in dates)
{
context.Expenses.Add(new Expense
{
Id = ++i, CompanyId = 1,
ExpenseNumber = $"EXP-{i:D3}",
PaymentAccountId = 1,
ExpenseAccountId = 999,
Amount = 100m,
Date = date,
PaymentMethod = PaymentMethod.Cash
});
}
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
var entry = Assert.Single(ledger!.Entries);
Assert.Equal(InPeriod.Date, entry.Date.Date);
}
// ── Running balance ───────────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_RunningBalance_DebitNormalAccount_CorrectlyComputed()
{
await using var context = CreateContext();
// Opening balance of 100 on Checking (debit-normal); null date means always applied
SeedAccount(context, id: 1, AccountSubType.Checking, openingBalance: 100m, openingBalanceDate: null);
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
context.Payments.AddRange(
new Payment { Id = 1, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 50m, PaymentDate = new DateTime(2026, 4, 10), PaymentMethod = PaymentMethod.Cash },
new Payment { Id = 2, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 75m, PaymentDate = new DateTime(2026, 4, 20), PaymentMethod = PaymentMethod.Cash });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(100m, ledger!.OpeningBalance);
Assert.Equal(2, ledger.Entries.Count);
Assert.Equal(150m, ledger.Entries[0].RunningBalance); // 100 + 50
Assert.Equal(225m, ledger.Entries[1].RunningBalance); // 150 + 75
Assert.Equal(225m, ledger.ClosingBalance);
}
[Fact]
public async Task GetAccountLedgerAsync_RunningBalance_CreditNormalAccount_CorrectlyComputed()
{
await using var context = CreateContext();
// Opening balance of 200 on Sales (credit-normal); null date means always applied
SeedAccount(context, id: 1, AccountSubType.Sales, openingBalance: 200m, openingBalanceDate: null);
// Source 4: InvoiceItems must have the Invoice seeded because the WHERE navigates through Invoice
context.Invoices.Add(new Invoice
{
Id = 10, CompanyId = 1,
InvoiceNumber = "INV-001",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 150m
});
context.InvoiceItems.AddRange(
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "A", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "B", Quantity = 1, UnitPrice = 50m, TotalPrice = 50m });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(200m, ledger!.OpeningBalance);
Assert.Equal(2, ledger.Entries.Count);
// Credit-normal: runningBalance += Credit - Debit
// After entry 1 (Credit=100): 200 + 100 = 300
// After entry 2 (Credit=50): 300 + 50 = 350
Assert.Equal(350m, ledger.ClosingBalance);
}
// ── Opening balance — future-dated excluded ───────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_FutureDatedOpeningBalance_ExcludedFromPriorBalance()
{
await using var context = CreateContext();
// Opening balance dated 2027-01-01 — later than PeriodEnd (2026-04-30), so excluded
SeedAccount(context, id: 1, AccountSubType.Checking,
openingBalance: 500m, openingBalanceDate: new DateTime(2027, 1, 1));
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10, DepositAccountId = 1,
Amount = 100m, PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
// OpeningBalance (prior balance) = 0, not 500 — future-dated opening balance excluded
Assert.Equal(0m, ledger!.OpeningBalance);
Assert.Equal(100m, ledger.ClosingBalance); // 0 + 100
}
// ── Period totals ─────────────────────────────────────────────────────
[Fact]
public async Task GetAccountLedgerAsync_PeriodTotals_CorrectlySumDebitsAndCredits()
{
await using var context = CreateContext();
SeedAccount(context, id: 1, AccountSubType.Checking);
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
// Source 1: payment deposited → DEBIT 200
context.Payments.Add(new Payment
{
Id = 1, CompanyId = 1,
InvoiceId = 10, DepositAccountId = 1,
Amount = 200m, PaymentDate = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
// Source 2: expense paid from account → CREDIT 50
context.Expenses.Add(new Expense
{
Id = 1, CompanyId = 1,
ExpenseNumber = "EXP-001",
PaymentAccountId = 1, // Checking pays
ExpenseAccountId = 999, // Expense account (different) — Source 6 does not also fire
Amount = 50m,
Date = InPeriod,
PaymentMethod = PaymentMethod.Cash
});
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
Assert.Equal(200m, ledger!.PeriodDebits);
Assert.Equal(50m, ledger.PeriodCredits);
Assert.Equal(150m, ledger.ClosingBalance); // debit-normal: 200 - 50
}
// ── Helpers ───────────────────────────────────────────────────────────
private static LedgerService CreateService(ApplicationDbContext context)
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());
private static Account SeedAccount(
ApplicationDbContext context,
int id,
AccountSubType subType,
int companyId = 1,
decimal openingBalance = 0m,
DateTime? openingBalanceDate = null)
{
var account = new Account
{
Id = id,
CompanyId = companyId,
AccountNumber = $"ACCT-{id:D4}",
Name = $"Account {id}",
AccountSubType = subType,
OpeningBalance = openingBalance,
OpeningBalanceDate = openingBalanceDate,
IsActive = true
};
context.Accounts.Add(account);
return account;
}
private static Invoice SeedInvoice(ApplicationDbContext context, int id, int companyId = 1)
{
var invoice = new Invoice
{
Id = id,
CompanyId = companyId,
InvoiceNumber = $"INV-{id:D4}",
CustomerId = 1,
Status = InvoiceStatus.Sent,
InvoiceDate = InPeriod,
Total = 0m
};
context.Invoices.Add(invoice);
return invoice;
}
private static void SeedVendor(ApplicationDbContext context, int id, int companyId = 1)
{
context.Vendors.Add(new Vendor
{
Id = id,
CompanyId = companyId,
CompanyName = $"Vendor {id}"
});
}
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId = 1)
{
context.Customers.Add(new Customer
{
Id = id,
CompanyId = companyId,
CompanyName = $"Customer {id}"
});
}
private static void SeedBill(ApplicationDbContext context, int id, int companyId = 1)
{
context.Bills.Add(new Bill
{
Id = id,
CompanyId = companyId,
BillNumber = $"BILL-{id:D4}",
VendorId = 999,
APAccountId = 999,
Status = BillStatus.Open,
BillDate = InPeriod,
Total = 0m
});
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}
@@ -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<KeyValuePair<string, string?>>(), "Production");
controller.ControllerContext = new ControllerContext
{
HttpContext = CreateHttpContext()
};
var result = await controller.TwilioSms(new TwilioSmsPayload { From = "+15551234567", Body = "STOP" });
Assert.IsType<ForbidResult>(result);
}
[Fact]
public async Task TwilioSms_AllowsMissingAuthToken_InDevelopment()
{
await using var dbContext = CreateDbContext();
var controller = CreateController(dbContext, Array.Empty<KeyValuePair<string, string?>>(), "Development");
controller.ControllerContext = new ControllerContext
{
HttpContext = CreateHttpContext()
};
var result = await controller.TwilioSms(new TwilioSmsPayload());
var content = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", content.ContentType);
Assert.Equal("<Response/>", content.Content);
}
private static WebhooksController CreateController(
ApplicationDbContext dbContext,
IEnumerable<KeyValuePair<string, string?>> configValues,
string environmentName)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var environment = Mock.Of<IWebHostEnvironment>(x => x.EnvironmentName == environmentName);
return new WebhooksController(
dbContext,
configuration,
environment,
Mock.Of<ILogger<WebhooksController>>());
}
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<IFormFeature>(new FormFeature(new FormCollection(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["From"] = "+15551234567",
["Body"] = "STOP"
})));
return context;
}
private static ApplicationDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}