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:
@@ -509,6 +509,57 @@ public class LedgerServiceTests
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// ── Cash refund reverses the sale: Sales Returns + Sales Tax debited, AR untouched ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccountLedgerAsync_CashRefund_ReversesTheSale_DebitsReturnsAndTax_NotAr()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "1000", Name = "Checking", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsActive = true });
|
||||
context.Accounts.Add(new Account { Id = 2, CompanyId = 1, AccountNumber = "1100", Name = "AR", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsActive = true });
|
||||
context.Accounts.Add(new Account { Id = 3, CompanyId = 1, AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsActive = true });
|
||||
context.Accounts.Add(new Account { Id = 4, CompanyId = 1, AccountNumber = "4960", Name = "Sales Returns", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsActive = true });
|
||||
|
||||
// Invoice $108 incl. $8 tax, tax posted to account 3.
|
||||
context.Invoices.Add(new Invoice
|
||||
{
|
||||
Id = 99, CompanyId = 1, InvoiceNumber = "INV-0099", CustomerId = 1,
|
||||
Status = InvoiceStatus.Sent, InvoiceDate = InPeriod,
|
||||
Total = 108m, TaxAmount = 8m, SalesTaxAccountId = 3
|
||||
});
|
||||
|
||||
// Full cash refund of $108 paid from Checking.
|
||||
context.Refunds.Add(new Refund
|
||||
{
|
||||
Id = 1, CompanyId = 1, InvoiceId = 99, Amount = 108m,
|
||||
RefundDate = InPeriod, RefundMethod = PaymentMethod.Check, DepositAccountId = 1
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var svc = CreateService(context);
|
||||
|
||||
// Sales Returns (4960): revenue portion debited ($100).
|
||||
var returns = await svc.GetAccountLedgerAsync(4, PeriodStart, PeriodEnd);
|
||||
var returnsEntry = Assert.Single(returns!.Entries, e => e.Source == "Refund");
|
||||
Assert.Equal(100m, returnsEntry.Debit);
|
||||
Assert.Equal(0m, returnsEntry.Credit);
|
||||
|
||||
// Sales Tax Payable: tax portion debited ($8), relieving the liability.
|
||||
var tax = await svc.GetAccountLedgerAsync(3, PeriodStart, PeriodEnd);
|
||||
var taxEntry = Assert.Single(tax!.Entries, e => e.Source == "Refund");
|
||||
Assert.Equal(8m, taxEntry.Debit);
|
||||
|
||||
// Bank: full $108 credited (cash leaves).
|
||||
var bank = await svc.GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
||||
var bankEntry = Assert.Single(bank!.Entries, e => e.Source == "Refund");
|
||||
Assert.Equal(108m, bankEntry.Credit);
|
||||
|
||||
// AR is no longer touched by refunds under the "reverse the sale" model.
|
||||
var ar = await svc.GetAccountLedgerAsync(2, PeriodStart, PeriodEnd);
|
||||
Assert.DoesNotContain(ar!.Entries, e => e.Source == "Refund");
|
||||
}
|
||||
|
||||
private static LedgerService CreateService(ApplicationDbContext context)
|
||||
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user