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