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