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
@@ -91,7 +91,9 @@ public class CustomersController : Controller
// Build orderBy function
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
{
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
"CompanyName" => q => gridRequest.SortDirection == "asc"
? q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
: q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName),
"ContactName" => q => gridRequest.SortDirection == "asc"
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
@@ -100,7 +102,7 @@ public class CustomersController : Controller
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
_ => q => q.OrderBy(c => c.CompanyName)
_ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
};
// Get paged data
@@ -1888,7 +1888,7 @@ public class InvoicesController : Controller
/// Details view can show an inline toast with the delivery outcome.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
{
try
{
@@ -1902,7 +1902,9 @@ public class InvoicesController : Controller
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
return Json(new { success = false, message = "Voided invoices cannot be resent." });
// Validate override email when provided
if (!sendEmail && !sendSms)
return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." });
overrideEmail = overrideEmail?.Trim();
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
return Json(new { success = false, message = "The email address provided is not valid." });
@@ -1915,32 +1917,55 @@ public class InvoicesController : Controller
? overrideEmail
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
if (string.IsNullOrWhiteSpace(recipientEmail))
if (sendEmail && string.IsNullOrWhiteSpace(recipientEmail))
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
// Ensure a permanent view token exists so the SMS link always works.
string? viewUrl = null;
if (sendSms)
{
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
}
byte[]? pdfBytes = null;
string? pdfFilename = null;
try
if (sendEmail)
{
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
}
catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
try
{
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
}
catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
}
}
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
await _notificationService.NotifyInvoiceSentAsync(
invoice, pdfBytes, pdfFilename,
overrideEmail: overrideEmail,
sendSms: sendSms,
viewUrl: viewUrl);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
if (latestLog?.Status == NotificationStatus.Skipped)
if (latestLog?.Status == NotificationStatus.Skipped && !sendSms)
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
var channels = new List<string>();
if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})");
if (sendSms) channels.Add("SMS");
return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." });
}
catch (Exception ex)
{
@@ -146,12 +146,19 @@ public class ReportsController : Controller
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
// === REVENUE ANALYTICS ===
// Pre-filter completed jobs by date range once for monthly calculations
var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
// CompletedDate is the authoritative "when the job finished" date.
// UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported
// jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort.
static DateTime JobMonthDate(Job j) =>
j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt;
var completedJobsInRange = completedJobs
.Where(j => JobMonthDate(j) >= startDate)
.ToList();
// Group by month for efficient monthly aggregations
var jobsByMonth = completedJobsInRange
.GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1))
.GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); })
.ToDictionary(g => g.Key, g => g.ToList());
// Monthly revenue trend
@@ -137,7 +137,7 @@ public class SeedDataController : Controller
OperatingCosts = true,
Bills = true,
Expenses = true,
Workers = true,
Workers = false, // workers stay static — never deleted on reset
Vendors = true,
NamedOvens = true,
Appointments = true,