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
+108
View File
@@ -0,0 +1,108 @@
# Accounting System Audit
> Living record of the accounting/GL audit and its remediation status.
> **Keep this file updated** whenever an accounting finding is opened or closed — the
> original audit was lost once because it was never written down. Don't let that happen again.
Last reconciled against code: **2026-06-19** (branch `dev`, commits through `9532812`).
Verification at that point: `dotnet build` clean (0 errors); `dotnet test tests/PowderCoating.UnitTests`**290 passed, 0 failed**.
---
## How the GL is modeled (context for the findings)
Balances are computed two different ways, and they must agree:
1. **At posting time** — controllers call `AccountBalanceService.DebitAsync/CreditAsync`, which
mutate the denormalized `Account.CurrentBalance`.
2. **At report time**`FinancialReportService` (Trial Balance, Balance Sheet, P&L, AR/AP aging,
statements) and `LedgerService` (per-account ledger + `RecalculateBalances`) recompute balances
from source documents.
`LedgerService` also backs the **Balance Reconciliation report** (the detective control added in
`c2cd19e`), which compares stored `CurrentBalance` vs the ledger-recomputed balance. Any account
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
and (b) produce misleading output in the reconciliation report.
---
## Resolved findings (this audit batch)
| # | Finding | Fix | Commit | Verified |
|---|---------|-----|--------|----------|
| **#4** | `Sales Tax Payable` (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). | `JournalEntries/SalesTaxPayment` GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. | `0c921ba` | Posting reviewed `JournalEntriesController:235-296`. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
| **#7** | Most report queries relied on the global tenant filter, which is **bypassed for SuperAdmin** → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit `CompanyId == companyId` predicate added to every query in `FinancialReportService`. | `9532812` | Confirmed: every query in `FinancialReportService.cs` carries an explicit `CompanyId` predicate. |
| **#9a** | A cash refund posted **DR AR** (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now **reverses the sale**: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in `Core/Accounting/RefundAllocation.Split`. | `2a82a1d` | Posting (`InvoicesController.IssueRefund`/`CancelRefund`) and both recompute paths (`LedgerService` §5b, `FinancialReportService` TB/BS/P&L) all use `RefundAllocation.Split` — they agree by construction. |
| **#9b** | Store-credit refunds & credit memos posted nothing to the GL on issue (only a `CreditMemo` + `Customer.CreditBalance`) → outstanding store credit invisible on the balance sheet. | New **2350 Customer Credits** liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | `9ce3612` | Posting sites confirmed (`CreditMemosController` 180-185/262-270/314-320; `InvoicesController` store-credit path). Reporting `cmContraRevenue`/`cmIssuedNonVoided` math reviewed. |
| — | No detective control to catch denormalized-balance drift. | **Balance Reconciliation report** (`Reports/Reconciliation`): per-account stored vs recomputed, plus AR/AP control-vs-subledger. | `c2cd19e` | Reviewed `FinancialReportService.GetBalanceReconciliationAsync`. NOTE: its usefulness is limited by O2 below. |
### Original numbering gap
The commits reference findings **#4, #7, #9a, #9b** — implying a list of at least #1#9. The original
audit document was never persisted and is unrecoverable. Findings **#1, #2, #3, #5, #6, #8** cannot be
matched to commits and their status is **unknown**. If the original list resurfaces, reconcile it here.
---
## Resolved findings (2026-06-19 remediation)
### O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — **RESOLVED**
- **Root cause refined:** there is no payroll feature posting to 2300; the deposit GL code has always used
2300 (resolved by number) as the Customer Deposits liability. Migration `AccountingDepositsGL` already
renamed it to "Customer Deposits" for tenants that lacked a 2300, but its `NOT EXISTS` guard **skipped**
pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the
wrong name.
- **Fix (chosen approach: rename 2300, move payroll to a new 2400):**
- Seed files now create **2300 = "Customer Deposits" (IsSystem)** and a separate **2400 = "Payroll
Liabilities"** — `SeedDataService.Accounts.cs`, `SeedData.cs`.
- `EnsureSystemAccountsAsync` self-heals: renames any 2300 still named "Payroll Liabilities" → "Customer
Deposits" (preserving user renames) and ensures 2400 exists.
- Migration `20260619233108_RenameDepositsAccountAddPayroll` does the same for existing tenants (rename
only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes
only empty 2400s; does not re-introduce the mislabel).
- Account **number 2300 is unchanged**, so the deposit posting code (`DepositsController`,
`InvoicesController`) needed no changes — its lookups are by `"2300"`.
### O2 — `LedgerService` never recomputed 4950 Sales Discounts → recalc corrupted it — **RESOLVED**
- **Fix:** added a 4950 section to both `LedgerService.GetAccountLedgerAsync` and `ComputePriorBalanceAsync`
that reproduces the *actual postings* so a balance recalc matches the stored balance:
invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) the unapplied
remainder of voided memos (CR at void). This mirrors what `AccountBalanceService` posts at
`InvoicesController:849/1108/1629` and `CreditMemosController`, so the Balance Reconciliation report no
longer shows false drift on 4950.
- **Note / micro-discrepancy:** the ledger uses `memo.AmountApplied` for the voided remainder (matching the
true posting), whereas `FinancialReportService` TB/BS derive the voided portion from applications against
non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided
invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug.
- Regression test: `LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue`.
Verification of O1+O2: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests`**291 passed**;
migration applied to the dev database successfully.
---
## Open findings (deferred — to do after O1/O2 per user)
### O3 — Write-path account lookups omit explicit `CompanyId` (defense-in-depth gap) — **LOW-MEDIUM**
- Finding #7 added explicit `CompanyId` predicates to the **read** path, but several **posting** lookups
still rely on the global filter alone, e.g. `CreditMemosController` (182-184, 268, 317-318) and
`InvoicesController` refund/credit account lookups use `FirstOrDefaultAsync(a => a.AccountNumber == "2350" && a.IsActive)`
with no `CompanyId`. `JournalEntriesController.SalesTaxPayment` (246) does it correctly.
- **Impact:** low in practice (these run under a non-SuperAdmin company context where the global filter
applies), but it violates the repo's standing rule and would post to the wrong tenant's account if ever
executed in a SuperAdmin/multi-company context.
- **Fix direction:** add `a.CompanyId == companyId` to every account lookup on the posting path.
### O4 — Sales-tax remittance can over-remit (drive 2200 negative) — **LOW**
- `SalesTaxPayment` validates amount > 0 but does **not** cap the payment at the outstanding 2200 balance.
- **Impact:** a typo can push Sales Tax Payable negative (a debit-balance liability), which is an
abnormal balance that then surfaces oddly on the balance sheet.
- **Fix direction:** show the current 2200 balance (already shown on the GET form) and reject/warn when
`amount` exceeds it.
---
## Suggested priority order
1. **O1** (mislabeled/commingled liability — real balance-sheet correctness issue)
2. **O2** (recalc corruption + reconciliation report reliability)
3. **O3** (defense-in-depth on write path)
4. **O4** (input guard)
@@ -1359,7 +1359,9 @@ New accounts walk through an 18-step setup wizard to configure company informati
new() { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2100", Name = "Credit Card", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
// 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
new() { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
new() { AccountNumber = "2500", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
// ── Equity ──────────────────────────────────────────────────────
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RenameDepositsAccountAddPayroll : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// O1 remediation. Account 2300 has always been used by the deposit GL posting code as the
// Customer Deposits liability (resolved by number), but pre-migration tenants still had it
// seeded/named "Payroll Liabilities" — so the liability was mislabeled on the balance sheet.
// Rename it to "Customer Deposits" and mark it system. Only touch accounts still carrying the
// old default name, so a tenant's own rename is preserved.
migrationBuilder.Sql(@"
UPDATE Accounts
SET Name = 'Customer Deposits',
Description = 'Deposits received from customers before an invoice is created; cleared when applied to an invoice',
IsSystem = 1
WHERE AccountNumber = '2300'
AND IsDeleted = 0
AND Name = 'Payroll Liabilities';
");
// Re-home payroll to a dedicated 2400 account for every company that lacks one, so the chart
// still offers a payroll liability without colliding with Customer Deposits at 2300.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2400',
'Payroll Liabilities',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
0, -- IsSystem = false
1, -- IsActive = true
'Payroll taxes and withholdings owed',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2400'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Best-effort reversal. The 2300 rename is intentionally NOT undone: it corrected a mislabeled
// account and reverting would re-introduce the bug. Only the empty 2400 accounts this migration
// added are soft-deleted (skip any that already carry a balance, i.e. are in use).
migrationBuilder.Sql(@"
UPDATE Accounts
SET IsDeleted = 1
WHERE AccountNumber = '2400'
AND IsDeleted = 0
AND Name = 'Payroll Liabilities'
AND CurrentBalance = 0;
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
}
}
}
@@ -7241,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7252,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7263,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -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
@@ -60,8 +60,12 @@ public partial class SeedDataService
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
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 },
// 2300 is the Customer Deposits liability — credited when a deposit is taken, debited when it is
// applied to an invoice (see DepositsController / InvoicesController, which resolve it by number).
// IsSystem because the GL posting code depends on it existing. Payroll lives at 2400 below.
new Account { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice", 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 = "2400", 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 = "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 ────────────────────────────────────────────────────────
@@ -191,6 +195,46 @@ public partial class SeedDataService
added++;
}
// 2300 used to be seeded as "Payroll Liabilities" but the deposit GL posting code has always
// resolved 2300 by number and used it as the Customer Deposits liability — so the account was
// mislabeled on the balance sheet. Rename it to "Customer Deposits" and mark it system. Only
// touch accounts still carrying the old default name so a user's own rename is preserved.
var legacyDepositsAcct = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2300"
&& !a.IsDeleted && a.Name == "Payroll Liabilities");
if (legacyDepositsAcct != null)
{
legacyDepositsAcct.Name = "Customer Deposits";
legacyDepositsAcct.Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice";
legacyDepositsAcct.IsSystem = true;
legacyDepositsAcct.UpdatedAt = now;
await _context.SaveChangesAsync();
}
// 2400 Payroll Liabilities — the payroll account displaced from 2300 (now Customer Deposits).
var has2400 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2400" && !a.IsDeleted);
if (!has2400)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "2400",
Name = "Payroll Liabilities",
AccountType = AccountType.Liability,
AccountSubType = AccountSubType.OtherCurrentLiability,
IsSystem = false,
IsActive = true,
Description = "Payroll taxes and withholdings owed",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
return added;
}
}
@@ -585,6 +585,40 @@ public class LedgerServiceTests
Assert.Equal(60m, ledger.ClosingBalance); // credit-normal liability: 100 issued 40 applied
}
// ── Sales Discounts (4950): recompute mirrors the actual postings so a balance ──
// ── recalc reproduces the stored balance instead of wiping it to JE-only (O2). ──
[Fact]
public async Task GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue()
{
await using var context = CreateContext();
context.Accounts.Add(new Account { Id = 1, CompanyId = 1, AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsActive = true });
// Invoice with a $30 discount → DR 4950 at invoice date.
context.Invoices.Add(new Invoice
{
Id = 99, CompanyId = 1, InvoiceNumber = "INV-0099", CustomerId = 1,
Status = InvoiceStatus.Sent, InvoiceDate = InPeriod, Total = 270m, DiscountAmount = 30m
});
// Active memo $100 → DR 4950 = full amount at issue (the applied portion stays as contra-revenue).
context.CreditMemos.Add(new CreditMemo { Id = 1, CompanyId = 1, MemoNumber = "CM-1", CustomerId = 1, Amount = 100m, AmountApplied = 40m, IssueDate = InPeriod, Status = CreditMemoStatus.PartiallyApplied });
// Voided memo $50 with $20 applied → DR 50 at issue, CR (5020)=30 unapplied remainder at void.
context.CreditMemos.Add(new CreditMemo { Id = 2, CompanyId = 1, MemoNumber = "CM-2", CustomerId = 1, Amount = 50m, AmountApplied = 20m, IssueDate = InPeriod, Status = CreditMemoStatus.Voided, UpdatedAt = InPeriod });
await context.SaveChangesAsync();
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
// Entries: invoice discount + two memo issuances + one void reversal.
Assert.Equal(4, ledger!.Entries.Count);
Assert.Equal(180m, ledger.PeriodDebits); // 30 + 100 + 50
Assert.Equal(30m, ledger.PeriodCredits); // voided unapplied remainder
// Credit-normal contra-revenue with a net debit balance → 150 (shows in the TB debit column).
Assert.Equal(-150m, ledger.ClosingBalance);
}
private static LedgerService CreateService(ApplicationDbContext context)
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());