using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; using PowderCoating.Web.ViewModels; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManageInvoices)] public class InvoicesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; private readonly ITenantContext _tenantContext; private readonly INotificationService _notificationService; private readonly IAccountBalanceService _accountBalanceService; public InvoicesController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, IPdfService pdfService, ITenantContext tenantContext, INotificationService notificationService, IAccountBalanceService accountBalanceService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _pdfService = pdfService; _tenantContext = tenantContext; _notificationService = notificationService; _accountBalanceService = accountBalanceService; } // ----------------------------------------------------------------------- // GET: /Invoices // ----------------------------------------------------------------------- /// /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles /// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the /// database receives a single targeted predicate — no full-table load then in-memory LINQ. /// Balance-due sort is computed in the ORDER BY expression rather than a stored column because /// balance = Total − AmountPaid − CreditApplied − GiftCertificateRedeemed changes on every payment. /// public async Task Index( string? searchTerm, InvoiceStatus? statusFilter, string? sortColumn, string sortDirection = "desc", bool outstandingOnly = false, bool thisMonthOnly = false, bool overdueOnly = false, int pageNumber = 1, int pageSize = 25) { try { var today = DateTime.Today; var startOfMonth = new DateTime(today.Year, today.Month, 1); var endOfMonth = startOfMonth.AddMonths(1); var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? "CreatedAt", SortDirection = sortColumn == null ? "desc" : sortDirection, SearchTerm = searchTerm }; gridRequest.Validate(); System.Linq.Expressions.Expression>? filter = null; if (overdueOnly) { filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) && i.DueDate.HasValue && i.DueDate.Value < today; } else if (outstandingOnly && !string.IsNullOrWhiteSpace(searchTerm)) { var s = searchTerm.ToLower(); filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) && (i.InvoiceNumber.ToLower().Contains(s) || i.Customer.CompanyName!.ToLower().Contains(s) || (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s)) || (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s)) || (i.Job != null && i.Job.JobNumber.ToLower().Contains(s))); } else if (outstandingOnly) { filter = i => i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue; } else if (thisMonthOnly && statusFilter.HasValue && !string.IsNullOrWhiteSpace(searchTerm)) { var st = statusFilter.Value; var s = searchTerm.ToLower(); filter = i => i.Status == st && i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth && (i.InvoiceNumber.ToLower().Contains(s) || i.Customer.CompanyName!.ToLower().Contains(s) || (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s)) || (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s)) || (i.Job != null && i.Job.JobNumber.ToLower().Contains(s))); } else if (thisMonthOnly && statusFilter.HasValue) { var st = statusFilter.Value; filter = i => i.Status == st && i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth; } else if (thisMonthOnly && !string.IsNullOrWhiteSpace(searchTerm)) { var s = searchTerm.ToLower(); filter = i => i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth && (i.InvoiceNumber.ToLower().Contains(s) || i.Customer.CompanyName!.ToLower().Contains(s) || (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s)) || (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s)) || (i.Job != null && i.Job.JobNumber.ToLower().Contains(s))); } else if (thisMonthOnly) { filter = i => i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth; } else if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue) { var s = searchTerm.ToLower(); var st = statusFilter.Value; filter = i => (i.InvoiceNumber.ToLower().Contains(s) || i.Customer.CompanyName!.ToLower().Contains(s) || (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s)) || (i.Job != null && i.Job.JobNumber.ToLower().Contains(s))) && i.Status == st; } else if (!string.IsNullOrWhiteSpace(searchTerm)) { var s = searchTerm.ToLower(); filter = i => i.InvoiceNumber.ToLower().Contains(s) || i.Customer.CompanyName!.ToLower().Contains(s) || (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s)) || (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s)) || (i.Job != null && i.Job.JobNumber.ToLower().Contains(s)); } else if (statusFilter.HasValue) { var st = statusFilter.Value; filter = i => i.Status == st; } Func, IOrderedQueryable> orderBy = gridRequest.SortColumn switch { "InvoiceNumber" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.InvoiceNumber) : q.OrderByDescending(x => x.InvoiceNumber), "Status" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.Status).ThenByDescending(x => x.InvoiceDate) : q.OrderByDescending(x => x.Status).ThenByDescending(x => x.InvoiceDate), "InvoiceDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.InvoiceDate).ThenBy(x => x.Id) : q.OrderByDescending(x => x.InvoiceDate).ThenByDescending(x => x.Id), "DueDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.DueDate).ThenBy(x => x.Id) : q.OrderByDescending(x => x.DueDate).ThenByDescending(x => x.Id), "Total" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.Total).ThenByDescending(x => x.InvoiceDate) : q.OrderByDescending(x => x.Total).ThenByDescending(x => x.InvoiceDate), "BalanceDue" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.Total - x.AmountPaid - x.CreditApplied - x.GiftCertificateRedeemed).ThenByDescending(x => x.InvoiceDate) : q.OrderByDescending(x => x.Total - x.AmountPaid - x.CreditApplied - x.GiftCertificateRedeemed).ThenByDescending(x => x.InvoiceDate), "CreatedAt" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(x => x.CreatedAt) : q.OrderByDescending(x => x.CreatedAt), _ => q => q.OrderByDescending(x => x.CreatedAt) }; var (items, totalCount) = await _unitOfWork.Invoices.GetPagedAsync( gridRequest.PageNumber, gridRequest.PageSize, filter, orderBy, i => i.Customer, i => i.Job); var dtos = _mapper.Map>(items); var pagedResult = new PagedResult { Items = dtos, PageNumber = gridRequest.PageNumber, PageSize = gridRequest.PageSize, TotalCount = totalCount, SortColumn = gridRequest.SortColumn, SortDirection = gridRequest.SortDirection, SearchTerm = searchTerm }; ViewBag.SearchTerm = searchTerm; ViewBag.StatusFilter = statusFilter; ViewBag.OutstandingOnly = outstandingOnly; ViewBag.ThisMonthOnly = thisMonthOnly; ViewBag.OverdueOnly = overdueOnly; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error loading invoices"); TempData["Error"] = "An error occurred while loading invoices."; return View(new PagedResult()); } } // ----------------------------------------------------------------------- // GET: /Invoices/Details/5 // ----------------------------------------------------------------------- /// /// Loads an invoice with all related data for the Details view. Also resolves: /// — Available credit memos for the customer (Active/PartiallyApplied only) so the /// "Apply Credit" modal is pre-loaded without a separate AJAX call. /// — The active payment link URL (token + expiry check). If expired, no URL is set and /// the view shows a "Regenerate" button instead. /// — Whether online payments are allowed: requires plan-level permission AND an active /// Stripe Connect account. Both conditions must be true; per-plan override wins if set. /// public async Task Details(int? id, string? guidedActivation = null) { if (id == null) return NotFound(); try { var invoice = await LoadInvoiceForViewAsync(id.Value); if (invoice == null) return NotFound(); var dto = await BuildInvoiceDtoAsync(invoice); await PopulateBankAccountsAsync(); // Credit memos available for this customer (active, unused balance) var availableCredits = await _unitOfWork.CreditMemos.FindAsync( cm => cm.CustomerId == invoice.CustomerId && (cm.Status == CreditMemoStatus.Active || cm.Status == CreditMemoStatus.PartiallyApplied)); ViewBag.AvailableCreditMemos = availableCredits .OrderByDescending(cm => cm.IssueDate) .Select(cm => new SelectListItem( $"{cm.MemoNumber} — {cm.RemainingBalance:C} remaining ({cm.Reason})", cm.Id.ToString())) .ToList(); // Payment link URL (only if active and not expired) if (!string.IsNullOrEmpty(invoice.PaymentLinkToken) && invoice.PaymentLinkExpiresAt > DateTime.UtcNow) ViewBag.PaymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; // Check whether this company's plan allows online payments var company = await _unitOfWork.Companies.GetByIdAsync(_tenantContext.GetCurrentCompanyId() ?? 0); var planConfig = company == null ? null : await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company?.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed && company?.StripeConnectStatus == StripeConnectStatus.Active; if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep) { ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel { Show = true, Title = "This is how billing connects back to the job.", Message = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.", ActionText = "Go to Dashboard", ActionController = "Dashboard", ActionName = "Index" }; } return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading invoice {Id}", id); TempData["Error"] = "An error occurred while loading the invoice."; return RedirectToAction(nameof(Index)); } } // ----------------------------------------------------------------------- // GET: /Invoices/Create?jobId=n // ----------------------------------------------------------------------- /// /// Prepares the Create Invoice form, optionally pre-populated from a job. When a jobId is /// supplied several important rules apply: /// — 1:1 enforcement: if an invoice already exists for the job (even soft-deleted ones via /// IgnoreQueryFilters), the user is immediately redirected to that invoice. /// — Line items are derived from job items, each carrying through its SourceJobItemId so the /// invoice can trace back to the exact job work. /// — Quote-level charges (oven batch cost, shop supplies, rush fee) are NOT on job items; /// they're read from the source quote and added as a single "Oven & Shop Processing Fees" /// line. Never reverse-engineer from job.FinalPrice because it drifts on each item edit. /// — Tax rate and discount come from the approved quote snapshot, not current company defaults, /// to honour the agreed price. Tax is then forced to 0 for tax-exempt customers regardless. /// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to /// account 4000 (default revenue) if no catalog item is linked. /// public async Task Create(int? jobId, string? guidedActivation = null) { try { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); var dto = new CreateInvoiceDto { PreparedById = currentUser.Id, InvoiceDate = DateTime.Today, DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30), TaxPercent = costs?.TaxPercent ?? 0, Terms = prefs?.DefaultPaymentTerms ?? "Net 30" }; if (jobId.HasValue) { var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems); if (job == null) return NotFound(); // Validate no existing invoice for this job var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); dto.JobId = job.Id; dto.CustomerId = job.CustomerId; dto.CustomerPO = job.CustomerPO; // Resolve catalog item revenue accounts for pre-population var catalogItemIds = job.JobItems .Where(ji => !ji.IsDeleted && ji.CatalogItemId.HasValue) .Select(ji => ji.CatalogItemId!.Value) .Distinct() .ToList(); var catalogItems = catalogItemIds.Any() ? (await _unitOfWork.CatalogItems.FindAsync(ci => catalogItemIds.Contains(ci.Id))) .ToDictionary(ci => ci.Id) : new Dictionary(); // Fall back to the default revenue account (4000) if a catalog item has no specific account var defaultRevenueAccount = await _unitOfWork.Accounts .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); // If the job came from a quote, load it so we can use the agreed pricing. // The quote stores the approved total including oven batch cost and shop supplies — // these are quote-level charges that are NOT stored on individual job items. PowderCoating.Core.Entities.Quote? sourceQuote = null; if (job.QuoteId.HasValue) { sourceQuote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value); } // Pre-populate from job items int order = 1; foreach (var item in job.JobItems.Where(ji => !ji.IsDeleted).OrderBy(ji => ji.Id)) { int? revenueAccountId = null; if (item.CatalogItemId.HasValue && catalogItems.TryGetValue(item.CatalogItemId.Value, out var ci)) revenueAccountId = ci.RevenueAccountId; revenueAccountId ??= defaultRevenueAccount?.Id; dto.InvoiceItems.Add(new CreateInvoiceItemDto { SourceJobItemId = item.Id, Description = item.Description ?? "Powder Coating", Quantity = 1, UnitPrice = item.TotalPrice, TotalPrice = item.TotalPrice, ColorName = item.ColorName, DisplayOrder = order++, RevenueAccountId = revenueAccountId }); } // If no job items, use job final price as single line. // FinalPrice is always the post-tax total (set by the pricing engine or imported from // an export). Treat it as the agreed total and force TaxPercent = 0 so the invoice // does not apply tax a second time. Without this, imported jobs double-tax because // their FinalPrice already includes the tax that was applied in the source environment. if (!dto.InvoiceItems.Any()) { var defaultRevAccId = defaultRevenueAccount?.Id; dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = $"Powder Coating Services — Job {job.JobNumber}", Quantity = 1, UnitPrice = job.FinalPrice, TotalPrice = job.FinalPrice, DisplayOrder = 1, RevenueAccountId = defaultRevAccId }); dto.TaxPercent = 0; } // If the job came from a quote, carry over the quote-level costs and agreed terms. // The quote SubTotal = sum(items) + oven batch cost + shop supplies. // Job items only capture per-item prices, so oven & shop supplies need a separate line. // Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice // because FinalPrice is recalculated on every item edit and can drift from the original quote. if (sourceQuote != null) { var processingFees = sourceQuote.OvenBatchCost + sourceQuote.ShopSuppliesAmount + sourceQuote.RushFee; if (processingFees > 0.01m) { dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = "Oven & Shop Processing Fees", Quantity = 1, UnitPrice = Math.Round(processingFees, 2), TotalPrice = Math.Round(processingFees, 2), DisplayOrder = order, RevenueAccountId = defaultRevenueAccount?.Id }); } // Use the quote's agreed tax rate and discount — not current company defaults dto.TaxPercent = sourceQuote.TaxPercent; dto.DiscountAmount = sourceQuote.DiscountAmount; } // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate if (job.Customer?.IsTaxExempt == true) dto.TaxPercent = 0; ViewBag.JobNumber = job.JobNumber; ViewBag.CustomerName = job.Customer != null ? (job.Customer.IsCommercial ? job.Customer.CompanyName : $"{job.Customer.ContactFirstName} {job.Customer.ContactLastName}".Trim()) : string.Empty; } await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error preparing invoice Create form"); TempData["Error"] = "An error occurred."; return RedirectToAction(nameof(Index)); } } // ----------------------------------------------------------------------- // POST: /Invoices/Create // ----------------------------------------------------------------------- /// /// Creates a new invoice within a single DB transaction. Key behaviours: /// — 1:1 job→invoice: re-checks for an existing invoice inside the POST (race-condition guard) /// using IgnoreQueryFilters so deleted invoices also block creation. /// — Tax formula: SubTotal − DiscountAmount = taxable; tax applied to that net, not the gross, /// consistent with how quotes calculate tax. /// — Auto-applies unapplied deposits: after the invoice is saved, all Deposit records for the /// job (and the job's source quote) with no AppliedToInvoiceId are converted to Payment /// records and the invoice status advances to PartiallyPaid or Paid automatically. /// — Double-entry accounting: credits revenue accounts per line item, credits sales-tax account /// if tax > 0, debits AR for the full invoice total. /// — Gift certificate generation: any line item flagged IsGiftCertificate triggers automatic /// GC creation inside the same transaction via . /// Variables that are needed after the async lambda (invoiceNumber, pendingDeposits, gcMsg) are /// declared outside the lambda and assigned inside — EF requires this pattern with closures. /// [HttpPost, ValidateAntiForgeryToken] public async Task Create(CreateInvoiceDto dto, string? guidedActivation = null) { try { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); if (!ModelState.IsValid) { await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } if (!dto.InvoiceItems.Any()) { ModelState.AddModelError("", "Please add at least one line item before saving."); await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Validate no existing invoice for this job before starting the transaction if (dto.JobId.HasValue) { var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true); if (existing != null) { ModelState.AddModelError("", "An invoice already exists for this job."); await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } } // Hoist variables that are needed after the transaction completes string invoiceNumber = ""; Invoice? invoice = null; List pendingDeposits = new(); string gcMsg = ""; await _unitOfWork.ExecuteInTransactionAsync(async () => { // Generate invoice number invoiceNumber = await GenerateInvoiceNumberAsync(currentUser.CompanyId); // Calculate totals (tax is applied after discount, consistent with quotes) var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice); var taxableAmount = subTotal - dto.DiscountAmount; var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2); var total = taxableAmount + taxAmount; // Resolve sales tax account when tax is charged int? salesTaxAccountId = taxAmount > 0 ? await ResolveSalesTaxAccountIdAsync(currentUser.CompanyId) : null; invoice = new Invoice { InvoiceNumber = invoiceNumber, JobId = dto.JobId, CustomerId = dto.CustomerId, PreparedById = currentUser.Id, Status = InvoiceStatus.Draft, InvoiceDate = dto.InvoiceDate, DueDate = dto.DueDate, SubTotal = subTotal, TaxPercent = dto.TaxPercent, TaxAmount = taxAmount, DiscountAmount = dto.DiscountAmount, Total = total, AmountPaid = 0, SalesTaxAccountId = salesTaxAccountId, Notes = dto.Notes, InternalNotes = dto.InternalNotes, Terms = dto.Terms, CustomerPO = dto.CustomerPO, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser.Email }; int order = 1; foreach (var item in dto.InvoiceItems) { invoice.InvoiceItems.Add(new InvoiceItem { Description = item.Description, Quantity = item.Quantity, UnitPrice = item.UnitPrice, TotalPrice = item.TotalPrice, ColorName = item.ColorName, Notes = item.Notes, SourceJobItemId = item.SourceJobItemId, CatalogItemId = item.CatalogItemId, DisplayOrder = item.DisplayOrder > 0 ? item.DisplayOrder : order, RevenueAccountId = item.RevenueAccountId, IsGiftCertificate = item.IsGiftCertificate, GcRecipientName = item.GcRecipientName, GcRecipientEmail = item.GcRecipientEmail, GcExpiryDate = item.GcExpiryDate, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }); order++; } await _unitOfWork.Invoices.AddAsync(invoice); // Update customer balance var customer = await _unitOfWork.Customers.GetByIdAsync(dto.CustomerId); if (customer != null) { customer.CurrentBalance += total; await _unitOfWork.Customers.UpdateAsync(customer); } await _unitOfWork.CompleteAsync(); // Auto-apply any unapplied deposits for this job (and its linked quote) var job = dto.JobId.HasValue ? await _unitOfWork.Jobs.GetByIdAsync(dto.JobId.Value) : null; pendingDeposits = (await _unitOfWork.Deposits.FindAsync( d => d.AppliedToInvoiceId == null && (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)))) .ToList(); foreach (var deposit in pendingDeposits) { // Create a Payment record for each deposit var payment = new Payment { InvoiceId = invoice.Id, Amount = deposit.Amount, PaymentDate = deposit.ReceivedDate, PaymentMethod = deposit.PaymentMethod, Reference = $"Deposit {deposit.ReceiptNumber}", Notes = deposit.Notes, RecordedById = currentUser.Id, CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser.Email }; await _unitOfWork.Payments.AddAsync(payment); invoice.AmountPaid += deposit.Amount; deposit.AppliedToInvoiceId = invoice.Id; deposit.AppliedDate = DateTime.UtcNow; deposit.UpdatedAt = DateTime.UtcNow; } if (pendingDeposits.Any()) { // Determine new invoice status based on amount paid invoice.Status = invoice.AmountPaid >= invoice.Total ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid; if (invoice.AmountPaid >= invoice.Total) invoice.PaidDate = DateTime.UtcNow; // Reduce customer balance by the deposits applied if (customer != null) { customer.CurrentBalance -= invoice.AmountPaid; await _unitOfWork.Customers.UpdateAsync(customer); } } await _unitOfWork.CompleteAsync(); // Update account balances: debit AR, credit revenue accounts + sales tax var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId); foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice); if (invoice.TaxAmount > 0) await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); await _accountBalanceService.DebitAsync(arAccountId, invoice.Total); await _unitOfWork.CompleteAsync(); // Auto-generate gift certificates for any GC line items gcMsg = await GenerateGiftCertificatesForInvoiceAsync(invoice, currentUser); }); // end ExecuteInTransactionAsync var depositMsg = pendingDeposits.Any() ? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied." : ""; var workflowJustCompleted = await StampInvoiceCreatedAsync(currentUser.CompanyId); TempData["Success"] = $"Invoice {invoiceNumber} created successfully.{depositMsg}{gcMsg}"; if (!string.IsNullOrWhiteSpace(guidedActivation) || workflowJustCompleted) { return RedirectToAction(nameof(Details), new { id = invoice!.Id, guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep }); } return RedirectToAction(nameof(Details), new { id = invoice!.Id }); } catch (Exception ex) { _logger.LogError(ex, "Error creating invoice"); TempData["Error"] = "An error occurred while creating the invoice."; var currentUser = await _userManager.GetUserAsync(User); if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId); ViewBag.GuidedActivation = guidedActivation; return View(dto); } } // ----------------------------------------------------------------------- // GET: /Invoices/Edit/5 // ----------------------------------------------------------------------- /// /// Loads the Edit form. Only Draft invoices are editable — any other status redirects to /// Details with an error. Sent/Paid/Voided invoices must be voided and recreated rather /// than edited, to preserve the audit trail for those states. /// public async Task Edit(int? id) { if (id == null) return NotFound(); try { var invoice = await LoadInvoiceForViewAsync(id.Value); if (invoice == null) return NotFound(); if (invoice.Status != InvoiceStatus.Draft) { TempData["Error"] = "Only Draft invoices can be edited."; return RedirectToAction(nameof(Details), new { id }); } var dto = new UpdateInvoiceDto { InvoiceDate = invoice.InvoiceDate, DueDate = invoice.DueDate, TaxPercent = invoice.TaxPercent, DiscountAmount = invoice.DiscountAmount, Notes = invoice.Notes, InternalNotes = invoice.InternalNotes, Terms = invoice.Terms, CustomerPO = invoice.CustomerPO, InvoiceItems = invoice.InvoiceItems .Where(i => !i.IsDeleted) .OrderBy(i => i.DisplayOrder) .Select(i => new CreateInvoiceItemDto { SourceJobItemId = i.SourceJobItemId, Description = i.Description, Quantity = i.Quantity, UnitPrice = i.UnitPrice, TotalPrice = i.TotalPrice, ColorName = i.ColorName, Notes = i.Notes, DisplayOrder = i.DisplayOrder, RevenueAccountId = i.RevenueAccountId }).ToList() }; ViewBag.InvoiceNumber = invoice.InvoiceNumber; ViewBag.InvoiceId = invoice.Id; ViewBag.JobNumber = invoice.Job?.JobNumber; ViewBag.CustomerName = invoice.Customer != null ? (invoice.Customer.IsCommercial ? invoice.Customer.CompanyName : $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()) : string.Empty; return View(dto); } catch (Exception ex) { _logger.LogError(ex, "Error loading invoice {Id} for edit", id); TempData["Error"] = "An error occurred."; return RedirectToAction(nameof(Index)); } } // ----------------------------------------------------------------------- // POST: /Invoices/Edit/5 // ----------------------------------------------------------------------- /// /// Saves edits to a Draft invoice. Line items are replaced via a soft-delete-and-add cycle /// (old items flagged IsDeleted, new items inserted) so the audit trail of what was originally /// on the invoice is preserved in the database. Customer.CurrentBalance is adjusted by the /// delta (newTotal − oldTotal) so outstanding AR stays accurate without recalculating from scratch. /// Only Draft invoices can be edited; guard is checked on both GET and POST. /// [HttpPost, ValidateAntiForgeryToken] public async Task Edit(int id, UpdateInvoiceDto dto) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); if (invoice.Status != InvoiceStatus.Draft) { TempData["Error"] = "Only Draft invoices can be edited."; return RedirectToAction(nameof(Details), new { id }); } if (!ModelState.IsValid) { ViewBag.InvoiceNumber = invoice.InvoiceNumber; ViewBag.InvoiceId = invoice.Id; ViewBag.JobNumber = invoice.Job?.JobNumber; ViewBag.CustomerName = invoice.Customer?.CompanyName; return View(dto); } var currentUser = await _userManager.GetUserAsync(User); // Recalculate totals (tax is applied after discount, consistent with quotes) var oldTotal = invoice.Total; var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice); var taxableAmount = subTotal - dto.DiscountAmount; var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2); var newTotal = taxableAmount + taxAmount; // Soft-delete old items and replace foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) { item.IsDeleted = true; item.DeletedAt = DateTime.UtcNow; item.DeletedBy = currentUser?.Email; } int order = 1; foreach (var item in dto.InvoiceItems) { invoice.InvoiceItems.Add(new InvoiceItem { Description = item.Description, Quantity = item.Quantity, UnitPrice = item.UnitPrice, TotalPrice = item.TotalPrice, ColorName = item.ColorName, Notes = item.Notes, SourceJobItemId = item.SourceJobItemId, DisplayOrder = item.DisplayOrder > 0 ? item.DisplayOrder : order, RevenueAccountId = item.RevenueAccountId, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow }); order++; } invoice.InvoiceDate = dto.InvoiceDate; invoice.DueDate = dto.DueDate; invoice.SubTotal = subTotal; invoice.TaxPercent = dto.TaxPercent; invoice.TaxAmount = taxAmount; invoice.DiscountAmount = dto.DiscountAmount; invoice.Total = newTotal; invoice.SalesTaxAccountId = taxAmount > 0 ? (invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(invoice.CompanyId)) : null; invoice.Notes = dto.Notes; invoice.InternalNotes = dto.InternalNotes; invoice.Terms = dto.Terms; invoice.CustomerPO = dto.CustomerPO; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; // Adjust customer balance var delta = newTotal - oldTotal; if (delta != 0) { var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance += delta; await _unitOfWork.Customers.UpdateAsync(customer); } } await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Invoice updated successfully."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error updating invoice {Id}", id); TempData["Error"] = "An error occurred while updating the invoice."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // POST: /Invoices/Send/5 // ----------------------------------------------------------------------- /// /// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and /// fires the customer notification with a PDF attachment. Notification failure is caught /// separately and logged as a warning — a failed email must not roll back the status change. /// The payment URL is assembled from the generated token and the current request host so it /// works identically in dev (localhost) and production without config changes. /// [HttpPost, ValidateAntiForgeryToken] public async Task Send(int id) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); if (invoice.Status != InvoiceStatus.Draft) { TempData["Error"] = "Only Draft invoices can be sent."; return RedirectToAction(nameof(Details), new { id }); } var currentUser = await _userManager.GetUserAsync(User); invoice.Status = InvoiceStatus.Sent; invoice.SentDate = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await TryGeneratePaymentTokenAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); // Generate PDF and send notification string? paymentUrl = null; 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.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 _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(notifLog); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; if (!pdfAndNotifSucceeded) TempData["WarningPermanent"] = "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) { _logger.LogError(ex, "Error sending invoice {Id}", id); TempData["Error"] = "An error occurred."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // POST: /Invoices/RecordPayment/5 // ----------------------------------------------------------------------- /// /// Records a manual payment against an invoice and updates all related balances atomically. /// Partial payments are supported: only Math.Min(dto.Amount, balanceDue) reduces the /// invoice; any overpayment is automatically converted to a store-credit CreditMemo so the /// customer's credit balance reflects the surplus. /// Status machine: BalanceDue <= 0 → Paid; else → PartiallyPaid. /// Double-entry: debits the chosen deposit (bank) account, credits AR for the full payment amount. /// Notification fires non-blocking after the transaction; its failure must not reverse the payment. /// [HttpPost, ValidateAntiForgeryToken] public async Task RecordPayment(int id, PaymentDtos.RecordPaymentDto dto) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); var balanceDue = invoice.BalanceDue; // Total - AmountPaid - CreditApplied - GiftCertificateRedeemed if (dto.Amount <= 0) { TempData["Error"] = "Payment amount must be greater than $0.00."; return RedirectToAction(nameof(Details), new { id }); } var currentUser = await _userManager.GetUserAsync(User); var applyToInvoice = Math.Min(dto.Amount, balanceDue); var overpayment = dto.Amount - applyToInvoice; Payment? payment = null; await _unitOfWork.ExecuteInTransactionAsync(async () => { payment = new Payment { InvoiceId = invoice.Id, Amount = dto.Amount, PaymentDate = dto.PaymentDate, PaymentMethod = dto.PaymentMethod, Reference = dto.Reference, Notes = dto.Notes, RecordedById = currentUser?.Id, DepositAccountId = dto.DepositAccountId, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser?.Email }; await _unitOfWork.Payments.AddAsync(payment); invoice.AmountPaid += applyToInvoice; if (invoice.BalanceDue <= 0) { invoice.Status = InvoiceStatus.Paid; invoice.PaidDate = DateTime.UtcNow; } else { invoice.Status = InvoiceStatus.PartiallyPaid; } invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await _unitOfWork.Invoices.UpdateAsync(invoice); var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance -= applyToInvoice; // Store any overpayment as store credit if (overpayment > 0) { var memoNumber = await GenerateMemoNumberAsync(invoice.CompanyId); var creditMemo = new CreditMemo { MemoNumber = memoNumber, CustomerId = invoice.CustomerId, OriginalInvoiceId = invoice.Id, Amount = overpayment, AmountApplied = 0, IssueDate = DateTime.UtcNow, Reason = $"Overpayment on invoice {invoice.InvoiceNumber}", Status = CreditMemoStatus.Active, IssuedById = currentUser?.Id, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser?.Email }; await _unitOfWork.CreditMemos.AddAsync(creditMemo); customer.CreditBalance += overpayment; } await _unitOfWork.Customers.UpdateAsync(customer); } // Update account balances: debit deposit account, credit AR await _accountBalanceService.DebitAsync(dto.DepositAccountId, dto.Amount); var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, dto.Amount); }); // end ExecuteInTransactionAsync // Notify (non-blocking) try { await _notificationService.NotifyPaymentReceivedAsync(invoice, payment); } catch (Exception notifyEx) { _logger.LogWarning(notifyEx, "Payment recorded but notification failed"); } var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(paymentNotifLog); TempData["Success"] = overpayment > 0 ? $"Payment of {dto.Amount:C} recorded. {overpayment:C} stored as store credit on customer account." : $"Payment of {dto.Amount:C} recorded successfully."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error recording payment for invoice {Id}", id); TempData["Error"] = "An error occurred while recording the payment."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // POST: /Invoices/DeletePayment/invoiceId/paymentId // ----------------------------------------------------------------------- /// /// Soft-deletes a payment and fully reverses its financial impact within a single transaction: /// — Reduces invoice.AmountPaid; resets status to Sent (if ever sent) or Draft if no SentDate. /// — Increments customer.CurrentBalance to restore the outstanding AR balance. /// — Reverses account balances: credits the deposit account (undoes the debit), debits AR (undoes the credit). /// Status after deletion depends on whether SentDate is set, not just remaining balance, /// so deleting a payment on a sent-but-unpaid invoice correctly returns it to Sent rather than Draft. /// [HttpPost, ValidateAntiForgeryToken] public async Task DeletePayment(int invoiceId, int paymentId) { try { var invoice = await LoadInvoiceForViewAsync(invoiceId); if (invoice == null) return NotFound(); var payment = invoice.Payments.FirstOrDefault(p => p.Id == paymentId && !p.IsDeleted); if (payment == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); await _unitOfWork.ExecuteInTransactionAsync(async () => { invoice.AmountPaid -= payment.Amount; if (invoice.AmountPaid <= 0) { invoice.AmountPaid = 0; invoice.Status = invoice.SentDate.HasValue ? InvoiceStatus.Sent : InvoiceStatus.Draft; invoice.PaidDate = null; } else { invoice.Status = InvoiceStatus.PartiallyPaid; invoice.PaidDate = null; } invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await _unitOfWork.Invoices.UpdateAsync(invoice); var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance += payment.Amount; await _unitOfWork.Customers.UpdateAsync(customer); } // Reverse account balances: credit deposit account, debit AR await _accountBalanceService.CreditAsync(payment.DepositAccountId, payment.Amount); var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.DebitAsync(arAccountId, payment.Amount); await _unitOfWork.Payments.SoftDeleteAsync(paymentId); }); // end ExecuteInTransactionAsync TempData["Success"] = $"Payment of {payment.Amount:C} deleted and balance reversed."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } catch (Exception ex) { _logger.LogError(ex, "Error deleting payment {PaymentId} for invoice {InvoiceId}", paymentId, invoiceId); TempData["Error"] = "An error occurred while deleting the payment."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } } // ----------------------------------------------------------------------- // POST: /Invoices/EditPayment // ----------------------------------------------------------------------- /// /// Updates metadata on an existing payment (date, method, reference, deposit account). /// The payment amount is immutable here — adjusting the amount requires delete + re-record. /// If the deposit account changed, the account balance entries are reversed on the old account /// and re-applied on the new one so the chart-of-accounts stays accurate without a full recalc. /// [HttpPost, ValidateAntiForgeryToken] public async Task EditPayment(PaymentDtos.EditPaymentDto dto) { try { var invoice = await LoadInvoiceForViewAsync(dto.InvoiceId); if (invoice == null) return NotFound(); var payment = invoice.Payments.FirstOrDefault(p => p.Id == dto.PaymentId && !p.IsDeleted); if (payment == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); await _unitOfWork.ExecuteInTransactionAsync(async () => { // If the deposit account changed, reverse the old balance entry and apply the new one if (payment.DepositAccountId != dto.DepositAccountId) { await _accountBalanceService.CreditAsync(payment.DepositAccountId, payment.Amount); await _accountBalanceService.DebitAsync(dto.DepositAccountId, payment.Amount); } payment.PaymentDate = dto.PaymentDate; payment.PaymentMethod = dto.PaymentMethod; payment.Reference = dto.Reference; payment.Notes = dto.Notes; payment.DepositAccountId = dto.DepositAccountId; payment.UpdatedAt = DateTime.UtcNow; payment.UpdatedBy = currentUser?.Email; await _unitOfWork.Payments.UpdateAsync(payment); }); // end ExecuteInTransactionAsync TempData["Success"] = "Payment updated successfully."; return RedirectToAction(nameof(Details), new { id = dto.InvoiceId }); } catch (Exception ex) { _logger.LogError(ex, "Error editing payment {PaymentId} for invoice {InvoiceId}", dto.PaymentId, dto.InvoiceId); TempData["Error"] = "An error occurred while updating the payment."; return RedirectToAction(nameof(Details), new { id = dto.InvoiceId }); } } // ----------------------------------------------------------------------- // POST: /Invoices/Void/5 // ----------------------------------------------------------------------- /// /// Voids an invoice, reversing all outstanding financial obligations in a single transaction. /// Key rules: /// — Only the remaining BalanceDue is reversed from the customer's CurrentBalance; payments /// and credits already reduced it when they were recorded, so reversing the full Total would /// double-count those offsets. /// — All payments are soft-deleted so they no longer appear in financial reports. Voiding is /// final — payments cannot be re-associated after a void. /// — Any gift certificates generated from this invoice are voided (unless already fully redeemed, /// in which case they remain to protect the certificate holder). /// — Account balances: credits AR for the open balance, debits each revenue account and sales-tax /// account to unwind the original Create double-entry. /// [HttpPost, ValidateAntiForgeryToken] public async Task Void(int id) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); if (invoice.Status == InvoiceStatus.Voided) { TempData["Error"] = "Invoice is already voided."; return RedirectToAction(nameof(Details), new { id }); } var currentUser = await _userManager.GetUserAsync(User); await _unitOfWork.ExecuteInTransactionAsync(async () => { // Reverse only the outstanding balance (payments/credits/GCs already reduced it when recorded) var balanceDue = invoice.BalanceDue; var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null && balanceDue > 0) { customer.CurrentBalance -= balanceDue; await _unitOfWork.Customers.UpdateAsync(customer); } // Soft-delete all payments so they no longer appear in financial reports foreach (var payment in invoice.Payments.Where(p => !p.IsDeleted)) { await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); } // Void any gift certificates that were generated from this invoice var gcItemIds = invoice.InvoiceItems .Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue) .Select(i => i.GeneratedGiftCertificateId!.Value) .ToList(); foreach (var gcId in gcItemIds) { var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId); if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed) { gc.Status = GiftCertificateStatus.Voided; gc.UpdatedAt = DateTime.UtcNow; await _unitOfWork.GiftCertificates.UpdateAsync(gc); } } // Reverse account balances: credit AR (open balance), debit revenue + sales tax var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, balanceDue); foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); if (invoice.TaxAmount > 0) await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); invoice.Status = InvoiceStatus.Voided; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await _unitOfWork.Invoices.UpdateAsync(invoice); }); // end ExecuteInTransactionAsync TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) { _logger.LogError(ex, "Error voiding invoice {Id}", id); TempData["Error"] = "An error occurred while voiding the invoice."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // GET: /Invoices/DownloadPdf/5 // ----------------------------------------------------------------------- /// /// Generates a QuestPDF invoice and returns it as an inline-download. Delegates to /// which fetches company branding, template settings, /// and the full invoice DTO in one call, then hands off to IPdfService. /// public async Task DownloadPdf(int? id) { if (id == null) return NotFound(); try { var invoice = await LoadInvoiceForViewAsync(id.Value); if (invoice == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId); return File(pdfBytes, "application/pdf", $"Invoice-{invoice.InvoiceNumber}.pdf"); } catch (Exception ex) { _logger.LogError(ex, "Error generating PDF for invoice {Id}. Inner: {Inner}", id, ex.InnerException?.Message ?? ex.Message); TempData["ErrorPermanent"] = $"PDF generation failed: {ex.Message}"; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // GET: /Invoices/ForJob/5 — redirect to existing or Create // ----------------------------------------------------------------------- /// /// Convenience router for the "Invoice this job" button on Job Details. If an invoice already /// exists for the job (uses IgnoreQueryFilters to also catch soft-deleted records, enforcing /// the 1:1 uniqueness rule), redirects straight to its Details page. Otherwise redirects to /// the Create form pre-seeded with the jobId. This keeps the navigation simple — the caller /// doesn't need to know whether an invoice exists yet. /// public async Task ForJob(int jobId) { try { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); return RedirectToAction(nameof(Create), new { jobId }); } catch (Exception ex) { _logger.LogError(ex, "Error in ForJob for job {JobId}", jobId); return RedirectToAction(nameof(Index)); } } // ----------------------------------------------------------------------- // POST: /Invoices/ResendInvoice/5 — AJAX, re-sends notification // ----------------------------------------------------------------------- /// /// AJAX action that re-sends the invoice notification email without changing the invoice status. /// Useful when a customer claims they never received it. Draft invoices are blocked (must use /// Send instead) and Voided invoices are blocked entirely. PDF generation failure is non-fatal — /// the email is sent without an attachment rather than aborting the resend. Returns JSON so the /// Details view can show an inline toast with the delivery outcome. /// [HttpPost, ValidateAntiForgeryToken] public async Task ResendInvoice(int id) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return Json(new { success = false, message = "Invoice not found." }); if (invoice.Status == InvoiceStatus.Draft) return Json(new { success = false, message = "Draft invoices must be sent using the Send action." }); if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff) return Json(new { success = false, message = "Voided invoices cannot be resent." }); var currentUser = await _userManager.GetUserAsync(User); var recipientName = invoice.Customer?.IsCommercial == true ? invoice.Customer.CompanyName ?? "Customer" : $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim(); var recipientEmail = invoice.Customer?.Email ?? string.Empty; byte[]? pdfBytes = null; string? pdfFilename = null; try { pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf"; } catch (Exception pdfEx) { _logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id); } await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); if (latestLog?.Status == NotificationStatus.Skipped) return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." }); return Json(new { success = true, message = $"Invoice resent to {recipientName} ({recipientEmail})." }); } catch (Exception ex) { _logger.LogError(ex, "Error resending invoice {Id}", id); return Json(new { success = false, message = $"An unexpected error occurred: {ex.Message}" }); } } // ----------------------------------------------------------------------- // GET: /Invoices/NotificationsSent/5 // ----------------------------------------------------------------------- /// /// AJAX endpoint that returns the full notification history for an invoice as JSON. /// Uses IgnoreQueryFilters to surface skipped/failed logs that may be soft-deleted. /// Called by the Details view's "Notification History" modal to let staff see whether /// emails were delivered, skipped (opt-out), or failed. /// [HttpGet] public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id); var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); } // ----------------------------------------------------------------------- // POST: /Invoices/Delete/5 — soft delete (Draft only) // ----------------------------------------------------------------------- /// /// Soft-deletes a Draft invoice and reverses all financial bookkeeping in a single transaction: /// — Reduces customer.CurrentBalance by the full invoice total (mirror of Create which added it). /// — Soft-deletes all line items and any payments (unlikely on Draft but handled defensively). /// — Un-applies any Deposit records that were auto-applied during Create, clearing their /// AppliedToInvoiceId so they can be re-applied if a new invoice is created for the same job. /// — Reverses account balances: credits AR, debits revenue + sales-tax accounts. /// Only Draft invoices can be deleted; non-draft invoices must be voided instead to preserve /// the full audit trail (Void leaves the invoice visible with Voided status). /// [HttpPost, ValidateAntiForgeryToken] public async Task Delete(int id) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(id); if (invoice == null) return NotFound(); if (invoice.Status != InvoiceStatus.Draft) { TempData["Error"] = "Only Draft invoices can be deleted. Void the invoice instead."; return RedirectToAction(nameof(Details), new { id }); } var currentUser = await _userManager.GetUserAsync(User); await _unitOfWork.ExecuteInTransactionAsync(async () => { // Reverse customer balance var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance -= invoice.Total; await _unitOfWork.Customers.UpdateAsync(customer); } // Soft-delete line items var invoiceItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == id); foreach (var item in invoiceItems) await _unitOfWork.InvoiceItems.SoftDeleteAsync(item.Id); // Soft-delete any payments (draft invoices shouldn't have them, but be safe) var payments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == id); foreach (var payment in payments) await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); // Un-apply any deposits that were applied to this invoice so they can be // re-applied if the invoice is recreated from the same job var appliedDeposits = await _unitOfWork.Deposits.FindAsync(d => d.AppliedToInvoiceId == id); foreach (var deposit in appliedDeposits) { deposit.AppliedToInvoiceId = null; deposit.AppliedDate = null; deposit.UpdatedAt = DateTime.UtcNow; } // Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, invoice.Total); foreach (var item in invoiceItems) await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); if (invoice.TaxAmount > 0) await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); await _unitOfWork.Invoices.SoftDeleteAsync(id); }); // end ExecuteInTransactionAsync TempData["Success"] = $"Invoice {invoice.InvoiceNumber} deleted."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error deleting invoice {Id}", id); TempData["Error"] = "An error occurred while deleting the invoice."; return RedirectToAction(nameof(Details), new { id }); } } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- /// /// Delegates to which expresses the full /// eight-table include chain. Returns null if not found or soft-deleted. /// private async Task LoadInvoiceForViewAsync(int id) => await _unitOfWork.Invoices.LoadForViewAsync(id); /// /// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper /// handles the core scalar fields; this method manually maps the child collections (Payments, /// InvoiceItems, Refunds, CreditApplications, GiftCertificateRedemptions) because they require /// filtering (IsDeleted), ordering, and in the case of InvoiceItems the GeneratedGiftCertificateCode /// denormalization that AutoMapper can't handle without a custom resolver. /// private async Task BuildInvoiceDtoAsync(Invoice invoice) { var dto = _mapper.Map(invoice); dto.Payments = _mapper.Map>(invoice.Payments.Where(p => !p.IsDeleted).OrderByDescending(p => p.PaymentDate).ToList()); dto.InvoiceItems = invoice.InvoiceItems.Where(i => !i.IsDeleted).OrderBy(i => i.DisplayOrder).Select(i => { var itemDto = _mapper.Map(i); itemDto.GeneratedGiftCertificateCode = i.GeneratedGiftCertificate?.CertificateCode; return itemDto; }).ToList(); // Refunds if (invoice.Refunds != null) { dto.Refunds = invoice.Refunds.Where(r => !r.IsDeleted).OrderByDescending(r => r.RefundDate).Select(r => new RefundDto { Id = r.Id, InvoiceId = r.InvoiceId, PaymentId = r.PaymentId, Amount = r.Amount, RefundDate = r.RefundDate, RefundMethod = r.RefundMethod, Reason = r.Reason, Reference = r.Reference, Notes = r.Notes, Status = r.Status, IssuedDate = r.IssuedDate, IssuedByName = r.IssuedBy != null ? $"{r.IssuedBy.FirstName} {r.IssuedBy.LastName}".Trim() : null }).ToList(); } // Credit applications if (invoice.CreditApplications != null) { dto.CreditApplications = invoice.CreditApplications.Where(ca => !ca.IsDeleted).OrderByDescending(ca => ca.AppliedDate).Select(ca => new CreditMemoApplicationDto { Id = ca.Id, CreditMemoId = ca.CreditMemoId, MemoNumber = ca.CreditMemo?.MemoNumber ?? string.Empty, InvoiceId = ca.InvoiceId, AmountApplied = ca.AmountApplied, AppliedDate = ca.AppliedDate }).ToList(); } // Gift certificate redemptions if (invoice.GiftCertificateRedemptions != null) { dto.GiftCertificateRedemptions = invoice.GiftCertificateRedemptions .Where(gr => !gr.IsDeleted) .OrderByDescending(gr => gr.RedeemedDate) .Select(gr => new Application.DTOs.GiftCertificate.GiftCertificateRedemptionDto { Id = gr.Id, GiftCertificateId = gr.GiftCertificateId, InvoiceId = gr.InvoiceId, InvoiceNumber = invoice.InvoiceNumber, AmountRedeemed = gr.AmountRedeemed, RedeemedDate = gr.RedeemedDate }).ToList(); } return dto; } /// /// Assembles all data needed for PDF generation — company info, branding prefs, invoice DTO — /// and delegates to IPdfService. Separated from the controller actions so both DownloadPdf /// and Send can call a single method without duplicating the company-lookup logic. /// Logo data (binary) and content-type are passed through from the Company entity so the PDF /// renderer can embed the tenant's logo without a separate storage lookup. /// private async Task BuildInvoicePdfAsync(Invoice invoice, int companyId) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId); var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); var companyInfo = new Application.DTOs.Company.CompanyInfoDto { CompanyName = company?.CompanyName ?? string.Empty, Phone = company?.Phone, Address = company?.Address, City = company?.City, State = company?.State, ZipCode = company?.ZipCode, PrimaryContactEmail = company?.PrimaryContactEmail }; var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { AccentColor = prefs?.InAccentColor ?? "#374151", FooterNote = prefs?.InFooterNote, DefaultTerms = prefs?.InDefaultTerms }; var dto = await BuildInvoiceDtoAsync(invoice); return await _pdfService.GenerateInvoicePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo, template); } // ----------------------------------------------------------------------- // Gift Certificate auto-generation // ----------------------------------------------------------------------- /// /// Auto-generates a GiftCertificate record for each invoice line item flagged as IsGiftCertificate /// that doesn't already have a GeneratedGiftCertificateId. Called inside the Create transaction. /// Certificate numbering follows the same YYMM-#### pattern as invoices and quotes, scoped per /// company using IgnoreQueryFilters so deleted certs don't cause code collisions. /// After creating the cert the InvoiceItem is back-patched with GeneratedGiftCertificateId so the /// Details view can show the certificate code directly without a join. /// Returns a human-readable summary string appended to the success toast. /// private async Task GenerateGiftCertificatesForInvoiceAsync(Invoice invoice, ApplicationUser currentUser) { var gcItems = invoice.InvoiceItems .Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId == null) .ToList(); if (!gcItems.Any()) return string.Empty; foreach (var item in gcItems) { // Generate a unique certificate code (scoped to this company) var gcPrefix = $"GC-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var companyCerts = await _unitOfWork.GiftCertificates.FindAsync( c => c.CompanyId == invoice.CompanyId && c.CertificateCode.StartsWith(gcPrefix), true); var maxNum = companyCerts .Select(c => { int.TryParse(c.CertificateCode.Replace(gcPrefix, ""), out int n); return n; }) .DefaultIfEmpty(0).Max(); var code = $"{gcPrefix}{(maxNum + 1):D4}"; var cert = new GiftCertificate { CertificateCode = code, OriginalAmount = item.TotalPrice, RedeemedAmount = 0, IssuedReason = GiftCertificateIssuedReason.Sold, PurchasePrice = item.TotalPrice, PurchasingCustomerId = invoice.CustomerId, RecipientName = item.GcRecipientName, RecipientEmail = item.GcRecipientEmail, ExpiryDate = item.GcExpiryDate, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow, SourceInvoiceItemId = item.Id, IssuedById = currentUser.Id, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser.Email }; await _unitOfWork.GiftCertificates.AddAsync(cert); await _unitOfWork.CompleteAsync(); item.GeneratedGiftCertificateId = cert.Id; await _unitOfWork.InvoiceItems.UpdateAsync(item); } await _unitOfWork.CompleteAsync(); return gcItems.Count == 1 ? $" 1 gift certificate generated." : $" {gcItems.Count} gift certificates generated."; } /// /// Generates the next sequential credit-memo number for the company in the format CM-YYMM-####. /// Uses IgnoreQueryFilters so soft-deleted memos are included in the max-number calculation, /// preventing number reuse if a memo is deleted. Same pattern as invoice and quote numbering. /// private async Task GenerateMemoNumberAsync(int companyId) { var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var existing = (await _unitOfWork.CreditMemos.FindAsync( m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true)) .Select(m => m.MemoNumber) .ToList(); var maxNum = 0; foreach (var num in existing) { var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : ""; if (int.TryParse(suffix, out int n) && n > maxNum) maxNum = n; } return $"{prefix}{(maxNum + 1):D4}"; } /// /// Generates the next sequential invoice number for the company in the format PREFIX-YYMM-####. /// The prefix is read from CompanyPreferences.InvoiceNumberPrefix (defaults to "INV"). /// Uses IgnoreQueryFilters to include soft-deleted invoices in the highest-number search, /// preventing number reuse if an invoice is deleted and then a new one is created in the same month. /// This mirrors the same pattern used by and the quote/job /// number generators. /// private async Task GenerateInvoiceNumberAsync(int companyId) { var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); var invoicePrefix = !string.IsNullOrWhiteSpace(prefs?.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV"; var prefix = $"{invoicePrefix}-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var last = await _unitOfWork.Invoices.GetLastInvoiceNumberByPrefixAsync(companyId, prefix); var maxNum = 0; if (last != null && last.Length >= prefix.Length + 4) { var suffix = last.Substring(prefix.Length); if (int.TryParse(suffix, out int n)) maxNum = n; } return $"{prefix}{(maxNum + 1):D4}"; } /// /// Populates ViewBag data used by both Create GET and Create POST (on validation failure re-display): /// — Active customer list for the customer dropdown. /// — Company default tax rate and set of tax-exempt customer IDs for client-side JS to auto-zero tax. /// — Merchandise catalog items serialized as camelCase JSON for the invoice line-item picker modal. /// private async Task PopulateCreateViewBagAsync(int companyId) { var customers = await _unitOfWork.Customers.GetAllAsync(); ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList(); // Expose company default tax rate and exempt customer IDs for client-side tax handling var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == companyId && !c.IsDeleted); ViewBag.CompanyTaxPercent = costs?.TaxPercent ?? 0; ViewBag.TaxExemptCustomerIds = customers .Where(c => c.IsActive && c.IsTaxExempt) .Select(c => c.Id) .ToHashSet(); // Merchandise items for the invoice merch picker (all active IsMerchandise items) var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( i => i.IsMerchandise && i.IsActive, false, i => i.Category); var merchItems = allMerchItems .OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId }) .ToList(); ViewBag.MerchandiseItems = System.Text.Json.JsonSerializer.Serialize(merchItems, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); } /// /// Loads active Checking and Savings accounts into ViewBag for the deposit-account dropdown on /// the RecordPayment and EditPayment modals. Only bank accounts (not AR, revenue, etc.) are shown /// because payments are deposited into a physical bank account. /// private async Task PopulateBankAccountsAsync() { var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive && (a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Savings)); ViewBag.BankAccounts = accounts .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); } /// Returns the AR account ID for the given company (first active AccountsReceivable account). private async Task GetArAccountIdAsync(int companyId) { var accounts = await _unitOfWork.Accounts.FindAsync( a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable); return accounts.FirstOrDefault()?.Id; } /// Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name. private async Task ResolveSalesTaxAccountIdAsync(int companyId) { var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.AccountNumber == "2200" && a.IsActive); taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax")); return taxAccount?.Id; } public static string GetStatusColorClass(InvoiceStatus status) => status switch { InvoiceStatus.Draft => "secondary", InvoiceStatus.Sent => "primary", InvoiceStatus.PartiallyPaid => "warning", InvoiceStatus.Paid => "success", InvoiceStatus.Overdue => "danger", InvoiceStatus.Voided => "dark", InvoiceStatus.WrittenOff => "dark", _ => "secondary" }; public static string GetStatusDisplay(InvoiceStatus status) => status switch { InvoiceStatus.Draft => "Draft", InvoiceStatus.Sent => "Sent", InvoiceStatus.PartiallyPaid => "Partially Paid", InvoiceStatus.Paid => "Paid", InvoiceStatus.Overdue => "Overdue", InvoiceStatus.Voided => "Voided", InvoiceStatus.WrittenOff => "Written Off", _ => status.ToString() }; // ── Refunds ────────────────────────────────────────────────────────────── /// /// Records a refund against an invoice with two distinct paths: /// — Store credit: immediately creates a CreditMemo and increments customer.CreditBalance. /// The refund status is set to Issued automatically since no physical money movement occurs. /// — Cash/card/ACH: records the refund as Pending (requiring physical payment by the shop) and /// reduces customer.CurrentBalance to reflect the amount now owed back to them. /// In both cases a Refund entity is created for the audit trail. The credit-memo ID is /// back-referenced on the refund so can void both together. /// [HttpPost] [ValidateAntiForgeryToken] public async Task IssueRefund(int invoiceId, IssueRefundDto dto) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer); if (invoice == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); var companyId = invoice.CompanyId; var isStoreCredit = dto.RefundMethod == PaymentMethod.StoreCredit; var refund = new Refund { InvoiceId = invoiceId, PaymentId = dto.PaymentId > 0 ? dto.PaymentId : null, Amount = dto.Amount, RefundDate = dto.RefundDate, RefundMethod = dto.RefundMethod, Reason = dto.Reason, Reference = dto.Reference, Notes = dto.Notes, Status = isStoreCredit ? RefundStatus.Issued : RefundStatus.Pending, IssuedDate = isStoreCredit ? DateTime.UtcNow : null, IssuedById = currentUser?.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.Refunds.AddAsync(refund); if (isStoreCredit) { // Create a CreditMemo for the store credit amount var memoNumber = await GenerateMemoNumberAsync(companyId); var memo = new CreditMemo { MemoNumber = memoNumber, CustomerId = invoice.CustomerId, OriginalInvoiceId = invoiceId, Amount = dto.Amount, AmountApplied = 0, IssueDate = DateTime.UtcNow, Reason = $"Refund as store credit: {dto.Reason}", Notes = dto.Notes, Status = CreditMemoStatus.Active, IssuedById = currentUser?.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CreditMemos.AddAsync(memo); await _unitOfWork.CompleteAsync(); // flush to get memo.Id refund.CreditMemoId = memo.Id; await _unitOfWork.Refunds.UpdateAsync(refund); if (invoice.Customer != null) { invoice.Customer.CreditBalance += dto.Amount; await _unitOfWork.Customers.UpdateAsync(invoice.Customer); } await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created."; } else { // Adjust customer AR balance — they're owed money back if (invoice.Customer != null) { invoice.Customer.CurrentBalance -= dto.Amount; await _unitOfWork.Customers.UpdateAsync(invoice.Customer); } await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually."; } } catch (Exception ex) { _logger.LogError(ex, "Error recording refund for invoice {InvoiceId}", invoiceId); TempData["Error"] = "An error occurred recording the refund."; } return RedirectToAction(nameof(Details), new { id = invoiceId }); } /// /// Marks a pending cash/card refund as Issued once the shop has physically sent the money. /// No balance adjustments happen here — those were made in when the /// refund was first recorded as Pending. /// [HttpPost] [ValidateAntiForgeryToken] public async Task MarkRefundIssued(int refundId) { var refund = await _unitOfWork.Refunds.GetByIdAsync(refundId); if (refund == null) return NotFound(); refund.Status = RefundStatus.Issued; refund.IssuedDate = DateTime.UtcNow; refund.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Refunds.UpdateAsync(refund); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Refund marked as issued."; return RedirectToAction(nameof(Details), new { id = refund.InvoiceId }); } /// /// Cancels a pending or issued refund and fully reverses the balance impact applied during /// . Store-credit refunds: voids the linked CreditMemo (if Active) /// and decrements customer.CreditBalance. Cash/card refunds: increments customer.CurrentBalance /// to restore the AR balance. The refund status is set to Cancelled in both cases. /// [HttpPost] [ValidateAntiForgeryToken] public async Task CancelRefund(int refundId) { var refund = await _unitOfWork.Refunds.GetByIdAsync(refundId, false, r => r.Invoice); if (refund == null) return NotFound(); var customer = await _unitOfWork.Customers.GetByIdAsync(refund.Invoice.CustomerId); if (refund.RefundMethod == PaymentMethod.StoreCredit) { // Cancel the linked CreditMemo and reverse the CreditBalance if (refund.CreditMemoId.HasValue) { var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value); if (memo != null && memo.Status == CreditMemoStatus.Active) { memo.Status = CreditMemoStatus.Voided; memo.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CreditMemos.UpdateAsync(memo); } } if (customer != null) { customer.CreditBalance -= refund.Amount; await _unitOfWork.Customers.UpdateAsync(customer); } } else { // Reverse the AR balance adjustment if (customer != null) { customer.CurrentBalance += refund.Amount; await _unitOfWork.Customers.UpdateAsync(customer); } } refund.Status = RefundStatus.Cancelled; refund.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Refunds.UpdateAsync(refund); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Refund cancelled."; return RedirectToAction(nameof(Details), new { id = refund.InvoiceId }); } // ── Credit Memos ───────────────────────────────────────────────────────── /// /// Issues a standalone credit memo against an invoice (e.g. for a price adjustment or rework /// goodwill credit). Increments customer.CreditBalance immediately so the credit is available /// for . Optionally links to a ReworkRecord if the credit stems from /// a documented rework event. /// [HttpPost] [ValidateAntiForgeryToken] public async Task IssueCreditMemo(int invoiceId, IssueCreditMemoDto dto) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer); if (invoice == null) return NotFound(); var currentUser = await _userManager.GetUserAsync(User); var companyId = invoice.CompanyId; // Generate memo number var memoNumber = await GenerateMemoNumberAsync(companyId); var memo = new CreditMemo { MemoNumber = memoNumber, CustomerId = invoice.CustomerId, OriginalInvoiceId = invoiceId, ReworkRecordId = dto.ReworkRecordId > 0 ? dto.ReworkRecordId : null, Amount = dto.Amount, AmountApplied = 0, IssueDate = DateTime.UtcNow, ExpiryDate = dto.ExpiryDate, Reason = dto.Reason, Notes = dto.Notes, Status = CreditMemoStatus.Active, IssuedById = currentUser?.Id, CompanyId = companyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CreditMemos.AddAsync(memo); // Add to customer's credit balance if (invoice.Customer != null) { invoice.Customer.CreditBalance += dto.Amount; await _unitOfWork.Customers.UpdateAsync(invoice.Customer); } await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer."; } catch (Exception ex) { _logger.LogError(ex, "Error issuing credit memo for invoice {InvoiceId}", invoiceId); TempData["Error"] = "An error occurred issuing the credit memo."; } return RedirectToAction(nameof(Details), new { id = invoiceId }); } /// /// Applies an active credit memo to an invoice's outstanding balance. The applied amount is /// capped at the minimum of the requested amount, the memo's RemainingBalance, and the invoice's /// current BalanceDue — so overapplication is impossible even with concurrent requests. /// A CreditMemoApplication record provides the per-invoice audit trail; the memo status advances /// to PartiallyApplied or FullyApplied; customer.CreditBalance is reduced; and if BalanceDue /// reaches zero the invoice is automatically marked Paid. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ApplyCredit(int invoiceId, ApplyCreditDto dto) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer); if (invoice == null) return NotFound(); var memo = await _unitOfWork.CreditMemos.GetByIdAsync(dto.CreditMemoId); if (memo == null || memo.Status == CreditMemoStatus.Voided) { TempData["Error"] = "Credit memo not found or voided."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } var applyAmount = Math.Min(dto.Amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue)); if (applyAmount <= 0) { TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } var currentUser = await _userManager.GetUserAsync(User); await _unitOfWork.ExecuteInTransactionAsync(async () => { // Create application record var application = new CreditMemoApplication { CreditMemoId = dto.CreditMemoId, InvoiceId = invoiceId, AmountApplied = applyAmount, AppliedDate = DateTime.UtcNow, AppliedById = currentUser?.Id, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CreditMemoApplications.AddAsync(application); // Update invoice invoice.CreditApplied += applyAmount; await _unitOfWork.Invoices.UpdateAsync(invoice); // Update credit memo memo.AmountApplied += applyAmount; memo.Status = memo.AmountApplied >= memo.Amount ? CreditMemoStatus.FullyApplied : CreditMemoStatus.PartiallyApplied; await _unitOfWork.CreditMemos.UpdateAsync(memo); // Reduce customer credit balance if (invoice.Customer != null) { invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount); await _unitOfWork.Customers.UpdateAsync(invoice.Customer); } // Update invoice status if now fully paid if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid) { invoice.Status = InvoiceStatus.Paid; invoice.PaidDate = DateTime.UtcNow; await _unitOfWork.Invoices.UpdateAsync(invoice); } await _unitOfWork.CompleteAsync(); }); // end ExecuteInTransactionAsync TempData["Success"] = $"Credit of {applyAmount:C} applied to invoice."; } catch (Exception ex) { _logger.LogError(ex, "Error applying credit to invoice {InvoiceId}", invoiceId); TempData["Error"] = "An error occurred applying the credit."; } return RedirectToAction(nameof(Details), new { id = invoiceId }); } /// /// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance. /// The portion already applied to invoices (AmountApplied) is NOT reversed — those applications /// already reduced the invoice BalanceDue and are part of the settled audit trail. /// Credit balance is floored at 0 via Math.Max to prevent negative credit balances from data /// inconsistencies (e.g. credit memo was partially applied, then balance was manually adjusted). /// [HttpPost] [ValidateAntiForgeryToken] public async Task VoidCreditMemo(int creditMemoId, int invoiceId) { var memo = await _unitOfWork.CreditMemos.GetByIdAsync(creditMemoId, false, m => m.Customer); if (memo == null) return NotFound(); memo.Status = CreditMemoStatus.Voided; memo.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CreditMemos.UpdateAsync(memo); // Reverse remaining balance from customer var remaining = memo.Amount - memo.AmountApplied; if (remaining > 0 && memo.Customer != null) { memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining); await _unitOfWork.Customers.UpdateAsync(memo.Customer); } await _unitOfWork.CompleteAsync(); TempData["Success"] = "Credit memo voided."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } // ----------------------------------------------------------------------- // GET: /Invoices/LookupGiftCertificate?code=GC-2503-0001 // AJAX endpoint — returns certificate info so the modal can validate before submit // ----------------------------------------------------------------------- /// /// AJAX lookup for a gift certificate by code. Returns balance, status, and recipient info /// so the ApplyGiftCertificate modal can show the user what they're applying before committing. /// Does NOT validate expiry or applicability here — that's enforced in /// to ensure the submit-time check is always the authoritative one. /// [HttpGet] public async Task LookupGiftCertificate(string code) { if (string.IsNullOrWhiteSpace(code)) return Json(new { found = false }); var cert = await _unitOfWork.GiftCertificates.FirstOrDefaultAsync( gc => gc.CertificateCode == code.Trim().ToUpper() && !gc.IsDeleted); if (cert == null) return Json(new { found = false }); return Json(new { found = true, id = cert.Id, certificateCode = cert.CertificateCode, remainingBalance = cert.RemainingBalance, status = cert.Status.ToString(), recipientName = cert.RecipientName, expiryDate = cert.ExpiryDate?.ToString("MM/dd/yyyy") }); } // ----------------------------------------------------------------------- // POST: /Invoices/ApplyGiftCertificate/{invoiceId} // ----------------------------------------------------------------------- /// /// Redeems a gift certificate against an invoice's outstanding balance. Enforces all validity /// rules at submit time: not voided, not fully redeemed, not expired. The applied amount is /// capped at the minimum of the requested amount, the certificate's RemainingBalance, and the /// invoice's current BalanceDue. Increments cert.RedeemedAmount and advances the certificate /// status to PartiallyRedeemed or FullyRedeemed. Updates invoice.GiftCertificateRedeemed (the /// field used in BalanceDue calculation) and auto-marks the invoice Paid if balance hits zero. /// Reduces customer.CurrentBalance by the applied amount to match the AR reduction. /// [HttpPost, ValidateAntiForgeryToken] public async Task ApplyGiftCertificate(int invoiceId, Application.DTOs.GiftCertificate.RedeemGiftCertificateDto dto) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false); if (invoice == null) return NotFound(); var cert = await _unitOfWork.GiftCertificates.FirstOrDefaultAsync( gc => gc.CertificateCode == dto.CertificateCode.Trim().ToUpper() && !gc.IsDeleted); if (cert == null || cert.Status == GiftCertificateStatus.Voided || cert.Status == GiftCertificateStatus.FullyRedeemed) { TempData["Error"] = "Gift certificate not found or already fully redeemed."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } if (cert.ExpiryDate.HasValue && cert.ExpiryDate.Value < DateTime.UtcNow) { TempData["Error"] = $"Gift certificate {cert.CertificateCode} expired on {cert.ExpiryDate.Value:MM/dd/yyyy}."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } var applyAmount = Math.Min(dto.Amount, Math.Min(cert.RemainingBalance, invoice.BalanceDue)); if (applyAmount <= 0) { TempData["Error"] = "No applicable amount — invoice may already be paid or certificate exhausted."; return RedirectToAction(nameof(Details), new { id = invoiceId }); } var currentUser = await _userManager.GetUserAsync(User); var redemption = new GiftCertificateRedemption { GiftCertificateId = cert.Id, InvoiceId = invoiceId, AmountRedeemed = applyAmount, RedeemedDate = DateTime.UtcNow, RedeemedById = currentUser?.Id, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow, CreatedBy = currentUser?.Email }; await _unitOfWork.GiftCertificateRedemptions.AddAsync(redemption); cert.RedeemedAmount += applyAmount; cert.Status = cert.RedeemedAmount >= cert.OriginalAmount ? GiftCertificateStatus.FullyRedeemed : GiftCertificateStatus.PartiallyRedeemed; await _unitOfWork.GiftCertificates.UpdateAsync(cert); invoice.GiftCertificateRedeemed += applyAmount; if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid) { invoice.Status = InvoiceStatus.Paid; invoice.PaidDate = DateTime.UtcNow; } await _unitOfWork.Invoices.UpdateAsync(invoice); // Reduce customer's outstanding AR balance var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance -= applyAmount; await _unitOfWork.Customers.UpdateAsync(customer); } await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice."; } catch (Exception ex) { _logger.LogError(ex, "Error applying gift certificate to invoice {InvoiceId}", invoiceId); TempData["Error"] = "An error occurred applying the gift certificate."; } return RedirectToAction(nameof(Details), new { id = invoiceId }); } // ─── Online Payment Link ────────────────────────────────────────────────── /// /// Generates (or regenerates) a payment link token for the invoice if online payments /// are enabled for the company. Sets a 5-day expiry. Does not save — caller must call CompleteAsync. /// private async Task TryGeneratePaymentTokenAsync(Invoice invoice) { var company = await _unitOfWork.Companies.GetByIdAsync(invoice.CompanyId); if (company == null) return null; if (company.StripeConnectStatus != StripeConnectStatus.Active) return null; if (invoice.BalanceDue <= 0) return null; var planConfig = await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); if (!onlinePaymentsAllowed) return null; var token = Guid.NewGuid().ToString("N"); invoice.PaymentLinkToken = token; invoice.PaymentLinkExpiresAt = DateTime.UtcNow.AddDays(5); invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending; return token; } /// /// Regenerates the Stripe online-payment link for an existing invoice by issuing a new token /// with a fresh 5-day expiry. Useful when the previous link expired before the customer paid. /// Returns JSON so the Details view can update the displayed URL without a full page reload. /// Blocks regeneration if the invoice is already paid (BalanceDue <= 0). /// [HttpPost, ValidateAntiForgeryToken] public async Task RegeneratePaymentLink(int id) { try { var invoice = await _unitOfWork.Invoices.GetByIdAsync(id); if (invoice == null) return NotFound(); if (invoice.BalanceDue <= 0) return Json(new { success = false, message = "This invoice has already been paid in full." }); var token = await TryGeneratePaymentTokenAsync(invoice); if (token == null) return Json(new { success = false, message = "Online payments are not available for this company." }); invoice.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); var paymentUrl = Url.Action("Index", "Payment", new { token }, Request.Scheme)! .Replace("/Payment/Index/", "/pay/"); return Json(new { success = true, paymentUrl }); } catch (Exception ex) { _logger.LogError(ex, "Error regenerating payment link for invoice {Id}", id); return Json(new { success = false, message = "An error occurred." }); } } // ----------------------------------------------------------------------- // GET: /Invoices/OnlinePayments // ----------------------------------------------------------------------- /// /// Online payment reconciliation view — shows all Stripe-originated payments and card refunds /// for a given date range. Defaults to the current calendar month. /// Uses invoice.UpdatedAt as the date proxy because PaidDate is only set when fully paid; /// partially paid invoices have no PaidDate but UpdatedAt advances on each payment event. /// Refunds are filtered to CreditDebitCard method only since other refund types (cash, check, /// store credit) are not Stripe-originated and belong in different reconciliation flows. /// [HttpGet] public async Task OnlinePayments(DateTime? from, DateTime? to) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var startDate = from ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1); var endDate = to ?? startDate.AddMonths(1).AddDays(-1); var endDateInclusive = endDate.AddDays(1); var invoices = await _unitOfWork.Invoices .GetOnlineInvoicesForPeriodAsync(companyId, startDate, endDateInclusive); var refunds = await _unitOfWork.Invoices .GetOnlineRefundsForPeriodAsync(companyId, startDate, endDateInclusive); var vm = new OnlinePaymentsViewModel { From = startDate, To = endDate, TotalCollected = invoices.Sum(i => i.OnlineAmountPaid), TotalRefunded = refunds.Sum(r => r.Amount), SurchargesCollected = invoices.Sum(i => i.OnlineSurchargeCollected), Invoices = invoices, Refunds = refunds }; return View(vm); } private async Task GetCompanyPreferencesAsync(int companyId) { return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); } private async Task StampInvoiceCreatedAsync(int companyId) { var prefs = await GetCompanyPreferencesAsync(companyId); if (prefs == null) return false; var changed = false; if (!prefs.FirstInvoiceCreatedAt.HasValue) { prefs.FirstInvoiceCreatedAt = DateTime.UtcNow; changed = true; _logger.LogInformation("Recorded first invoice creation for company {CompanyId}", companyId); } if (changed) await _unitOfWork.CompleteAsync(); return false; } }