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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Accounting;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Enums;
|
||||
@@ -2485,6 +2486,14 @@ public class InvoicesController : Controller
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Sales Returns & Allowances contra-revenue account (4960) for refunds.</summary>
|
||||
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "4960");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
|
||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||
{
|
||||
@@ -2667,20 +2676,20 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
// Adjust customer AR balance — they're owed money back
|
||||
if (invoice.Customer != null)
|
||||
{
|
||||
invoice.Customer.CurrentBalance -= dto.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||
}
|
||||
|
||||
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
|
||||
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
|
||||
// Customer AR balance is intentionally left unchanged — the invoice stays paid and the
|
||||
// sale is reversed via the contra accounts. The split is centralised in RefundAllocation
|
||||
// so LedgerService and FinancialReportService recompute the same way.
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
|
||||
// Mirrors how FinancialReportService accounts for refunds:
|
||||
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
|
||||
var arAccountId = await GetArAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
|
||||
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
|
||||
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
|
||||
|
||||
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
|
||||
if (taxPortion > 0)
|
||||
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
|
||||
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||
@@ -2751,16 +2760,15 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reverse the AR balance adjustment
|
||||
if (customer != null)
|
||||
{
|
||||
customer.CurrentBalance += refund.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
|
||||
// The customer's AR balance was not touched when the refund was issued, so it is not touched here.
|
||||
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
|
||||
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
|
||||
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
|
||||
|
||||
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
|
||||
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
|
||||
await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
|
||||
if (taxPortion > 0)
|
||||
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
|
||||
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>());
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using PowderCoating.Core.Accounting;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class RefundAllocationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(108, 8, 108, 100, 8)] // full refund, proportional tax
|
||||
[InlineData(54, 8, 108, 50, 4)] // half refund → half the tax
|
||||
[InlineData(100, 0, 100, 100, 0)] // no tax on the invoice
|
||||
[InlineData(100, 8, 0, 100, 0)] // zero invoice total → all returns, no tax
|
||||
public void Split_AllocatesTaxProportionally_AndAlwaysSumsToRefund(
|
||||
decimal amount, decimal invoiceTax, decimal invoiceTotal, decimal expectedReturns, decimal expectedTax)
|
||||
{
|
||||
var (returns, tax) = RefundAllocation.Split(amount, invoiceTax, invoiceTotal);
|
||||
|
||||
Assert.Equal(expectedReturns, returns);
|
||||
Assert.Equal(expectedTax, tax);
|
||||
// Invariant: the two portions must always reconstruct the refund exactly (no penny lost).
|
||||
Assert.Equal(amount, returns + tax);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user