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
@@ -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.";
}