diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs index 168eddc..7a682aa 100644 --- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -94,10 +94,16 @@ public class FinancialReportService : IFinancialReportService .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; - var periodCredits = await _context.CreditMemoApplications - .Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd - && a.Invoice.Status != InvoiceStatus.Voided) - .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; + // Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period = + // memos issued in the period minus the unapplied remainder of memos voided in the period. + var periodCmIssued = await _context.CreditMemos + .Where(m => m.CompanyId == companyId && m.IssueDate >= from && m.IssueDate <= toEnd) + .SumAsync(m => (decimal?)m.Amount) ?? 0m; + var periodCmVoided = await _context.CreditMemos + .Where(m => m.CompanyId == companyId && m.Status == CreditMemoStatus.Voided + && m.UpdatedAt >= from && m.UpdatedAt <= toEnd) + .SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0m; + var periodCredits = periodCmIssued - periodCmVoided; var totalDeductions = periodDiscounts + periodCredits; if (totalDeductions > 0) revenueLines.Add(new FinancialReportLine @@ -296,9 +302,25 @@ public class FinancialReportService : IFinancialReportService && p.Invoice.Status != InvoiceStatus.WrittenOff) .SumAsync(p => (decimal?)p.Amount) ?? 0; // Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice). - arCredits += await _context.CreditMemoApplications + var cmAppliedBs = await _context.CreditMemoApplications .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0; + arCredits += cmAppliedBs; + + // Customer Credits (2350): a credit memo books DR Sales Discounts / CR Customer Credits on issue, + // then DR Customer Credits / CR AR on apply. Contra-revenue (retained earnings) = issued amount + // (active in full + applied portion of voided); the 2350 liability = unapplied balance on active memos. + var cmIssuedNonVoidedBs = await _context.CreditMemos + .Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd) + .SumAsync(m => (decimal?)m.Amount) ?? 0m; + var cmAppliedNonVoidedBs = await _context.CreditMemoApplications + .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided + && a.CreditMemo.Status != CreditMemoStatus.Voided) + .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; + var cmContraRevenueBs = cmIssuedNonVoidedBs + (cmAppliedBs - cmAppliedNonVoidedBs); + var customerCreditsAcctIdBs = await _context.Accounts + .Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted) + .Select(a => (int?)a.Id).FirstOrDefaultAsync(); // 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. @@ -373,11 +395,9 @@ public class FinancialReportService : IFinancialReportService .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m); - // Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts). - var lifetimeCreditMemos = isCash ? 0m - : (await _context.CreditMemoApplications - .Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided) - .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m); + // Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is + // reduced by the issued amount (active memos in full + applied portion of voided memos). + var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs; var lifetimeDirectExp = await _context.Expenses .Where(e => e.Date <= asOfEnd) .SumAsync(e => (decimal?)e.Amount) ?? 0; @@ -470,6 +490,11 @@ public class FinancialReportService : IFinancialReportService credits += gcLiabilityCreditsBs; // GC issued → CR liability debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability } + if (customerCreditsAcctIdBs.HasValue && a.Id == customerCreditsAcctIdBs.Value) + { + credits += cmIssuedNonVoidedBs; // credit memos issued → CR liability + debits += cmAppliedNonVoidedBs; // applied → DR liability + } if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value) { credits += custDepositsCreditsBs; // deposits taken → CR liability @@ -988,6 +1013,23 @@ public class FinancialReportService : IFinancialReportService && a.Invoice.Status != InvoiceStatus.Voided) .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; + // Customer Credits (2350) model: a credit memo books DR Sales Discounts / CR Customer Credits on + // issue, then DR Customer Credits / CR AR on apply. So the 4950 contra-revenue is the *issued* + // amount (active memos in full + the applied portion of voided memos), and the 2350 liability is + // the unapplied balance on active memos. AR is still credited by applications (cmApplied). + var cmIssuedNonVoided = await _context.CreditMemos + .Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd) + .SumAsync(m => (decimal?)m.Amount) ?? 0m; + var cmAppliedNonVoided = await _context.CreditMemoApplications + .Where(a => a.AppliedDate <= asOfEnd + && a.Invoice.Status != InvoiceStatus.Voided + && a.CreditMemo.Status != CreditMemoStatus.Voided) + .SumAsync(a => (decimal?)a.AmountApplied) ?? 0m; + var cmContraRevenue = cmIssuedNonVoided + (cmApplied - cmAppliedNonVoided); // DR 4950 + var customerCreditsAcctId = await _context.Accounts + .Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted) + .Select(a => (int?)a.Id).FirstOrDefaultAsync(); + var discountsByAcct = new Dictionary(); if (discountAcctId.HasValue) { @@ -997,8 +1039,8 @@ public class FinancialReportService : IFinancialReportService && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) .SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m; - if (totalDiscounts + cmApplied > 0) - discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied; + if (totalDiscounts + cmContraRevenue > 0) + discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue; } // JE lines: posted entries debit/credit all account types @@ -1149,6 +1191,11 @@ public class FinancialReportService : IFinancialReportService credits += custDepositsCredits; // deposits taken → CR liability debits += custDepositsDebits; // deposits applied → DR liability } + if (customerCreditsAcctId.HasValue && a.Id == customerCreditsAcctId.Value) + { + credits += cmIssuedNonVoided; // credit memos issued → CR liability + debits += cmAppliedNonVoided; // applied → DR liability + } } // Manual JEs apply to all account types (including AR/AP for unusual adjustments) diff --git a/src/PowderCoating.Infrastructure/Services/LedgerService.cs b/src/PowderCoating.Infrastructure/Services/LedgerService.cs index 60aedd5..f113ddd 100644 --- a/src/PowderCoating.Infrastructure/Services/LedgerService.cs +++ b/src/PowderCoating.Infrastructure/Services/LedgerService.cs @@ -495,6 +495,39 @@ public class LedgerService : ILedgerService }); } + // ── 12b. Customer Credits liability (account 2350) ──────────────────── + // CR when a credit memo (incl. store-credit refund) is issued; DR when applied to an invoice. + // Voided memos are excluded (their issue/void net to zero). + if (account.AccountNumber == "2350") + { + var memosIssued = await _context.CreditMemos + .Where(m => m.Status != CreditMemoStatus.Voided + && m.IssueDate >= fromDate && m.IssueDate <= toDate) + .ToListAsync(); + foreach (var m in memosIssued) + entries.Add(new LedgerEntryDto + { + Date = m.IssueDate, Reference = m.MemoNumber, + Source = "Credit Memo", Description = "Store credit issued", + Debit = 0, Credit = m.Amount, + LinkController = "CreditMemos", LinkId = m.Id + }); + + var memosApplied = await _context.CreditMemoApplications + .Include(a => a.CreditMemo).Include(a => a.Invoice) + .Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided + && a.AppliedDate >= fromDate && a.AppliedDate <= toDate) + .ToListAsync(); + foreach (var a in memosApplied) + entries.Add(new LedgerEntryDto + { + Date = a.AppliedDate, Reference = a.CreditMemo?.MemoNumber ?? $"CM-{a.CreditMemoId}", + Source = "Credit Applied", Description = $"Applied to {a.Invoice?.InvoiceNumber}", + Debit = a.AmountApplied, Credit = 0, + LinkController = "Invoices", LinkId = a.InvoiceId + }); + } + // ── 10. Journal Entry lines touching this account ────────────────── var jeLines = await _context.JournalEntryLines .Include(l => l.JournalEntry) @@ -716,6 +749,17 @@ public class LedgerService : ILedgerService .SumAsync(d => (decimal?)d.Amount) ?? 0; } + // 12b. Customer Credits liability (account 2350) + if (account.AccountNumber == "2350") + { + credits += await _context.CreditMemos + .Where(m => m.Status != CreditMemoStatus.Voided && m.IssueDate < beforeDate) + .SumAsync(m => (decimal?)m.Amount) ?? 0; + debits += await _context.CreditMemoApplications + .Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided && a.AppliedDate < beforeDate) + .SumAsync(a => (decimal?)a.AmountApplied) ?? 0; + } + // 10. Posted journal entry lines touching this account (prior to period) debits += await _context.JournalEntryLines .Where(l => l.AccountId == accountId diff --git a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs index e56fd27..da478c3 100644 --- a/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs +++ b/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs @@ -61,6 +61,7 @@ public partial class SeedDataService new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now }, + new Account { AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Store credit owed to customers (credit memos not yet applied)", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now }, // ── EQUITY ──────────────────────────────────────────────────────── @@ -166,6 +167,30 @@ public partial class SeedDataService added++; } + // 2350 Customer Credits — liability for store credit owed to customers. Credited when a credit + // memo (incl. store-credit refunds) is issued; debited when applied to an invoice. + var has2350 = await _context.Set() + .IgnoreQueryFilters() + .AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2350" && !a.IsDeleted); + + if (!has2350) + { + _context.Set().Add(new Account + { + AccountNumber = "2350", + Name = "Customer Credits", + AccountType = AccountType.Liability, + AccountSubType = AccountSubType.OtherCurrentLiability, + IsSystem = true, + IsActive = true, + Description = "Store credit owed to customers (credit memos not yet applied)", + CompanyId = company.Id, + CreatedAt = now + }); + await _context.SaveChangesAsync(); + added++; + } + return added; } } diff --git a/src/PowderCoating.Web/Controllers/CreditMemosController.cs b/src/PowderCoating.Web/Controllers/CreditMemosController.cs index 012c485..2842253 100644 --- a/src/PowderCoating.Web/Controllers/CreditMemosController.cs +++ b/src/PowderCoating.Web/Controllers/CreditMemosController.cs @@ -177,6 +177,13 @@ public class CreditMemosController : Controller await _unitOfWork.CompleteAsync(); + // GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue) + // / CR Customer Credits (2350). Relieved when the memo is applied to an invoice. + var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.AccountNumber == "4950" && a.IsActive); + var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.AccountNumber == "2350" && a.IsActive); + await _accountBalanceService.DebitAsync(discountAcct?.Id, vm.Amount); + await _accountBalanceService.CreditAsync(customerCreditsAcct?.Id, vm.Amount); + TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}."; return RedirectToAction(nameof(Details), new { id = memo.Id }); } @@ -252,18 +259,14 @@ public class CreditMemosController : Controller await _unitOfWork.Invoices.UpdateAsync(invoice); } - // GL: DR 4950 Sales Discounts (contra-revenue) / CR AR. - // The dynamic report computation attributes credit memo applications to both - // accounts already; this call keeps Account.CurrentBalance in sync for - // RecalculateAllAsync and any tools that read it directly. + // GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR. + // The contra-revenue (Sales Discounts) was recognized when the credit was issued. + // Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers. var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive); - var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( - a => a.AccountNumber == "4950" && a.IsActive) - ?? await _unitOfWork.Accounts.FirstOrDefaultAsync( - a => a.AccountType == AccountType.Revenue && a.IsActive - && a.Name.ToLower().Contains("discount")); - await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount); + var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountNumber == "2350" && a.IsActive); + await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount); await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount); await _unitOfWork.CompleteAsync(); @@ -308,6 +311,15 @@ public class CreditMemosController : Controller await _unitOfWork.Customers.UpdateAsync(memo.Customer); } + // GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts. + if (remaining > 0) + { + var ccAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.AccountNumber == "2350" && a.IsActive); + var sdAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.AccountNumber == "4950" && a.IsActive); + await _accountBalanceService.DebitAsync(ccAcct?.Id, remaining); + await _accountBalanceService.CreditAsync(sdAcct?.Id, remaining); + } + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit."; return RedirectToAction(nameof(Details), new { id }); diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 1c6058f..d723a37 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -2494,6 +2494,14 @@ public class InvoicesController : Controller return acct?.Id; } + /// Returns the Customer Credits liability account (2350) for store credit / credit memos. + private async Task GetCustomerCreditsAccountIdAsync(int companyId) + { + var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.IsActive && a.AccountNumber == "2350"); + return acct?.Id; + } + /// Returns the AR account ID for the given company (first active AccountsReceivable account). private async Task GetArAccountIdAsync(int companyId) { @@ -2672,6 +2680,14 @@ public class InvoicesController : Controller } await _unitOfWork.CompleteAsync(); + + // GL: store credit is a liability owed to the customer — DR Sales Discounts (contra-revenue) + // / CR Customer Credits (2350). The liability is relieved when the credit memo is applied. + var scDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId); + var scCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId); + await _accountBalanceService.DebitAsync(scDiscountAcctId, dto.Amount); + await _accountBalanceService.CreditAsync(scCustomerCreditsAcctId, dto.Amount); + TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created."; } else @@ -2740,12 +2756,14 @@ public class InvoicesController : Controller if (refund.RefundMethod == PaymentMethod.StoreCredit) { - // Cancel the linked CreditMemo and reverse the CreditBalance + // Cancel the linked CreditMemo and reverse the unapplied store-credit remainder. + decimal creditReversed = refund.Amount; if (refund.CreditMemoId.HasValue) { var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value); if (memo != null && memo.Status == CreditMemoStatus.Active) { + creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder memo.Status = CreditMemoStatus.Voided; memo.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CreditMemos.UpdateAsync(memo); @@ -2754,9 +2772,18 @@ public class InvoicesController : Controller if (customer != null) { - customer.CreditBalance -= refund.Amount; + customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed); await _unitOfWork.Customers.UpdateAsync(customer); } + + // GL: reverse the unapplied store-credit issuance — DR Customer Credits / CR Sales Discounts. + if (creditReversed > 0) + { + var ccAcctId = await GetCustomerCreditsAccountIdAsync(refund.Invoice.CompanyId); + var sdAcctId = await GetSalesDiscountAccountIdAsync(refund.Invoice.CompanyId); + await _accountBalanceService.DebitAsync(ccAcctId, creditReversed); + await _accountBalanceService.CreditAsync(sdAcctId, creditReversed); + } } else { @@ -2831,6 +2858,14 @@ public class InvoicesController : Controller } await _unitOfWork.CompleteAsync(); + + // GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue) + // / CR Customer Credits (2350). Relieved when the memo is applied to an invoice. + var cmDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId); + var cmCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId); + await _accountBalanceService.DebitAsync(cmDiscountAcctId, dto.Amount); + await _accountBalanceService.CreditAsync(cmCustomerCreditsAcctId, dto.Amount); + TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer."; } catch (Exception ex) @@ -2917,9 +2952,11 @@ public class InvoicesController : Controller } // GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply. + // GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR. + // (The contra-revenue was already recognized as Sales Discounts when the credit was issued.) var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); - var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId); - await _accountBalanceService.DebitAsync(discountAcctId, applyAmount); + var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId); + await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount); await _accountBalanceService.CreditAsync(arAccountId, applyAmount); await _unitOfWork.CompleteAsync(); @@ -2962,6 +2999,15 @@ public class InvoicesController : Controller await _unitOfWork.Customers.UpdateAsync(memo.Customer); } + // GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts. + if (remaining > 0) + { + var ccAcctId = await GetCustomerCreditsAccountIdAsync(memo.CompanyId); + var sdAcctId = await GetSalesDiscountAccountIdAsync(memo.CompanyId); + await _accountBalanceService.DebitAsync(ccAcctId, remaining); + await _accountBalanceService.CreditAsync(sdAcctId, remaining); + } + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Credit memo voided."; return RedirectToAction(nameof(Details), new { id = invoiceId }); diff --git a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs index f42e938..42a7e59 100644 --- a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs +++ b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs @@ -560,6 +560,31 @@ public class LedgerServiceTests Assert.DoesNotContain(ar!.Entries, e => e.Source == "Refund"); } + // ── Credit memo: Customer Credits (2350) tracks outstanding store credit, excludes voided ── + + [Fact] + public async Task GetAccountLedgerAsync_CustomerCredits2350_TracksOutstandingStoreCredit_ExcludesVoided() + { + await using var context = CreateContext(); + + context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsActive = true }); + SeedInvoice(context, id: 99); + + // Active memo: $100 issued, $40 applied → $60 outstanding liability. + context.CreditMemos.Add(new CreditMemo { Id = 1, CompanyId = 1, MemoNumber = "CM-1", CustomerId = 1, Amount = 100m, AmountApplied = 40m, IssueDate = InPeriod, Status = CreditMemoStatus.PartiallyApplied }); + context.CreditMemoApplications.Add(new CreditMemoApplication { Id = 1, CompanyId = 1, CreditMemoId = 1, InvoiceId = 99, AmountApplied = 40m, AppliedDate = InPeriod }); + + // Voided memo: must contribute nothing to the liability. + context.CreditMemos.Add(new CreditMemo { Id = 2, CompanyId = 1, MemoNumber = "CM-2", CustomerId = 1, Amount = 50m, AmountApplied = 0m, IssueDate = InPeriod, Status = CreditMemoStatus.Voided }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + // CR 100 (issue) + DR 40 (apply) on the active memo; the voided memo is excluded. + Assert.Equal(2, ledger!.Entries.Count); + Assert.Equal(60m, ledger.ClosingBalance); // credit-normal liability: 100 issued − 40 applied + } + private static LedgerService CreateService(ApplicationDbContext context) => new LedgerService(context, Mock.Of>());