Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/VendorCreditsController.cs
T
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

389 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class VendorCreditsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly IAccountBalanceService _accountBalanceService;
public VendorCreditsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountBalanceService = accountBalanceService;
}
private bool AllowAccounting() =>
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
// ── Index ────────────────────────────────────────────────────────────────
/// <summary>Lists vendor credits grouped by status with unapplied balance summary.</summary>
public async Task<IActionResult> Index(string? status)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var all = (await _unitOfWork.VendorCredits.FindAsync(
vc => vc.CompanyId == companyId,
false,
vc => vc.Vendor))
.OrderByDescending(vc => vc.CreditDate)
.ThenByDescending(vc => vc.Id)
.ToList();
var displayed = status switch
{
"Open" => all.Where(vc => vc.Status == VendorCreditStatus.Open).ToList(),
"Partial" => all.Where(vc => vc.Status == VendorCreditStatus.PartiallyApplied).ToList(),
"Applied" => all.Where(vc => vc.Status == VendorCreditStatus.Applied).ToList(),
"Voided" => all.Where(vc => vc.Status == VendorCreditStatus.Voided).ToList(),
_ => all
};
ViewBag.StatusFilter = status ?? "All";
ViewBag.TotalCount = all.Count;
ViewBag.OpenCount = all.Count(vc => vc.Status == VendorCreditStatus.Open);
ViewBag.PartialCount = all.Count(vc => vc.Status == VendorCreditStatus.PartiallyApplied);
ViewBag.TotalUnapplied = all
.Where(vc => vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied)
.Sum(vc => vc.RemainingAmount);
return View(displayed);
}
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
await PopulateDropdownsAsync();
return View(new VendorCredit { CreditDate = DateTime.Today });
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
VendorCredit model,
int[] lineAccountIds,
string[] lineDescriptions,
decimal[] lineAmounts)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var lines = BuildLines(lineAccountIds, lineDescriptions, lineAmounts);
if (lines.Count == 0)
{
TempData["Error"] = "At least one line item is required.";
await PopulateDropdownsAsync();
return View(model);
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
model.CreditNumber = await GenerateCreditNumberAsync(companyId);
model.Status = VendorCreditStatus.Open;
model.Total = lines.Sum(l => l.Amount);
model.RemainingAmount = model.Total;
model.LineItems = lines;
await _unitOfWork.VendorCredits.AddAsync(model);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Vendor credit {model.CreditNumber} created.";
return RedirectToAction(nameof(Details), new { id = model.Id });
}
// ── Details ──────────────────────────────────────────────────────────────
public async Task<IActionResult> Details(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = (await _unitOfWork.VendorCredits.FindAsync(
v => v.Id == id,
false,
v => v.Vendor,
v => v.APAccount,
v => v.LineItems,
v => v.Applications))
.FirstOrDefault();
if (vc == null) return NotFound();
// Load account names for lines
var accountIds = vc.LineItems
.Where(l => l.AccountId.HasValue)
.Select(l => l.AccountId!.Value)
.Distinct()
.ToList();
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} {a.Name}");
// Load bills referenced by applications
if (vc.Applications.Any())
{
var billIds = vc.Applications.Select(a => a.BillId).ToList();
var bills = await _unitOfWork.Bills.FindAsync(b => billIds.Contains(b.Id));
ViewBag.BillMap = bills.ToDictionary(b => b.Id, b => b.BillNumber);
}
else
{
ViewBag.BillMap = new Dictionary<int, string>();
}
// Unapplied bills from the same vendor for the "Apply" panel
if (vc.Status is VendorCreditStatus.Open or VendorCreditStatus.PartiallyApplied)
{
var openBills = await _unitOfWork.Bills.FindAsync(
b => b.VendorId == vc.VendorId
&& (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid));
ViewBag.OpenBills = openBills.OrderBy(b => b.DueDate).ToList();
}
return View(vc);
}
// ── Post ─────────────────────────────────────────────────────────────────
/// <summary>
/// Posts a vendor credit to the GL:
/// DR Accounts Payable (APAccountId) — vendor owes us, reduces AP
/// CR Expense/COGS accounts (each line) — reverses the original expense
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Post(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = (await _unitOfWork.VendorCredits.FindAsync(
v => v.Id == id,
false,
v => v.LineItems))
.FirstOrDefault();
if (vc == null) return NotFound();
if (vc.Status != VendorCreditStatus.Open)
{
TempData["Error"] = "Only open (unposted) credits can be posted.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// DR AP (reduces what we owe the vendor)
await _accountBalanceService.DebitAsync(vc.APAccountId, vc.Total);
// CR each expense account (reverses original expense)
foreach (var line in vc.LineItems)
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
// Record posting date so Void() can reverse only if GL entries were actually made.
vc.PostedDate = DateTime.UtcNow;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
// Status stays Open — the credit is now in the GL but not yet applied to a bill
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Vendor credit {vc.CreditNumber} posted to GL.";
return RedirectToAction(nameof(Details), new { id });
}
// ── Apply ─────────────────────────────────────────────────────────────────
/// <summary>
/// Applies a vendor credit against a vendor bill. Reduces Bill.AmountPaid (increasing balance)
/// and VendorCredit.RemainingAmount. No additional GL posting — AP was already adjusted when
/// the credit was posted.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Apply(int id, int billId, decimal amount)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
var bill = await _unitOfWork.Bills.GetByIdAsync(billId);
if (vc == null || bill == null) return NotFound();
if (amount <= 0 || amount > vc.RemainingAmount || amount > bill.BalanceDue)
{
TempData["Error"] = "Invalid application amount.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
var application = new VendorCreditApplication
{
VendorCreditId = vc.Id,
BillId = bill.Id,
Amount = amount,
AppliedDate = DateTime.UtcNow
};
await _unitOfWork.VendorCreditApplications.AddAsync(application);
// Update bill — treated as a payment against the balance
bill.AmountPaid += amount;
if (bill.AmountPaid >= bill.Total)
bill.Status = BillStatus.Paid;
else if (bill.AmountPaid > 0)
bill.Status = BillStatus.PartiallyPaid;
// Update credit
vc.RemainingAmount -= amount;
vc.Status = vc.RemainingAmount <= 0
? VendorCreditStatus.Applied
: VendorCreditStatus.PartiallyApplied;
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Applied {amount:C} of credit {vc.CreditNumber} to bill {bill.BillNumber}.";
return RedirectToAction(nameof(Details), new { id });
}
// ── Void ─────────────────────────────────────────────────────────────────
/// <summary>
/// Voids a vendor credit. If the credit was previously posted (PostedDate is set), reverses the
/// original GL entries: CR Accounts Payable / DR each expense line item, restoring both balances.
/// Only the unapplied RemainingAmount of AP is reversed — applied portions reduced bill balances
/// that are already settled and remain part of the immutable audit trail.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Void(int id)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = (await _unitOfWork.VendorCredits.FindAsync(
v => v.Id == id, false,
v => v.LineItems))
.FirstOrDefault();
if (vc == null) return NotFound();
if (vc.Status == VendorCreditStatus.Applied)
{
TempData["Error"] = "Fully applied credits cannot be voided.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// Reverse GL only if Post() was previously called; unposted credits have no GL entries.
if (vc.PostedDate.HasValue && vc.RemainingAmount > 0)
{
// CR AP for the unapplied amount (undoes the debit made at Post time)
await _accountBalanceService.CreditAsync(vc.APAccountId, vc.RemainingAmount);
// DR each expense line proportionally (unapplied fraction of each line)
var applyRatio = vc.Total > 0 ? vc.RemainingAmount / vc.Total : 1m;
foreach (var line in vc.LineItems)
await _accountBalanceService.DebitAsync(line.AccountId, line.Amount * applyRatio);
}
vc.Status = VendorCreditStatus.Voided;
vc.RemainingAmount = 0;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
return RedirectToAction(nameof(Index));
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static List<VendorCreditLineItem> BuildLines(
int[] accountIds, string[] descriptions, decimal[] amounts)
{
var lines = new List<VendorCreditLineItem>();
for (int i = 0; i < accountIds.Length; i++)
{
if (i < amounts.Length && amounts[i] > 0)
lines.Add(new VendorCreditLineItem
{
AccountId = accountIds[i] > 0 ? accountIds[i] : null,
Description = i < descriptions.Length ? descriptions[i] : string.Empty,
Amount = amounts[i]
});
}
return lines;
}
/// <summary>
/// Generates the next sequential credit number in the format VC-YYMM-####.
/// Uses ignoreQueryFilters so voided/deleted records are included and numbers are never reused.
/// </summary>
private async Task<string> GenerateCreditNumberAsync(int companyId)
{
var prefix = $"VC-{DateTime.Now:yyMM}-";
var all = await _unitOfWork.VendorCredits.FindAsync(
vc => vc.CompanyId == companyId && vc.CreditNumber.StartsWith(prefix),
ignoreQueryFilters: true);
int next = 1;
if (all.Any())
{
var nums = all
.Select(vc => vc.CreditNumber[prefix.Length..])
.Select(s => int.TryParse(s, out int n) ? n : 0);
next = nums.Max() + 1;
}
return $"{prefix}{next:D4}";
}
private async Task PopulateDropdownsAsync()
{
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
ViewBag.VendorList = vendors
.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem { Value = v.Id.ToString(), Text = v.CompanyName })
.ToList();
ViewBag.APAccountList = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
{
Value = a.Id.ToString(),
Text = $"{a.AccountNumber} {a.Name}"
})
.ToList();
ViewBag.ExpenseAccountList = accounts
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem
{
Value = a.Id.ToString(),
Text = $"{a.AccountNumber} {a.Name}"
})
.ToList();
}
}