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>
This commit is contained in:
2026-06-11 13:20:04 -04:00
parent 249128e852
commit 7735fe3cce
16 changed files with 1142 additions and 487 deletions
@@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
@@ -13,15 +14,18 @@ public partial class SeedDataService : ISeedDataService
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IAccountBalanceService _accountBalanceService;
public SeedDataService(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager)
RoleManager<IdentityRole> roleManager,
IAccountBalanceService accountBalanceService)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_accountBalanceService = accountBalanceService;
}
/// <summary>
@@ -426,8 +430,53 @@ public partial class SeedDataService : ISeedDataService
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
// Ensure chart of accounts exists before bills/expenses — both seeders silently return 0
// if the AP or checking account is missing. SeedDefaultChartOfAccountsAsync is idempotent.
try
{
var accountsAdded = await SeedDefaultChartOfAccountsAsync(company);
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
if (accountsAdded > 0)
details.Add($"✓ {accountsAdded} chart of account(s) created");
if (systemAccountsAdded > 0)
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
}
catch (Exception ex) { errors.Add($"✗ Chart of accounts: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
// Accounts survive resets (no removal sweep), so the chart-of-accounts seeder skips them
// on every reset after the first. But 12 months of seeded expenses outpace ~3 months of
// seeded revenue, and without a prior-period cash balance the checking account shows a
// large negative. Patch the opening balances unconditionally so every reset is realistic.
try
{
var checkingAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Checking);
if (checkingAcct != null && checkingAcct.OpeningBalance == 0)
{
checkingAcct.OpeningBalance = 75_000m;
checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
checkingAcct.CurrentBalance = 75_000m;
await _context.SaveChangesAsync();
details.Add("✓ Checking account opening balance set to $75,000");
}
var savingsAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Savings);
if (savingsAcct != null && savingsAcct.OpeningBalance == 0)
{
savingsAcct.OpeningBalance = 14_500m;
savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
savingsAcct.CurrentBalance = 14_500m;
await _context.SaveChangesAsync();
details.Add("✓ Savings account opening balance set to $14,500");
}
}
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
if (company.CompanyCode == "DEMO")
@@ -443,6 +492,15 @@ public partial class SeedDataService : ISeedDataService
catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); }
}
// Replay all GL transactions so CurrentBalance reflects the full seeded history,
// including the opening balances patched above.
try
{
await _accountBalanceService.RecalculateAllAsync(company.Id);
details.Add("✓ Account balances recalculated");
}
catch (Exception ex) { errors.Add($"✗ Account balance recalculation: {ex.Message}"); _context.ChangeTracker.Clear(); }
if (errors.Any())
{
details.AddRange(errors);