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 _logger; private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly IFinancialReportService _financialReports; public CustomersController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, INotificationService notificationService, ISubscriptionService subscriptionService, ITenantContext tenantContext, UserManager userManager, IFinancialReportService financialReports) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _notificationService = notificationService; _subscriptionService = subscriptionService; _tenantContext = tenantContext; _userManager = userManager; _financialReports = financialReports; } /// /// 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. /// public async Task 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>? 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, IOrderedQueryable> 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.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()); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// public async Task 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> 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, IOrderedQueryable> 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>(items); var pagedResult = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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. /// public async Task 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, IOrderedQueryable> 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>(jobItems); var pagedJobs = new Application.DTOs.Common.PagedResult { Items = jobDtos, PageNumber = jobPage, PageSize = jobSize, TotalCount = jobTotal }; // --- Quotes --- Func, IOrderedQueryable> 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>(quoteItems); var pagedQuotes = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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. /// public async Task 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, IOrderedQueryable> 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>(items); var pagedResult = new Application.DTOs.Common.PagedResult { 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 }); } } /// /// 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 so the /// dropdown is available immediately without a round-trip. /// public async Task 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(); } /// /// 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 ; the resulting /// notification log entry is then surfaced as a toast so staff can confirm delivery. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task 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)); } } /// /// 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. /// [HttpGet] public async Task 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(); } } /// /// 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 /// can return them faithfully. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 }); } } /// /// 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 . /// The memo is not tied to any invoice at creation; it will be applied automatically /// when the next invoice is created for this customer. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task 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 }); } /// /// Displays or downloads a dated activity statement for a customer. /// Pass pdf=true to download the QuestPDF version; otherwise renders the HTML view. /// [HttpGet] public async Task 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); } /// /// Generates the next sequential credit memo number in CM-YYMM-#### format. /// Uses ignoreQueryFilters: true 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. /// private async Task 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}"; } /// /// 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. /// 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(); } }