Fix Customer Deposits account mislabel and Sales Discounts recalc (audit O1, O2)

O1: account 2300 has always been used by the deposit GL code as the Customer
Deposits liability (resolved by number), but it was seeded/named "Payroll
Liabilities" for tenants the AccountingDepositsGL migration's NOT EXISTS guard
skipped — so the liability was mislabeled on the balance sheet. Rename 2300 to
"Customer Deposits" (IsSystem) and move payroll to a new 2400 account:
  - both seed paths (SeedDataService.Accounts, SeedData)
  - EnsureSystemAccountsAsync self-heal (renames only where still default-named,
    preserving user renames; ensures 2400 exists)
  - migration RenameDepositsAccountAddPayroll for existing tenants
Account number 2300 is unchanged, so the deposit posting code needs no changes.

O2: LedgerService never recomputed 4950 Sales Discounts, so "Recalculate
Balances" wiped it to JE-only and the Balance Reconciliation report showed false
drift. Add a 4950 section to GetAccountLedgerAsync and ComputePriorBalanceAsync
that reproduces the actual postings (invoice discounts DR + credit-memo issuance
DR, less the unapplied remainder of voided memos CR), matching AccountBalanceService.

Adds a LedgerService regression test for 4950. Documents both fixes plus the
remaining open findings (O3, O4) in docs/ACCOUNTING_AUDIT.md so the audit is no
longer lost. Build clean; 291 unit tests pass; migration applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:37:57 -04:00
parent 9532812b9f
commit 1005be0c9e
8 changed files with 11763 additions and 5 deletions
@@ -528,6 +528,57 @@ public class LedgerService : ILedgerService
});
}
// ── 12c. Sales Discounts contra-revenue (account 4950) ────────────────
// Mirrors the actual postings made by AccountBalanceService so a balance recompute reproduces
// the stored CurrentBalance (otherwise "Recalculate Balances" would wipe 4950 down to JE-only):
// • Invoice discounts → DR 4950 at invoice date (InvoicesController invoice create/edit).
// • Credit memo issuance → DR 4950 = full memo amount at issue (CreditMemosController.Create
// and the store-credit refund path, which both create a CreditMemo row).
// • Credit memo void → CR 4950 = unapplied remainder at void (reverses the unused part).
// Keep this in step with FinancialReportService's 4950 computation (discountsByAcct + cmContraRevenue).
if (account.AccountNumber == "4950")
{
var discountInvoices = await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
.ToListAsync();
foreach (var inv in discountInvoices)
entries.Add(new LedgerEntryDto
{
Date = inv.InvoiceDate, Reference = inv.InvoiceNumber,
Source = "Invoice", Description = $"Discount on {inv.InvoiceNumber}",
Debit = inv.DiscountAmount, Credit = 0,
LinkController = "Invoices", LinkId = inv.Id
});
var discountMemosIssued = await _context.CreditMemos
.Where(m => m.IssueDate >= fromDate && m.IssueDate <= toDate)
.ToListAsync();
foreach (var m in discountMemosIssued)
entries.Add(new LedgerEntryDto
{
Date = m.IssueDate, Reference = m.MemoNumber,
Source = "Credit Memo", Description = "Store credit issued (contra-revenue)",
Debit = m.Amount, Credit = 0,
LinkController = "CreditMemos", LinkId = m.Id
});
var discountMemosVoided = await _context.CreditMemos
.Where(m => m.Status == CreditMemoStatus.Voided
&& m.UpdatedAt >= fromDate && m.UpdatedAt <= toDate
&& m.Amount > m.AmountApplied)
.ToListAsync();
foreach (var m in discountMemosVoided)
entries.Add(new LedgerEntryDto
{
Date = m.UpdatedAt.GetValueOrDefault(), Reference = m.MemoNumber,
Source = "Credit Memo Voided", Description = "Reversed unapplied store credit",
Debit = 0, Credit = m.Amount - m.AmountApplied,
LinkController = "CreditMemos", LinkId = m.Id
});
}
// ── 10. Journal Entry lines touching this account ──────────────────
var jeLines = await _context.JournalEntryLines
.Include(l => l.JournalEntry)
@@ -760,6 +811,24 @@ public class LedgerService : ILedgerService
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
}
// 12c. Sales Discounts contra-revenue (account 4950). Mirrors section 12c in GetAccountLedgerAsync
// so the prior-period opening balance matches the actual postings (invoice discounts + memo issues,
// less the unapplied remainder of voided memos).
if (account.AccountNumber == "4950")
{
debits += await _context.Invoices
.Where(i => i.DiscountAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0;
debits += await _context.CreditMemos
.Where(m => m.IssueDate < beforeDate)
.SumAsync(m => (decimal?)m.Amount) ?? 0;
credits += await _context.CreditMemos
.Where(m => m.Status == CreditMemoStatus.Voided && m.UpdatedAt < beforeDate && m.Amount > m.AmountApplied)
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
}
// 10. Posted journal entry lines touching this account (prior to period)
debits += await _context.JournalEntryLines
.Where(l => l.AccountId == accountId