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>
This commit is contained in:
2026-05-13 12:42:46 -04:00
parent 787d1504ef
commit 27bfd4db4d
24 changed files with 33296 additions and 83 deletions
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
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;
@@ -15,6 +16,9 @@ namespace PowderCoating.Web.Controllers;
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
/// customer.CreditBalance atomically inside a transaction.
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
/// a revenue deduction and an AR reduction.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CreditMemosController : Controller
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<CreditMemosController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public CreditMemosController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ILogger<CreditMemosController> logger)
ILogger<CreditMemosController> logger,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
@@ -245,6 +252,20 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice);
}
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The dynamic report computation attributes credit memo applications to both
// accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive)
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive
&& a.Name.ToLower().Contains("discount"));
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync();
});
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -22,17 +23,20 @@ public class DepositsController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
_logoService = logoService;
_accountBalanceService = accountBalanceService;
}
// -----------------------------------------------------------------------
@@ -76,27 +80,34 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
var deposit = new Deposit
{
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
DepositAccountId = checkingAcctId,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
};
await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new
{
success = true,
@@ -137,6 +148,11 @@ public class DepositsController : Controller
if (deposit.AppliedToInvoiceId != null)
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
// Reverse the GL entry made at recording time: CR Checking / DR Customer Deposits 2300.
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(deposit.CompanyId);
await _accountBalanceService.CreditAsync(deposit.DepositAccountId, deposit.Amount);
await _accountBalanceService.DebitAsync(custDepositsAcctId, deposit.Amount);
await _unitOfWork.Deposits.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
@@ -419,6 +435,24 @@ public class DepositsController : Controller
return hex.StartsWith("#") ? hex : fallback;
}
/// <summary>Returns the first active Checking or Cash account for the company, or null.</summary>
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
return acct?.Id;
}
/// <summary>Returns account 2300 "Customer Deposits" liability for the company, or null.</summary>
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPdfService _pdfService;
private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public GiftCertificatesController(
IUnitOfWork unitOfWork,
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager,
IPdfService pdfService,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
_userManager = userManager;
_pdfService = pdfService;
_logoService = logoService;
_accountBalanceService = accountBalanceService;
}
/// <summary>
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
// GL: CR Gift Certificate Liability (2500) for the face value.
// Debit side varies by reason:
// Sold → DR Checking (received cash outside invoice flow)
// Others → DR Sales Discounts 4950 (promotional/goodwill cost)
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
}
else
{
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
}
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
return RedirectToAction(nameof(Details), new { id = cert.Id });
}
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
var remaining = cert.RemainingBalance;
cert.Status = GiftCertificateStatus.Voided;
cert.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
await _unitOfWork.CompleteAsync();
// GL: DR GC Liability / CR Other Income (breakage — the company keeps the unredeemed amount)
if (remaining > 0)
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
}
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
return RedirectToAction(nameof(Index));
}
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
ViewBag.Customers = list;
}
/// <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;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -677,19 +677,22 @@ public class InvoicesController : Controller
foreach (var deposit in pendingDeposits)
{
// Create a Payment record for each deposit
// 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,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
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);
@@ -719,13 +722,31 @@ public class InvoicesController : Controller
await _unitOfWork.CompleteAsync();
// Update account balances: debit AR, credit revenue accounts + sales tax
// 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
@@ -873,8 +894,17 @@ public class InvoicesController : Controller
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 oldTotal = invoice.Total;
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
var taxableAmount = subTotal - dto.DiscountAmount;
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
@@ -940,6 +970,31 @@ public class InvoicesController : Controller
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
@@ -1347,29 +1402,49 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
}
// Void any gift certificates that were generated from this invoice
var gcItemIds = invoice.InvoiceItems
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)
.Select(i => i.GeneratedGiftCertificateId!.Value)
.ToList();
foreach (var gcId in gcItemIds)
// 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(gcId);
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
{
gc.Status = GiftCertificateStatus.Voided;
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
// 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))
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
{
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;
@@ -1721,13 +1796,30 @@ public class InvoicesController : Controller
deposit.UpdatedAt = DateTime.UtcNow;
}
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax
// 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.
@@ -1920,6 +2012,12 @@ public class InvoicesController : Controller
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();
@@ -2083,6 +2181,24 @@ public class InvoicesController : Controller
.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)
{
@@ -2135,6 +2251,28 @@ public class InvoicesController : Controller
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",
@@ -2191,6 +2329,8 @@ public class InvoicesController : Controller
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,
@@ -2249,6 +2389,14 @@ public class InvoicesController : Controller
}
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.";
}
}
@@ -2323,6 +2471,11 @@ public class InvoicesController : Controller
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;
@@ -2469,6 +2622,12 @@ public class InvoicesController : Controller
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
@@ -2629,6 +2788,13 @@ public class InvoicesController : Controller
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.";
}
@@ -9,6 +9,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -26,6 +27,7 @@ public class PaymentController : Controller
private readonly IInAppNotificationService _inApp;
private readonly IConfiguration _configuration;
private readonly ILogger<PaymentController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public PaymentController(
ApplicationDbContext context,
@@ -33,7 +35,8 @@ public class PaymentController : Controller
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger<PaymentController> logger)
ILogger<PaymentController> logger,
IAccountBalanceService accountBalanceService)
{
_context = context;
_stripeConnect = stripeConnect;
@@ -41,6 +44,7 @@ public class PaymentController : Controller
_inApp = inApp;
_configuration = configuration;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
// ─── GET /pay/{token} ────────────────────────────────────────────────────
@@ -378,8 +382,30 @@ public class PaymentController : Controller
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
};
_context.Payments.Add(stripePayment);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
@@ -553,19 +579,22 @@ public class PaymentController : Controller
var refundAmountDollars = latestRefund.Amount / 100m;
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdR,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
@@ -588,6 +617,10 @@ public class PaymentController : Controller
_context.Update(invoice);
await _context.SaveChangesAsync();
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
refundAmountDollars, invoice.Id, latestRefund.Id);
}
@@ -652,19 +685,22 @@ public class PaymentController : Controller
if (alreadyRecorded) return;
var amount = dispute.Amount / 100m;
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdD,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
@@ -687,6 +723,9 @@ public class PaymentController : Controller
_context.Update(invoice);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
}
}
@@ -696,6 +735,27 @@ public class PaymentController : Controller
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
/// because there is no authenticated tenant context in webhook handlers.
/// </summary>
/// <summary>
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
/// </summary>
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
{
var ar = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var checking = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
return (ar, checking);
}
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
{
if (string.IsNullOrEmpty(paymentIntentId)) return null;
@@ -195,6 +195,10 @@ public class VendorCreditsController : Controller
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();
});
@@ -260,6 +264,12 @@ public class VendorCreditsController : Controller
// ── 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]
@@ -267,7 +277,10 @@ public class VendorCreditsController : Controller
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
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)
@@ -276,9 +289,25 @@ public class VendorCreditsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
vc.Status = VendorCreditStatus.Voided;
vc.RemainingAmount = 0;
await _unitOfWork.CompleteAsync();
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));
@@ -1145,6 +1145,20 @@
<option value="5">Store Credit</option>
</select>
</div>
<div class="mb-3" id="refundDepositAccountRow">
<label class="form-label fw-semibold">Refund From Account</label>
<select name="DepositAccountId" class="form-select">
<option value="">(Not tracked)</option>
@if (ViewBag.BankAccounts != null)
{
@foreach (var acct in (IEnumerable<SelectListItem>)ViewBag.BankAccounts)
{
<option value="@acct.Value">@acct.Text</option>
}
}
</select>
<div class="form-text">Bank or cash account the refund is paid from.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
<input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty claim, duplicate charge..." required />
@@ -1171,6 +1185,7 @@
document.getElementById('refundAlertCash').classList.toggle('d-none', isCredit);
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
document.getElementById('refundDepositAccountRow').classList.toggle('d-none', isCredit);
});
</script>
</div>