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
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA.
/// </summary>
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
{
try
{
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
if (sendSms)
{
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["viewUrl"] = urlForSms
};
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.InvoiceSent,
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = message,
ErrorMessage = smsError,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
}
catch (Exception ex)
{
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
"Invoice {{invoiceNumber}} from {{companyName}}",
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
),
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
),
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
"Payment Received — Invoice {{invoiceNumber}}",
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"