Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.Invoices.cs
T
spouliot 7735fe3cce Demo data realism + invoice resend via SMS on any status
Seed data fixes:
- Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added
  entities — root cause of all "same month" chart issues
- Customer seeder: generates 15 customers/month from Jan → current month;
  keeps 10 commercial anchors in deterministic order for job seeder index map
- Invoice seeder: historical range bumped from 2→8 paid invoices/month so
  P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses)
- Month -1 bumped to 7 paid invoices to stay above expenses
- Jobs: set UpdatedAt to historical event date so analytics don't need null fallback
- Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for
  revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs
- SeedDataService: inject IAccountBalanceService; auto-recalculate account balances
  after seeding; patch checking/savings opening balances unconditionally on reset
- Customer list: sort by CompanyName ?? ContactLastName so individuals and
  commercial accounts interleave instead of appearing as two blocks

Invoice resend:
- ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only
  resend no longer requires an email address on file
- Ensures PublicViewToken exists before SMS so the view link is always valid
- canResend in Details view now allows Paid invoices (removed != Paid guard)
- Resend button shows channel-choice modal when customer has both email + SMS,
  direct SMS button when SMS only, or email button when email only
- New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice
- resendInvoice() JS updated to pass sendEmail/sendSms query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:20:04 -04:00

253 lines
15 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 26 invoices spanning three months of billing history: six paid in month 3,
/// eight paid or partially paid in month 2, nine in month 1, and three recent plus
/// one overdue in the current month. Each invoice is linked to a seeded job and
/// has one or two line items plus an optional <see cref="Payment"/> record.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted invoices already exist for this company.
/// </para>
/// <para>
/// The method resolves four accounting accounts by number/sub-type from the previously
/// seeded chart of accounts (4000 Powder Coating Revenue, 4100 Sandblasting Revenue,
/// 1000 Checking, 2200 Sales Tax Payable). If any account is not found its FK is simply
/// omitted (null) rather than aborting the seed run.
/// </para>
/// <para>
/// The private <c>Inv()</c> local async function handles building, saving, and optionally
/// recording a payment for a single invoice. It is a local function rather than a separate
/// method because it captures the outer variables <c>jobs</c>, <c>seq</c>, <c>ji</c>,
/// and the four account references by closure.
/// </para>
/// <para>
/// Payment dates follow the production convention: for fully-paid invoices the payment is
/// recorded five days before the due date; for partially-paid invoices the deposit is
/// recorded four days after the invoice date.
/// </para>
/// <para>
/// The "overdue" demo invoice is created 35 days ago with Net-14 terms, placing it 21 days
/// past due — enough to appear prominently in the AR Aging report.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed invoices for.</param>
/// <returns>Number of invoices inserted, or 0 if already seeded or no eligible jobs exist.</returns>
private async Task<int> SeedInvoicesAsync(Company company)
{
var existingCount = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.CountAsync(i => i.CompanyId == company.Id && !i.IsDeleted);
if (existingCount > 0)
return 0;
// ── Dependencies ──────────────────────────────────────────────────────
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobItems)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& j.FinalPrice > 0 && j.CustomerId > 0)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0) return 0;
var revenueAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4000" && !a.IsDeleted);
var sandblastAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4100" && !a.IsDeleted);
var checkingAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id
&& a.AccountSubType == AccountSubType.Checking && !a.IsDeleted);
var salesTaxAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2200" && !a.IsDeleted);
var preparedBy = await _userManager.Users
.Where(u => u.CompanyId == company.Id).FirstOrDefaultAsync();
var now = DateTime.UtcNow;
var pfx = $"INV-{now:yy}{now.Month:D2}-";
var seq = 1;
var seeded = 0;
var ji = 0; // rotating job index
// Rotates through the available jobs in order. Using modulo wrapping means the
// seeder never throws even if there are fewer jobs than invoices to create.
Job NextJob() { var j = jobs[ji % jobs.Count]; ji++; return j; }
// ── Core builder ──────────────────────────────────────────────────────
//
// Builds and persists one Invoice + optional Payment in a single call.
// Each invoice is saved immediately (not batched) so EF generates an ID before
// the Payment FK is set. Tax is only added as a line item when taxPct > 0.
async Task Inv(
InvoiceStatus status,
int daysAgo, // invoice creation date
int dueDays, // net terms in days
decimal taxPct,
string terms,
string? notes,
// payment fields — ignored when status is Draft or Sent
PaymentMethod pMethod = PaymentMethod.BankTransferACH,
string? pRef = null)
{
var job = NextJob();
var date = now.AddDays(-daysAgo);
var sub = job.FinalPrice;
var taxAmt = Math.Round(sub * taxPct / 100m, 2);
var total = sub + taxAmt;
var isPaid = status == InvoiceStatus.Paid;
var isPart = status == InvoiceStatus.PartiallyPaid;
var amtPaid = isPaid ? total : isPart ? Math.Round(total * 0.5m, 2) : 0m;
var desc = job.JobItems?.FirstOrDefault()?.Description ?? "Powder Coating Services";
var inv = new Invoice
{
InvoiceNumber = $"{pfx}{seq++:D4}",
JobId = job.Id,
CustomerId = job.CustomerId,
PreparedById = preparedBy?.Id,
Status = status,
InvoiceDate = date,
DueDate = date.AddDays(dueDays),
SentDate = status != InvoiceStatus.Draft ? date : null,
PaidDate = isPaid ? date.AddDays(dueDays - 5) : null,
SubTotal = sub,
TaxPercent = taxPct,
TaxAmount = taxAmt,
Total = total,
AmountPaid = amtPaid,
Terms = terms,
Notes = notes,
CustomerPO = job.CustomerPO,
SalesTaxAccountId = taxAmt > 0 ? salesTaxAcct?.Id : null,
CompanyId = company.Id,
CreatedAt = date
};
inv.InvoiceItems.Add(new InvoiceItem
{
Description = desc,
Quantity = 1,
UnitPrice = sub,
TotalPrice = sub,
RevenueAccountId = revenueAcct?.Id,
DisplayOrder = 1,
CompanyId = company.Id,
CreatedAt = date
});
if (taxAmt > 0)
inv.InvoiceItems.Add(new InvoiceItem
{
Description = $"Sales Tax ({taxPct:0.##}%)",
Quantity = 1,
UnitPrice = taxAmt,
TotalPrice = taxAmt,
RevenueAccountId = salesTaxAcct?.Id,
DisplayOrder = 2,
CompanyId = company.Id,
CreatedAt = date
});
await _context.Set<Invoice>().AddAsync(inv);
await _context.SaveChangesAsync();
seeded++;
if (amtPaid > 0)
{
// Paid: payment date = dueDate - 5d. Partial: a few days after invoice
var pDate = isPaid ? date.AddDays(dueDays - 5) : date.AddDays(4);
await _context.Set<Payment>().AddAsync(new Payment
{
InvoiceId = inv.Id,
Amount = amtPaid,
PaymentDate = pDate,
PaymentMethod = pMethod,
Reference = pRef,
Notes = isPaid ? "Paid in full" : "50% deposit",
RecordedById = preparedBy?.Id,
DepositAccountId = checkingAcct?.Id,
CompanyId = company.Id,
CreatedAt = pDate
});
await _context.SaveChangesAsync();
}
}
// ── Months 12 through 4: 8 paid invoices per month ────────────────
// 8 × avg ~$650 = ~$5,200/month collected revenue, which exceeds the seeded
// ~$4,200/month in operating expenses, so the P&L chart shows a consistent profit.
// Payment methods and tax rates alternate for variety in the ledger.
var histMethods = new[] { PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.CreditDebitCard, PaymentMethod.Cash };
for (int monthBack = 12; monthBack >= 4; monthBack--)
{
for (int inv = 0; inv < 8; inv++)
{
var daysAgo = monthBack * 30 + 25 - (inv * 3);
var taxPct = (monthBack + inv) % 2 == 0 ? 7.5m : 0m;
var method = histMethods[(monthBack * 8 + inv) % histMethods.Length];
var chkRef = method == PaymentMethod.Check ? $"CHK-{9000 + monthBack * 8 + inv:D4}" : null;
await Inv(InvoiceStatus.Paid, daysAgo, 30, taxPct, "Net 30", "Thank you for your business!", method, chkRef);
}
}
// ── Month 3 (6 paid) ─────────────────────────────────────────────────
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041");
await Inv(InvoiceStatus.Paid, 80, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 76, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 72, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 68, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1044");
// ── Month 2 (6 paid + 2 partial) ────────────────────────────────────
await Inv(InvoiceStatus.Paid, 62, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 58, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1048");
await Inv(InvoiceStatus.Paid, 55, 14, 0m, "Due on receipt", "Paid on completion.", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 51, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 47, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 43, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1052");
await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053");
await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH);
// ── Month 1 (7 paid + 2 partial + 2 sent) ───────────────────────────
// 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses.
await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054");
await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 24, 14, 7.5m, "Net 14", "Thank you!", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 20, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 18, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1058");
await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060");
await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days.");
// ── Current month (1 overdue + 2 sent + 1 draft) ─────────────────────
// Overdue: created 35 days ago on Net 14 terms → 21 days past due (130 bucket)
await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately.");
await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null);
// ── AR Aging demo invoices — populate all four overdue buckets ────────
// 3160 day bucket: issued 55 days ago, Net 10 → 45 days past due
await Inv(InvoiceStatus.Sent, 55, 10, 0m, "Net 10", "PAST DUE 45 days — second notice sent.");
// 6190 day bucket: issued 80 days ago, Net 10 → 70 days past due
await Inv(InvoiceStatus.Sent, 80, 10, 7.5m, "Net 10", "PAST DUE 70 days — final notice. Collections pending.");
// 90+ day bucket: issued 120 days ago, Net 14 → 106 days past due
await Inv(InvoiceStatus.PartiallyPaid, 120, 14, 0m, "Net 14", "PAST DUE 106 days — partial payment received, balance outstanding.");
return seeded;
}
}