Seed and self-heal Gift Certificate Liability account 2500 (audit O5)
Account 2500 is resolved by number as the GC liability (GiftCertificatesController),
but the per-tenant seeder never created it — so tenants onboarded after the
AccountingGapsPhase2 migration had no GC liability account and gift-certificate GL
postings silently no-op'd. The default-company seeder also created 2500 as
"Long-Term Loan", mislabeling that company's GC obligations.
- SeedDataService.Accounts: seed 2500 "Gift Certificate Liability" (IsSystem)
- SeedData: seed 2500 as GC liability; move long-term loan to 2900
- EnsureSystemAccountsAsync: self-heal — rename a 2500 still named "Long-Term Loan"
(preserving user renames) and ensure a 2500 exists
- migration FixGiftCertificateLiabilityAccount: move long-term loan to 2900 where a
2500="Long-Term Loan" exists without a 2900, relabel the mislabeled 2500, and
safety-net insert a 2500 for any company lacking one
Non-destructive: no account Id/number/balance is changed (same pattern as O1).
Verified on dev: existing GC-liability rows preserved, no spurious accounts added.
All audit findings O1-O5 resolved. Build clean; 291 unit tests pass; migration applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,24 @@ migration applied to the dev database successfully.
|
|||||||
|
|
||||||
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**.
|
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**.
|
||||||
|
|
||||||
|
### O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — **RESOLVED**
|
||||||
|
- **Root cause (same shape as O1):** 2500 is resolved by number as the GC liability
|
||||||
|
(`GiftCertificatesController`). The `AccountingGapsPhase2` migration seeded it for tenants existing at
|
||||||
|
deploy, but (a) the per-tenant seeder `SeedDataService.Accounts.cs` never created a 2500, so tenants
|
||||||
|
onboarded afterward had **no GC liability account** and GC GL postings silently no-op'd; and (b) the
|
||||||
|
default-company seeder `SeedData.cs` created 2500 as **"Long-Term Loan"**, so that company's GC
|
||||||
|
obligations were mislabeled (and the migration's `NOT EXISTS` guard skipped it).
|
||||||
|
- **Fix:**
|
||||||
|
- `SeedDataService.Accounts.cs` now seeds **2500 "Gift Certificate Liability" (IsSystem)**.
|
||||||
|
- `SeedData.cs` now seeds 2500 as GC liability and moves the long-term loan to **2900**.
|
||||||
|
- `EnsureSystemAccountsAsync` self-heals: renames any 2500 still named "Long-Term Loan" → "Gift
|
||||||
|
Certificate Liability" (preserving user renames) and ensures a 2500 exists.
|
||||||
|
- Migration `20260620002950_FixGiftCertificateLiabilityAccount`: moves long-term loan to 2900 where a
|
||||||
|
2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a
|
||||||
|
2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
|
||||||
|
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build
|
||||||
|
clean; migration applied; **291 unit tests pass**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Remaining (non-O1–O4) — known lower-risk follow-up
|
## Remaining (non-O1–O4) — known lower-risk follow-up
|
||||||
|
|||||||
@@ -1362,7 +1362,9 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
|||||||
// 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
|
// 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 = "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 = "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 },
|
// 2500 = Gift Certificate Liability (resolved by number in the GC GL posting code); long-term loan moved to 2900.
|
||||||
|
new() { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new() { AccountNumber = "2900", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── Equity ──────────────────────────────────────────────────────
|
// ── Equity ──────────────────────────────────────────────────────
|
||||||
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|||||||
+11382
File diff suppressed because it is too large
Load Diff
+123
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixGiftCertificateLiabilityAccount : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// O5 remediation. Account 2500 is resolved by number as the Gift Certificate Liability
|
||||||
|
// (GiftCertificatesController), but the default-company chart seeded it as "Long-Term Loan",
|
||||||
|
// so GC obligations were mislabeled there and the AccountingGapsPhase2 GC-liability seed was
|
||||||
|
// skipped by its NOT EXISTS guard.
|
||||||
|
|
||||||
|
// 1) Preserve the long-term loan account: move it to 2900 for any company whose 2500 is still
|
||||||
|
// named "Long-Term Loan" and that lacks a 2900. (Companies onboarded via the per-tenant
|
||||||
|
// seeder already have a 2900 "Business Loan", so the NOT EXISTS guard leaves them alone.)
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||||
|
SELECT '2900', 'Long-Term Loan',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
11, -- AccountSubType.LongTermLiability
|
||||||
|
0, 1, 'Long-term equipment or business loan',
|
||||||
|
c.Id, GETUTCDATE(), 0, 0, 0
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2500' AND a.IsDeleted = 0 AND a.Name = 'Long-Term Loan')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2900' AND a.IsDeleted = 0);
|
||||||
|
");
|
||||||
|
|
||||||
|
// 2) Relabel the mislabeled 2500 to Gift Certificate Liability (only where it still carries the
|
||||||
|
// old default name, so a user's own rename is preserved). Id / number / balance untouched.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts
|
||||||
|
SET Name = 'Gift Certificate Liability',
|
||||||
|
Description = 'Outstanding gift certificate obligations owed to certificate holders',
|
||||||
|
IsSystem = 1
|
||||||
|
WHERE AccountNumber = '2500' AND IsDeleted = 0 AND Name = 'Long-Term Loan';
|
||||||
|
");
|
||||||
|
|
||||||
|
// 3) Safety net: ensure every company has a 2500 Gift Certificate Liability (covers any tenant
|
||||||
|
// onboarded after AccountingGapsPhase2 ran that never received one — without it GC GL no-ops).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||||
|
SELECT '2500', 'Gift Certificate Liability',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
12, -- AccountSubType.OtherCurrentLiability
|
||||||
|
1, 1, 'Outstanding gift certificate obligations owed to certificate holders',
|
||||||
|
c.Id, GETUTCDATE(), 0, 0, 0
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2500' AND a.IsDeleted = 0);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Best-effort reversal: the 2500 relabel is intentionally NOT undone (reverting would
|
||||||
|
// re-introduce the mislabel), and 2500 rows are left in place since most pre-date this
|
||||||
|
// migration. Only soft-delete the empty 2900 accounts this migration added.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts SET IsDeleted = 1
|
||||||
|
WHERE AccountNumber = '2900' AND IsDeleted = 0 AND Name = 'Long-Term Loan' AND CurrentBalance = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7241,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611),
|
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7252,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618),
|
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7263,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619),
|
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ public partial class SeedDataService
|
|||||||
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 = "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 = "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 = "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 },
|
||||||
|
// 2500 Gift Certificate Liability — credited when a GC is issued, debited when redeemed/voided
|
||||||
|
// (resolved by number in GiftCertificatesController). IsSystem because the GL posting depends on it.
|
||||||
|
new Account { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Outstanding gift certificate obligations owed to certificate holders", 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 },
|
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 ────────────────────────────────────────────────────────
|
// ── EQUITY ────────────────────────────────────────────────────────
|
||||||
@@ -235,6 +238,46 @@ public partial class SeedDataService
|
|||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2500 has always been resolved by number as the Gift Certificate Liability (GiftCertificatesController),
|
||||||
|
// but the default-company seed created it as "Long-Term Loan" — so GC obligations were mislabeled there.
|
||||||
|
// Rename it (only where it still carries the old default name) and mark it system.
|
||||||
|
var legacyGcAcct = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500"
|
||||||
|
&& !a.IsDeleted && a.Name == "Long-Term Loan");
|
||||||
|
if (legacyGcAcct != null)
|
||||||
|
{
|
||||||
|
legacyGcAcct.Name = "Gift Certificate Liability";
|
||||||
|
legacyGcAcct.Description = "Outstanding gift certificate obligations owed to certificate holders";
|
||||||
|
legacyGcAcct.IsSystem = true;
|
||||||
|
legacyGcAcct.UpdatedAt = now;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2500 Gift Certificate Liability — ensure it exists for companies that never got one (e.g. tenants
|
||||||
|
// onboarded after the AccountingGapsPhase2 migration ran). Without it, GC GL postings silently no-op.
|
||||||
|
var has2500 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has2500)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "2500",
|
||||||
|
Name = "Gift Certificate Liability",
|
||||||
|
AccountType = AccountType.Liability,
|
||||||
|
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Outstanding gift certificate obligations owed to certificate holders",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user