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