Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/NotificationService.cs
T
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00

1791 lines
88 KiB
C#
Raw Blame History

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