diff --git a/src/PowderCoating.Core/Accounting/RefundAllocation.cs b/src/PowderCoating.Core/Accounting/RefundAllocation.cs new file mode 100644 index 0000000..74c7045 --- /dev/null +++ b/src/PowderCoating.Core/Accounting/RefundAllocation.cs @@ -0,0 +1,29 @@ +namespace PowderCoating.Core.Accounting; + +/// +/// 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 (InvoicesController) and the two +/// reporting recomputes (LedgerService, FinancialReportService) always agree — if they +/// computed it independently the trial balance could drift. +/// +public static class RefundAllocation +{ + /// + /// Splits (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. + /// + 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); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs index d7468d7..168eddc 100644 --- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -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(); + 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(); + 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 diff --git a/src/PowderCoating.Infrastructure/Services/LedgerService.cs b/src/PowderCoating.Infrastructure/Services/LedgerService.cs index 01c9d78..60aedd5 100644 --- a/src/PowderCoating.Infrastructure/Services/LedgerService.cs +++ b/src/PowderCoating.Infrastructure/Services/LedgerService.cs @@ -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 diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs index 381913a..e56fd27 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs @@ -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() + .IgnoreQueryFilters() + .AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4960" && !a.IsDeleted); + + if (!has4960) + { + _context.Set().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; } } diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 1e3c9d5..1c6058f 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -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; } + /// Returns the Sales Returns & Allowances contra-revenue account (4960) for refunds. + private async Task GetSalesReturnsAccountIdAsync(int companyId) + { + var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.IsActive && a.AccountNumber == "4960"); + return acct?.Id; + } + /// Returns the AR account ID for the given company (first active AccountsReceivable account). private async Task 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); } diff --git a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs index 08bacd7..f42e938 100644 --- a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs +++ b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs @@ -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>()); diff --git a/tests/PowderCoating.UnitTests/RefundAllocationTests.cs b/tests/PowderCoating.UnitTests/RefundAllocationTests.cs new file mode 100644 index 0000000..647633f --- /dev/null +++ b/tests/PowderCoating.UnitTests/RefundAllocationTests.cs @@ -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); + } +}