Add invoice SMS notifications and customer intake kiosk

Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor

Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 16:25:27 -04:00
parent 27bfd4db4d
commit 6a918c2afc
41 changed files with 24265 additions and 23 deletions
@@ -1003,11 +1003,18 @@ public class InvoicesController : Controller
try
{
var currentUserForPdf = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog);
}
@@ -1033,13 +1040,13 @@ public class InvoicesController : Controller
// -----------------------------------------------------------------------
/// <summary>
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
/// fires the customer notification with a PDF attachment. Notification failure is caught
/// separately and logged as a warning — a failed email must not roll back the status change.
/// The payment URL is assembled from the generated token and the current request host so it
/// works identically in dev (localhost) and production without config changes.
/// fires the customer notification. Staff can choose email, SMS, or both via the modal.
/// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
/// only generated when Stripe Connect is active (expiring pay link for email/view page).
/// Notification failure is caught separately — a failed send must not roll back the status change.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(int id, string? overrideEmail = null)
public async Task<IActionResult> Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
{
try
{
@@ -1058,27 +1065,39 @@ public class InvoicesController : Controller
invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email;
// Permanent view token — always generate so SMS always has a link
if (string.IsNullOrEmpty(invoice.PublicViewToken))
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await TryGeneratePaymentTokenAsync(invoice);
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
// Generate PDF and send notification
string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
bool pdfAndNotifSucceeded = false;
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
bool notifSucceeded = false;
try
{
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
pdfAndNotifSucceeded = true;
byte[]? pdfBytes = null;
if (sendEmail)
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
await _notificationService.NotifyInvoiceSentAsync(
invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf",
paymentUrl, overrideEmail: overrideEmail?.Trim(),
sendSms: sendSms, viewUrl: viewUrl);
notifSucceeded = true;
}
catch (Exception notifyEx)
{
_logger.LogError(notifyEx,
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " +
"Invoice {InvoiceId} ({InvoiceNumber}): notification failed. " +
"Inner: {InnerMessage}. Invoice status was already saved as Sent.",
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
}
@@ -1087,8 +1106,8 @@ public class InvoicesController : Controller
this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
if (!pdfAndNotifSucceeded)
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
if (!notifSucceeded)
TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your configuration.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)