Make cash refunds reverse the sale instead of re-opening AR

Audit finding 9a: a cash refund posted DR AR (control up) while the customer
subledger went down — opposite directions, guaranteeing AR drift. Per the chosen
model, a cash refund now *reverses the sale*: DR Sales Returns (revenue portion)
+ DR Sales Tax Payable (tax portion) / CR Bank, with the customer AR balance left
untouched (the invoice stays paid; the sale is contra'd).

- New 4960 "Sales Returns & Allowances" contra-revenue account (seed + self-heal).
- Tax/revenue split centralized in Core RefundAllocation.Split so the posting and
  both reporting recomputes compute it identically (a mismatch would unbalance the
  trial balance).
- InvoicesController IssueRefund/CancelRefund: new posting + reversal; no AR/
  customer-balance change.
- LedgerService (per-account + prior balance): refunds debit Sales Returns + Sales
  Tax, no longer debit AR; bank credit unchanged.
- FinancialReportService: trial balance, balance sheet (retained earnings + tax
  liability), and P&L (contra-revenue line) all updated to match. Store-credit
  refunds are excluded everywhere (they post via CreditMemo, not the GL — 9b).

Verified: $108 refund (incl. $8 tax) -> bank -100... checks: assets -108 =
liabilities -8 + equity -100. New tests cover the split invariant and the ledger
postings. Build clean; 283 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 19:58:42 -04:00
parent f54b945053
commit 2a82a1d34b
7 changed files with 288 additions and 51 deletions
@@ -7,6 +7,7 @@ using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Core.Enums;
@@ -2485,6 +2486,14 @@ public class InvoicesController : Controller
return acct?.Id;
}
/// <summary>Returns the Sales Returns &amp; Allowances contra-revenue account (4960) for refunds.</summary>
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4960");
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)
{
@@ -2667,20 +2676,20 @@ public class InvoicesController : Controller
}
else
{
// Adjust customer AR balance — they're owed money back
if (invoice.Customer != null)
{
invoice.Customer.CurrentBalance -= dto.Amount;
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
}
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
// Customer AR balance is intentionally left unchanged — the invoice stays paid and the
// sale is reversed via the contra accounts. The split is centralised in RefundAllocation
// so LedgerService and FinancialReportService recompute the same way.
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);
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
if (taxPortion > 0)
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
@@ -2751,16 +2760,15 @@ public class InvoicesController : Controller
}
else
{
// Reverse the AR balance adjustment
if (customer != null)
{
customer.CurrentBalance += refund.Amount;
await _unitOfWork.Customers.UpdateAsync(customer);
}
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
// The customer's AR balance was not touched when the refund was issued, so it is not touched here.
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
// 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.CreditAsync(salesReturnsAccountId, returnsPortion);
if (taxPortion > 0)
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
}