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
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Accounting;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
@@ -47,6 +48,14 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(p => (decimal?)p.Amount) ?? 0;
if (cashRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
// Cash refunds are cash paid back out — they reduce cash-basis revenue.
var cashRefunds = await _context.Refunds
.Where(r => !r.IsDeleted && r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate >= from && r.RefundDate <= toEnd)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
if (cashRefunds > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "4960", AccountName = "Less: Refunds Paid", Amount = -cashRefunds });
}
else
{
@@ -98,6 +107,22 @@ public class FinancialReportService : IFinancialReportService
Amount = -totalDeductions
});
// Cash refunds reverse the sale — the revenue portion is contra-revenue (the tax portion
// relieves Sales Tax Payable, not revenue). Store-credit refunds are excluded (no GL posting).
var periodRefunds = await _context.Refunds
.Where(r => !r.IsDeleted && r.Invoice != null && r.RefundMethod != PaymentMethod.StoreCredit
&& r.RefundDate >= from && r.RefundDate <= toEnd)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total })
.ToListAsync();
var periodRefundReturns = periodRefunds.Sum(r => RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total).ReturnsPortion);
if (periodRefundReturns > 0)
revenueLines.Add(new FinancialReportLine
{
AccountNumber = "4960",
AccountName = "Less: Sales Returns",
Amount = -periodRefundReturns
});
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
var periodGcReclassified = await _context.InvoiceItems
.Where(ii => ii.IsGiftCertificate
@@ -274,10 +299,23 @@ public class FinancialReportService : IFinancialReportService
arCredits += await _context.CreditMemoApplications
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
arCredits -= await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
// Cash refunds reverse the sale: revenue portion reduces retained earnings (Sales Returns),
// tax portion relieves Sales Tax Payable, cash leaves the bank (refundsByAcctBs). AR is untouched.
// Store-credit refunds post via CreditMemo, not the GL, so are excluded.
var saleReversingRefundsBs = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
.ToListAsync();
decimal refundReturnsTotalBs = 0m;
var refundTaxByAcctBs = new Dictionary<int, decimal>();
foreach (var r in saleReversingRefundsBs)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
refundReturnsTotalBs += returnsPortion;
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
refundTaxByAcctBs[r.SalesTaxAccountId.Value] = refundTaxByAcctBs.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
}
// Refunds by bank account: money that left the account (CR to checking/bank).
var refundsByAcctBs = await _context.Refunds
@@ -390,6 +428,7 @@ public class FinancialReportService : IFinancialReportService
var retainedEarnings = lifetimeRevenue + jeRevNet
- lifetimeDiscounts
- lifetimeCreditMemos
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
+ lifetimeGcBreakage // breakage income when GC voided with balance
- lifetimeDirectExp
@@ -425,6 +464,7 @@ public class FinancialReportService : IFinancialReportService
credits += taxByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
debits += refundTaxByAcctBs.GetValueOrDefault(a.Id); // refund tax portion relieves the tax liability
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
{
credits += gcLiabilityCreditsBs; // GC issued → CR liability
@@ -990,11 +1030,28 @@ public class FinancialReportService : IFinancialReportService
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
var refundTotal = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
arTotalCredits -= refundTotal;
// Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
// longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded.
var saleReversingRefunds = await _context.Refunds
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
&& r.RefundMethod != PaymentMethod.StoreCredit)
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
.ToListAsync();
decimal refundReturnsTotal = 0m;
var refundTaxByAcct = new Dictionary<int, decimal>();
foreach (var r in saleReversingRefunds)
{
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
refundReturnsTotal += returnsPortion;
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
refundTaxByAcct[r.SalesTaxAccountId.Value] = refundTaxByAcct.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
}
var salesReturnsAcctId = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4960" && a.IsActive && !a.IsDeleted)
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
// Refunds by bank account: money leaving the account (CR to checking/bank).
var refundsByAcct = await _context.Refunds
@@ -1079,6 +1136,9 @@ public class FinancialReportService : IFinancialReportService
debits += discountsByAcct.GetValueOrDefault(a.Id);
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
debits += refundReturnsTotal; // revenue portion of cash refunds
debits += refundTaxByAcct.GetValueOrDefault(a.Id); // tax portion relieves the tax liability
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
{
credits += gcLiabilityCredits; // GC issued → CR liability
@@ -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
@@ -77,6 +77,7 @@ public partial class SeedDataService
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
// reducing net revenue to match the discounted AR amount that was posted.
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4960", Name = "Sales Returns & Allowances", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)", CompanyId = company.Id, CreatedAt = now },
// ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
@@ -141,6 +142,30 @@ public partial class SeedDataService
added++;
}
// 4960 Sales Returns & Allowances — contra-revenue account that receives the revenue portion
// of customer refunds under the "reverse the sale" model (DR Sales Returns + DR Sales Tax / CR Bank).
var has4960 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4960" && !a.IsDeleted);
if (!has4960)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "4960",
Name = "Sales Returns & Allowances",
AccountType = AccountType.Revenue,
AccountSubType = AccountSubType.OtherIncome,
IsSystem = true,
IsActive = true,
Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
return added;
}
}