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 += $"""
""";
}
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!
| Invoice | {invoice.InvoiceNumber} |
| Amount Paid | {amountPaid:C} |
{surchargeRow}
| 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!
| Quote | {quote.QuoteNumber} |
| Deposit Paid | {amountPaid:C} |
{surchargeRow}
| 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";
}
}