Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/InvoicesController.cs
T
spouliot fd38785942 Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:27:10 -04:00

3128 lines
149 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using PowderCoating.Web.Helpers;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
public class InvoicesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<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
/// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the
/// database receives a single targeted predicate — no full-table load then in-memory LINQ.
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
/// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
InvoiceStatus? statusFilter,
string? sortColumn,
string sortDirection = "desc",
bool outstandingOnly = false,
bool thisMonthOnly = false,
bool overdueOnly = false,
int pageNumber = 1,
int pageSize = 25)
{
try
{
var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1);
var gridRequest = new GridRequest
{
PageNumber = pageNumber,
PageSize = pageSize,
SortColumn = sortColumn ?? "CreatedAt",
SortDirection = sortColumn == null ? "desc" : sortDirection,
SearchTerm = searchTerm
};
gridRequest.Validate();
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
if (overdueOnly)
{
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& i.DueDate.HasValue && i.DueDate.Value < today;
}
else if (outstandingOnly && !string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& (i.InvoiceNumber.ToLower().Contains(s)
|| i.Customer.CompanyName!.ToLower().Contains(s)
|| (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s))
|| (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s))
|| (i.Job != null && i.Job.JobNumber.ToLower().Contains(s)));
}
else if (outstandingOnly)
{
filter = i => i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue;
}
else if (thisMonthOnly && statusFilter.HasValue && !string.IsNullOrWhiteSpace(searchTerm))
{
var st = statusFilter.Value;
var s = searchTerm.ToLower();
filter = i => i.Status == st
&& i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth
&& (i.InvoiceNumber.ToLower().Contains(s)
|| i.Customer.CompanyName!.ToLower().Contains(s)
|| (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s))
|| (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s))
|| (i.Job != null && i.Job.JobNumber.ToLower().Contains(s)));
}
else if (thisMonthOnly && statusFilter.HasValue)
{
var st = statusFilter.Value;
filter = i => i.Status == st && i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth;
}
else if (thisMonthOnly && !string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
filter = i => i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth
&& (i.InvoiceNumber.ToLower().Contains(s)
|| i.Customer.CompanyName!.ToLower().Contains(s)
|| (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s))
|| (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s))
|| (i.Job != null && i.Job.JobNumber.ToLower().Contains(s)));
}
else if (thisMonthOnly)
{
filter = i => i.InvoiceDate >= startOfMonth && i.InvoiceDate < endOfMonth;
}
else if (!string.IsNullOrWhiteSpace(searchTerm) && statusFilter.HasValue)
{
var s = searchTerm.ToLower();
var st = statusFilter.Value;
filter = i => (i.InvoiceNumber.ToLower().Contains(s)
|| i.Customer.CompanyName!.ToLower().Contains(s)
|| (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s))
|| (i.Job != null && i.Job.JobNumber.ToLower().Contains(s)))
&& i.Status == st;
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
filter = i => i.InvoiceNumber.ToLower().Contains(s)
|| i.Customer.CompanyName!.ToLower().Contains(s)
|| (i.CustomerPO != null && i.CustomerPO.ToLower().Contains(s))
|| (i.ExternalReference != null && i.ExternalReference.ToLower().Contains(s))
|| (i.Job != null && i.Job.JobNumber.ToLower().Contains(s));
}
else if (statusFilter.HasValue)
{
var st = statusFilter.Value;
filter = i => i.Status == st;
}
Func<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.OutstandingOnly = outstandingOnly;
ViewBag.ThisMonthOnly = thisMonthOnly;
ViewBag.OverdueOnly = overdueOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading invoices");
TempData["Error"] = "An error occurred while loading invoices.";
return View(new PagedResult<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 expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
{
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
{
Show = true,
Title = "This is how billing connects back to the job.",
Message = "Youve 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;
// 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 default revenue account (4000) if a catalog item has no specific account
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
// every save and reflects any edits made after quote conversion.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<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.
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
if (sourceQuote != null)
{
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee;
if (processingFees > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Oven & Shop Processing Fees",
Quantity = 1,
UnitPrice = Math.Round(processingFees, 2),
TotalPrice = Math.Round(processingFees, 2),
DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
// Use the quote's agreed tax rate and discount — these represent the customer-approved
// price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems)
{
// Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card.
if (job.OvenBatchCost > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Oven Processing Fee",
Quantity = 1,
UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = Math.Round(job.OvenBatchCost, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
if (facilityOverhead > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Facility Overhead",
Quantity = 1,
UnitPrice = Math.Round(facilityOverhead, 2),
TotalPrice = Math.Round(facilityOverhead, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
if (job.ShopSuppliesAmount > 0.01m)
{
var suppliesDesc = job.ShopSuppliesPercent > 0
? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)"
: "Shop Supplies";
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = suppliesDesc,
Quantity = 1,
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
var rushFee = jobBreakdown?.RushFee ?? 0m;
if (rushFee > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Rush Fee",
Quantity = 1,
UnitPrice = Math.Round(rushFee, 2),
TotalPrice = Math.Round(rushFee, 2),
DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
}
// Inherit payment terms from the source quote or the customer — more specific than
// the company-wide default set in the outer DTO. Quote terms take priority because
// they represent the agreed price; customer terms are next best for direct jobs.
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
if (!string.IsNullOrWhiteSpace(inheritedTerms))
{
dto.Terms = inheritedTerms;
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
?? dto.DueDate;
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
dto.EarlyPaymentDiscountPercent = discPct;
dto.EarlyPaymentDiscountDays = discDays;
}
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
if (job.Customer?.IsTaxExempt == true)
dto.TaxPercent = 0;
ViewBag.JobNumber = job.JobNumber;
ViewBag.CustomerName = job.Customer != null
? (job.Customer.IsCommercial
? job.Customer.CompanyName
: $"{job.Customer.ContactFirstName} {job.Customer.ContactLastName}".Trim())
: string.Empty;
}
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
ViewBag.GuidedActivation = guidedActivation;
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error preparing invoice Create form");
TempData["Error"] = "An error occurred.";
return RedirectToAction(nameof(Index));
}
}
// -----------------------------------------------------------------------
// POST: /Invoices/Create
// -----------------------------------------------------------------------
/// <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,
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,
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.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email;
// Adjust customer balance
var delta = newTotal - oldTotal;
if (delta != 0)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
if (customer != null)
{
customer.CurrentBalance += delta;
await _unitOfWork.Customers.UpdateAsync(customer);
}
}
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
// Reverse old GL entries then re-post new ones so account balances stay accurate.
// Reversal is the mirror of the original Create double-entry: swap every Debit↔Credit.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
int? discAcctId = null;
if (oldDiscountAmt > 0 || invoice.DiscountAmount > 0)
discAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, oldTotal);
foreach (var (revAcctId, price) in oldItems)
await _accountBalanceService.DebitAsync(revAcctId, price);
if (oldTaxAmount > 0)
await _accountBalanceService.DebitAsync(oldTaxAcctId, oldTaxAmount);
if (oldDiscountAmt > 0)
await _accountBalanceService.CreditAsync(discAcctId, oldDiscountAmt);
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
if (invoice.TaxAmount > 0)
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
if (invoice.DiscountAmount > 0)
await _accountBalanceService.DebitAsync(discAcctId, invoice.DiscountAmount);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Invoice updated successfully.";
// Optionally re-send the updated invoice PDF to the customer
if (resendToCustomer && invoice.Status is (InvoiceStatus.Sent or InvoiceStatus.Overdue))
{
try
{
var currentUserForPdf = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog);
}
catch (Exception notifyEx)
{
_logger.LogWarning(notifyEx, "Re-send of updated invoice {Id} failed", id);
TempData["WarningPermanent"] = "Invoice saved, but re-sending the email failed. You can re-send manually from the invoice details.";
}
}
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating invoice {Id}", id);
TempData["Error"] = "An error occurred while updating the invoice.";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// POST: /Invoices/Send/5
// -----------------------------------------------------------------------
/// <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);
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)
try
{
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
}
catch (Exception notifyEx)
{
_logger.LogWarning(notifyEx, "Payment recorded but notification failed");
}
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(paymentNotifLog);
TempData["Success"] = overpayment > 0
? $"Payment of {dto.Amount:C} recorded. {overpayment:C} stored as store credit on customer account."
: $"Payment of {dto.Amount:C} recorded successfully.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording payment for invoice {Id}", id);
TempData["Error"] = "An error occurred while recording the payment.";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// POST: /Invoices/DeletePayment/invoiceId/paymentId
// -----------------------------------------------------------------------
/// <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);
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);
}
// 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
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error voiding invoice {Id}", id);
TempData["Error"] = "An error occurred while voiding the invoice.";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// 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/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)
{
try
{
var invoice = await LoadInvoiceForViewAsync(id);
if (invoice == null)
return Json(new { success = false, message = "Invoice not found." });
if (invoice.Status == InvoiceStatus.Draft)
return Json(new { success = false, message = "Draft invoices must be sent using the Send action." });
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
return Json(new { success = false, message = "Voided invoices cannot be resent." });
// Validate override email when provided
overrideEmail = overrideEmail?.Trim();
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
return Json(new { success = false, message = "The email address provided is not valid." });
var currentUser = await _userManager.GetUserAsync(User);
var recipientName = invoice.Customer?.IsCommercial == true
? invoice.Customer.CompanyName ?? "Customer"
: $"{invoice.Customer?.ContactFirstName} {invoice.Customer?.ContactLastName}".Trim();
var recipientEmail = !string.IsNullOrWhiteSpace(overrideEmail)
? overrideEmail
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
if (string.IsNullOrWhiteSpace(recipientEmail))
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
byte[]? pdfBytes = null;
string? pdfFilename = null;
try
{
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
}
catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
}
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
if (latestLog?.Status == NotificationStatus.Skipped)
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resending invoice {Id}", id);
return Json(new { success = false, message = $"An unexpected error occurred: {ex.Message}" });
}
}
// -----------------------------------------------------------------------
// GET: /Invoices/NotificationsSent/5
// -----------------------------------------------------------------------
/// <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);
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);
/// <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.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.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 accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
&& (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings));
ViewBag.BankAccounts = accounts
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
}
/// <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.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.IsActive && a.AccountNumber == "2300");
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.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.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.AccountNumber == "2200" && a.IsActive);
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => 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.AccountNumber == "4950" && a.IsActive);
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => 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.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();
TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
}
else
{
// Adjust customer AR balance — they're owed money back
if (invoice.Customer != null)
{
invoice.Customer.CurrentBalance -= dto.Amount;
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
await _unitOfWork.CompleteAsync();
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
// Mirrors how FinancialReportService accounts for refunds:
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
var arAccountId = await GetArAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording refund for invoice {InvoiceId}", invoiceId);
TempData["Error"] = "An error occurred recording the refund.";
}
return RedirectToAction(nameof(Details), new { id = invoiceId });
}
/// <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 CreditBalance
if (refund.CreditMemoId.HasValue)
{
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
if (memo != null && memo.Status == CreditMemoStatus.Active)
{
memo.Status = CreditMemoStatus.Voided;
memo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CreditMemos.UpdateAsync(memo);
}
}
if (customer != null)
{
customer.CreditBalance -= refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
}
}
else
{
// Reverse the AR balance adjustment
if (customer != null)
{
customer.CurrentBalance += refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
}
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
}
refund.Status = RefundStatus.Cancelled;
refund.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Refunds.UpdateAsync(refund);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Refund cancelled.";
return RedirectToAction(nameof(Details), new { id = refund.InvoiceId });
}
// ── Credit Memos ─────────────────────────────────────────────────────────
/// <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();
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.
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
await _unitOfWork.CompleteAsync();
}); // end ExecuteInTransactionAsync
TempData["Success"] = $"Credit of {applyAmount:C} applied to invoice.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying credit to invoice {InvoiceId}", invoiceId);
TempData["Error"] = "An error occurred applying the credit.";
}
return RedirectToAction(nameof(Details), new { id = invoiceId });
}
/// <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);
}
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; }
}