Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs
T
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

144 lines
16 KiB
C#

using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a default chart of accounts for a newly onboarded company, covering all five
/// standard double-entry categories: Assets, Liabilities, Equity, Revenue (with separate
/// powder coating and sandblasting lines), Cost of Goods Sold, and Expenses.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted accounts already exist for this
/// company, so re-running seed does not produce duplicate account numbers.
/// </para>
/// <para>
/// Several accounts are marked <c>IsSystem = true</c> (Checking, AR, AP, and main Powder
/// Coating Revenue). System accounts are referenced by account number in other seeders
/// (e.g. <c>SeedInvoicesAsync</c> looks up account "4000" for invoice line items) and
/// should not be renamed or deleted by end users.
/// </para>
/// <para>
/// Account numbers follow a conventional small-business numbering scheme:
/// 1xxx = Assets, 2xxx = Liabilities, 3xxx = Equity, 4xxx = Revenue,
/// 5xxx = Cost of Goods Sold, 6xxx = Expenses. This makes the chart immediately
/// recognisable to accountants without custom configuration.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed the chart of accounts for.</param>
/// <returns>Number of account records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedDefaultChartOfAccountsAsync(Company company)
{
var existingCount = await _context.Set<Account>()
.IgnoreQueryFilters()
.CountAsync(a => a.CompanyId == company.Id && !a.IsDeleted);
if (existingCount > 0)
return 0; // Already seeded
var now = DateTime.UtcNow;
var accounts = new List<Account>
{
// ── ASSETS ────────────────────────────────────────────────────────
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1300", Name = "Equipment", AccountType = AccountType.Asset, AccountSubType = AccountSubType.FixedAsset, IsSystem = false, IsActive = true, Description = "Ovens, booths, sandblasters, and equipment", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1310", Name = "Accumulated Depreciation", AccountType = AccountType.Asset, AccountSubType = AccountSubType.FixedAsset, IsSystem = false, IsActive = true, Description = "Accumulated depreciation on equipment", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1400", Name = "Prepaid Expenses", AccountType = AccountType.Asset, AccountSubType = AccountSubType.OtherCurrentAsset, IsSystem = false, IsActive = true, Description = "Prepaid insurance, rent, and other items", CompanyId = company.Id, CreatedAt = now },
// ── LIABILITIES ───────────────────────────────────────────────────
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 },
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 ────────────────────────────────────────────────────────
new Account { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = false, IsActive = true, Description = "Owner's invested capital", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "3100", Name = "Retained Earnings", AccountType = AccountType.Equity, AccountSubType = AccountSubType.RetainedEarnings, IsSystem = false, IsActive = true, Description = "Cumulative net income retained in business", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "3200", Name = "Owner's Draw", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = false, IsActive = true, Description = "Withdrawals by owner", CompanyId = company.Id, CreatedAt = now },
// ── REVENUE ───────────────────────────────────────────────────────
new Account { AccountNumber = "4000", Name = "Powder Coating Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.Sales, IsSystem = true, IsActive = true, Description = "Revenue from powder coating services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
// reducing net revenue to match the discounted AR amount that was posted.
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
// ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "5100", Name = "Powder & Materials", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Powder coatings and direct materials used", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "5200", Name = "Consumables & Shop Supplies", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Masking, tape, and other job supplies", CompanyId = company.Id, CreatedAt = now },
// ── EXPENSES ──────────────────────────────────────────────────────
new Account { AccountNumber = "6000", Name = "Advertising & Marketing", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Advertising, IsSystem = false, IsActive = true, Description = "Advertising, marketing, and promotions", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6100", Name = "Equipment & Repairs", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Equipment, IsSystem = false, IsActive = true, Description = "Equipment maintenance and repair costs", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6200", Name = "Insurance", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Insurance, IsSystem = false, IsActive = true, Description = "Business, liability, and equipment insurance", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6300", Name = "Payroll & Labor", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Payroll, IsSystem = false, IsActive = true, Description = "Wages, salaries, and payroll taxes", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6400", Name = "Professional Fees", AccountType = AccountType.Expense, AccountSubType = AccountSubType.ProfessionalFees, IsSystem = false, IsActive = true, Description = "Accounting, legal, and consulting fees", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6500", Name = "Rent & Facilities", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Rent, IsSystem = false, IsActive = true, Description = "Shop rent and facility costs", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6600", Name = "Utilities", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Utilities, IsSystem = false, IsActive = true, Description = "Electric, gas, water, and internet", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6700", Name = "Vehicle & Transportation", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Vehicle, IsSystem = false, IsActive = true, Description = "Fuel, vehicle maintenance, and transport", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6800", Name = "Office Supplies", AccountType = AccountType.Expense, AccountSubType = AccountSubType.OfficeSupplies, IsSystem = false, IsActive = true, Description = "General office and administrative supplies", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6900", Name = "Bank Charges & Fees", AccountType = AccountType.Expense, AccountSubType = AccountSubType.BankCharges, IsSystem = false, IsActive = true, Description = "Bank fees, merchant processing, and wire fees", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6950", Name = "Depreciation Expense", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Depreciation, IsSystem = false, IsActive = true, Description = "Depreciation on fixed assets", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6990", Name = "Other Expenses", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Other, IsSystem = false, IsActive = true, Description = "Miscellaneous business expenses", CompanyId = company.Id, CreatedAt = now },
};
await _context.Set<Account>().AddRangeAsync(accounts);
await _context.SaveChangesAsync();
return accounts.Count;
}
/// <summary>
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
/// call repeatedly from the "Seed Lookup Tables" flow.
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
/// companies get all accounts in one pass while existing companies receive only the missing ones.
/// </summary>
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
private async Task<int> EnsureSystemAccountsAsync(Company company)
{
var now = DateTime.UtcNow;
int added = 0;
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
var has4950 = await _context.Set<Account>()
.IgnoreQueryFilters()
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
if (!has4950)
{
_context.Set<Account>().Add(new Account
{
AccountNumber = "4950",
Name = "Sales Discounts",
AccountType = AccountType.Revenue,
AccountSubType = AccountSubType.OtherIncome,
IsSystem = true,
IsActive = true,
Description = "Contra-revenue for invoice discounts granted to customers",
CompanyId = company.Id,
CreatedAt = now
});
await _context.SaveChangesAsync();
added++;
}
return added;
}
}