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; /// /// 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 . /// 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 or . /// 6. Write a 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. /// 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 _logger; private readonly ITenantContext _tenantContext; private readonly IHttpContextAccessor _httpContextAccessor; public NotificationService( IEmailService emailService, ISmsService smsService, ApplicationDbContext context, IConfiguration configuration, IPlatformSettingsService platformSettings, ILogger logger, ITenantContext tenantContext, IHttpContextAccessor httpContextAccessor) { _emailService = emailService; _smsService = smsService; _context = context; _configuration = configuration; _platformSettings = platformSettings; _logger = logger; _tenantContext = tenantContext; _httpContextAccessor = httpContextAccessor; } // ----------------------------------------------------------------------- // Public interface methods // ----------------------------------------------------------------------- /// /// Sends the "quote ready for review" email to the customer or prospect, with the PDF /// attached and an inline "View & 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. /// public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = 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) { var prospectEmail = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : quote.ProspectEmail; if (string.IsNullOrWhiteSpace(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( 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 = 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); // Override address (ad-hoc staff entry) takes priority over customer record. var emailToUse = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : customer.Email; var quoteEmails = ParseEmailList(emailToUse); // Bypass NotifyByEmail preference when staff explicitly supplies an override address. if ((customer.NotifyByEmail || !string.IsNullOrWhiteSpace(overrideEmail)) && quoteEmails.Count > 0) { 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, recipientsLog) = await SendToEmailListAsync( emailToUse, 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, QuoteId = quote.Id, CompanyId = quote.CompanyId }); } else if (quoteEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent, customerName, string.Join(", ", quoteEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id)); } } catch (Exception ex) { _logger.LogError(ex, "NotifyQuoteSentAsync failed for quote {QuoteId}", quote.Id); } } /// /// 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. /// 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 — requires explicit staff-recorded consent (TCPA compliance) if (!quote.ProspectSmsConsent) return (false, "SMS consent has not been recorded for this prospect. Edit the quote to record verbal consent before sending via SMS."); 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 smsValues = new Dictionary { ["companyName"] = companyName, ["quoteNumber"] = quote.QuoteNumber ?? string.Empty, ["quoteTotal"] = quote.Total.ToString("C"), ["approvalUrl"] = approvalUrl }; var message = await GetRenderedSmsAsync( quote.CompanyId, NotificationType.QuoteSent, smsValues, $"{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."); } } /// /// 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. /// 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); var approvedEmails = ParseEmailList(customer.Email); if (customer.NotifyByEmail && approvedEmails.Count > 0) { var values = new Dictionary { ["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, recipientsLog) = await SendToEmailListAsync( 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, QuoteId = quote.Id, CompanyId = quote.CompanyId }); } else if (approvedEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved, customerName, string.Join(", ", approvedEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id)); } } catch (Exception ex) { _logger.LogError(ex, "NotifyQuoteApprovedAsync failed for quote {QuoteId}", quote.Id); } } /// /// 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. /// 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 var statusEmails = ParseEmailList(customer.Email); if (customer.NotifyByEmail && statusEmails.Count > 0) { // 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 { ["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, recipientsLog) = await SendToEmailListAsync( 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, JobId = job.Id, CompanyId = job.CompanyId }); } else if (statusEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged, customerName, string.Join(", ", statusEmails), job.CompanyId, customerId: customer.Id, jobId: job.Id)); } } catch (Exception ex) { _logger.LogError(ex, "NotifyJobStatusChangedAsync failed for job {JobId}", job.Id); } } /// /// 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. /// 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 var completedEmails = ParseEmailList(customer.Email); if (customer.NotifyByEmail && completedEmails.Count > 0) { var values = new Dictionary { ["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, recipientsLog) = await SendToEmailListAsync( 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, JobId = job.Id, CompanyId = job.CompanyId }); } else if (completedEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted, customerName, string.Join(", ", completedEmails), 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 { ["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); } } /// /// 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. /// public async Task 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 { ["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; } } /// /// 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. /// 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."); } } /// /// 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 parameter). Without a payment URL the email is a /// standard "here is your invoice" message with no payment CTA. /// 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 { 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); // Override email (staff-provided ad-hoc address) takes priority over customer record. // Use BillingEmail when set (commercial accounting dept); fall back to primary Email. var invoiceEmail = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : (!string.IsNullOrWhiteSpace(customer.BillingEmail) ? customer.BillingEmail : customer.Email); var invoiceEmails = ParseEmailList(invoiceEmail); // Bypass NotifyByEmail preference when staff explicitly supplies an override address. if ((customer.NotifyByEmail || !string.IsNullOrWhiteSpace(overrideEmail)) && invoiceEmails.Count > 0) { var dueText = invoice.DueDate.HasValue ? $" Payment is due by {invoice.DueDate.Value:MMMM d, yyyy}." : string.Empty; var values = new Dictionary { ["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 += $"""
Pay Invoice Online

This link expires in 5 days. You may also pay in person at our shop.

"""; } 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, recipientsLog) = await SendToEmailListAsync( invoiceEmail, 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, InvoiceId = invoice.Id, CompanyId = invoice.CompanyId }); } else if (invoiceEmails.Count > 0) { 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 { ["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) { _logger.LogError(ex, "NotifyInvoiceSentAsync failed for invoice {InvoiceId}", invoice.Id); } } /// /// Sends a payment receipt to the customer when a manual (in-person/check) payment is recorded /// by staff. This is distinct from which handles /// Stripe-originated payments and includes the Stripe paymentIntentId in the receipt. /// 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); var paymentEmails = ParseEmailList(customer.Email); if (customer.NotifyByEmail && paymentEmails.Count > 0) { var balanceText = invoice.BalanceDue > 0 ? $" Remaining balance: {invoice.BalanceDue:C}." : " Your account is paid in full."; var values = new Dictionary { ["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, recipientsLog) = await SendToEmailListAsync( 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, InvoiceId = invoice.Id, CompanyId = invoice.CompanyId }); } else if (paymentEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived, customerName, string.Join(", ", paymentEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id)); } } catch (Exception ex) { _logger.LogError(ex, "NotifyPaymentReceivedAsync failed for invoice {InvoiceId}", invoice.Id); } } /// /// Sends an overdue payment reminder. The 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 . /// 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); var reminderEmails = ParseEmailList(customer.Email); if (customer.NotifyByEmail && reminderEmails.Count > 0) { var dueDate = invoice.DueDate.HasValue ? invoice.DueDate.Value.ToString("MMMM d, yyyy") : "N/A"; var values = new Dictionary { ["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, recipientsLog) = await SendToEmailListAsync( 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 = recipientsLog, Subject = subject, Message = plainText, ErrorMessage = error, SentAt = DateTime.UtcNow, CustomerId = customer.Id, InvoiceId = invoice.Id, CompanyId = invoice.CompanyId }); } else if (reminderEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder, customerName, string.Join(", ", reminderEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id)); } } catch (Exception ex) { _logger.LogError(ex, "NotifyPaymentReminderAsync failed for invoice {InvoiceId}", invoice.Id); } } /// /// 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. /// 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)) ? $"

Reason for declining:
{WebUtility.HtmlEncode(declineReason)}

" : string.Empty; var values = new Dictionary { ["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 notificationType = approved ? NotificationType.QuoteApprovedByCustomer : NotificationType.QuoteDeclinedByCustomer; var (subject, htmlBody) = await GetRenderedEmailAsync( quote.CompanyId, notificationType, 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, 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); } } /// /// 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). /// 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 { ["companyName"] = companyName, ["customerName"] = GetCustomerDisplayName(customer) }; var smsMessage = await GetRenderedSmsAsync( customer.CompanyId, NotificationType.SmsConsentConfirmation, values, $"{companyName}: You're now enrolled for SMS job updates and quote approvals. 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 // ----------------------------------------------------------------------- /// /// Load template from DB; fall back to DefaultTemplates if not found or inactive. /// /// /// 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 /// when none is configured. Returns null if neither exists. /// 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); } /// /// Renders an email template and returns (subject, htmlBody). /// Falls back to the hardcoded defaultSubject if no template subject is defined. /// private async Task<(string Subject, string HtmlBody)> GetRenderedEmailAsync( int companyId, NotificationType type, Dictionary 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); } /// /// Renders an SMS template and returns the rendered plain-text body. /// Falls back to defaultBody if no template is found. /// private async Task GetRenderedSmsAsync( int companyId, NotificationType type, Dictionary values, string defaultBody) { var raw = await LoadTemplateAsync(companyId, type, NotificationChannel.Sms); return raw?.Body != null ? RenderTemplate(raw.Value.Body, values) : defaultBody; } /// Replaces {{key}} tokens with their values. private static string RenderTemplate(string template, Dictionary values) { foreach (var (key, value) in values) template = template.Replace($"{{{{{key}}}}}", value, StringComparison.OrdinalIgnoreCase); return template; } /// Converts HTML to approximate plain text for SendGrid's plainTextBody parameter. private static string StripHtml(string html) { if (string.IsNullOrEmpty(html)) return string.Empty; var text = Regex.Replace(html, @"", "\n", RegexOptions.IgnoreCase); text = Regex.Replace(text, @"

", "\n\n", RegexOptions.IgnoreCase); text = Regex.Replace(text, @"]*>", "\n---\n", RegexOptions.IgnoreCase); text = Regex.Replace(text, @"<[^>]+>", string.Empty); text = text.Replace(" ", " ") .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace(""", "\"") .Replace("'", "'"); text = Regex.Replace(text, @" {2,}", " "); text = Regex.Replace(text, @"\n{3,}", "\n\n"); return text.Trim(); } // ----------------------------------------------------------------------- // Value dictionary builders // ----------------------------------------------------------------------- private Dictionary 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 { ["companyName"] = companyName, ["customerName"] = recipientName, ["quoteNumber"] = quote.QuoteNumber ?? string.Empty, ["quoteTotal"] = quote.Total.ToString("C"), ["quoteExpiry"] = expiry, ["approvalUrl"] = approvalUrl }; } /// /// Sends appointment reminder emails when an appointment's reminder window opens. /// Two emails are dispatched independently: /// /// Customer email — sent when a customer is linked, has an email address, and has /// email notifications enabled (). /// Staff email — sent to (the user who created /// the appointment). This fires regardless of whether a customer is linked. /// /// Called exclusively by /// /// after it stamps ReminderSentAt — the caller owns deduplication. /// public async Task NotifyAppointmentReminderAsync(Appointment appointment) { try { var (companyName, company) = await GetCompanyAsync(appointment.CompanyId); var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId); var baseUrl = await GetBaseUrlAsync(); var locationLine = !string.IsNullOrWhiteSpace(appointment.Location) ? $"
Location: {WebUtility.HtmlEncode(appointment.Location)}" : string.Empty; var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy"); var appointmentTime = appointment.IsAllDay ? "All Day" : appointment.ScheduledStartTime.ToString("h:mm tt"); var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}"; // ── Customer email ──────────────────────────────────────────────── if (appointment.CustomerId != null) { var customer = appointment.Customer ?? await _context.Customers.FindAsync(appointment.CustomerId.Value); if (customer != null) { var customerName = GetCustomerDisplayName(customer); var reminderEmails = ParseEmailList(customer.Email); if (!customer.NotifyByEmail || reminderEmails.Count == 0) { if (reminderEmails.Count > 0) { await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder, customerName, string.Join(", ", reminderEmails), appointment.CompanyId, customerId: customer.Id)); } } else { var customerValues = new Dictionary { ["companyName"] = companyName, ["customerName"] = customerName, ["appointmentTitle"] = appointment.Title, ["appointmentDate"] = appointmentDate, ["appointmentTime"] = appointmentTime, ["locationLine"] = locationLine }; var (custSubject, custHtml) = await GetRenderedEmailAsync( appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject); var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl); var custPlainText = StripHtml(custFullHtml); var (custOk, custErr, custLog) = await SendToEmailListAsync( customer.Email, customerName, custSubject, custPlainText, custFullHtml, replyToEmail: replyToEmail, replyToName: replyToName); await WriteLog(new NotificationLog { Channel = NotificationChannel.Email, NotificationType = NotificationType.AppointmentReminder, Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed, RecipientName = customerName, Recipient = custLog, Subject = custSubject, Message = custPlainText, ErrorMessage = custErr, SentAt = DateTime.UtcNow, CustomerId = customer.Id, CompanyId = appointment.CompanyId }); } } } // ── Staff email ─────────────────────────────────────────────────── // Send to whoever created the appointment so they get an out-of-app reminder. if (!string.IsNullOrWhiteSpace(appointment.CreatedBy)) { // Look up the user's display name from Identity if available. var staffUser = await _context.Users .FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy); var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName) ? staffUser.FullName : appointment.CreatedBy; // Include a customer line only when a customer is linked. var customerLine = appointment.Customer != null ? $"
Customer: {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}" : string.Empty; var staffValues = new Dictionary { ["companyName"] = companyName, ["staffName"] = staffName, ["appointmentTitle"] = appointment.Title, ["appointmentDate"] = appointmentDate, ["appointmentTime"] = appointmentTime, ["customerLine"] = customerLine, ["locationLine"] = locationLine }; var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}"; var (staffSubject, staffHtml) = await GetRenderedEmailAsync( appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject); var staffPlainText = StripHtml(staffHtml); var (staffOk, staffErr, staffLog) = await SendToEmailListAsync( appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml, replyToEmail: replyToEmail, replyToName: replyToName); await WriteLog(new NotificationLog { Channel = NotificationChannel.Email, NotificationType = NotificationType.AppointmentReminderStaff, Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed, RecipientName = staffName, Recipient = staffLog, Subject = staffSubject, Message = staffPlainText, ErrorMessage = staffErr, SentAt = DateTime.UtcNow, CompanyId = appointment.CompanyId }); } } catch (Exception ex) { _logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id); } } // ----------------------------------------------------------------------- // 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}}", "

Dear {{customerName}},

Thank you for your interest. Quote {{quoteNumber}} for {{quoteTotal}}{{quoteExpiry}} has been prepared for your review.

View & Approve Your Quote

Or copy this link: {{approvalUrl}}

Thank you for choosing {{companyName}}.

" ), [(NotificationType.QuoteApproved, NotificationChannel.Email)] = ( "Quote {{quoteNumber}} Approved — {{companyName}}", "

Dear {{customerName}},

Your quote {{quoteNumber}} has been approved. We'll be in touch to schedule the work.

Thank you for choosing {{companyName}}.

" ), [(NotificationType.JobStatusChanged, NotificationChannel.Email)] = ( "Job {{jobNumber}} Status Update — {{companyName}}", "

Dear {{customerName}},

Your job {{jobNumber}} status is now: {{jobStatus}}.{{jobDueDate}}

Thank you for choosing {{companyName}}.

" ), [(NotificationType.JobReadyForPickup, NotificationChannel.Email)] = ( "Job {{jobNumber}} Ready for Pickup — {{companyName}}", "

Dear {{customerName}},

Your job {{jobNumber}} is ready for pickup!

Thank you for choosing {{companyName}}.

" ), [(NotificationType.JobCompleted, NotificationChannel.Email)] = ( "Job {{jobNumber}} Complete — {{companyName}}", "

Dear {{customerName}},

Your job {{jobNumber}} is complete. Final price: {{finalPrice}}. It is now ready for pickup.

Thank you for choosing {{companyName}}.

" ), [(NotificationType.QuoteSent, NotificationChannel.Sms)] = ( null, "{{companyName}}: Quote {{quoteNumber}} for {{quoteTotal}} is ready for your review. Approve or decline: {{approvalUrl}} Reply STOP to opt out." ), [(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 updates and quote approvals. Reply STOP at any time to opt out. Msg & data rates may apply." ), [(NotificationType.InvoiceSent, NotificationChannel.Email)] = ( "Invoice {{invoiceNumber}} from {{companyName}}", "

Dear {{customerName}},

Please find your invoice {{invoiceNumber}} for {{invoiceTotal}} attached.{{invoiceDueDate}}

Thank you for your business with {{companyName}}.

" ), [(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}}", "

Dear {{customerName}},

We have received your payment of {{paymentAmount}} on {{paymentDate}} for invoice {{invoiceNumber}}.{{balanceDue}}

Thank you for your business with {{companyName}}.

" ), [(NotificationType.QuoteApprovedByCustomer, NotificationChannel.Email)] = ( "Customer Response: Quote {{quoteNumber}} — {{companyName}}", "

Hello,

A customer has responded to quote {{quoteNumber}}.

Customer: {{customerName}}
Response: {{response}}

Log in to the portal to review and follow up.

" ), [(NotificationType.QuoteDeclinedByCustomer, NotificationChannel.Email)] = ( "Customer Response: Quote {{quoteNumber}} — {{companyName}}", "

Hello,

A customer has responded to quote {{quoteNumber}}.

Customer: {{customerName}}
Response: {{response}}

{{declineReasonSection}}

Log in to the portal to review and follow up.

" ), [(NotificationType.PaymentReminder, NotificationChannel.Email)] = ( "Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)", "

Dear {{customerName}},

This is a friendly reminder that invoice {{invoiceNumber}} for {{invoiceTotal}} was due on {{dueDate}} and is now {{daysOverdue}} days overdue.

Outstanding balance: {{balanceDue}}

Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.

Thank you for your business with {{companyName}}.

" ), [(NotificationType.AppointmentReminder, NotificationChannel.Email)] = ( "Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}", "

Dear {{customerName}},

This is a reminder that you have an upcoming appointment with {{companyName}}.

Appointment: {{appointmentTitle}}
Date & Time: {{appointmentDate}} at {{appointmentTime}}{{locationLine}}

If you have any questions or need to reschedule, please contact us at your earliest convenience.

Thank you for choosing {{companyName}}.

" ), [(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = ( "[Reminder] {{appointmentTitle}} — {{appointmentDate}}", "

Hi {{staffName}},

This is a reminder that you have an upcoming appointment.

Appointment: {{appointmentTitle}}
Date & Time: {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}

— {{companyName}}

" ), }; public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel) => All.TryGetValue((type, channel), out var t) ? t : null; } // ----------------------------------------------------------------------- // Email footer helpers // ----------------------------------------------------------------------- /// /// Appends CAN-SPAM required footer as HTML. /// 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 = "
" + "

"; 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 += $"Unsubscribe"; } footer += "

"; return htmlBody + footer; } private static string BuildAddressLine(Company company) { var parts = new List(); 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 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 // ----------------------------------------------------------------------- /// /// 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. /// 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 }; } /// /// 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. /// 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"); } } /// /// 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. /// private async Task 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; } /// /// 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. /// private async Task<(string Name, Company? Entity)> GetCompanyAsync(int companyId) { var company = await _context.Companies.FindAsync(companyId); return (company?.CompanyName ?? "Powder Coating", company); } /// /// 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). /// 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); } /// /// 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 which handles manually recorded payments. /// 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 || ParseEmailList(customer.Email).Count == 0) 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 ? $"Processing Fee{surchargePaid:C}" : 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 fully paid. No further action is required."; var html = $@"

Payment Received

Hi {System.Net.WebUtility.HtmlEncode(customerName)},

We have received your payment. Thank you!

{surchargeRow}
Invoice{invoice.InvoiceNumber}
Amount Paid{amountPaid:C}
Reference{paymentIntentId}
Balance Due{balanceDue:C}

{balanceNote}

Thank you for your business,
{System.Net.WebUtility.HtmlEncode(companyName)}

"; await SendToEmailListAsync(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 (ParseEmailList(toEmail).Count == 0) 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 ? $"Processing Fee{surchargePaid:C}" : string.Empty; var remainingColor = remaining > 0 ? "#dc2626" : "#16a34a"; var remainingNote = remaining > 0 ? "A remaining deposit balance is still outstanding." : "Your deposit is fully paid. We will be in touch to schedule your job."; var html = $@"

Deposit Received

Hi {System.Net.WebUtility.HtmlEncode(customerName)},

We have received your deposit. Thank you!

{surchargeRow}
Quote{quote.QuoteNumber}
Deposit Paid{amountPaid:C}
Reference{paymentIntentId}
Deposit Remaining{remaining:C}

{remainingNote}

Thank you for your business,
{System.Net.WebUtility.HtmlEncode(companyName)}

"; await SendToEmailListAsync(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 7–21 days to respond. — Powder Coating Logix Payments"; var html = $@"

Chargeback Alert

A chargeback has been opened on invoice {invoice.InvoiceNumber}.

Dispute ID{disputeId}
Amount{amount:C}
Reason{System.Net.WebUtility.HtmlEncode(reason)}
Invoice{invoice.InvoiceNumber}

Log in to your Stripe Dashboard to respond to this dispute. You typically have 7–21 days to respond before it is automatically decided against you.

"; 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); } } /// /// Parses a comma-separated email field into individual, trimmed addresses. /// Silently ignores blank entries; does not validate format beyond requiring '@'. /// private static List ParseEmailList(string? emailField) { if (string.IsNullOrWhiteSpace(emailField)) return []; return emailField .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(e => e.Contains('@')) .ToList(); } /// /// Sends the same email to every address in a comma-separated . /// Returns (anySuccess, lastError, comma-joined recipient string for logging). /// private async Task<(bool Success, string? ErrorMessage, string RecipientsLog)> SendToEmailListAsync( string? emailList, string toName, string subject, string plainText, string? htmlBody = null, byte[]? attachmentData = null, string? attachmentFilename = null, string? attachmentContentType = null, string? replyToEmail = null, string? replyToName = null) { var emails = ParseEmailList(emailList); if (emails.Count == 0) return (false, "No valid email addresses", string.Empty); bool anySuccess = false; string? lastError = null; foreach (var email in emails) { var (ok, err) = await _emailService.SendEmailAsync( email, toName, subject, plainText, htmlBody, attachmentData, attachmentFilename, attachmentContentType, replyToEmail, replyToName); if (ok) anySuccess = true; else lastError = err; } return (anySuccess, anySuccess ? null : lastError, string.Join(", ", emails)); } 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"; } }