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