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:
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
+11382
File diff suppressed because it is too large
Load Diff
+119
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user