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
@@ -0,0 +1,29 @@
namespace PowderCoating.Core.Accounting;
/// <summary>
/// Single source of truth for splitting a customer refund into its revenue (returns) portion and
/// its sales-tax portion, under the "reverse the sale" model. A refund of a paid invoice reverses
/// the original sale: the revenue portion is debited to Sales Returns (contra-revenue) and the tax
/// portion is debited to Sales Tax Payable (reducing the liability), with cash credited out.
///
/// The split is proportional to the parent invoice's tax ratio so a partial refund reverses the
/// right amount of tax. Centralised here so the posting (<c>InvoicesController</c>) and the two
/// reporting recomputes (<c>LedgerService</c>, <c>FinancialReportService</c>) always agree — if they
/// computed it independently the trial balance could drift.
/// </summary>
public static class RefundAllocation
{
/// <summary>
/// Splits <paramref name="refundAmount"/> (tax-inclusive) into (returnsPortion, taxPortion) using
/// the parent invoice's tax ratio. When the invoice has no total or no tax, the whole refund is
/// the returns portion and the tax portion is zero.
/// </summary>
public static (decimal ReturnsPortion, decimal TaxPortion) Split(
decimal refundAmount, decimal invoiceTaxAmount, decimal invoiceTotal)
{
var taxPortion = invoiceTotal > 0m && invoiceTaxAmount > 0m
? Math.Round(refundAmount * invoiceTaxAmount / invoiceTotal, 2, MidpointRounding.AwayFromZero)
: 0m;
return (refundAmount - taxPortion, taxPortion);
}
}