cfe937c0c3
When voiding an invoice that has non-deposit payments (e.g. CC charges), those payments are now converted to CRED- Deposit records so the money trail is preserved and the credit auto-applies to the replacement invoice. Deposits that were applied to the voided invoice are also re-released so they can auto-apply again. Void confirmation dialog and success message both reflect the credit amount when applicable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3107 lines
148 KiB
C#
3107 lines
148 KiB
C#
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 = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.",
|
||
ActionText = "Go to Dashboard",
|
||
ActionController = "Dashboard",
|
||
ActionName = "Index"
|
||
};
|
||
}
|
||
|
||
return View(dto);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error loading invoice {Id}", id);
|
||
TempData["Error"] = "An error occurred while loading the invoice.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// GET: /Invoices/Create?jobId=n
|
||
// -----------------------------------------------------------------------
|
||
/// <summary>
|
||
/// Prepares the Create Invoice form, optionally pre-populated from a job. When a jobId is
|
||
/// supplied several important rules apply:
|
||
/// — 1:1 enforcement: if an invoice already exists for the job (even soft-deleted ones via
|
||
/// IgnoreQueryFilters), the user is immediately redirected to that invoice.
|
||
/// — Line items are derived from job items, each carrying through its SourceJobItemId so the
|
||
/// invoice can trace back to the exact job work.
|
||
/// — Quote-level charges (oven batch cost, shop supplies, rush fee) are NOT on job items;
|
||
/// they're read from the source quote and added as a single "Oven & 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();
|
||
|
||
// 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;
|
||
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
SourceJobItemId = item.Id,
|
||
CatalogItemId = item.CatalogItemId,
|
||
Description = item.Description ?? "Powder Coating",
|
||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||
UnitPrice = item.UnitPrice,
|
||
TotalPrice = item.TotalPrice,
|
||
ColorName = item.ColorName,
|
||
Notes = item.Notes,
|
||
DisplayOrder = order++,
|
||
RevenueAccountId = revenueAccountId
|
||
});
|
||
}
|
||
|
||
// Track whether there were real job items before any fallback
|
||
bool hadJobItems = dto.InvoiceItems.Any();
|
||
|
||
// If no job items, use job final price as single line.
|
||
// FinalPrice is always the post-tax total (set by the pricing engine or imported from
|
||
// an export). Treat it as the agreed total and force TaxPercent = 0 so the invoice
|
||
// does not apply tax a second time. Without this, imported jobs double-tax because
|
||
// their FinalPrice already includes the tax that was applied in the source environment.
|
||
if (!hadJobItems)
|
||
{
|
||
var defaultRevAccId = defaultRevenueAccount?.Id;
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = $"Powder Coating Services — Job {job.JobNumber}",
|
||
Quantity = 1,
|
||
UnitPrice = job.FinalPrice,
|
||
TotalPrice = job.FinalPrice,
|
||
DisplayOrder = 1,
|
||
RevenueAccountId = defaultRevAccId
|
||
});
|
||
dto.TaxPercent = 0;
|
||
}
|
||
|
||
// If the job came from a quote, carry over the quote-level costs and agreed terms.
|
||
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
|
||
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
|
||
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
|
||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||
if (sourceQuote != null)
|
||
{
|
||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||
var processingFees = sourceQuote.OvenBatchCost
|
||
+ sourceQuote.FacilityOverheadCost
|
||
+ sourceQuote.ShopSuppliesAmount
|
||
+ sourceQuote.RushFee;
|
||
|
||
if (processingFees > 0.01m)
|
||
{
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = "Oven & Shop Processing Fees",
|
||
Quantity = 1,
|
||
UnitPrice = Math.Round(processingFees, 2),
|
||
TotalPrice = Math.Round(processingFees, 2),
|
||
DisplayOrder = order,
|
||
RevenueAccountId = defaultRevenueAccount?.Id
|
||
});
|
||
}
|
||
|
||
// Use the quote's agreed tax rate and discount — these represent the customer-approved
|
||
// price and must not be recomputed from the job's current state.
|
||
dto.TaxPercent = sourceQuote.TaxPercent;
|
||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
||
}
|
||
else if (hadJobItems)
|
||
{
|
||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||
|
||
if (job.OvenBatchCost > 0.01m)
|
||
{
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = "Oven Processing Fee",
|
||
Quantity = 1,
|
||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||
DisplayOrder = order++,
|
||
RevenueAccountId = defaultRevenueAccount?.Id
|
||
});
|
||
}
|
||
|
||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||
if (facilityOverhead > 0.01m)
|
||
{
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = "Facility Overhead",
|
||
Quantity = 1,
|
||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||
DisplayOrder = order++,
|
||
RevenueAccountId = defaultRevenueAccount?.Id
|
||
});
|
||
}
|
||
|
||
if (job.ShopSuppliesAmount > 0.01m)
|
||
{
|
||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||
? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)"
|
||
: "Shop Supplies";
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = suppliesDesc,
|
||
Quantity = 1,
|
||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||
DisplayOrder = order++,
|
||
RevenueAccountId = defaultRevenueAccount?.Id
|
||
});
|
||
}
|
||
|
||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||
if (rushFee > 0.01m)
|
||
{
|
||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||
{
|
||
Description = "Rush Fee",
|
||
Quantity = 1,
|
||
UnitPrice = Math.Round(rushFee, 2),
|
||
TotalPrice = Math.Round(rushFee, 2),
|
||
DisplayOrder = order,
|
||
RevenueAccountId = defaultRevenueAccount?.Id
|
||
});
|
||
}
|
||
|
||
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
|
||
}
|
||
|
||
// Inherit payment terms from the source quote or the customer — more specific than
|
||
// the company-wide default set in the outer DTO. Quote terms take priority because
|
||
// they represent the agreed price; customer terms are next best for direct jobs.
|
||
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
|
||
if (!string.IsNullOrWhiteSpace(inheritedTerms))
|
||
{
|
||
dto.Terms = inheritedTerms;
|
||
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
|
||
?? dto.DueDate;
|
||
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
|
||
dto.EarlyPaymentDiscountPercent = discPct;
|
||
dto.EarlyPaymentDiscountDays = discDays;
|
||
}
|
||
|
||
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
|
||
if (job.Customer?.IsTaxExempt == true)
|
||
dto.TaxPercent = 0;
|
||
|
||
ViewBag.JobNumber = job.JobNumber;
|
||
ViewBag.CustomerName = job.Customer != null
|
||
? (job.Customer.IsCommercial
|
||
? job.Customer.CompanyName
|
||
: $"{job.Customer.ContactFirstName} {job.Customer.ContactLastName}".Trim())
|
||
: string.Empty;
|
||
}
|
||
|
||
await PopulateCreateViewBagAsync(currentUser.CompanyId, dto.Terms);
|
||
ViewBag.GuidedActivation = guidedActivation;
|
||
return View(dto);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error preparing invoice Create form");
|
||
TempData["Error"] = "An error occurred.";
|
||
return RedirectToAction(nameof(Index));
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// POST: /Invoices/Create
|
||
// -----------------------------------------------------------------------
|
||
/// <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 > 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 <= 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);
|
||
var totalCreditCreated = 0m; // populated inside transaction, used in success message
|
||
|
||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||
{
|
||
|
||
// Reverse only the outstanding balance (payments/credits/GCs already reduced it when recorded)
|
||
var balanceDue = invoice.BalanceDue;
|
||
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
|
||
if (customer != null && balanceDue > 0)
|
||
{
|
||
customer.CurrentBalance -= balanceDue;
|
||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||
}
|
||
|
||
// Soft-delete all payments so they no longer appear in financial reports
|
||
foreach (var payment in invoice.Payments.Where(p => !p.IsDeleted))
|
||
{
|
||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||
}
|
||
|
||
// Re-release any deposits that were applied to this invoice so they can be
|
||
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
|
||
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
|
||
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
|
||
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
|
||
var totalDepositReleased = 0m;
|
||
foreach (var deposit in appliedDeposits)
|
||
{
|
||
deposit.AppliedToInvoiceId = null;
|
||
deposit.AppliedDate = null;
|
||
deposit.UpdatedAt = DateTime.UtcNow;
|
||
await _unitOfWork.Deposits.UpdateAsync(deposit);
|
||
totalDepositReleased += deposit.Amount;
|
||
}
|
||
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
|
||
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
|
||
// pattern as the rest of the void (regular payment GL entries are also left as-is).
|
||
if (totalDepositReleased > 0)
|
||
{
|
||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
|
||
}
|
||
|
||
// Convert non-deposit payments (cash, card, check, online) to customer credits so
|
||
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
|
||
// Deposit record linked to the same job; it will auto-apply when the replacement
|
||
// invoice is created, exactly like a normal deposit.
|
||
var nonDepositPayments = invoice.Payments
|
||
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
|
||
.ToList();
|
||
if (nonDepositPayments.Any())
|
||
{
|
||
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||
var existingNums = (await _unitOfWork.Deposits.FindAsync(
|
||
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
|
||
ignoreQueryFilters: true))
|
||
.Select(d => d.ReceiptNumber).ToList();
|
||
var maxNum = 0;
|
||
foreach (var rn in existingNums)
|
||
{
|
||
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
|
||
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
|
||
}
|
||
|
||
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||
foreach (var payment in nonDepositPayments)
|
||
{
|
||
maxNum++;
|
||
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
|
||
{
|
||
CompanyId = invoice.CompanyId,
|
||
CustomerId = invoice.CustomerId,
|
||
JobId = invoice.JobId,
|
||
Amount = payment.Amount,
|
||
PaymentMethod = payment.PaymentMethod,
|
||
ReceivedDate = payment.PaymentDate,
|
||
Reference = payment.Reference,
|
||
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
|
||
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
|
||
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
|
||
CreatedAt = DateTime.UtcNow
|
||
});
|
||
totalCreditCreated += payment.Amount;
|
||
}
|
||
|
||
// CR CustomerDeposits to create the liability matching the cash already in Checking
|
||
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
|
||
}
|
||
|
||
// Void any gift certificates that were generated from this invoice.
|
||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||
var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
|
||
foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
|
||
{
|
||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
|
||
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
||
{
|
||
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
|
||
gc.Status = GiftCertificateStatus.Voided;
|
||
gc.UpdatedAt = DateTime.UtcNow;
|
||
await _unitOfWork.GiftCertificates.UpdateAsync(gc);
|
||
}
|
||
// FullyRedeemed GCs: not voided, nothing to reverse (GC Liability already at 0).
|
||
}
|
||
|
||
// Reverse account balances: credit AR (open balance), debit revenue + sales tax.
|
||
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
|
||
// GC line items: instead of debiting revenue (which was already reclassified to GC Liability
|
||
// at creation), debit GC Liability for the unredeemed portion, netting the obligation to 0.
|
||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||
{
|
||
if (item.IsGiftCertificate)
|
||
{
|
||
// GC item: debit GC Liability for unredeemed portion; skip fully-redeemed items.
|
||
if (gcLiabilityAcctId.HasValue && gcRemainingByItemId.TryGetValue(item.Id, out var remaining) && remaining > 0)
|
||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||
}
|
||
else
|
||
{
|
||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||
}
|
||
}
|
||
if (invoice.TaxAmount > 0)
|
||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||
if (invoice.DiscountAmount > 0)
|
||
{
|
||
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
|
||
}
|
||
|
||
invoice.Status = InvoiceStatus.Voided;
|
||
invoice.UpdatedAt = DateTime.UtcNow;
|
||
invoice.UpdatedBy = currentUser?.Email;
|
||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||
|
||
}); // end ExecuteInTransactionAsync
|
||
|
||
var creditMsg = totalCreditCreated > 0
|
||
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
|
||
: "";
|
||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error voiding invoice {Id}", id);
|
||
TempData["Error"] = "An error occurred while voiding the invoice.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// POST: /Invoices/WriteOff/5
|
||
// -----------------------------------------------------------------------
|
||
/// <summary>
|
||
/// Writes off an uncollectible invoice. Posts a GL journal entry:
|
||
/// DR Bad Debt Expense (user-selected account) for the remaining BalanceDue
|
||
/// CR Accounts Receivable for the same amount
|
||
/// Then marks the invoice WrittenOff and reduces customer.CurrentBalance.
|
||
/// Only the outstanding BalanceDue is written off; amounts already collected are unaffected.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> WriteOff(int id, int? expenseAccountId, string? notes)
|
||
{
|
||
try
|
||
{
|
||
var invoice = await LoadInvoiceForViewAsync(id);
|
||
if (invoice == null) return NotFound();
|
||
|
||
if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Voided or InvoiceStatus.WrittenOff)
|
||
{
|
||
TempData["Error"] = "Invoice cannot be written off in its current status.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var balanceDue = invoice.BalanceDue;
|
||
if (balanceDue <= 0)
|
||
{
|
||
TempData["Error"] = "Invoice has no outstanding balance to write off.";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||
var badDebtAccountId = expenseAccountId > 0
|
||
? expenseAccountId
|
||
: await GetBadDebtAccountIdAsync(invoice.CompanyId);
|
||
|
||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||
{
|
||
// GL: DR Bad Debt Expense / CR AR
|
||
await _accountBalanceService.DebitAsync(badDebtAccountId, balanceDue);
|
||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||
|
||
// Post a supporting JE for the audit trail
|
||
var je = new JournalEntry
|
||
{
|
||
EntryNumber = await GenerateJournalEntryNumberAsync(invoice.CompanyId),
|
||
EntryDate = DateTime.UtcNow,
|
||
Description = $"Write-off of invoice {invoice.InvoiceNumber}{(string.IsNullOrWhiteSpace(notes) ? "" : $" — {notes}")}",
|
||
Reference = invoice.InvoiceNumber,
|
||
Status = JournalEntryStatus.Posted,
|
||
PostedBy = currentUser?.Email,
|
||
PostedAt = DateTime.UtcNow,
|
||
CompanyId = invoice.CompanyId,
|
||
CreatedAt = DateTime.UtcNow,
|
||
Lines = new List<JournalEntryLine>
|
||
{
|
||
new JournalEntryLine
|
||
{
|
||
AccountId = badDebtAccountId ?? 0,
|
||
Description = $"Bad debt — invoice {invoice.InvoiceNumber}",
|
||
DebitAmount = balanceDue,
|
||
CreditAmount = 0,
|
||
CompanyId = invoice.CompanyId,
|
||
CreatedAt = DateTime.UtcNow
|
||
},
|
||
new JournalEntryLine
|
||
{
|
||
AccountId = arAccountId ?? 0,
|
||
Description = $"Write-off AR — invoice {invoice.InvoiceNumber}",
|
||
DebitAmount = 0,
|
||
CreditAmount = balanceDue,
|
||
CompanyId = invoice.CompanyId,
|
||
CreatedAt = DateTime.UtcNow
|
||
}
|
||
}
|
||
};
|
||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||
|
||
// Reduce customer running balance
|
||
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
|
||
if (customer != null)
|
||
{
|
||
customer.CurrentBalance = Math.Max(0, customer.CurrentBalance - balanceDue);
|
||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||
}
|
||
|
||
invoice.Status = InvoiceStatus.WrittenOff;
|
||
invoice.UpdatedAt = DateTime.UtcNow;
|
||
invoice.UpdatedBy = currentUser?.Email;
|
||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
});
|
||
|
||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} written off ({balanceDue:C} posted to Bad Debt Expense).";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error writing off invoice {Id}", id);
|
||
TempData["Error"] = "An error occurred while writing off the invoice.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// GET: /Invoices/DownloadPdf/5
|
||
// -----------------------------------------------------------------------
|
||
/// <summary>
|
||
/// Generates a QuestPDF invoice and returns it as an inline-download. Delegates to
|
||
/// <see cref="BuildInvoicePdfAsync"/> which fetches company branding, template settings,
|
||
/// and the full invoice DTO in one call, then hands off to IPdfService.
|
||
/// </summary>
|
||
public async Task<IActionResult> DownloadPdf(int? id, bool inline = false)
|
||
{
|
||
if (id == null) return NotFound();
|
||
|
||
try
|
||
{
|
||
var invoice = await LoadInvoiceForViewAsync(id.Value);
|
||
if (invoice == null) return NotFound();
|
||
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
if (currentUser == null) return Unauthorized();
|
||
|
||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser.CompanyId);
|
||
var fileName = $"Invoice-{invoice.InvoiceNumber}.pdf";
|
||
|
||
if (inline)
|
||
{
|
||
// Return with inline content-disposition so the browser renders the PDF
|
||
// in a new tab, enabling the native print dialog.
|
||
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
|
||
return File(pdfBytes, "application/pdf");
|
||
}
|
||
|
||
return File(pdfBytes, "application/pdf", fileName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error generating PDF for invoice {Id}. Inner: {Inner}", id, ex.InnerException?.Message ?? ex.Message);
|
||
TempData["ErrorPermanent"] = $"PDF generation failed: {ex.Message}";
|
||
return RedirectToAction(nameof(Details), new { id });
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// GET: /Invoices/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 <= 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>
|
||
/// 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);
|
||
}
|
||
}
|