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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user