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.Accounting;
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<ApplicationUser> _userManager;
    private readonly ILogger<InvoicesController> _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<ApplicationUser> userManager,
        ILogger<InvoicesController> 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",
    ];

    /// <summary>
    /// Builds the payment terms SelectList for Create/Edit views. Always includes the provided
    /// <paramref name="selectedTerm"/> even if it is a custom value not in the standard list.
    /// </summary>
    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
    // -----------------------------------------------------------------------
    /// <summary>
    /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
    /// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth)
    /// 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.
    /// </summary>
    public async Task<IActionResult> Index(
        string? searchTerm,
        InvoiceStatus? statusFilter,
        string? statusGroup,
        string? sortColumn,
        string sortDirection = "desc",
        bool outstandingOnly = false,
        bool thisMonthOnly = false,
        bool overdueOnly = false,
        int pageNumber = 1,
        int pageSize = 25)
    {
        try
        {
            // Default landing: show unpaid invoices so the list is immediately actionable.
            if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
                string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
                return RedirectToAction("Index", new { statusGroup = "unpaid" });

            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<Func<Invoice, bool>>? filter = null;

            // Status-group pills take priority over the dropdown and legacy flags.
            if (!string.IsNullOrEmpty(statusGroup))
            {
                filter = statusGroup switch
                {
                    "unpaid"  => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
                    "partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
                    "paid"    => i => i.Status == InvoiceStatus.Paid,
                    _         => null  // "all" — no predicate
                };
            }
            else 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<IQueryable<Invoice>, IOrderedQueryable<Invoice>> 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<List<InvoiceListDto>>(items);

            var pagedResult = PagedResult<InvoiceListDto>.From(gridRequest, dtos, totalCount);
            pagedResult.SearchTerm = searchTerm;

            ViewBag.SearchTerm = searchTerm;
            ViewBag.StatusFilter = statusFilter;
            ViewBag.StatusGroup = statusGroup;
            ViewBag.OutstandingOnly = outstandingOnly;
            ViewBag.ThisMonthOnly = thisMonthOnly;
            ViewBag.OverdueOnly = overdueOnly;
            ViewBag.SortColumn = gridRequest.SortColumn;
            ViewBag.SortDirection = gridRequest.SortDirection;

            // Pill badge counts — always global (not scoped to current filter/page)
            ViewBag.UnpaidCount  = await _unitOfWork.Invoices.CountAsync(i =>
                i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
            ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
            ViewBag.PaidCount    = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
            ViewBag.AllCount     = await _unitOfWork.Invoices.CountAsync();

            return View(pagedResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error loading invoices");
            TempData["Error"] = "An error occurred while loading invoices.";
            return View(new PagedResult<InvoiceListDto>());
        }
    }

    // -----------------------------------------------------------------------
    // GET: /Invoices/Details/5
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    public async Task<IActionResult> 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 expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
            var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
                a => a.CompanyId == expenseCompanyId && 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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 &amp; 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.
    /// </summary>
    public async Task<IActionResult> 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();

                // Pre-load coats so we can derive color names for invoice line items
                var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
                var allCoats = activeItemIds.Any()
                    ? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
                    : new List<JobItemCoat>();
                var coatsByItem = allCoats
                    .GroupBy(c => c.JobItemId)
                    .ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());

                // 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;
                dto.ProjectName = job.ProjectName;

                // 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<int, CatalogItem>();

                // Fall back to the company's configured default revenue account when a catalog item
                // has no specific account; if none is configured (or it has since been deactivated),
                // fall back to the seeded 4000 account. The IsActive check mirrors the 4000 lookup so a
                // deactivated default doesn't keep being posted to.
                Account? defaultRevenueAccount = null;
                if (prefs?.DefaultRevenueAccountId != null)
                    defaultRevenueAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
                        a => a.Id == prefs.DefaultRevenueAccountId.Value && a.IsActive);
                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<QuotePricingBreakdownDto>(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;

                    // Derive color from coats when the item itself has no explicit color set
                    var derivedColor = item.ColorName;
                    if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
                    {
                        var coatColors = itemCoats
                            .Where(c => !string.IsNullOrEmpty(c.ColorName))
                            .Select(c => c.ColorName!);
                        derivedColor = string.Join(" / ", coatColors);
                    }

                    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        = derivedColor,
                        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.
                // For fee components, prefer the job's own breakdown snapshot (updated every time the job
                // is saved) over the source quote — the quote's FacilityOverheadCost was only added in
                // migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though
                // overhead was included in the quote total. Tax and discount still come from the quote
                // because those represent the customer-approved agreed terms.
                if (sourceQuote != null)
                {
                    // Prefer job breakdown values for dynamic fee components; fall back to quote for
                    // compatibility with jobs that were never re-saved after the May 2026 migration.
                    var ovenCost     = jobBreakdown != null ? jobBreakdown.OvenBatchCost      : sourceQuote.OvenBatchCost;
                    var overhead     = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost;
                    var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount  : sourceQuote.ShopSuppliesAmount;
                    var rushFee      = jobBreakdown != null ? jobBreakdown.RushFee             : sourceQuote.RushFee;

                    // Bundle all quote-level charges so the invoice subtotal matches the job total.
                    var processingFees = ovenCost + overhead + shopSupplies + 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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 &gt; 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 <see cref="GenerateGiftCertificatesForInvoiceAsync"/>.
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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<Deposit> 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,
                ProjectName = dto.ProjectName,
                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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    public async Task<IActionResult> 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,
                ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
                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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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.ProjectName = dto.ProjectName;
            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, invoice.CompanyId);
                    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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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, invoice.CompanyId);
            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
    // -----------------------------------------------------------------------
    /// <summary>
    /// Records a manual payment against an invoice and updates all related balances atomically.
    /// Partial payments are supported: only <c>Math.Min(dto.Amount, balanceDue)</c> 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 &lt;= 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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) — skipped if user explicitly suppressed it
            if (!dto.SuppressNotification)
            {
                try
                {
                    await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
                }
                catch (Exception notifyEx)
                {
                    _logger.LogWarning(notifyEx, "Payment recorded but notification failed");
                }
            }


            var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
            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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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<int, decimal>(); // 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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<JournalEntryLine>
                    {
                        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
    // -----------------------------------------------------------------------
    /// <summary>
    /// Generates a QuestPDF invoice and returns it as an inline-download. Delegates to
    /// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
    /// and the full invoice DTO in one call, then hands off to IPdfService.
    /// </summary>
    public async Task<IActionResult> 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/DownloadPackingSlip/5
    // -----------------------------------------------------------------------
    /// <summary>
    /// Generates a no-price packing slip PDF for physical pickup/delivery paperwork.
    /// Reuses the same company branding and invoice data pipeline as DownloadPdf but
    /// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns.
    /// </summary>
    public async Task<IActionResult> DownloadPackingSlip(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 company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
            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 (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
            var dto = await BuildInvoiceDtoAsync(invoice);
            var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo);
            var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf";

            if (inline)
            {
                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 packing slip for invoice {Id}", id);
            TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}";
            return RedirectToAction(nameof(Details), new { id });
        }
    }

    // -----------------------------------------------------------------------
    // GET: /Invoices/ForJob/5  — redirect to existing or Create
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    public async Task<IActionResult> 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
    {
        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." });

            if (!sendEmail && !sendSms)
                return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." });

            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 (sendEmail && string.IsNullOrWhiteSpace(recipientEmail))
                return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });

            // Ensure a permanent view token exists so the SMS link always works.
            string? viewUrl = null;
            if (sendSms)
            {
                if (string.IsNullOrEmpty(invoice.PublicViewToken))
                {
                    invoice.PublicViewToken = Guid.NewGuid().ToString("N");
                    await _unitOfWork.Invoices.UpdateAsync(invoice);
                    await _unitOfWork.CompleteAsync();
                }
                viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
            }

            byte[]? pdfBytes = null;
            string? pdfFilename = null;
            if (sendEmail)
            {
                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,
                sendSms: sendSms,
                viewUrl: viewUrl);

            var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);

            if (latestLog?.Status == NotificationStatus.Failed)
                return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });

            if (latestLog?.Status == NotificationStatus.Skipped && !sendSms)
                return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });

            var channels = new List<string>();
            if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})");
            if (sendSms) channels.Add("SMS");
            return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." });
        }
        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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> NotificationsSent(int id)
    {
        var tz = ViewBag.CompanyTimeZone as string;
        var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
        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)
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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).
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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
    // -----------------------------------------------------------------------

    /// <summary>
    /// Delegates to <see cref="IInvoiceRepository.LoadForViewAsync"/> which expresses the full
    /// eight-table include chain. Returns null if not found or soft-deleted.
    /// </summary>
    private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
        await _unitOfWork.Invoices.LoadForViewAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<InvoiceDto> BuildInvoiceDtoAsync(Invoice invoice)
    {
        var dto = _mapper.Map<InvoiceDto>(invoice);
        dto.Payments = _mapper.Map<List<PaymentDtos.PaymentDto>>(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<InvoiceItemDto>(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;
    }

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<byte[]> 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    private async Task<string> 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.";
    }

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<string> 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}";
    }

    /// <summary>
    /// 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 <see cref="GenerateMemoNumberAsync"/> and the quote/job
    /// number generators.
    /// </summary>
    private async Task<string> 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}";
    }

    /// <summary>
    /// 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.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> 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
        });
    }

    /// <summary>
    /// 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.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> 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.CompanyId == customer.CompanyId && r.IsDefault && r.IsActive && !r.IsDeleted);

        return Json(new
        {
            taxPercent  = defaultRate?.Rate ?? 0m,
            taxRateName = defaultRate?.Name
        });
    }

    /// <summary>
    /// 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.
    /// </summary>
    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.CompanyId == companyId && 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 });
    }

    /// <summary>
    /// 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.
    /// </summary>
    private async Task PopulateBankAccountsAsync()
    {
        var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
        var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && 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();
    }

    /// <summary>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.</summary>
    private async Task<int?> GetCheckingAccountIdAsync(int companyId)
    {
        var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
                             || a.AccountSubType == AccountSubType.Cash));
        return acct?.Id;
    }

    /// <summary>Returns account 2300 "Customer Deposits" liability ID for the company, or null.</summary>
    private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
    {
        var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
        return acct?.Id;
    }

    /// <summary>Returns the Sales Returns &amp; Allowances contra-revenue account (4960) for refunds.</summary>
    private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
    {
        var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4960");
        return acct?.Id;
    }

    /// <summary>Returns the Customer Credits liability account (2350) for store credit / credit memos.</summary>
    private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
    {
        var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2350");
        return acct?.Id;
    }

    /// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
    private async Task<int?> GetArAccountIdAsync(int companyId)
    {
        var accounts = await _unitOfWork.Accounts.FindAsync(
            a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
        return accounts.FirstOrDefault()?.Id;
    }

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
    {
        var expenses = await _unitOfWork.Accounts.FindAsync(
            a => a.CompanyId == companyId && 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;
    }

    /// <summary>
    /// Generates the next sequential JE number in JE-YYMM-#### format.
    /// Queries across soft-deleted entries to prevent reuse after deletion.
    /// </summary>
    private async Task<string> 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}";
    }

    /// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
    private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
    {
        var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
        taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
        return taxAccount?.Id;
    }

    /// <summary>
    /// 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).
    /// </summary>
    private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
    {
        var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
        discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
        return discountAccount?.Id;
    }

    /// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
    private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
    {
        var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
            a => a.CompanyId == companyId && 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 ──────────────────────────────────────────────────────────────

    /// <summary>
    /// 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 <see cref="CancelRefund"/> can void both together.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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();

                // GL: store credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
                // / CR Customer Credits (2350). The liability is relieved when the credit memo is applied.
                var scDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
                var scCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
                await _accountBalanceService.DebitAsync(scDiscountAcctId, dto.Amount);
                await _accountBalanceService.CreditAsync(scCustomerCreditsAcctId, dto.Amount);

                TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
            }
            else
            {
                // "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
                // GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
                // Customer AR balance is intentionally left unchanged — the invoice stays paid and the
                // sale is reversed via the contra accounts. The split is centralised in RefundAllocation
                // so LedgerService and FinancialReportService recompute the same way.
                await _unitOfWork.CompleteAsync();

                var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
                var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
                var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);

                await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
                if (taxPortion > 0)
                    await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
                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 });
    }

    /// <summary>
    /// 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 <see cref="IssueRefund"/> when the
    /// refund was first recorded as Pending.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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 });
    }

    /// <summary>
    /// Cancels a pending or issued refund and fully reverses the balance impact applied during
    /// <see cref="IssueRefund"/>. 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.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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 unapplied store-credit remainder.
            decimal creditReversed = refund.Amount;
            if (refund.CreditMemoId.HasValue)
            {
                var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
                if (memo != null && memo.Status == CreditMemoStatus.Active)
                {
                    creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
                    memo.Status = CreditMemoStatus.Voided;
                    memo.UpdatedAt = DateTime.UtcNow;
                    await _unitOfWork.CreditMemos.UpdateAsync(memo);
                }
            }

            if (customer != null)
            {
                customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed);
                await _unitOfWork.Customers.UpdateAsync(customer);
            }

            // GL: reverse the unapplied store-credit issuance — DR Customer Credits / CR Sales Discounts.
            if (creditReversed > 0)
            {
                var ccAcctId = await GetCustomerCreditsAccountIdAsync(refund.Invoice.CompanyId);
                var sdAcctId = await GetSalesDiscountAccountIdAsync(refund.Invoice.CompanyId);
                await _accountBalanceService.DebitAsync(ccAcctId, creditReversed);
                await _accountBalanceService.CreditAsync(sdAcctId, creditReversed);
            }
        }
        else
        {
            // Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
            // The customer's AR balance was not touched when the refund was issued, so it is not touched here.
            var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
            var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
            var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);

            await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
            if (taxPortion > 0)
                await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
            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 ─────────────────────────────────────────────────────────

    /// <summary>
    /// 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 <see cref="ApplyCredit"/>. Optionally links to a ReworkRecord if the credit stems from
    /// a documented rework event.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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();

            // GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
            // / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
            var cmDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
            var cmCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
            await _accountBalanceService.DebitAsync(cmDiscountAcctId, dto.Amount);
            await _accountBalanceService.CreditAsync(cmCustomerCreditsAcctId, dto.Amount);

            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 });
    }

    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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.
            // GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
            // (The contra-revenue was already recognized as Sales Discounts when the credit was issued.)
            var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
            var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId);
            await _accountBalanceService.DebitAsync(customerCreditsAcctId, 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 });
    }

    /// <summary>
    /// 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).
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> 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);
        }

        // GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
        if (remaining > 0)
        {
            var ccAcctId = await GetCustomerCreditsAccountIdAsync(memo.CompanyId);
            var sdAcctId = await GetSalesDiscountAccountIdAsync(memo.CompanyId);
            await _accountBalanceService.DebitAsync(ccAcctId, remaining);
            await _accountBalanceService.CreditAsync(sdAcctId, remaining);
        }

        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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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 <see cref="ApplyGiftCertificate"/>
    /// to ensure the submit-time check is always the authoritative one.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> 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}
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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 ──────────────────────────────────────────────────

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<string?> 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;
    }

    /// <summary>
    /// 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 &lt;= 0).
    /// </summary>
    [HttpPost, ValidateAntiForgeryToken]
    public async Task<IActionResult> 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
    // -----------------------------------------------------------------------
    /// <summary>
    /// 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.
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> 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<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
    {
        return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
    }

    private async Task<bool> 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;
    }

    /// <summary>
    /// Inline-edits description, quantity, and unit price on a single invoice line item.
    /// Blocked on paid/voided invoices (same gate as the full Edit action).
    /// Returns updated totals so the page can reflect the change without a reload.
    /// </summary>
    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
    {
        var currentUser = await _userManager.GetUserAsync(User);
        if (currentUser == null) return Unauthorized();

        var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
        if (item == null) return NotFound();

        var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
        if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();

        if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
            return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });

        item.Description = request.Description.Trim();
        item.Quantity    = request.Quantity;
        item.UnitPrice   = request.UnitPrice;
        item.TotalPrice  = Math.Round(request.Quantity * request.UnitPrice, 2);
        await _unitOfWork.InvoiceItems.UpdateAsync(item);

        var allItems    = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
        var newSubTotal = allItems.Sum(i => i.TotalPrice);
        invoice.SubTotal   = newSubTotal;
        invoice.TaxAmount  = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
        invoice.Total      = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
        await _unitOfWork.Invoices.UpdateAsync(invoice);
        await _unitOfWork.CompleteAsync();

        return Json(new {
            lineTotal  = item.TotalPrice,
            subtotal   = invoice.SubTotal,
            taxAmount  = invoice.TaxAmount,
            total      = invoice.Total,
            balanceDue = invoice.BalanceDue
        });
    }

    /// <summary>
    /// Returns logo bytes and content type for PDF generation.
    /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
    /// </summary>
    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);
    }
}

public class PatchInvoiceItemRequest
{
    public int     ItemId      { get; set; }
    public string  Description { get; set; } = string.Empty;
    public decimal Quantity    { get; set; }
    public decimal UnitPrice   { get; set; }
}
