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