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:
@@ -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 & 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user