Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/NotificationService.cs
T
spouliot a2d48c8b58 Add SMS quote approval, fix Twilio credentials, fix passkey post-login redirect
- Add 'Send Quote via SMS' button on quote details page that sends the approval
  link to the customer via SMS (respects NotifyBySms, handles prospects via ProspectPhone)
- Reuses existing valid approval token rather than regenerating, so a previously
  emailed link stays valid when SMS is also sent
- Fix Twilio appsettings.json placeholders (real credentials moved to gitignored
  appsettings.Development.json)
- Fix passkey login ignoring ReturnUrl: biometric login on the login page now
  respects the form's ReturnUrl hidden field so QR-code and deep-link flows
  redirect correctly after authentication instead of always going to the dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 09:38:55 -04:00

1497 lines
71 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Net;
using System.Text.RegularExpressions;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Orchestrates all outbound customer notifications (email + SMS) for the platform.
/// Every notification follows the same pattern:
/// 1. Look up the company name and reply-to address.
/// 2. Load the notification template from DB (company-customizable); fall back to <see cref="DefaultTemplates"/>.
/// 3. Render the template with the event-specific value dictionary.
/// 4. Check the customer's opt-in flags (NotifyByEmail, NotifyBySms) before sending.
/// 5. Send via <see cref="IEmailService"/> or <see cref="ISmsService"/>.
/// 6. Write a <see cref="NotificationLog"/> row regardless of success/failure — including
/// "Skipped" rows when the customer has opted out, so the audit trail is complete.
///
/// All public methods catch and log exceptions internally — callers should never need try/catch
/// around notification calls. A failed notification is non-fatal to the triggering operation.
/// </summary>
public class NotificationService : INotificationService
{
private readonly IEmailService _emailService;
private readonly ISmsService _smsService;
private readonly ApplicationDbContext _context;
private readonly IConfiguration _configuration;
private readonly IPlatformSettingsService _platformSettings;
private readonly ILogger<NotificationService> _logger;
private readonly ITenantContext _tenantContext;
private readonly IHttpContextAccessor _httpContextAccessor;
public NotificationService(
IEmailService emailService,
ISmsService smsService,
ApplicationDbContext context,
IConfiguration configuration,
IPlatformSettingsService platformSettings,
ILogger<NotificationService> logger,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor)
{
_emailService = emailService;
_smsService = smsService;
_context = context;
_configuration = configuration;
_platformSettings = platformSettings;
_logger = logger;
_tenantContext = tenantContext;
_httpContextAccessor = httpContextAccessor;
}
// -----------------------------------------------------------------------
// Public interface methods
// -----------------------------------------------------------------------
/// <summary>
/// Sends the "quote ready for review" email to the customer or prospect, with the PDF
/// attached and an inline "View &amp; Approve" button linking to the customer approval portal.
/// Handles two recipient types:
/// - Prospect (no CustomerId): sends to ProspectEmail directly; no opt-out check.
/// - Customer: respects NotifyByEmail; still writes a Skipped log if opted out.
/// Always writes a NotificationLog row so the Notifications Sent tab shows delivery history.
/// </summary>
public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null)
{
try
{
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
// Prospect quote (no customer record yet)
if (quote.CustomerId == null)
{
if (string.IsNullOrWhiteSpace(quote.ProspectEmail))
return;
var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Valued Customer";
var baseUrl = await GetBaseUrlAsync();
var values = BuildQuoteSentValues(companyName, prospectName, quote, baseUrl);
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
quote.ProspectEmail, prospectName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = prospectName,
Recipient = quote.ProspectEmail,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
return;
}
// Registered customer
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
if (customer == null) return;
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var baseUrl = await GetBaseUrlAsync();
var values = BuildQuoteSentValues(companyName, customerName, quote, baseUrl);
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteSentAsync failed for quote {QuoteId}", quote.Id);
}
}
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Prospect quotes use ProspectPhone; registered customers require NotifyBySms + a phone number.
/// Returns (success, errorMessage) so the controller can surface the result.
/// </summary>
public async Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote)
{
try
{
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company))
return (false, "SMS is not enabled for this account.");
var baseUrl = await GetBaseUrlAsync();
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
: null;
if (string.IsNullOrEmpty(approvalUrl))
return (false, "No approval link available for this quote.");
string? smsPhone;
string recipientName;
int? customerId = null;
if (quote.CustomerId == null)
{
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
smsPhone = quote.ProspectPhone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, "No phone number on file for this prospect.");
recipientName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Valued Customer";
}
else
{
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
if (customer == null) return (false, "Customer not found.");
recipientName = GetCustomerDisplayName(customer);
customerId = customer.Id;
smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, $"{recipientName} has no phone number on file.");
if (!customer.NotifyBySms)
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.QuoteSent,
recipientName, smsPhone, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
return (false, $"{recipientName} has SMS notifications disabled.");
}
}
var message = $"{companyName}: Quote {quote.QuoteNumber} for {quote.Total:C} is ready for your review. Approve or decline: {approvalUrl} Reply STOP to opt out.";
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = recipientName,
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customerId,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteSentSmsAsync failed for quote {QuoteId}", quote.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends a confirmation email to the customer when a quote is approved (either by the
/// customer via the online portal or internally by staff). Prospect quotes (no CustomerId)
/// are skipped — they have no confirmed customer record to notify yet.
/// </summary>
public async Task NotifyQuoteApprovedAsync(Quote quote)
{
try
{
if (quote.CustomerId == null) return;
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["quoteNumber"] = quote.QuoteNumber ?? string.Empty
};
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, NotificationType.QuoteApproved, values,
$"Quote {quote.QuoteNumber} Approved — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.QuoteApproved,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteApprovedAsync failed for quote {QuoteId}", quote.Id);
}
}
/// <summary>
/// Notifies the customer when a job's status changes, via email and/or SMS based on their
/// preferences. The "ReadyForPickup" status triggers a special pickup template (with a
/// different subject line) rather than the generic status-change template, so the message
/// is clearly actionable. All other statuses use the generic JobStatusChanged template.
/// </summary>
public async Task NotifyJobStatusChangedAsync(Job job, string newStatusCode, string newStatusDisplayName)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(job.CompanyId);
var customerName = GetCustomerDisplayName(customer);
var notifType = newStatusCode == "READY_FOR_PICKUP"
? NotificationType.JobReadyForPickup
: NotificationType.JobStatusChanged;
// Email for all status changes
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
// ScheduledDate is when the shop plans to complete the work;
// DueDate is the customer deadline — fall back to it only when no scheduled date is set.
var expectedDate = job.ScheduledDate ?? job.DueDate;
var duePart = expectedDate.HasValue
? $" Expected completion: {expectedDate.Value:MMMM d, yyyy}."
: string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty,
["jobStatus"] = newStatusDisplayName,
["jobDueDate"] = duePart
};
var defaultSubject = newStatusCode == "READY_FOR_PICKUP"
? $"Job {job.JobNumber} Ready for Pickup — {companyName}"
: $"Job {job.JobNumber} Status Update — {companyName}";
var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, notifType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = notifType,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyJobStatusChangedAsync failed for job {JobId}", job.Id);
}
}
/// <summary>
/// Sends a job-completed notification. Separate from the generic status-change notification
/// because the completion message may include a link to pay the invoice online, and uses a
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
/// </summary>
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(job.CompanyId);
var customerName = GetCustomerDisplayName(customer);
// Email
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty,
["finalPrice"] = job.FinalPrice.ToString("C")
};
var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, NotificationType.JobCompleted, values,
$"Job {job.JobNumber} Complete — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
if (!suppressSms)
{
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
if (smsAllowed)
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyJobCompletedAsync failed for job {JobId}", job.Id);
}
}
/// <summary>
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
/// </summary>
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return null;
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
var customerName = GetCustomerDisplayName(customer);
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
return await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
}
catch (Exception ex)
{
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
return null;
}
}
/// <summary>
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
/// Appends opt-out instructions if not already present, then sends and logs the result.
/// </summary>
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return (false, "Customer not found.");
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
var (_, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
// Ensure TCPA-required opt-out language is present
const string stopSuffix = "Reply STOP to opt out.";
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
message = message.TrimEnd() + " " + stopSuffix;
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = GetCustomerDisplayName(customer),
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
/// (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)
{
try
{
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var dueText = invoice.DueDate.HasValue
? $" Payment is due by {invoice.DueDate.Value:MMMM d, yyyy}."
: string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["invoiceDueDate"] = dueText
};
var (subject, htmlBody) = await GetRenderedEmailAsync(
invoice.CompanyId, NotificationType.InvoiceSent, values,
$"Invoice {invoice.InvoiceNumber} from {companyName}");
// Append pay-now button if a payment link is available
if (!string.IsNullOrEmpty(paymentUrl))
{
htmlBody += $"""
<div style="margin:24px 0;text-align:center;">
<a href="{paymentUrl}" style="background:#198754;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Pay Invoice Online
</a>
<p style="margin-top:8px;font-size:12px;color:#6c757d;">
This link expires in 5 days. You may also pay in person at our shop.
</p>
</div>
""";
}
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = !string.IsNullOrEmpty(paymentUrl)
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.InvoiceSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyInvoiceSentAsync failed for invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Sends a payment receipt to the customer when a manual (in-person/check) payment is recorded
/// by staff. This is distinct from <see cref="NotifyOnlinePaymentReceivedAsync"/> which handles
/// Stripe-originated payments and includes the Stripe paymentIntentId in the receipt.
/// </summary>
public async Task NotifyPaymentReceivedAsync(Invoice invoice, Payment payment)
{
try
{
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var balanceText = invoice.BalanceDue > 0
? $" Remaining balance: {invoice.BalanceDue:C}."
: " Your account is paid in full.";
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["invoiceNumber"] = invoice.InvoiceNumber,
["paymentAmount"] = payment.Amount.ToString("C"),
["paymentDate"] = payment.PaymentDate.ToString("MMMM d, yyyy"),
["balanceDue"] = balanceText
};
var (subject, htmlBody) = await GetRenderedEmailAsync(
invoice.CompanyId, NotificationType.PaymentReceived, values,
$"Payment Received — Invoice {invoice.InvoiceNumber}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.PaymentReceived,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyPaymentReceivedAsync failed for invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Sends an overdue payment reminder. The <paramref name="daysOverdue"/> value is included
/// in the template values so the subject and body can express urgency appropriately
/// (e.g. "30 days overdue" vs "90 days overdue"). Called by the AR Aging report's
/// "Draft Follow-up Email" AI feature and by <see cref="SubscriptionExpiryBackgroundService"/>.
/// </summary>
public async Task NotifyPaymentReminderAsync(Invoice invoice, int daysOverdue)
{
try
{
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
if (customer == null) return;
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
{
var dueDate = invoice.DueDate.HasValue
? invoice.DueDate.Value.ToString("MMMM d, yyyy")
: "N/A";
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["balanceDue"] = invoice.BalanceDue.ToString("C"),
["dueDate"] = dueDate,
["daysOverdue"] = daysOverdue.ToString()
};
var (subject, htmlBody) = await GetRenderedEmailAsync(
invoice.CompanyId, NotificationType.PaymentReminder, values,
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.PaymentReminder,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyPaymentReminderAsync failed for invoice {InvoiceId}", invoice.Id);
}
}
/// <summary>
/// Notifies the COMPANY (not the customer) when a customer approves or declines a quote
/// via the online approval portal. The email goes to the company's EmailFromAddress so the
/// right staff member sees the response. This is an internal business alert, not a
/// customer-facing message. Also fires the in-app bell notification for immediate visibility.
/// </summary>
public async Task NotifyQuoteActedByCustomerAsync(Quote quote, bool approved, string? declineReason)
{
try
{
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
// Determine company notification email — use EmailFromAddress as recipient, falling back to configured SendGrid from email
var prefs = await _context.CompanyPreferences
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
var companyEmail = prefs?.EmailFromAddress
?? _configuration["SendGrid:FromEmail"]
?? string.Empty;
if (string.IsNullOrWhiteSpace(companyEmail))
{
_logger.LogWarning("No company email configured for QuoteActedByCustomer notification for quote {QuoteId}", quote.Id);
return;
}
// Determine customer/prospect display name
string customerName;
if (quote.Customer != null)
customerName = GetCustomerDisplayName(quote.Customer);
else if (quote.CustomerId.HasValue)
{
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
customerName = customer != null ? GetCustomerDisplayName(customer) : "Unknown Customer";
}
else
{
customerName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Prospect";
}
var responseText = approved ? "APPROVED" : "DECLINED";
var declineReasonSection = (!approved && !string.IsNullOrWhiteSpace(declineReason))
? $"<p><strong>Reason for declining:</strong><br/>{WebUtility.HtmlEncode(declineReason)}</p>"
: string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["quoteNumber"] = quote.QuoteNumber ?? string.Empty,
["response"] = responseText,
["declineReasonSection"] = declineReasonSection
};
var defaultSubject = approved
? $"Quote {quote.QuoteNumber} APPROVED — {customerName}"
: $"Quote {quote.QuoteNumber} DECLINED — {customerName}";
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, NotificationType.QuoteDeclinedByCustomer, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
companyEmail, companyName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.QuoteDeclinedByCustomer,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = companyName,
Recipient = companyEmail,
Subject = subject,
Message = plainText,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = quote.CustomerId,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteActedByCustomerAsync failed for quote {QuoteId}", quote.Id);
}
}
/// <summary>
/// Sends a one-time SMS opt-in confirmation to the customer immediately after they grant
/// SMS consent. Required by carrier regulations — the first message after consent must
/// confirm what they signed up for. No-ops when SmsEnabled is false (feature flag).
/// </summary>
public async Task NotifySmsConsentGrantedAsync(Customer customer)
{
try
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return;
var (companyName, company) = await GetCompanyAsync(customer.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = GetCustomerDisplayName(customer)
};
var smsMessage = await GetRenderedSmsAsync(
customer.CompanyId, NotificationType.SmsConsentConfirmation, values,
$"{companyName}: You're now enrolled for SMS job & pickup updates. Reply STOP at any time to opt out. Msg & data rates may apply.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.SmsConsentConfirmation,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = GetCustomerDisplayName(customer),
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = customer.CompanyId
});
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifySmsConsentGrantedAsync failed for customer {CustomerId}", customer.Id);
}
}
// -----------------------------------------------------------------------
// Template loading & rendering
// -----------------------------------------------------------------------
/// <summary>
/// Load template from DB; fall back to DefaultTemplates if not found or inactive.
/// </summary>
/// <summary>
/// Loads a notification template for the given type and channel from the DB.
/// Returns the DB template when one exists and is active; falls back to the hardcoded
/// <see cref="DefaultTemplates"/> when none is configured. Returns null if neither exists.
/// </summary>
private async Task<(string? Subject, string Body)?> LoadTemplateAsync(
int companyId, NotificationType type, NotificationChannel channel)
{
var template = await _context.NotificationTemplates
.AsNoTracking()
.FirstOrDefaultAsync(t =>
t.CompanyId == companyId &&
t.NotificationType == type &&
t.Channel == channel &&
!t.IsDeleted);
if (template != null && template.IsActive)
return (template.Subject, template.Body);
return DefaultTemplates.Get(type, channel);
}
/// <summary>
/// Renders an email template and returns (subject, htmlBody).
/// Falls back to the hardcoded defaultSubject if no template subject is defined.
/// </summary>
private async Task<(string Subject, string HtmlBody)> GetRenderedEmailAsync(
int companyId, NotificationType type, Dictionary<string, string> values, string defaultSubject)
{
var raw = await LoadTemplateAsync(companyId, type, NotificationChannel.Email);
var subject = raw?.Subject != null ? RenderTemplate(raw.Value.Subject, values) : defaultSubject;
var body = raw?.Body != null ? RenderTemplate(raw.Value.Body, values) : string.Empty;
return (subject, body);
}
/// <summary>
/// Renders an SMS template and returns the rendered plain-text body.
/// Falls back to defaultBody if no template is found.
/// </summary>
private async Task<string> GetRenderedSmsAsync(
int companyId, NotificationType type, Dictionary<string, string> values, string defaultBody)
{
var raw = await LoadTemplateAsync(companyId, type, NotificationChannel.Sms);
return raw?.Body != null ? RenderTemplate(raw.Value.Body, values) : defaultBody;
}
/// <summary>Replaces {{key}} tokens with their values.</summary>
private static string RenderTemplate(string template, Dictionary<string, string> values)
{
foreach (var (key, value) in values)
template = template.Replace($"{{{{{key}}}}}", value, StringComparison.OrdinalIgnoreCase);
return template;
}
/// <summary>Converts HTML to approximate plain text for SendGrid's plainTextBody parameter.</summary>
private static string StripHtml(string html)
{
if (string.IsNullOrEmpty(html)) return string.Empty;
var text = Regex.Replace(html, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, @"</p>", "\n\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, @"<hr[^>]*>", "\n---\n", RegexOptions.IgnoreCase);
text = Regex.Replace(text, @"<[^>]+>", string.Empty);
text = text.Replace("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace("&quot;", "\"")
.Replace("&#39;", "'");
text = Regex.Replace(text, @" {2,}", " ");
text = Regex.Replace(text, @"\n{3,}", "\n\n");
return text.Trim();
}
// -----------------------------------------------------------------------
// Value dictionary builders
// -----------------------------------------------------------------------
private Dictionary<string, string> BuildQuoteSentValues(string companyName, string recipientName, Quote quote, string? baseUrl)
{
var expiry = quote.ExpirationDate.HasValue
? $" valid until {quote.ExpirationDate.Value:MMMM d, yyyy}"
: string.Empty;
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
: string.Empty;
return new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = recipientName,
["quoteNumber"] = quote.QuoteNumber ?? string.Empty,
["quoteTotal"] = quote.Total.ToString("C"),
["quoteExpiry"] = expiry,
["approvalUrl"] = approvalUrl
};
}
// -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template)
// -----------------------------------------------------------------------
private static class DefaultTemplates
{
private static readonly Dictionary<(NotificationType, NotificationChannel), (string? Subject, string Body)> All
= new()
{
[(NotificationType.QuoteSent, NotificationChannel.Email)] = (
"Your Quote {{quoteNumber}} from {{companyName}}",
"<p>Dear {{customerName}},</p><p>Thank you for your interest. Quote <strong>{{quoteNumber}}</strong> for <strong>{{quoteTotal}}</strong>{{quoteExpiry}} has been prepared for your review.</p><p style=\"margin:24px 0;text-align:center;\"><a href=\"{{approvalUrl}}\" style=\"background:#4f46e5;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;display:inline-block;\">View &amp; Approve Your Quote</a></p><p style=\"font-size:0.85rem;color:#6b7280;\">Or copy this link: {{approvalUrl}}</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.QuoteApproved, NotificationChannel.Email)] = (
"Quote {{quoteNumber}} Approved — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your quote <strong>{{quoteNumber}}</strong> has been approved. We'll be in touch to schedule the work.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobStatusChanged, NotificationChannel.Email)] = (
"Job {{jobNumber}} Status Update — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> status is now: <strong>{{jobStatus}}</strong>.{{jobDueDate}}</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobReadyForPickup, NotificationChannel.Email)] = (
"Job {{jobNumber}} Ready for Pickup — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is ready for pickup!</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobCompleted, NotificationChannel.Email)] = (
"Job {{jobNumber}} Complete — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is complete. Final price: <strong>{{finalPrice}}</strong>. It is now ready for pickup.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobCompleted, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Job {{jobNumber}} is done and ready for pickup! Reply STOP to opt out."
),
[(NotificationType.SmsConsentConfirmation, NotificationChannel.Sms)] = (
null,
"{{companyName}}: You're now enrolled for SMS job & pickup updates. Reply STOP at any time to opt out. Msg & data rates may apply."
),
[(NotificationType.InvoiceSent, NotificationChannel.Email)] = (
"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.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>"
),
[(NotificationType.QuoteDeclinedByCustomer, NotificationChannel.Email)] = (
"Customer Response: Quote {{quoteNumber}} — {{companyName}}",
"<p>Hello,</p><p>A customer has responded to quote <strong>{{quoteNumber}}</strong>.</p><p><strong>Customer:</strong> {{customerName}}<br/><strong>Response:</strong> {{response}}</p>{{declineReasonSection}}<p>Log in to the portal to review and follow up.</p>"
),
[(NotificationType.PaymentReminder, NotificationChannel.Email)] = (
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
),
};
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
=> All.TryGetValue((type, channel), out var t) ? t : null;
}
// -----------------------------------------------------------------------
// Email footer helpers
// -----------------------------------------------------------------------
/// <summary>
/// Appends CAN-SPAM required footer as HTML.
/// </summary>
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
{
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
if (!hasUnsubscribeUrl && !hasAddress)
return htmlBody;
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
if (hasAddress)
{
var addressLine = BuildAddressLine(company!);
footer += $"{WebUtility.HtmlEncode(company!.CompanyName)} | {WebUtility.HtmlEncode(addressLine)}";
if (hasUnsubscribeUrl) footer += " | ";
}
if (hasUnsubscribeUrl)
{
var unsubscribeUrl = $"{baseUrl}/Unsubscribe/Email/{token}";
footer += $"<a href=\"{unsubscribeUrl}\" style=\"color: #888;\">Unsubscribe</a>";
}
footer += "</p>";
return htmlBody + footer;
}
private static string BuildAddressLine(Company company)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(company.Address)) parts.Add(company.Address);
if (!string.IsNullOrWhiteSpace(company.City)) parts.Add(company.City);
if (!string.IsNullOrWhiteSpace(company.State)) parts.Add(company.State);
if (!string.IsNullOrWhiteSpace(company.ZipCode)) parts.Add(company.ZipCode);
return string.Join(", ", parts);
}
private async Task<string?> GetBaseUrlAsync()
{
var configured = await _platformSettings.GetAsync(PlatformSettingKeys.BaseUrl);
if (!string.IsNullOrWhiteSpace(configured))
return configured.TrimEnd('/');
var request = _httpContextAccessor.HttpContext?.Request;
if (request != null)
return $"{request.Scheme}://{request.Host}";
return null;
}
// -----------------------------------------------------------------------
// Shared helpers
// -----------------------------------------------------------------------
/// <summary>
/// Builds a NotificationLog row with Status = Skipped. Written when the customer has opted
/// out of a channel so the audit trail shows we attempted to notify but honoured their preference.
/// </summary>
private static NotificationLog SkippedLog(
NotificationChannel channel,
NotificationType type,
string name,
string recipient,
int companyId,
int? customerId = null,
int? jobId = null,
int? quoteId = null,
int? invoiceId = null)
{
return new NotificationLog
{
Channel = channel,
NotificationType = type,
Status = NotificationStatus.Skipped,
RecipientName = name,
Recipient = recipient,
Message = "Notification skipped — customer opted out.",
SentAt = DateTime.UtcNow,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
InvoiceId = invoiceId,
CompanyId = companyId
};
}
/// <summary>
/// Persists a NotificationLog to the database. Uses a dedicated try/catch so a logging
/// failure never bubbles up to the caller — a failed log write is bad, but not worth
/// crashing the operation that triggered the notification.
/// </summary>
private async Task WriteLog(NotificationLog log)
{
try
{
await _context.NotificationLogs.AddAsync(log);
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write notification log");
}
}
/// <summary>
/// Returns true only when all four gating tiers allow SMS for this company:
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
/// </summary>
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
{
var platformOn = string.Equals(
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
"true", StringComparison.OrdinalIgnoreCase);
if (!platformOn) return false;
if (company == null) return false;
if (company.SmsDisabledByAdmin) return false;
var planConfig = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
if (planConfig?.AllowSms != true) return false;
return company.SmsEnabled;
}
/// <summary>
/// Loads the company entity and its display name. Returns a safe fallback name
/// so templates never render blank company names even if the row is missing.
/// </summary>
private async Task<(string Name, Company? Entity)> GetCompanyAsync(int companyId)
{
var company = await _context.Companies.FindAsync(companyId);
return (company?.CompanyName ?? "Powder Coating", company);
}
/// <summary>
/// Returns the From email address and display name to use for outgoing emails.
/// Company preferences take priority; falls back to null (EmailService applies appsettings fallback).
/// </summary>
private async Task<(string? ReplyToEmail, string? ReplyToName)> GetEmailFromAsync(int companyId)
{
var prefs = await _context.CompanyPreferences
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
}
/// <summary>
/// Sends a payment receipt for a Stripe-originated online payment. Includes the Stripe
/// paymentIntentId as a reference number so the customer can match it to their card statement.
/// Also shows a processing fee line when a surcharge was applied (Stripe Connect pass-through).
/// Distinct from <see cref="NotifyPaymentReceivedAsync"/> which handles manually recorded payments.
/// </summary>
public async Task NotifyOnlinePaymentReceivedAsync(Invoice invoice, decimal amountPaid, decimal surchargePaid, string paymentIntentId)
{
try
{
var customer = invoice.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
if (customer == null || string.IsNullOrWhiteSpace(customer.Email)) return;
var customerName = GetCustomerDisplayName(customer);
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
var companyName = company?.CompanyName ?? "Your Service Provider";
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var subject = $"Payment Received — Invoice {invoice.InvoiceNumber}";
var surchargeLine = surchargePaid > 0 ? $"\n Processing Fee: {surchargePaid:C}" : string.Empty;
var balanceDue = Math.Max(0, invoice.BalanceDue);
var plain = $@"Hi {customerName},
We have received your payment. Thank you!
Invoice: {invoice.InvoiceNumber}
Amount Paid: {amountPaid:C}{surchargeLine}
Reference: {paymentIntentId}
Balance Due: {balanceDue:C}
{(balanceDue > 0 ? "A remaining balance is still outstanding on this invoice." : "This invoice is now fully paid. No further action is required.")}
Thank you for your business,
{companyName}";
var surchargeRow = surchargePaid > 0
? $"<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Processing Fee</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'>{surchargePaid:C}</td></tr>"
: string.Empty;
var balanceColor = balanceDue > 0 ? "#dc2626" : "#16a34a";
var balanceNote = balanceDue > 0
? "A remaining balance is still outstanding on this invoice."
: "This invoice is now <strong>fully paid</strong>. No further action is required.";
var html = $@"<div style='font-family:sans-serif;max-width:600px;margin:auto;'>
<h2 style='color:#4f46e5;'>Payment Received</h2>
<p>Hi {System.Net.WebUtility.HtmlEncode(customerName)},</p>
<p>We have received your payment. Thank you!</p>
<table style='width:100%;border-collapse:collapse;margin:16px 0;'>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Invoice</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{invoice.InvoiceNumber}</strong></td></tr>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Amount Paid</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{amountPaid:C}</strong></td></tr>
{surchargeRow}
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Reference</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;'>{paymentIntentId}</td></tr>
<tr><td style='padding:8px;color:#6b7280;'>Balance Due</td><td style='padding:8px;'><strong style='color:{balanceColor};'>{balanceDue:C}</strong></td></tr>
</table>
<p>{balanceNote}</p>
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>";
await _emailService.SendEmailAsync(customer.Email, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send online payment receipt for invoice {InvoiceId}", invoice.Id);
}
}
public async Task NotifyDepositReceivedAsync(Quote quote, decimal amountPaid, decimal surchargePaid, string paymentIntentId)
{
try
{
// Determine recipient — linked customer or prospect contact
string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(toEmail)) return;
string customerName;
if (quote.Customer != null)
customerName = GetCustomerDisplayName(quote.Customer);
else if (!string.IsNullOrWhiteSpace(quote.ProspectContactName))
customerName = quote.ProspectContactName;
else
customerName = "Valued Customer";
var company = await _context.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
var companyName = company?.CompanyName ?? "Your Service Provider";
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
var depositRequired = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
var remaining = Math.Max(0, depositRequired - quote.DepositAmountPaid);
var surchargeLine = surchargePaid > 0 ? $"\n Processing Fee: {surchargePaid:C}" : string.Empty;
var subject = $"Deposit Received — Quote {quote.QuoteNumber}";
var plain = $@"Hi {customerName},
We have received your deposit. Thank you!
Quote: {quote.QuoteNumber}
Deposit Paid: {amountPaid:C}{surchargeLine}
Reference: {paymentIntentId}
Deposit Balance Remaining: {remaining:C}
{(remaining > 0 ? "A remaining deposit balance is still outstanding." : "Your deposit is fully paid. We will be in touch to schedule your job.")}
Thank you for your business,
{companyName}";
var surchargeRow = surchargePaid > 0
? $"<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Processing Fee</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'>{surchargePaid:C}</td></tr>"
: string.Empty;
var remainingColor = remaining > 0 ? "#dc2626" : "#16a34a";
var remainingNote = remaining > 0
? "A remaining deposit balance is still outstanding."
: "Your deposit is <strong>fully paid</strong>. We will be in touch to schedule your job.";
var html = $@"<div style='font-family:sans-serif;max-width:600px;margin:auto;'>
<h2 style='color:#4f46e5;'>Deposit Received</h2>
<p>Hi {System.Net.WebUtility.HtmlEncode(customerName)},</p>
<p>We have received your deposit. Thank you!</p>
<table style='width:100%;border-collapse:collapse;margin:16px 0;'>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Quote</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{quote.QuoteNumber}</strong></td></tr>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Deposit Paid</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{amountPaid:C}</strong></td></tr>
{surchargeRow}
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Reference</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;'>{paymentIntentId}</td></tr>
<tr><td style='padding:8px;color:#6b7280;'>Deposit Remaining</td><td style='padding:8px;'><strong style='color:{remainingColor};'>{remaining:C}</strong></td></tr>
</table>
<p>{remainingNote}</p>
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
</div>";
await _emailService.SendEmailAsync(toEmail, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send deposit receipt for quote {QuoteId}", quote.Id);
}
}
public async Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason)
{
try
{
var prefs = await _context.CompanyPreferences.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == invoice.CompanyId && !p.IsDeleted);
if (prefs == null || string.IsNullOrWhiteSpace(prefs.EmailFromAddress)) return;
var (replyToEmail, _) = await GetEmailFromAsync(invoice.CompanyId);
var subject = $"CHARGEBACK ALERT — Invoice {invoice.InvoiceNumber} disputed";
var plain = $@"A chargeback has been opened on invoice {invoice.InvoiceNumber}.
Dispute ID: {disputeId}
Amount: {amount:C}
Reason: {reason}
Invoice #: {invoice.InvoiceNumber}
Log in to your Stripe Dashboard to respond to this dispute. You typically have 721 days to respond.
— Powder Coating Logix Payments";
var html = $@"<div style='font-family:sans-serif;max-width:600px;margin:auto;'>
<h2 style='color:#dc2626;'>Chargeback Alert</h2>
<p>A chargeback has been opened on invoice <strong>{invoice.InvoiceNumber}</strong>.</p>
<table style='width:100%;border-collapse:collapse;margin:16px 0;'>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Dispute ID</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;font-family:monospace;'>{disputeId}</td></tr>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Amount</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong style='color:#dc2626;'>{amount:C}</strong></td></tr>
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Reason</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'>{System.Net.WebUtility.HtmlEncode(reason)}</td></tr>
<tr><td style='padding:8px;color:#6b7280;'>Invoice</td><td style='padding:8px;'>{invoice.InvoiceNumber}</td></tr>
</table>
<p><strong>Log in to your Stripe Dashboard to respond to this dispute.</strong> You typically have 721 days to respond before it is automatically decided against you.</p>
</div>";
await _emailService.SendEmailAsync(prefs.EmailFromAddress, "Shop Admin", subject, plain, html,
replyToEmail: replyToEmail, replyToName: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send chargeback alert for invoice {InvoiceId}", invoice.Id);
}
}
private static string GetCustomerDisplayName(Customer customer)
{
if (!customer.IsCommercial)
{
var contact = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (!string.IsNullOrEmpty(contact)) return contact;
}
if (!string.IsNullOrWhiteSpace(customer.CompanyName))
return customer.CompanyName;
return $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() is { Length: > 0 } n
? n
: "Valued Customer";
}
}