Track store credit as a Customer Credits liability (GL)

Audit finding 9b: store-credit refunds and credit memos posted nothing to the GL
on issue (only a CreditMemo + Customer.CreditBalance), so outstanding store credit
was invisible on the balance sheet and the contra-revenue was recognized only on
apply. Introduces a 2350 "Customer Credits" liability so the credit is on the books
from issue to apply.

Model (chosen): lifecycle-equivalent to before, plus the liability is tracked.
- Issue (credit memos, goodwill, and store-credit refunds): DR Sales Discounts
  (4950) / CR Customer Credits (2350).
- Apply: DR Customer Credits / CR AR (was DR Sales Discounts / CR AR).
- Void unapplied remainder: DR Customer Credits / CR Sales Discounts.

Posting updated in all 8 sites: CreditMemosController Create/Apply/Void and
InvoicesController IssueCreditMemo/IssueRefund(store credit)/ApplyCredit/
VoidCreditMemo/CancelRefund. New 2350 account (seed + self-heal).

Reporting moved in lockstep so the books still balance: the 4950 contra-revenue
shifts from applied -> issued (active memos in full + applied portion of voided),
the 2350 liability = unapplied balance on active memos, AR still credited by
applications. Updated in FinancialReportService (balance sheet retained earnings,
trial balance, P&L) and LedgerService (per-account + prior-balance 2350 section).
Verified the balance-sheet identity for active and voided memos by hand; new
ledger test covers the 2350 lifecycle. Build clean; 284 unit tests pass.

Note: pre-existing quirks left untouched (out of 9b scope) — account 2300 is
seeded as "Payroll Liabilities" but resolved as Customer Deposits in code, and
LedgerService doesn't recompute 4950 so RecalculateBalances understates it; both
predate this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 08:57:22 -04:00
parent 2a82a1d34b
commit 9ce361235f
6 changed files with 225 additions and 26 deletions
@@ -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<int, decimal>();
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)