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
@@ -153,6 +153,86 @@ public class PaymentController : Controller
return Ok(new { clientSecret, surchargeAmount = surcharge });
}
// ─── GET /invoice/{token} ────────────────────────────────────────────────
/// <summary>
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
/// since SMS cannot attach a PDF.
/// </summary>
[HttpGet("/invoice/{token}")]
public async Task<IActionResult> InvoiceView(string token)
{
try
{
var invoice = await _context.Invoices
.AsNoTracking()
.Include(i => i.InvoiceItems)
.Include(i => i.Customer)
.Include(i => i.Job)
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
if (invoice == null)
return View("PaymentError", "This invoice link is invalid or has been removed.");
var company = await _context.Companies.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null)
return View("PaymentError", "Unable to load invoice details.");
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
&& invoice.BalanceDue > 0)
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
: null;
var vm = new InvoiceViewViewModel
{
InvoiceNumber = invoice.InvoiceNumber,
InvoiceDate = invoice.InvoiceDate,
DueDate = invoice.DueDate,
CustomerName = invoice.Customer != null
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
: "Valued Customer",
CompanyName = company.CompanyName,
CompanyPhone = company.Phone,
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
LogoFilePath = company.LogoFilePath,
SubTotal = invoice.SubTotal,
TaxPercent = invoice.TaxPercent,
TaxAmount = invoice.TaxAmount,
DiscountAmount = invoice.DiscountAmount,
Total = invoice.Total,
AmountPaid = invoice.AmountPaid,
BalanceDue = invoice.BalanceDue,
Status = invoice.Status,
Notes = invoice.Notes,
Terms = invoice.Terms,
JobNumber = invoice.Job?.JobNumber,
PaymentUrl = paymentUrl,
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
{
Description = i.Description,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.TotalPrice
}).ToList()
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
return View("PaymentError", "An error occurred loading this invoice.");
}
}
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
/// <summary>
@@ -897,6 +977,39 @@ public class DepositPaymentPageViewModel
public string StripeAccountId { get; set; } = string.Empty;
}
public class InvoiceViewViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public DateTime? DueDate { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? CompanyAddress { get; set; }
public string? LogoFilePath { get; set; }
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal DiscountAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
public decimal BalanceDue { get; set; }
public InvoiceStatus Status { get; set; }
public string? Notes { get; set; }
public string? Terms { get; set; }
public string? JobNumber { get; set; }
public string? PaymentUrl { get; set; }
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
}
public class InvoiceViewLineItem
{
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
public class CreateIntentRequest
{
public decimal Amount { get; set; }