Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Bills.cs
T
2026-04-23 21:38:24 -04:00

676 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a realistic 3-month history of vendor bills (Accounts Payable) across four
/// spending categories — powder orders, consumables/hardware, equipment repairs, and
/// utilities — with a mix of paid, partially-paid, and open statuses.
/// </summary>
/// <remarks>
/// The seeder constructs a narrative AP ledger covering approximately 90 days so that
/// the AI Cash Flow Forecast, AR Aging, and Anomaly Detection reports have meaningful
/// data to analyse from day one of a demo.
///
/// <b>Prerequisites:</b> The method resolves all required accounts by
/// <see cref="AccountSubType"/> or account number from the company's chart of accounts.
/// It also resolves vendors by partial name match (e.g., "Prismatic", "Columbia").
/// If the AP account, the checking account, or at least one vendor is missing the method
/// returns 0 (skips) rather than throwing, because those dependencies are seeded in
/// earlier steps which may have failed.
///
/// <b>Local helper <c>AddBill</c>:</b> Assigns the sequential bill number
/// (format <c>BILL-YYMM-####</c>), stamps <c>CompanyId</c> on the bill and all its
/// line items, saves, then optionally creates a <see cref="BillPayment"/> with a
/// sequential payment number (<c>BPMT-YYMM-####</c>). Separating save-per-bill from
/// the payment save preserves the FK relationship without needing explicit navigation
/// property loading.
///
/// <b>Double-entry note:</b> The bill entity itself records the AP credit (via
/// <c>APAccountId</c>); individual line items carry the debit account (e.g., powder
/// expense account 5100). The payment records the AP debit and cash credit (via
/// <c>BankAccountId</c>). The seed data mirrors this pattern but does NOT create
/// journal entry rows — those are handled by the AP payment workflow in production.
///
/// <b>Status timeline:</b>
/// - Months 3 to 1: bills with matching paid payments (closed AP).
/// - Current month: open bills (due soon) to populate the AP Aging report.
/// - One overdue specialty bill (past due date, still open) to trigger the anomaly
/// detection feature's "overdue payable" flag.
///
/// Idempotency: returns 0 immediately if any bills exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed bills for.</param>
/// <returns>The total number of <see cref="Bill"/> records created.</returns>
private async Task<int> SeedBillsAsync(Company company)
{
var existingCount = await _context.Set<Bill>()
.IgnoreQueryFilters()
.CountAsync(b => b.CompanyId == company.Id && !b.IsDeleted);
if (existingCount > 0)
return 0;
// ── Account lookups ───────────────────────────────────────────────────
var apAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.AccountsPayable);
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Checking);
var powderAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "5100");
var consumablesAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "5200");
var equipRepairsAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6100");
var utilitiesAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6600");
if (apAccount == null || checkingAccount == null)
return 0;
// ── Vendor lookups ────────────────────────────────────────────────────
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
.ToListAsync();
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault();
var aceHardware = vendors.FirstOrDefault(v => v.CompanyName.Contains("Ace")) ?? vendors.FirstOrDefault();
var fastenal = vendors.FirstOrDefault(v => v.CompanyName.Contains("Fastenal")) ?? vendors.FirstOrDefault();
var fallback = vendors.FirstOrDefault();
if (fallback == null)
return 0;
var now = DateTime.UtcNow;
var prefix = $"BILL-{now:yy}{now.Month:D2}-";
var pmtPfx = $"BPMT-{now:yy}{now.Month:D2}-";
var seeded = 0;
var billSeq = 1;
var pmtSeq = 1;
// Helper: add a bill and optional payment, save, return bill
async Task<Bill> AddBill(
Bill bill, BillPayment? payment = null)
{
bill.BillNumber = $"{prefix}{billSeq++:D4}";
bill.CompanyId = company.Id;
foreach (var li in bill.LineItems) { li.CompanyId = company.Id; li.CreatedAt = bill.CreatedAt; }
await _context.Set<Bill>().AddAsync(bill);
await _context.SaveChangesAsync();
seeded++;
if (payment != null)
{
payment.PaymentNumber = $"{pmtPfx}{pmtSeq++:D4}";
payment.BillId = bill.Id;
payment.CompanyId = company.Id;
payment.CreatedAt = payment.PaymentDate;
await _context.Set<BillPayment>().AddAsync(payment);
await _context.SaveChangesAsync();
}
return bill;
}
// ── POWDER ORDERS ─────────────────────────────────────────────────────
// Month -3: Large powder restock — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-77211",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-90),
DueDate = now.AddDays(-60),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Quarterly powder restock — month 1",
SubTotal = 1_145.00m,
Total = 1_145.00m,
AmountPaid = 1_145.00m,
CreatedAt = now.AddDays(-90),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 165.00m, Amount = 330.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Silver Powder — 25 lbs", Quantity = 2, UnitPrice = 144.50m, Amount = 289.00m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 1, UnitPrice = 170.00m, Amount = 170.00m, DisplayOrder = 4 }
}
}, new BillPayment
{
VendorId = (prismatic ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-60),
Amount = 1_145.00m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "PP-77211 — paid in full"
});
// Month -2: Mid-cycle specialty colors — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "CC-4401",
VendorId = (columbia ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-65),
DueDate = now.AddDays(-35),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Specialty metallic & candy colors",
SubTotal = 986.00m,
Total = 986.00m,
AmountPaid = 986.00m,
CreatedAt = now.AddDays(-65),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Chrome Effect Powder — 10 lbs", Quantity = 2, UnitPrice = 168.00m, Amount = 336.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Hammertone Bronze — 10 lbs", Quantity = 1, UnitPrice = 150.50m, Amount = 150.50m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps & Hooks", Quantity = 1, UnitPrice = 64.50m, Amount = 64.50m, DisplayOrder = 4 }
}
}, new BillPayment
{
VendorId = (columbia ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-35),
Amount = 986.00m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "CC-4401 — paid in full"
});
// Month -1: Regular restock — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-83440",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-45),
DueDate = now.AddDays(-15),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Monthly powder restock",
SubTotal = 892.50m,
Total = 892.50m,
AmountPaid = 892.50m,
CreatedAt = now.AddDays(-45),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 5, UnitPrice = 89.00m, Amount = 445.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 4, UnitPrice = 86.50m, Amount = 346.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Plugs Assortment", Quantity = 1, UnitPrice = 101.50m, Amount = 101.50m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (prismatic ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-15),
Amount = 892.50m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "PP-83440 — paid in full"
});
// Current month: Open (due soon)
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-88530",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-8),
DueDate = now.AddDays(22),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Current month powder order",
SubTotal = 1_050.00m,
Total = 1_050.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-8),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 6, UnitPrice = 89.00m, Amount = 534.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss Red Powder — 10 lbs", Quantity = 2, UnitPrice = 132.00m, Amount = 264.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks (10-pack)", Quantity = 2, UnitPrice = 126.00m, Amount = 252.00m, DisplayOrder = 3 }
}
});
// Overdue specialty order
await AddBill(new Bill
{
VendorInvoiceNumber = "CC-5509",
VendorId = (columbia ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-50),
DueDate = now.AddDays(-5),
Status = BillStatus.Open,
Terms = "Net 45",
Memo = "Specialty colors — overdue",
SubTotal = 1_240.00m,
Total = 1_240.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-50),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Chrome Effect — 10 lbs", Quantity = 3, UnitPrice = 168.00m, Amount = 504.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Hammertone Bronze — 10 lbs", Quantity = 2, UnitPrice = 150.50m, Amount = 301.00m, DisplayOrder = 3 }
}
});
// ── CONSUMABLES / HARDWARE ─────────────────────────────────────────────
// Month -3: Fastenal hardware — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-18822",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-85),
DueDate = now.AddDays(-55),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Hardware & consumables restock",
SubTotal = 412.50m,
Total = 412.50m,
AmountPaid = 412.50m,
CreatedAt = now.AddDays(-85),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "J-Hook Hangers Assortment", Quantity = 2, UnitPrice = 89.75m, Amount = 179.50m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Mixed (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives", Quantity = 1, UnitPrice = 113.00m, Amount = 113.00m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (fastenal ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-55),
Amount = 412.50m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1082",
Memo = "FST-18822 — paid in full"
});
// Month -1: Fastenal — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-20041",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-40),
DueDate = now.AddDays(-10),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Monthly hardware restock",
SubTotal = 298.00m,
Total = 298.00m,
AmountPaid = 298.00m,
CreatedAt = now.AddDays(-40),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Sandpaper & Abrasive Pads (assorted)", Quantity = 2, UnitPrice = 74.50m, Amount = 149.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Small (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Safety Gloves (12-pair pack)", Quantity = 1, UnitPrice = 29.00m, Amount = 29.00m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (fastenal ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10),
Amount = 298.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1086",
Memo = "FST-20041 — paid in full"
});
// Current: Fastenal — Open
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-20441",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-7),
DueDate = now.AddDays(23),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Monthly hardware & fastener restock",
SubTotal = 318.75m,
Total = 318.75m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-7),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 198.75m, Amount = 198.75m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Mixed (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 }
}
});
// ── EQUIPMENT REPAIRS ─────────────────────────────────────────────────
// Month -3: Sandblaster service — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "ACE-6901",
VendorId = (aceHardware ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-80),
DueDate = now.AddDays(-50),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Sandblaster nozzle & cabinet seal replacement",
SubTotal = 310.00m,
Total = 310.00m,
AmountPaid = 310.00m,
CreatedAt = now.AddDays(-80),
LineItems =
{
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Blast Nozzle Tungsten — 3/8\"", Quantity = 2, UnitPrice = 85.00m, Amount = 170.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Cabinet Door Seal Kit", Quantity = 1, UnitPrice = 140.00m, Amount = 140.00m, DisplayOrder = 2 }
}
}, new BillPayment
{
VendorId = (aceHardware ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-50),
Amount = 310.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1079",
Memo = "ACE-6901 — sandblaster parts"
});
// Month -1: Oven repair — Partially paid
await AddBill(new Bill
{
VendorInvoiceNumber = "ACE-7714",
VendorId = (aceHardware ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-20),
DueDate = now.AddDays(10),
Status = BillStatus.PartiallyPaid,
Terms = "Net 30",
Memo = "Oven heating element + shop supplies",
SubTotal = 540.00m,
Total = 540.00m,
AmountPaid = 200.00m,
CreatedAt = now.AddDays(-20),
LineItems =
{
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Oven Heating Element — 240V 5000W", Quantity = 1, UnitPrice = 385.00m, Amount = 385.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Shop Supplies — Wire Brushes, Sandpaper", Quantity = 1, UnitPrice = 155.00m, Amount = 155.00m, DisplayOrder = 2 }
}
}, new BillPayment
{
VendorId = (aceHardware ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10),
Amount = 200.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1087",
Memo = "ACE-7714 — partial payment"
});
// ── UTILITIES (3 months each) ─────────────────────────────────────────
// Electric — month -3 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-01",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-90),
DueDate = now.AddDays(-75),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — 3 months ago",
SubTotal = 592.10m,
Total = 592.10m,
AmountPaid = 592.10m,
CreatedAt = now.AddDays(-90),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 592.10m, Amount = 592.10m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-75),
Amount = 592.10m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — month -2 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-02",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-60),
DueDate = now.AddDays(-45),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — 2 months ago",
SubTotal = 618.42m,
Total = 618.42m,
AmountPaid = 618.42m,
CreatedAt = now.AddDays(-60),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 618.42m, Amount = 618.42m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-45),
Amount = 618.42m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — month -1 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-03",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-30),
DueDate = now.AddDays(-15),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — last month",
SubTotal = 574.88m,
Total = 574.88m,
AmountPaid = 574.88m,
CreatedAt = now.AddDays(-30),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 574.88m, Amount = 574.88m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-15),
Amount = 574.88m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — current month (open)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-04",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-2),
DueDate = now.AddDays(15),
Status = BillStatus.Open,
Terms = "Due on Receipt",
Memo = "Electric — current month",
SubTotal = 601.30m,
Total = 601.30m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-2),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 601.30m, Amount = 601.30m, DisplayOrder = 1 }
}
});
return seeded;
}
/// <summary>
/// Seeds recurring direct-expense transactions for the company covering the past ~90 days:
/// shop rent, natural gas, business insurance, Google Ads / Yelp, software subscriptions,
/// office supplies, and monthly bank/card-processing fees.
/// </summary>
/// <remarks>
/// Expenses differ from bills in that they are single-line, immediately-paid transactions
/// (no AP intermediary) — they debit an expense account and credit a cash or credit-card
/// account directly. This mirrors the accounting pattern used by the Expenses module.
///
/// <b>Account resolution:</b> Accounts are looked up by account number (e.g., "6500" for
/// rent, "6600" for utilities) rather than by name so that customised display names do not
/// break the seeder. A cascading fallback chain ensures the method still seeds something
/// useful even if the chart of accounts was partially seeded — the first non-null expense
/// account found is used as a last-resort category. If no expense account and no checking
/// account are found, the method returns 0 and logs nothing (silent skip handled by caller).
///
/// <b>Credit card vs. checking:</b> Advertising and software subscriptions are charged to
/// the credit card account when one exists (matching real-world business practice). All
/// other expenses use the checking account. If no credit card account was seeded, the
/// checking account is used as fallback for all categories.
///
/// <b>Local helper <c>AddExp</c>:</b> Assigns a sequential expense number
/// (format <c>EXP-YYMM-####</c>), saves immediately per expense so that the sequence
/// counter increments correctly and individual failures can be diagnosed from logs.
///
/// Idempotency: returns 0 immediately if any expense records exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed expenses for.</param>
/// <returns>The total number of <see cref="Expense"/> records created.</returns>
private async Task<int> SeedExpensesAsync(Company company)
{
var existingCount = await _context.Set<Expense>()
.IgnoreQueryFilters()
.CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
if (existingCount > 0)
return 0;
// ── Account lookups ───────────────────────────────────────────────────
var checkingAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.Checking);
var creditCardAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.CreditCard);
var utilitiesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6600");
var rentAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6500");
var advertisingAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6000");
var officeSuppliesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6800");
var bankChargesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6900");
var insuranceAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6200");
if (checkingAccount == null)
return 0;
var fallbackExpense = utilitiesAcct ?? rentAcct ?? advertisingAcct
?? await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountType == AccountType.Expense);
if (fallbackExpense == null)
return 0;
var vendors = await _context.Set<Vendor>().IgnoreQueryFilters()
.Where(v => v.CompanyId == company.Id && !v.IsDeleted).ToListAsync();
var now = DateTime.UtcNow;
var pfx = $"EXP-{now:yy}{now.Month:D2}-";
var seeded = 0;
var expSeq = 1;
var cc = creditCardAccount ?? checkingAccount;
async Task AddExp(
DateTime date, int? vendorId, Account expAcct, Account pmtAcct,
PaymentMethod method, decimal amount, string memo)
{
await _context.Set<Expense>().AddAsync(new Expense
{
ExpenseNumber = $"{pfx}{expSeq++:D4}",
Date = date,
VendorId = vendorId,
ExpenseAccountId = expAcct.Id,
PaymentAccountId = pmtAcct.Id,
PaymentMethod = method,
Amount = amount,
Memo = memo,
CompanyId = company.Id,
CreatedAt = date
});
await _context.SaveChangesAsync();
seeded++;
}
// ── SHOP RENT — 3 months ──────────────────────────────────────────────
var rentAccount = rentAcct ?? fallbackExpense;
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago");
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago");
await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month");
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
// ── NATURAL GAS — 3 months ────────────────────────────────────────────
var utilAccount = utilitiesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago");
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago");
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month");
// ── INSURANCE ─────────────────────────────────────────────────────────
var insAccount = insuranceAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1");
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
// ── MARKETING / ADVERTISING ───────────────────────────────────────────
var adAccount = advertisingAcct ?? fallbackExpense;
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign");
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month");
// ── SOFTWARE SUBSCRIPTIONS ────────────────────────────────────────────
var swAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
// ── OFFICE SUPPLIES ───────────────────────────────────────────────────
var offAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
// ── BANK FEES ─────────────────────────────────────────────────────────
var bankAccount = bankChargesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees");
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees");
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees");
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees");
return seeded;
}
}