676 lines
36 KiB
C#
676 lines
36 KiB
C#
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;
|
||
}
|
||
}
|