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:
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
@@ -199,6 +200,43 @@ public class LedgerService : ILedgerService
|
||||
LinkId = inv.Id
|
||||
});
|
||||
|
||||
// ── 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable) ──
|
||||
// The revenue portion debits Sales Returns; the tax portion debits the invoice's sales-tax
|
||||
// account (relieving the liability). Cash leaving the bank is handled in the bank section above.
|
||||
// Store-credit refunds are excluded — they post via CreditMemo, not the GL (see CancelRefund).
|
||||
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||
{
|
||||
var saleReversingRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in saleReversingRefunds)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||
|
||||
if (account.AccountNumber == "4960" && returnsPortion != 0)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||
Description = $"Sales return — {r.Invoice.InvoiceNumber}",
|
||||
Debit = returnsPortion, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
if (r.Invoice.SalesTaxAccountId == accountId && taxPortion != 0)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||
Description = $"Tax refunded — {r.Invoice.InvoiceNumber}",
|
||||
Debit = taxPortion, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
|
||||
// e.g. Expense account 6200 receives direct expense entries
|
||||
var expensesTo = await _context.Expenses
|
||||
@@ -312,24 +350,8 @@ public class LedgerService : ILedgerService
|
||||
LinkId = cm.InvoiceId
|
||||
});
|
||||
|
||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||
var arRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in arRefunds)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = r.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
// NOTE: cash refunds no longer touch AR. Under the "reverse the sale" model they debit
|
||||
// Sales Returns + Sales Tax Payable and credit the bank (see section 5b above).
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
@@ -594,6 +616,27 @@ public class LedgerService : ILedgerService
|
||||
&& i.InvoiceDate < beforeDate)
|
||||
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
||||
|
||||
// 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable). Store-credit
|
||||
// refunds are excluded (no GL posting). Mirrors section 5b in GetAccountLedgerAsync.
|
||||
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||
{
|
||||
var priorRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||
&& r.RefundDate < beforeDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in priorRefunds)
|
||||
{
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||
if (account.AccountNumber == "4960")
|
||||
debits += returnsPortion;
|
||||
if (r.Invoice.SalesTaxAccountId == accountId)
|
||||
debits += taxPortion;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Direct expenses categorized to this account (DEBIT)
|
||||
debits += await _context.Expenses
|
||||
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
|
||||
@@ -624,9 +667,8 @@ public class LedgerService : ILedgerService
|
||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
debits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
// NOTE: cash refunds no longer debit AR — they reverse the sale (Sales Returns + Sales Tax),
|
||||
// handled in section 5b above.
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
|
||||
Reference in New Issue
Block a user