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
@@ -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 });
@@ -2494,6 +2494,14 @@ public class InvoicesController : Controller
return acct?.Id;
}
/// <summary>Returns the Customer Credits liability account (2350) for store credit / credit memos.</summary>
private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2350");
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)
{
@@ -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 });