d3a5d827f9
F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
CustomersController + VendorsController; Statement views + PDF download via
StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.
F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
endpoint on InvoicesController auto-populates Terms + due date on customer select;
early payment discount notice on Invoice Create.
F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
(Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
AJAX endpoint; Tax Rates in Settings gear menu.
Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1007 lines
42 KiB
C#
1007 lines
42 KiB
C#
using AutoMapper;
|
|
using PowderCoating.Shared.Constants;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Application.DTOs.Common;
|
|
using PowderCoating.Application.DTOs.Customer;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Web.Helpers;
|
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.CanManageCustomers)]
|
|
public class CustomersController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILogger<CustomersController> _logger;
|
|
private readonly INotificationService _notificationService;
|
|
private readonly ISubscriptionService _subscriptionService;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly IFinancialReportService _financialReports;
|
|
|
|
public CustomersController(
|
|
IUnitOfWork unitOfWork,
|
|
IMapper mapper,
|
|
ILogger<CustomersController> logger,
|
|
INotificationService notificationService,
|
|
ISubscriptionService subscriptionService,
|
|
ITenantContext tenantContext,
|
|
UserManager<ApplicationUser> userManager,
|
|
IFinancialReportService financialReports)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_logger = logger;
|
|
_notificationService = notificationService;
|
|
_subscriptionService = subscriptionService;
|
|
_tenantContext = tenantContext;
|
|
_userManager = userManager;
|
|
_financialReports = financialReports;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the paginated, searchable customer list. Sanitizes the search term before
|
|
/// building the EF filter expression to guard against injection; sorting is resolved
|
|
/// server-side via a switch so the column name never reaches raw SQL.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(
|
|
string? searchTerm,
|
|
string? sortColumn,
|
|
string sortDirection = "asc",
|
|
int pageNumber = 1,
|
|
int pageSize = 25)
|
|
{
|
|
try
|
|
{
|
|
// SECURITY: Sanitize search input to prevent injection attacks
|
|
searchTerm = SecurityHelper.SanitizeSearchTerm(searchTerm);
|
|
|
|
// Create and validate grid request
|
|
var gridRequest = new GridRequest
|
|
{
|
|
PageNumber = pageNumber,
|
|
PageSize = pageSize,
|
|
SortColumn = sortColumn ?? "CompanyName",
|
|
SortDirection = sortDirection,
|
|
SearchTerm = searchTerm
|
|
};
|
|
gridRequest.Validate();
|
|
|
|
// Build search filter
|
|
System.Linq.Expressions.Expression<Func<Customer, bool>>? filter = null;
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
var search = searchTerm.ToLower();
|
|
filter = c => c.CompanyName.ToLower().Contains(search)
|
|
|| (c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(search))
|
|
|| (c.ContactLastName != null && c.ContactLastName.ToLower().Contains(search))
|
|
|| (c.Email != null && c.Email.ToLower().Contains(search))
|
|
|| (c.Phone != null && c.Phone.ToLower().Contains(search));
|
|
}
|
|
|
|
// Build orderBy function
|
|
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
|
|
{
|
|
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
|
|
"ContactName" => q => gridRequest.SortDirection == "asc"
|
|
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
|
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
|
|
"Email" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Email) : q.OrderByDescending(c => c.Email),
|
|
"Phone" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.Phone) : q.OrderByDescending(c => c.Phone),
|
|
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
|
|
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
|
|
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
|
|
_ => q => q.OrderBy(c => c.CompanyName)
|
|
};
|
|
|
|
// Get paged data
|
|
var (items, totalCount) = await _unitOfWork.Customers.GetPagedAsync(
|
|
gridRequest.PageNumber,
|
|
gridRequest.PageSize,
|
|
filter,
|
|
orderBy);
|
|
|
|
// Map to DTOs
|
|
var customerDtos = items.Select(c => new CustomerListDto
|
|
{
|
|
Id = c.Id,
|
|
CompanyName = c.CompanyName,
|
|
ContactName = !string.IsNullOrEmpty(c.ContactFirstName) || !string.IsNullOrEmpty(c.ContactLastName)
|
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
|
: string.Empty,
|
|
Phone = c.Phone,
|
|
Email = c.Email,
|
|
IsCommercial = c.IsCommercial,
|
|
CurrentBalance = c.CurrentBalance,
|
|
IsActive = c.IsActive,
|
|
LastContactDate = c.LastContactDate
|
|
}).ToList();
|
|
|
|
var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);
|
|
|
|
// Set ViewBag for sorting
|
|
ViewBag.SearchTerm = searchTerm;
|
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
|
|
|
return View(pagedResult);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving customers");
|
|
this.ToastError("An error occurred while loading customers.");
|
|
return View(new PagedResult<CustomerListDto>());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
|
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
|
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
|
/// </summary>
|
|
public async Task<IActionResult> Details(int? id)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var creditMemos = await _unitOfWork.CreditMemos.FindAsync(
|
|
cm => cm.CustomerId == id.Value && cm.Status != CreditMemoStatus.Voided);
|
|
ViewBag.CreditMemos = creditMemos
|
|
.OrderByDescending(cm => cm.IssueDate)
|
|
.Take(10)
|
|
.ToList();
|
|
|
|
var customerDto = _mapper.Map<CustomerDto>(customer);
|
|
return View(customerDto);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while loading the customer.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the full paginated job history for a single customer. Defaults to descending
|
|
/// by creation date so the newest jobs appear first. JobStatus and JobPriority are
|
|
/// eagerly loaded so their display names and sort orders are available without N+1 queries.
|
|
/// </summary>
|
|
public async Task<IActionResult> JobHistory(
|
|
int? id,
|
|
string? searchTerm,
|
|
string? sortColumn,
|
|
string sortDirection = "desc",
|
|
int pageNumber = 1,
|
|
int pageSize = 25)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var gridRequest = new GridRequest
|
|
{
|
|
PageNumber = pageNumber,
|
|
PageSize = pageSize,
|
|
SortColumn = sortColumn ?? "CreatedAt",
|
|
SortDirection = sortDirection,
|
|
SearchTerm = searchTerm
|
|
};
|
|
gridRequest.Validate();
|
|
|
|
System.Linq.Expressions.Expression<Func<Core.Entities.Job, bool>> filter;
|
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
|
{
|
|
var search = searchTerm.ToLower();
|
|
filter = j => j.CustomerId == id.Value
|
|
&& (j.JobNumber.ToLower().Contains(search)
|
|
|| j.Description.ToLower().Contains(search)
|
|
|| (j.CustomerPO != null && j.CustomerPO.ToLower().Contains(search))
|
|
|| j.JobStatus.DisplayName.ToLower().Contains(search)
|
|
|| j.JobPriority.DisplayName.ToLower().Contains(search));
|
|
}
|
|
else
|
|
{
|
|
filter = j => j.CustomerId == id.Value;
|
|
}
|
|
|
|
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> orderBy = gridRequest.SortColumn switch
|
|
{
|
|
"JobNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
|
|
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
|
|
"Priority" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
|
|
"ScheduledDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.ScheduledDate) : q.OrderByDescending(j => j.ScheduledDate),
|
|
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
|
|
"FinalPrice" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
|
|
_ => q => q.OrderByDescending(j => j.CreatedAt)
|
|
};
|
|
|
|
var (items, totalCount) = await _unitOfWork.Jobs.GetPagedAsync(
|
|
gridRequest.PageNumber,
|
|
gridRequest.PageSize,
|
|
filter,
|
|
orderBy,
|
|
j => j.JobStatus,
|
|
j => j.JobPriority);
|
|
|
|
// Use AutoMapper to map jobs to DTOs
|
|
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(items);
|
|
|
|
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
|
|
{
|
|
Items = jobDtos,
|
|
PageNumber = gridRequest.PageNumber,
|
|
PageSize = gridRequest.PageSize,
|
|
TotalCount = totalCount
|
|
};
|
|
|
|
ViewBag.CustomerId = id.Value;
|
|
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
|
? customer.CompanyName
|
|
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
|
ViewBag.SearchTerm = searchTerm;
|
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
|
|
|
return View(pagedResult);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving job history for customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while loading the job history.");
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the customer activity tab-panel, showing jobs and quotes in independent
|
|
/// paginated grids. Each grid carries its own sort/page state in the query string so
|
|
/// both can be navigated without resetting the other. Page sizes are clamped to
|
|
/// [10, 100] server-side to prevent accidental full-table loads.
|
|
/// </summary>
|
|
public async Task<IActionResult> Activity(
|
|
int? id,
|
|
string activeTab = "jobs",
|
|
string jobSort = "CreatedAt",
|
|
string jobDir = "desc",
|
|
int jobPage = 1,
|
|
int jobSize = 25,
|
|
string quoteSort = "QuoteDate",
|
|
string quoteDir = "desc",
|
|
int quotePage = 1,
|
|
int quoteSize = 25)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var customerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
|
? customer.CompanyName
|
|
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
|
|
|
// Clamp page sizes
|
|
jobSize = Math.Clamp(jobSize, 10, 100);
|
|
quoteSize = Math.Clamp(quoteSize, 10, 100);
|
|
if (jobPage < 1) jobPage = 1;
|
|
if (quotePage < 1) quotePage = 1;
|
|
|
|
// --- Jobs ---
|
|
Func<IQueryable<Core.Entities.Job>, IOrderedQueryable<Core.Entities.Job>> jobOrderBy = jobSort switch
|
|
{
|
|
"JobNumber" => q => jobDir == "asc" ? q.OrderBy(j => j.JobNumber) : q.OrderByDescending(j => j.JobNumber),
|
|
"Status" => q => jobDir == "asc" ? q.OrderBy(j => j.JobStatus.DisplayOrder) : q.OrderByDescending(j => j.JobStatus.DisplayOrder),
|
|
"Priority" => q => jobDir == "asc" ? q.OrderBy(j => j.JobPriority.DisplayOrder) : q.OrderByDescending(j => j.JobPriority.DisplayOrder),
|
|
"DueDate" => q => jobDir == "asc" ? q.OrderBy(j => j.DueDate) : q.OrderByDescending(j => j.DueDate),
|
|
"FinalPrice"=> q => jobDir == "asc" ? q.OrderBy(j => j.FinalPrice) : q.OrderByDescending(j => j.FinalPrice),
|
|
_ => q => jobDir == "asc" ? q.OrderBy(j => j.CreatedAt) : q.OrderByDescending(j => j.CreatedAt)
|
|
};
|
|
|
|
var (jobItems, jobTotal) = await _unitOfWork.Jobs.GetPagedAsync(
|
|
jobPage, jobSize,
|
|
j => j.CustomerId == id.Value,
|
|
jobOrderBy,
|
|
j => j.JobStatus,
|
|
j => j.JobPriority);
|
|
|
|
var jobDtos = _mapper.Map<List<Application.DTOs.Job.JobListDto>>(jobItems);
|
|
var pagedJobs = new Application.DTOs.Common.PagedResult<Application.DTOs.Job.JobListDto>
|
|
{
|
|
Items = jobDtos,
|
|
PageNumber = jobPage,
|
|
PageSize = jobSize,
|
|
TotalCount = jobTotal
|
|
};
|
|
|
|
// --- Quotes ---
|
|
Func<IQueryable<Core.Entities.Quote>, IOrderedQueryable<Core.Entities.Quote>> quoteOrderBy = quoteSort switch
|
|
{
|
|
"QuoteNumber" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteNumber) : q.OrderByDescending(x => x.QuoteNumber),
|
|
"Status" => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteStatus.DisplayOrder) : q.OrderByDescending(x => x.QuoteStatus.DisplayOrder),
|
|
"Total" => q => quoteDir == "asc" ? q.OrderBy(x => x.Total) : q.OrderByDescending(x => x.Total),
|
|
"Expiration" => q => quoteDir == "asc" ? q.OrderBy(x => x.ExpirationDate) : q.OrderByDescending(x => x.ExpirationDate),
|
|
_ => q => quoteDir == "asc" ? q.OrderBy(x => x.QuoteDate) : q.OrderByDescending(x => x.QuoteDate)
|
|
};
|
|
|
|
var (quoteItems, quoteTotal) = await _unitOfWork.Quotes.GetPagedAsync(
|
|
quotePage, quoteSize,
|
|
q => q.CustomerId == id.Value,
|
|
quoteOrderBy,
|
|
q => q.QuoteStatus);
|
|
|
|
var quoteDtos = _mapper.Map<List<Application.DTOs.Quote.QuoteListDto>>(quoteItems);
|
|
var pagedQuotes = new Application.DTOs.Common.PagedResult<Application.DTOs.Quote.QuoteListDto>
|
|
{
|
|
Items = quoteDtos,
|
|
PageNumber = quotePage,
|
|
PageSize = quoteSize,
|
|
TotalCount = quoteTotal
|
|
};
|
|
|
|
ViewBag.CustomerId = id.Value;
|
|
ViewBag.CustomerName = customerName;
|
|
ViewBag.ActiveTab = activeTab;
|
|
ViewBag.Jobs = pagedJobs;
|
|
ViewBag.JobSort = jobSort;
|
|
ViewBag.JobDir = jobDir;
|
|
ViewBag.Quotes = pagedQuotes;
|
|
ViewBag.QuoteSort = quoteSort;
|
|
ViewBag.QuoteDir = quoteDir;
|
|
|
|
return View();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving activity for customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while loading customer activity.");
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the paginated invoice list scoped to a single customer. Defaults to
|
|
/// descending invoice date so the most recent invoice is shown first, matching the
|
|
/// typical accounts-receivable workflow of reviewing the latest outstanding balance.
|
|
/// </summary>
|
|
public async Task<IActionResult> Invoices(
|
|
int? id,
|
|
string? sortColumn,
|
|
string sortDirection = "desc",
|
|
int pageNumber = 1,
|
|
int pageSize = 25)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var gridRequest = new GridRequest
|
|
{
|
|
PageNumber = pageNumber,
|
|
PageSize = pageSize,
|
|
SortColumn = sortColumn ?? "InvoiceDate",
|
|
SortDirection = sortDirection
|
|
};
|
|
gridRequest.Validate();
|
|
|
|
Func<IQueryable<Core.Entities.Invoice>, IOrderedQueryable<Core.Entities.Invoice>> orderBy = gridRequest.SortColumn switch
|
|
{
|
|
"InvoiceNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceNumber) : q.OrderByDescending(i => i.InvoiceNumber),
|
|
"Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Status) : q.OrderByDescending(i => i.Status),
|
|
"DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.DueDate) : q.OrderByDescending(i => i.DueDate),
|
|
"Total" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.Total) : q.OrderByDescending(i => i.Total),
|
|
"BalanceDue" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.BalanceDue) : q.OrderByDescending(i => i.BalanceDue),
|
|
_ => q => gridRequest.SortDirection == "asc" ? q.OrderBy(i => i.InvoiceDate) : q.OrderByDescending(i => i.InvoiceDate)
|
|
};
|
|
|
|
var (items, totalCount) = await _unitOfWork.Invoices.GetPagedAsync(
|
|
gridRequest.PageNumber,
|
|
gridRequest.PageSize,
|
|
i => i.CustomerId == id.Value,
|
|
orderBy);
|
|
|
|
var invoiceDtos = _mapper.Map<List<Application.DTOs.Invoice.InvoiceListDto>>(items);
|
|
|
|
var pagedResult = new Application.DTOs.Common.PagedResult<Application.DTOs.Invoice.InvoiceListDto>
|
|
{
|
|
Items = invoiceDtos,
|
|
PageNumber = gridRequest.PageNumber,
|
|
PageSize = gridRequest.PageSize,
|
|
TotalCount = totalCount
|
|
};
|
|
|
|
ViewBag.CustomerId = id.Value;
|
|
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.CompanyName)
|
|
? customer.CompanyName
|
|
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
|
|
|
return View(pagedResult);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving invoices for customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while loading the invoices.");
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the customer creation form. Checks the subscription plan limit before
|
|
/// allowing access; redirects with an error if the company has reached its customer cap.
|
|
/// Pricing tiers are pre-loaded via <see cref="PopulatePricingTiersAsync"/> so the
|
|
/// dropdown is available immediately without a round-trip.
|
|
/// </summary>
|
|
public async Task<IActionResult> Create()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
|
|
{
|
|
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
|
|
TempData["Error"] = $"You have reached your plan limit of {max} customers. " +
|
|
"Please upgrade your plan to add more customers.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
await PopulatePricingTiersAsync();
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists a new customer record. Re-validates the subscription plan limit on POST
|
|
/// to guard against concurrent session exploitation. If the staff member records
|
|
/// verbal SMS consent, sets NotifyBySms, stamps SmsConsentedAt, and immediately
|
|
/// sends a confirmation SMS via <see cref="INotificationService"/>; the resulting
|
|
/// notification log entry is then surfaced as a toast so staff can confirm delivery.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Create(CreateCustomerDto dto)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
await PopulatePricingTiersAsync();
|
|
return View(dto);
|
|
}
|
|
|
|
// Subscription limit check
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
if (!await _subscriptionService.CanAddCustomerAsync(companyId))
|
|
{
|
|
var (used, max) = await _subscriptionService.GetCustomerCountAsync(companyId);
|
|
ModelState.AddModelError(string.Empty,
|
|
$"You have reached your plan limit of {max} customers. " +
|
|
"Please upgrade your plan to add more customers.");
|
|
await PopulatePricingTiersAsync();
|
|
return View(dto);
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = _mapper.Map<Customer>(dto);
|
|
customer.CreatedAt = DateTime.UtcNow;
|
|
customer.IsActive = true;
|
|
|
|
// SMS consent: only enable if staff has checked the consent box
|
|
if (dto.SmsConsentGranted && !string.IsNullOrWhiteSpace(dto.MobilePhone ?? dto.Phone))
|
|
{
|
|
customer.NotifyBySms = true;
|
|
customer.SmsConsentedAt = DateTime.UtcNow;
|
|
customer.SmsConsentMethod = "Staff-recorded verbal consent";
|
|
}
|
|
else
|
|
{
|
|
customer.NotifyBySms = false;
|
|
}
|
|
|
|
await _unitOfWork.Customers.AddAsync(customer);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
// Send welcome/confirmation SMS after the customer record is saved
|
|
if (customer.NotifyBySms)
|
|
{
|
|
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
|
|
|
var logs = await _unitOfWork.NotificationLogs.FindAsync(
|
|
n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
|
|
var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
|
|
this.SetNotificationResultToast(smsLog);
|
|
}
|
|
|
|
this.ToastSuccess("Customer created successfully.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating customer");
|
|
this.ToastError("An error occurred while creating the customer.");
|
|
return View(dto);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the customer edit form, pre-populated via AutoMapper from the stored entity.
|
|
/// Pricing tiers are loaded so the dropdown reflects current active tiers even if
|
|
/// the tier list has changed since the customer was created.
|
|
/// </summary>
|
|
public async Task<IActionResult> Edit(int? id)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var dto = _mapper.Map<UpdateCustomerDto>(customer);
|
|
await PopulatePricingTiersAsync();
|
|
return View(dto);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving customer {CustomerId} for edit", id);
|
|
this.ToastError("An error occurred while loading the customer.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists edits to an existing customer. Special handling covers two sensitive fields:
|
|
/// (1) Tax-exempt certificate bytes are captured before AutoMapper runs and restored after,
|
|
/// preventing the Edit form from inadvertently clearing an already-uploaded file.
|
|
/// (2) SMS consent is append-only: newly granted consent stamps SmsConsentedAt and sends
|
|
/// a confirmation SMS; existing consent is not re-granted, but NotifyBySms can be
|
|
/// toggled on/off after the first grant to pause or resume messages.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(int id, UpdateCustomerDto dto)
|
|
{
|
|
if (id != dto.Id)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
await PopulatePricingTiersAsync();
|
|
return View(dto);
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
// Capture consent state before mapping (mapping ignores SmsConsentedAt/Method)
|
|
var hadConsentBefore = customer.SmsConsentedAt.HasValue;
|
|
|
|
// Preserve cert data before mapping (mapper ignores these, but save explicitly for safety)
|
|
var certData = customer.TaxExemptCertificateData;
|
|
var certContentType = customer.TaxExemptCertificateContentType;
|
|
var certFileName = customer.TaxExemptCertificateFileName;
|
|
|
|
_mapper.Map(dto, customer);
|
|
customer.UpdatedAt = DateTime.UtcNow;
|
|
|
|
// Restore cert data (defensive: ensure Edit form never clears uploaded certificates)
|
|
customer.TaxExemptCertificateData = certData;
|
|
customer.TaxExemptCertificateContentType = certContentType;
|
|
customer.TaxExemptCertificateFileName = certFileName;
|
|
|
|
// If consent was not previously granted and staff has now checked the consent box
|
|
var newlyGranted = !hadConsentBefore && dto.SmsConsentGranted
|
|
&& !string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone);
|
|
|
|
if (newlyGranted)
|
|
{
|
|
customer.NotifyBySms = true;
|
|
customer.SmsConsentedAt = DateTime.UtcNow;
|
|
customer.SmsConsentMethod = "Staff-recorded verbal consent";
|
|
}
|
|
else if (!hadConsentBefore)
|
|
{
|
|
// No consent exists and none newly granted — keep SMS off
|
|
customer.NotifyBySms = false;
|
|
}
|
|
// If hadConsentBefore: NotifyBySms is mapped from the toggle as-is (allow pause/resume)
|
|
|
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
// Send welcome SMS if consent was just granted
|
|
if (newlyGranted)
|
|
{
|
|
try { await _notificationService.NotifySmsConsentGrantedAsync(customer); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); }
|
|
|
|
var logs = await _unitOfWork.NotificationLogs.FindAsync(
|
|
n => n.CustomerId == customer.Id, ignoreQueryFilters: true);
|
|
var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault();
|
|
this.SetNotificationResultToast(smsLog);
|
|
}
|
|
|
|
this.ToastSuccess("Customer updated successfully.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while updating the customer.");
|
|
return View(dto);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the delete confirmation page for a customer. The view shows a full
|
|
/// summary so the user can verify they are about to remove the correct record
|
|
/// before submitting the confirmation POST.
|
|
/// </summary>
|
|
public async Task<IActionResult> Delete(int? id)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id.Value);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var customerDto = _mapper.Map<CustomerDto>(customer);
|
|
return View(customerDto);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving customer {CustomerId} for delete", id);
|
|
this.ToastError("An error occurred while loading the customer.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs a soft delete on the customer record. A soft delete sets IsDeleted = true
|
|
/// rather than removing the row, preserving referential integrity with historical jobs,
|
|
/// quotes, and invoices. The record is filtered from all normal queries by the global
|
|
/// EF query filter, and can only be seen by SuperAdmins with ignoreQueryFilters.
|
|
/// </summary>
|
|
[HttpPost, ActionName("Delete")]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteConfirmed(int id)
|
|
{
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
await _unitOfWork.Customers.SoftDeleteAsync(customer);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
this.ToastSuccess("Customer deleted successfully.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while deleting the customer.");
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams the tax-exempt certificate file stored as binary in the Customer entity back
|
|
/// to the browser using the original content-type and filename. Certificates are stored
|
|
/// in-database (not on disk) to keep them tenant-isolated without requiring blob storage
|
|
/// configuration in every deployment environment.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> TaxExemptCertificate(int id)
|
|
{
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null || customer.TaxExemptCertificateData == null || customer.TaxExemptCertificateData.Length == 0)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var contentType = customer.TaxExemptCertificateContentType ?? "application/pdf";
|
|
var fileName = customer.TaxExemptCertificateFileName ?? "tax-exempt-certificate.pdf";
|
|
|
|
return File(customer.TaxExemptCertificateData, contentType, fileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving tax exempt certificate for customer {CustomerId}", id);
|
|
return NotFound();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts a tax-exempt certificate upload (PDF, JPG, or PNG, max 10 MB) and stores the
|
|
/// raw bytes directly on the Customer entity. Storing in-database avoids the need for a
|
|
/// shared file system or Azure Blob container in single-tenant or on-premise deployments.
|
|
/// Content-type and original filename are stored alongside the bytes so
|
|
/// <see cref="TaxExemptCertificate"/> can return them faithfully.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UploadTaxExemptCertificate(int id, IFormFile certificateFile)
|
|
{
|
|
try
|
|
{
|
|
if (certificateFile == null || certificateFile.Length == 0)
|
|
{
|
|
this.ToastError("Please select a file to upload.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
|
|
// Validate file type
|
|
var allowedContentTypes = new[] { "application/pdf", "image/jpeg", "image/jpg", "image/png" };
|
|
if (!allowedContentTypes.Contains(certificateFile.ContentType, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
this.ToastError("Invalid file type. Only PDF and image files (JPG, PNG) are allowed.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
|
|
// Validate file size (10 MB max)
|
|
const long maxSize = 10 * 1024 * 1024;
|
|
if (certificateFile.Length > maxSize)
|
|
{
|
|
this.ToastError("File size exceeds the 10 MB limit.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
// Read file data
|
|
using var ms = new MemoryStream();
|
|
await certificateFile.CopyToAsync(ms);
|
|
customer.TaxExemptCertificateData = ms.ToArray();
|
|
customer.TaxExemptCertificateContentType = certificateFile.ContentType;
|
|
customer.TaxExemptCertificateFileName = certificateFile.FileName;
|
|
|
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
this.ToastSuccess("Tax exempt certificate uploaded successfully.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error uploading tax exempt certificate for customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while uploading the certificate.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the tax-exempt certificate from the customer record by nulling all three
|
|
/// certificate fields (Data, ContentType, FileName). A separate action (rather than
|
|
/// embedding the delete in Edit) avoids race conditions where the Edit form could
|
|
/// clear the certificate bytes if the user saves without re-uploading.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteTaxExemptCertificate(int id)
|
|
{
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
customer.TaxExemptCertificateData = null;
|
|
customer.TaxExemptCertificateContentType = null;
|
|
customer.TaxExemptCertificateFileName = null;
|
|
|
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
|
await _unitOfWork.SaveChangesAsync();
|
|
|
|
this.ToastSuccess("Tax exempt certificate deleted successfully.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting tax exempt certificate for customer {CustomerId}", id);
|
|
this.ToastError("An error occurred while deleting the certificate.");
|
|
return RedirectToAction(nameof(Edit), new { id });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a standalone credit memo and increments the customer's CreditBalance.
|
|
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
|
|
/// number follows the CM-YYMM-#### format generated by <see cref="GenerateCreditMemoNumberAsync"/>.
|
|
/// The memo is not tied to any invoice at creation; it will be applied automatically
|
|
/// when the next invoice is created for this customer.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public async Task<IActionResult> AddCredit(int id, AddCreditDto dto)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
TempData["Error"] = "Invalid credit amount or missing reason.";
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
|
|
try
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
|
if (customer == null) return NotFound();
|
|
|
|
var currentUser = await _userManager.GetUserAsync(User);
|
|
var memoNumber = await GenerateCreditMemoNumberAsync();
|
|
|
|
var memo = new CreditMemo
|
|
{
|
|
MemoNumber = memoNumber,
|
|
CustomerId = id,
|
|
OriginalInvoiceId = null, // not tied to an invoice
|
|
Amount = dto.Amount,
|
|
AmountApplied = 0,
|
|
IssueDate = DateTime.UtcNow,
|
|
ExpiryDate = dto.ExpiryDate,
|
|
Reason = dto.Reason,
|
|
Notes = dto.Notes,
|
|
Status = CreditMemoStatus.Active,
|
|
IssuedById = currentUser?.Id,
|
|
CompanyId = customer.CompanyId,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CreatedBy = currentUser?.Email
|
|
};
|
|
|
|
await _unitOfWork.CreditMemos.AddAsync(memo);
|
|
|
|
customer.CreditBalance += dto.Amount;
|
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = $"Credit of {dto.Amount:C} added to {customer.CompanyName ?? customer.ContactFirstName}'s account (Memo {memoNumber}).";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error adding credit to customer {CustomerId}", id);
|
|
TempData["Error"] = "An error occurred while adding the credit.";
|
|
}
|
|
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays or downloads a dated activity statement for a customer.
|
|
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
|
var toDate = to ?? DateTime.Today;
|
|
|
|
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
|
|
|
if (pdf)
|
|
{
|
|
var bytes = StatementPdfHelper.Generate(
|
|
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
|
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
|
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
|
}
|
|
|
|
return View(dto);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
|
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
|
/// soft-deleted records are included in the sequence check and their numbers are
|
|
/// never reused, maintaining an audit-friendly gap-free numbering history.
|
|
/// </summary>
|
|
private async Task<string> GenerateCreditMemoNumberAsync()
|
|
{
|
|
var prefix = $"CM-{DateTime.Now:yyMM}-";
|
|
var last = (await _unitOfWork.CreditMemos.FindAsync(
|
|
m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
|
.OrderByDescending(m => m.MemoNumber)
|
|
.Select(m => m.MemoNumber)
|
|
.FirstOrDefault();
|
|
|
|
int next = 1;
|
|
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
|
next = num + 1;
|
|
|
|
return $"{prefix}{next:D4}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads all active pricing tiers into ViewBag.PricingTiers for the Create and Edit
|
|
/// dropdowns. Tiers are sorted alphabetically and include the discount percentage in
|
|
/// the label text so staff can see the benefit without opening the tier detail page.
|
|
/// </summary>
|
|
private async Task PopulatePricingTiersAsync()
|
|
{
|
|
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
|
|
ViewBag.PricingTiers = tiers
|
|
.OrderBy(t => t.TierName)
|
|
.Select(t => new SelectListItem
|
|
{
|
|
Value = t.Id.ToString(),
|
|
Text = t.DiscountPercent > 0
|
|
? $"{t.TierName} ({t.DiscountPercent:0.##}% discount)"
|
|
: t.TierName
|
|
})
|
|
.ToList();
|
|
}
|
|
}
|