using System.Text.Json; 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.DTOs.Quote; 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; private readonly ICompanyLogoService _logoService; public InvoicesController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, IPdfService pdfService, ITenantContext tenantContext, INotificationService notificationService, IAccountBalanceService accountBalanceService, ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _pdfService = pdfService; _tenantContext = tenantContext; _notificationService = notificationService; _accountBalanceService = accountBalanceService; _logoService = logoService; } private static readonly string[] StandardPaymentTerms = [ "Due on Receipt", "Net 15", "Net 30", "Net 45", "Net 60", "Net 90", "2% 10 Net 30", "COD", ]; /// /// Builds the payment terms SelectList for Create/Edit views. Always includes the provided /// even if it is a custom value not in the standard list. /// private static SelectList BuildPaymentTermsSelectList(string? selectedTerm) { var terms = StandardPaymentTerms.ToList(); if (!string.IsNullOrWhiteSpace(selectedTerm) && !terms.Contains(selectedTerm, StringComparer.OrdinalIgnoreCase)) terms.Insert(0, selectedTerm); return new SelectList(terms, selectedTerm); } // ----------------------------------------------------------------------- // 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 = PagedResult.From(gridRequest, dtos, totalCount); pagedResult.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; // Expense accounts for the write-off bad-debt modal var expenseAccounts = await _unitOfWork.Accounts.FindAsync( a => a.IsActive && a.AccountType == AccountType.Expense); ViewBag.ExpenseAccounts = expenseAccounts .OrderBy(a => a.AccountNumber).ThenBy(a => a.Name) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) .ToList(); 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 defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30"; var dto = new CreateInvoiceDto { PreparedById = currentUser.Id, InvoiceDate = DateTime.Today, DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today), TaxPercent = costs?.TaxPercent ?? 0, Terms = defaultTerms }; 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 active invoice for this job (voided ones are kept as history) var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value); if (existing != null && existing.Status != InvoiceStatus.Voided) 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); // Deserialize the job's pricing snapshot up front — it is authoritative for discount, // tax, and fees for both quote-based and direct jobs, because it is recalculated on // every save and reflects any edits made after quote conversion. QuotePricingBreakdownDto? jobBreakdown = null; if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) jobBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); // 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, CatalogItemId = item.CatalogItemId, Description = item.Description ?? "Powder Coating", Quantity = item.Quantity > 0 ? item.Quantity : 1, UnitPrice = item.UnitPrice, TotalPrice = item.TotalPrice, ColorName = item.ColorName, Notes = item.Notes, DisplayOrder = order++, RevenueAccountId = revenueAccountId }); } // Track whether there were real job items before any fallback bool hadJobItems = dto.InvoiceItems.Any(); // 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 (!hadJobItems) { 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) { // Bundle all quote-level charges so the invoice subtotal matches the quote total. // FacilityOverheadCost is included — it is a real cost baked into the quoted price. var processingFees = sourceQuote.OvenBatchCost + sourceQuote.FacilityOverheadCost + 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 — these represent the customer-approved // price and must not be recomputed from the job's current state. dto.TaxPercent = sourceQuote.TaxPercent; dto.DiscountAmount = sourceQuote.DiscountAmount; } else if (hadJobItems) { // Direct job — no source quote. Read all charges from the pricing snapshot so the // invoice always matches the total shown on the job's Pricing Summary card. if (job.OvenBatchCost > 0.01m) { dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = "Oven Processing Fee", Quantity = 1, UnitPrice = Math.Round(job.OvenBatchCost, 2), TotalPrice = Math.Round(job.OvenBatchCost, 2), DisplayOrder = order++, RevenueAccountId = defaultRevenueAccount?.Id }); } var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m; if (facilityOverhead > 0.01m) { dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = "Facility Overhead", Quantity = 1, UnitPrice = Math.Round(facilityOverhead, 2), TotalPrice = Math.Round(facilityOverhead, 2), DisplayOrder = order++, RevenueAccountId = defaultRevenueAccount?.Id }); } if (job.ShopSuppliesAmount > 0.01m) { var suppliesDesc = job.ShopSuppliesPercent > 0 ? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)" : "Shop Supplies"; dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = suppliesDesc, Quantity = 1, UnitPrice = Math.Round(job.ShopSuppliesAmount, 2), TotalPrice = Math.Round(job.ShopSuppliesAmount, 2), DisplayOrder = order++, RevenueAccountId = defaultRevenueAccount?.Id }); } var rushFee = jobBreakdown?.RushFee ?? 0m; if (rushFee > 0.01m) { dto.InvoiceItems.Add(new CreateInvoiceItemDto { Description = "Rush Fee", Quantity = 1, UnitPrice = Math.Round(rushFee, 2), TotalPrice = Math.Round(rushFee, 2), DisplayOrder = order, RevenueAccountId = defaultRevenueAccount?.Id }); } dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0; } // Inherit payment terms from the source quote or the customer — more specific than // the company-wide default set in the outer DTO. Quote terms take priority because // they represent the agreed price; customer terms are next best for direct jobs. var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms; if (!string.IsNullOrWhiteSpace(inheritedTerms)) { dto.Terms = inheritedTerms; dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today) ?? dto.DueDate; var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms); dto.EarlyPaymentDiscountPercent = discPct; dto.EarlyPaymentDiscountDays = discDays; } // 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, dto.Terms); 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, dto.Terms); 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, dto.Terms); ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Validate no existing active invoice for this job before starting the transaction. // Voided invoices are treated as history — clear their JobId FK so the unique index // slot is freed and the new invoice can be saved. if (dto.JobId.HasValue) { var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value); if (existing != null) { if (existing.Status != InvoiceStatus.Voided) { ModelState.AddModelError("", "An invoice already exists for this job."); await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms); ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Clear the voided invoice's JobId so the unique (CompanyId, JobId) index // allows the new invoice to be inserted. existing.JobId = null; await _unitOfWork.Invoices.UpdateAsync(existing); await _unitOfWork.SaveChangesAsync(); } } // 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) { // DepositAccountId is intentionally null: the bank account was already debited // when the deposit was recorded (DR Checking / CR Customer Deposits 2300). // Setting it here would double-count the bank debit in the Trial Balance. var payment = new Payment { InvoiceId = invoice.Id, Amount = deposit.Amount, PaymentDate = deposit.ReceivedDate, PaymentMethod = deposit.PaymentMethod, Reference = $"Deposit {deposit.ReceiptNumber}", Notes = deposit.Notes, DepositAccountId = null, 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. // Discount contra-entry: DR Sales Discounts so the GL balances. // Without it, credits (revenue + tax) exceed the AR debit by the discount amount. 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); if (invoice.DiscountAmount > 0) { var discountAccountId = await GetSalesDiscountAccountIdAsync(currentUser.CompanyId); await _accountBalanceService.DebitAsync(discountAccountId, invoice.DiscountAmount); } // GL for auto-applied deposits: DR Customer Deposits 2300 (clears the liability) / CR AR. // The bank was already debited when the deposit was recorded, so Checking is not touched here. if (pendingDeposits.Any()) { var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId); foreach (var dep in pendingDeposits) { await _accountBalanceService.DebitAsync(custDepositsAcctId, dep.Amount); await _accountBalanceService.CreditAsync(arAccountId, dep.Amount); } } 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, dto.Terms); ViewBag.GuidedActivation = guidedActivation; return View(dto); } } // ----------------------------------------------------------------------- // GET: /Invoices/Edit/5 // ----------------------------------------------------------------------- /// /// Loads the Edit form. Draft, Sent, and Overdue invoices are editable. Paid, PartiallyPaid, /// Voided, and WrittenOff invoices are locked — those statuses represent committed financial /// records that should not be altered after the fact. /// 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 is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue)) { TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) 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; ViewBag.InvoiceStatus = invoice.Status; var customerEmail = invoice.Customer?.BillingEmail ?? invoice.Customer?.Email; ViewBag.CanResend = invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue) && !string.IsNullOrWhiteSpace(customerEmail); ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms); 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 an open invoice (Draft, Sent, or Overdue). Line items are replaced via a /// soft-delete-and-add cycle so the original items are preserved in the audit trail. /// Customer.CurrentBalance is adjusted by the delta (newTotal − oldTotal). Status is kept /// as-is (Sent stays Sent) so the customer-facing record remains consistent. If resendToCustomer /// is true and the invoice is Sent/Overdue, a fresh PDF is emailed to the customer. /// [HttpPost, ValidateAntiForgeryToken] public async Task Edit(int id, UpdateInvoiceDto dto, bool resendToCustomer = false) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue)) { TempData["Error"] = "Only open invoices (Draft, Sent, Overdue) 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; ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(dto.Terms); return View(dto); } var currentUser = await _userManager.GetUserAsync(User); // Capture GL state before any mutations so the reversal is exact. var oldTotal = invoice.Total; var oldTaxAmount = invoice.TaxAmount; var oldTaxAcctId = invoice.SalesTaxAccountId; var oldDiscountAmt = invoice.DiscountAmount; var oldItems = invoice.InvoiceItems .Where(i => !i.IsDeleted) .Select(i => (RevAcctId: i.RevenueAccountId, Price: i.TotalPrice)) .ToList(); // Recalculate 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 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(); // Reverse old GL entries then re-post new ones so account balances stay accurate. // Reversal is the mirror of the original Create double-entry: swap every Debit↔Credit. var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); int? discAcctId = null; if (oldDiscountAmt > 0 || invoice.DiscountAmount > 0) discAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, oldTotal); foreach (var (revAcctId, price) in oldItems) await _accountBalanceService.DebitAsync(revAcctId, price); if (oldTaxAmount > 0) await _accountBalanceService.DebitAsync(oldTaxAcctId, oldTaxAmount); if (oldDiscountAmt > 0) await _accountBalanceService.CreditAsync(discAcctId, oldDiscountAmt); await _accountBalanceService.DebitAsync(arAccountId, invoice.Total); 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); if (invoice.DiscountAmount > 0) await _accountBalanceService.DebitAsync(discAcctId, invoice.DiscountAmount); await _unitOfWork.CompleteAsync(); TempData["Success"] = "Invoice updated successfully."; // Optionally re-send the updated invoice PDF to the customer if (resendToCustomer && invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue)) { try { var currentUserForPdf = await _userManager.GetUserAsync(User); if (string.IsNullOrEmpty(invoice.PublicViewToken)) { invoice.PublicViewToken = Guid.NewGuid().ToString("N"); await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); } var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId); string? paymentUrl = null; if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}"; await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl); var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(notifLog); } catch (Exception notifyEx) { _logger.LogWarning(notifyEx, "Re-send of updated invoice {Id} failed", id); TempData["WarningPermanent"] = "Invoice saved, but re-sending the email failed. You can re-send manually from the invoice details."; } } 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. Staff can choose email, SMS, or both via the modal. /// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is /// only generated when Stripe Connect is active (expiring pay link for email/view page). /// Notification failure is caught separately — a failed send must not roll back the status change. /// [HttpPost, ValidateAntiForgeryToken] public async Task Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false) { 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; // Permanent view token — always generate so SMS always has a link if (string.IsNullOrEmpty(invoice.PublicViewToken)) invoice.PublicViewToken = Guid.NewGuid().ToString("N"); await TryGeneratePaymentTokenAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); string? paymentUrl = null; if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}"; bool notifSucceeded = false; try { byte[]? pdfBytes = null; if (sendEmail) pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); await _notificationService.NotifyInvoiceSentAsync( invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim(), sendSms: sendSms, viewUrl: viewUrl); notifSucceeded = true; } catch (Exception notifyEx) { _logger.LogError(notifyEx, "Invoice {InvoiceId} ({InvoiceNumber}): notification 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 (!notifSucceeded) TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your 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); var totalCreditCreated = 0m; // populated inside transaction, used in success message 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); } // Re-release any deposits that were applied to this invoice so they can be // auto-applied to the replacement invoice. Without this, AppliedToInvoiceId // stays set and the deposit lookup (AppliedToInvoiceId == null) skips them. var appliedDeposits = await _unitOfWork.Deposits.FindAsync( d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted); var totalDepositReleased = 0m; foreach (var deposit in appliedDeposits) { deposit.AppliedToInvoiceId = null; deposit.AppliedDate = null; deposit.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Deposits.UpdateAsync(deposit); totalDepositReleased += deposit.Amount; } // Restore the CustomerDeposits 2300 liability that was cleared when the deposits // were applied. Mirrors the DR at apply time; follows the same simplified reversal // pattern as the rest of the void (regular payment GL entries are also left as-is). if (totalDepositReleased > 0) { var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased); } // Convert non-deposit payments (cash, card, check, online) to customer credits so // the money isn't lost when the invoice is voided. Each payment becomes a CRED- // Deposit record linked to the same job; it will auto-apply when the replacement // invoice is created, exactly like a normal deposit. var nonDepositPayments = invoice.Payments .Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit ")) .ToList(); if (nonDepositPayments.Any()) { var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; var existingNums = (await _unitOfWork.Deposits.FindAsync( d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix), ignoreQueryFilters: true)) .Select(d => d.ReceiptNumber).ToList(); var maxNum = 0; foreach (var rn in existingNums) { var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : ""; if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed; } var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId); foreach (var payment in nonDepositPayments) { maxNum++; await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit { CompanyId = invoice.CompanyId, CustomerId = invoice.CustomerId, JobId = invoice.JobId, Amount = payment.Amount, PaymentMethod = payment.PaymentMethod, ReceivedDate = payment.PaymentDate, Reference = payment.Reference, Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" + (string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"), ReceiptNumber = $"{credPrefix}{maxNum:D4}", CreatedAt = DateTime.UtcNow }); totalCreditCreated += payment.Amount; } // CR CustomerDeposits to create the liability matching the cash already in Checking await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated); } // Void any gift certificates that were generated from this invoice. // Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it. var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId); var gcRemainingByItemId = new Dictionary(); // invoiceItemId → remaining balance foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)) { var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value); if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed) { gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance; gc.Status = GiftCertificateStatus.Voided; gc.UpdatedAt = DateTime.UtcNow; await _unitOfWork.GiftCertificates.UpdateAsync(gc); } // FullyRedeemed GCs: not voided, nothing to reverse (GC Liability already at 0). } // Reverse account balances: credit AR (open balance), debit revenue + sales tax. // Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit. // GC line items: instead of debiting revenue (which was already reclassified to GC Liability // at creation), debit GC Liability for the unredeemed portion, netting the obligation to 0. var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, balanceDue); foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted)) { if (item.IsGiftCertificate) { // GC item: debit GC Liability for unredeemed portion; skip fully-redeemed items. if (gcLiabilityAcctId.HasValue && gcRemainingByItemId.TryGetValue(item.Id, out var remaining) && remaining > 0) await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining); } else { await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); } } if (invoice.TaxAmount > 0) await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount); if (invoice.DiscountAmount > 0) { var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount); } invoice.Status = InvoiceStatus.Voided; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await _unitOfWork.Invoices.UpdateAsync(invoice); }); // end ExecuteInTransactionAsync var creditMsg = totalCreditCreated > 0 ? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice." : ""; TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}"; 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 }); } } // ----------------------------------------------------------------------- // POST: /Invoices/WriteOff/5 // ----------------------------------------------------------------------- /// /// Writes off an uncollectible invoice. Posts a GL journal entry: /// DR Bad Debt Expense (user-selected account) for the remaining BalanceDue /// CR Accounts Receivable for the same amount /// Then marks the invoice WrittenOff and reduces customer.CurrentBalance. /// Only the outstanding BalanceDue is written off; amounts already collected are unaffected. /// [HttpPost] [ValidateAntiForgeryToken] public async Task WriteOff(int id, int? expenseAccountId, string? notes) { try { var invoice = await LoadInvoiceForViewAsync(id); if (invoice == null) return NotFound(); if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Voided or InvoiceStatus.WrittenOff) { TempData["Error"] = "Invoice cannot be written off in its current status."; return RedirectToAction(nameof(Details), new { id }); } var balanceDue = invoice.BalanceDue; if (balanceDue <= 0) { TempData["Error"] = "Invoice has no outstanding balance to write off."; return RedirectToAction(nameof(Details), new { id }); } var currentUser = await _userManager.GetUserAsync(User); var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); var badDebtAccountId = expenseAccountId > 0 ? expenseAccountId : await GetBadDebtAccountIdAsync(invoice.CompanyId); await _unitOfWork.ExecuteInTransactionAsync(async () => { // GL: DR Bad Debt Expense / CR AR await _accountBalanceService.DebitAsync(badDebtAccountId, balanceDue); await _accountBalanceService.CreditAsync(arAccountId, balanceDue); // Post a supporting JE for the audit trail var je = new JournalEntry { EntryNumber = await GenerateJournalEntryNumberAsync(invoice.CompanyId), EntryDate = DateTime.UtcNow, Description = $"Write-off of invoice {invoice.InvoiceNumber}{(string.IsNullOrWhiteSpace(notes) ? "" : $" — {notes}")}", Reference = invoice.InvoiceNumber, Status = JournalEntryStatus.Posted, PostedBy = currentUser?.Email, PostedAt = DateTime.UtcNow, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow, Lines = new List { new JournalEntryLine { AccountId = badDebtAccountId ?? 0, Description = $"Bad debt — invoice {invoice.InvoiceNumber}", DebitAmount = balanceDue, CreditAmount = 0, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow }, new JournalEntryLine { AccountId = arAccountId ?? 0, Description = $"Write-off AR — invoice {invoice.InvoiceNumber}", DebitAmount = 0, CreditAmount = balanceDue, CompanyId = invoice.CompanyId, CreatedAt = DateTime.UtcNow } } }; await _unitOfWork.JournalEntries.AddAsync(je); // Reduce customer running balance var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId); if (customer != null) { customer.CurrentBalance = Math.Max(0, customer.CurrentBalance - balanceDue); await _unitOfWork.Customers.UpdateAsync(customer); } invoice.Status = InvoiceStatus.WrittenOff; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedBy = currentUser?.Email; await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.CompleteAsync(); }); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} written off ({balanceDue:C} posted to Bad Debt Expense)."; } catch (Exception ex) { _logger.LogError(ex, "Error writing off invoice {Id}", id); TempData["Error"] = "An error occurred while writing off 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, bool inline = false) { 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); var fileName = $"Invoice-{invoice.InvoiceNumber}.pdf"; if (inline) { // Return with inline content-disposition so the browser renders the PDF // in a new tab, enabling the native print dialog. Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\""; return File(pdfBytes, "application/pdf"); } return File(pdfBytes, "application/pdf", fileName); } 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); // Voided invoices are kept as history — don't block creation of a new one if (existing != null && existing.Status != InvoiceStatus.Voided) 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, string? overrideEmail = null) { 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." }); // Validate override email when provided overrideEmail = overrideEmail?.Trim(); if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@')) return Json(new { success = false, message = "The email address provided is not valid." }); 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 = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty; if (string.IsNullOrWhiteSpace(recipientEmail)) return Json(new { success = false, message = "No email address on file. Please provide an address to send to." }); 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, overrideEmail: overrideEmail); 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 sent to {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. // Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit. var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); // Reverse deposit-apply GL: DR AR / CR Customer Deposits 2300 for each previously applied // deposit. The deposits are now unapplied and the liability is restored. if (appliedDeposits.Any()) { var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId); foreach (var dep in appliedDeposits) { await _accountBalanceService.DebitAsync(arAccountId, dep.Amount); await _accountBalanceService.CreditAsync(custDepositsAcctId, dep.Amount); } } 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); if (invoice.DiscountAmount > 0) { var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount); } // Clear the JobId FK before soft-deleting so the unique index slot is freed // and a new invoice can be created for the same job if needed. invoice.JobId = null; await _unitOfWork.Invoices.UpdateAsync(invoice); 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 (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var dto = await BuildInvoiceDtoAsync(invoice); return await _pdfService.GenerateInvoicePdfAsync(dto, logoData, 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); // GL: DR Revenue (line item account) / CR Gift Certificate Liability (2500). // Reclassifies the GC item's revenue as a deferred obligation until the cert is redeemed. var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId); await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice); await _accountBalanceService.CreditAsync(gcLiabilityAcctId, item.TotalPrice); } 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}"; } /// /// Returns the customer's payment terms, derived due date, and early-payment discount info /// for the Invoice Create form so JavaScript can auto-populate those fields on customer selection. /// [HttpGet] public async Task GetCustomerPaymentTerms(int customerId) { var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); if (customer == null) return NotFound(); var invoiceDate = DateTime.Today; var dueDate = PaymentTermsParser.CalculateDueDate(customer.PaymentTerms, invoiceDate); var (discountPercent, discountDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(customer.PaymentTerms); return Json(new { paymentTerms = customer.PaymentTerms, dueDate = dueDate?.ToString("yyyy-MM-dd"), earlyPaymentDiscountPercent = discountPercent, earlyPaymentDiscountDays = discountDays, isTaxExempt = customer.IsTaxExempt }); } /// /// Returns the default active tax rate for the current company, or zero for tax-exempt customers. /// Called by the Invoice Create form when the customer selection changes. /// [HttpGet] public async Task GetTaxRateForCustomer(int customerId) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); if (customer == null) return NotFound(); if (customer.IsTaxExempt) return Json(new { taxPercent = 0m, taxRateName = (string?)null }); var defaultRate = await _unitOfWork.TaxRates .FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted); return Json(new { taxPercent = defaultRate?.Rate ?? 0m, taxRateName = defaultRate?.Name }); } /// /// 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, string? selectedTerms = null) { var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); 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(); // Payment terms dropdown — pre-select selectedTerms if provided, else company default var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); var defaultTerms = selectedTerms ?? prefs?.DefaultPaymentTerms ?? "Net 30"; ViewBag.PaymentTermsOptions = BuildPaymentTermsSelectList(defaultTerms); // 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.Cash || 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 primary Checking or Cash account ID for the company, used as the /// deposit account when auto-applying deposits that were recorded without an explicit account. private async Task GetCheckingAccountIdAsync(int companyId) { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && (a.AccountSubType == AccountSubType.Checking || a.AccountSubType == AccountSubType.Cash)); return acct?.Id; } /// Returns account 2300 "Customer Deposits" liability ID for the company, or null. private async Task GetCustomerDepositsAccountIdAsync(int companyId) { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountNumber == "2300"); return acct?.Id; } /// 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; } /// /// Returns the Bad Debt Expense account for write-offs — prefers an account whose name /// contains "bad debt", falls back to the first active Expense-type account. /// private async Task GetBadDebtAccountIdAsync(int companyId) { var expenses = await _unitOfWork.Accounts.FindAsync( a => a.IsActive && a.AccountType == AccountType.Expense); return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase) || a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id ?? expenses.FirstOrDefault()?.Id; } /// /// Generates the next sequential JE number in JE-YYMM-#### format. /// Queries across soft-deleted entries to prevent reuse after deletion. /// private async Task GenerateJournalEntryNumberAsync(int companyId) { var prefix = $"JE-{DateTime.Now:yyMM}-"; var all = await _unitOfWork.JournalEntries.FindAsync( je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix), ignoreQueryFilters: true); int next = 1; if (all.Any()) { var nums = all.Select(je => je.EntryNumber[prefix.Length..]) .Select(s => int.TryParse(s, out int n) ? n : 0); next = nums.Max() + 1; } return $"{prefix}{next:D4}"; } /// 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; } /// /// Looks up the "4950 Sales Discounts" contra-revenue account for this company, falling back /// to any active Revenue account whose name contains "discount". Returns null only when no /// such account exists (e.g. for companies whose chart of accounts predates the 4950 seed). /// private async Task GetSalesDiscountAccountIdAsync(int companyId) { var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.AccountNumber == "4950" && a.IsActive); discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount")); return discountAccount?.Id; } /// Returns the Gift Certificate Liability account ID (account 2500) for the company. private async Task GetGcLiabilityAccountIdAsync(int companyId) { var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.IsActive && a.AccountNumber == "2500"); return acct?.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, // DepositAccountId only applies to cash/card refunds; store-credit refunds have no bank movement. DepositAccountId = isStoreCredit ? null : dto.DepositAccountId, 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(); // GL: DR AR (un-collects the payment) / CR Bank (cash leaves). // Mirrors how FinancialReportService accounts for refunds: // arTotalCredits -= refundTotal; refundsByAcct credits the bank account. var arAccountId = await GetArAccountIdAsync(companyId); await _accountBalanceService.DebitAsync(arAccountId, dto.Amount); await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount); 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); } // GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund. var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId); await _accountBalanceService.CreditAsync(arAccountId, refund.Amount); await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount); } 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); } // GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply. var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); await _accountBalanceService.DebitAsync(discountAcctId, applyAmount); await _accountBalanceService.CreditAsync(arAccountId, applyAmount); 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); } // GL: DR Gift Certificate Liability (2500) / CR AR. // Discharges the deferred obligation and reduces the invoice's outstanding AR balance. var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId); var arAcctId = await GetArAccountIdAsync(invoice.CompanyId); await _accountBalanceService.DebitAsync(gcLiabilityAcctId, applyAmount); await _accountBalanceService.CreditAsync(arAcctId, applyAmount); 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; } /// /// Returns logo bytes and content type for PDF generation. /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData). /// private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) { if (company == null) return (null, null); if (!string.IsNullOrEmpty(company.LogoFilePath)) { var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath); if (ok) return (content, contentType); } return (company.LogoData, company.LogoContentType); } }