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