d3863c713b
QuoteDeclinedByCustomer was used for both approve and decline responses, so approval notifications showed the wrong type in the log. Added a distinct QuoteApprovedByCustomer = 16 enum value, wired up the correct type in NotificationService, added default templates in both the service fallback dictionary and SeedData, and updated placeholder hints in CompanySettings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1519 lines
72 KiB
C#
1519 lines
72 KiB
C#
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 & Approve" button linking to the customer approval portal.
|
||
/// Handles two recipient types:
|
||
/// - Prospect (no CustomerId): sends to ProspectEmail directly; no opt-out check.
|
||
/// - Customer: respects NotifyByEmail; still writes a Skipped log if opted out.
|
||
/// Always writes a NotificationLog row so the Notifications Sent tab shows delivery history.
|
||
/// </summary>
|
||
public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null)
|
||
{
|
||
try
|
||
{
|
||
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
|
||
|
||
// Prospect quote (no customer record yet)
|
||
if (quote.CustomerId == null)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(quote.ProspectEmail))
|
||
return;
|
||
|
||
var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
|
||
? quote.ProspectContactName
|
||
: quote.ProspectCompanyName ?? "Valued Customer";
|
||
|
||
var baseUrl = await GetBaseUrlAsync();
|
||
var values = BuildQuoteSentValues(companyName, prospectName, quote, baseUrl);
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
quote.ProspectEmail, prospectName, subject, plainText, fullHtml,
|
||
pdfAttachment, pdfFilename, "application/pdf",
|
||
replyToEmail, replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.QuoteSent,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = prospectName,
|
||
Recipient = quote.ProspectEmail,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
QuoteId = quote.Id,
|
||
CompanyId = quote.CompanyId
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Registered customer
|
||
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
|
||
if (customer == null) return;
|
||
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var baseUrl = await GetBaseUrlAsync();
|
||
var values = BuildQuoteSentValues(companyName, customerName, quote, baseUrl);
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
pdfAttachment, pdfFilename, "application/pdf",
|
||
replyToEmail, replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.QuoteSent,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
QuoteId = quote.Id,
|
||
CompanyId = quote.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent,
|
||
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyQuoteSentAsync failed for quote {QuoteId}", quote.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends the quote approval link to the customer via SMS.
|
||
/// Prospect quotes use ProspectPhone; registered customers require NotifyBySms + a phone number.
|
||
/// Returns (success, errorMessage) so the controller can surface the result.
|
||
/// </summary>
|
||
public async Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote)
|
||
{
|
||
try
|
||
{
|
||
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
|
||
if (!await IsSmsAllowedForCompanyAsync(company))
|
||
return (false, "SMS is not enabled for this account.");
|
||
|
||
var baseUrl = await GetBaseUrlAsync();
|
||
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
|
||
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
|
||
: null;
|
||
|
||
if (string.IsNullOrEmpty(approvalUrl))
|
||
return (false, "No approval link available for this quote.");
|
||
|
||
string? smsPhone;
|
||
string recipientName;
|
||
int? customerId = null;
|
||
|
||
if (quote.CustomerId == null)
|
||
{
|
||
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
|
||
smsPhone = quote.ProspectPhone;
|
||
if (string.IsNullOrWhiteSpace(smsPhone))
|
||
return (false, "No phone number on file for this prospect.");
|
||
|
||
recipientName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
|
||
? quote.ProspectContactName
|
||
: quote.ProspectCompanyName ?? "Valued Customer";
|
||
}
|
||
else
|
||
{
|
||
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
|
||
if (customer == null) return (false, "Customer not found.");
|
||
|
||
recipientName = GetCustomerDisplayName(customer);
|
||
customerId = customer.Id;
|
||
smsPhone = customer.MobilePhone ?? customer.Phone;
|
||
|
||
if (string.IsNullOrWhiteSpace(smsPhone))
|
||
return (false, $"{recipientName} has no phone number on file.");
|
||
|
||
if (!customer.NotifyBySms)
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.QuoteSent,
|
||
recipientName, smsPhone, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
|
||
return (false, $"{recipientName} has SMS notifications disabled.");
|
||
}
|
||
}
|
||
|
||
var 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);
|
||
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["quoteNumber"] = quote.QuoteNumber ?? string.Empty
|
||
};
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.QuoteApproved,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
QuoteId = quote.Id,
|
||
CompanyId = quote.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved,
|
||
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyQuoteApprovedAsync failed for quote {QuoteId}", quote.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Notifies the customer when a job's status changes, via email and/or SMS based on their
|
||
/// preferences. The "ReadyForPickup" status triggers a special pickup template (with a
|
||
/// different subject line) rather than the generic status-change template, so the message
|
||
/// is clearly actionable. All other statuses use the generic JobStatusChanged template.
|
||
/// </summary>
|
||
public async Task NotifyJobStatusChangedAsync(Job job, string newStatusCode, string newStatusDisplayName)
|
||
{
|
||
try
|
||
{
|
||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||
if (customer == null) return;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(job.CompanyId);
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
var notifType = newStatusCode == "READY_FOR_PICKUP"
|
||
? NotificationType.JobReadyForPickup
|
||
: NotificationType.JobStatusChanged;
|
||
|
||
// Email for all status changes
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
// ScheduledDate is when the shop plans to complete the work;
|
||
// DueDate is the customer deadline — fall back to it only when no scheduled date is set.
|
||
var expectedDate = job.ScheduledDate ?? job.DueDate;
|
||
var duePart = expectedDate.HasValue
|
||
? $" Expected completion: {expectedDate.Value:MMMM d, yyyy}."
|
||
: string.Empty;
|
||
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["jobNumber"] = job.JobNumber ?? string.Empty,
|
||
["jobStatus"] = newStatusDisplayName,
|
||
["jobDueDate"] = duePart
|
||
};
|
||
|
||
var defaultSubject = newStatusCode == "READY_FOR_PICKUP"
|
||
? $"Job {job.JobNumber} Ready for Pickup — {companyName}"
|
||
: $"Job {job.JobNumber} Status Update — {companyName}";
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
job.CompanyId, notifType, values, defaultSubject);
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = notifType,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
JobId = job.Id,
|
||
CompanyId = job.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged,
|
||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||
}
|
||
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyJobStatusChangedAsync failed for job {JobId}", job.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends a job-completed notification. Separate from the generic status-change notification
|
||
/// because the completion message may include a link to pay the invoice online, and uses a
|
||
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
|
||
/// </summary>
|
||
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
|
||
{
|
||
try
|
||
{
|
||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||
if (customer == null) return;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(job.CompanyId);
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
// Email
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["jobNumber"] = job.JobNumber ?? string.Empty,
|
||
["finalPrice"] = job.FinalPrice.ToString("C")
|
||
};
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
job.CompanyId, NotificationType.JobCompleted, values,
|
||
$"Job {job.JobNumber} Complete — {companyName}");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.JobCompleted,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
JobId = job.Id,
|
||
CompanyId = job.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted,
|
||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||
}
|
||
|
||
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
|
||
if (!suppressSms)
|
||
{
|
||
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||
if (smsAllowed)
|
||
{
|
||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||
{
|
||
var smsValues = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||
};
|
||
|
||
var smsMessage = await GetRenderedSmsAsync(
|
||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||
|
||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Sms,
|
||
NotificationType = NotificationType.JobCompleted,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = smsPhone,
|
||
Message = smsMessage,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
JobId = job.Id,
|
||
CompanyId = job.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
|
||
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyJobCompletedAsync failed for job {JobId}", job.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
|
||
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
|
||
/// </summary>
|
||
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
|
||
{
|
||
try
|
||
{
|
||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||
if (customer == null) return null;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
|
||
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
|
||
|
||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
|
||
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
var smsValues = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||
};
|
||
|
||
return await GetRenderedSmsAsync(
|
||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
|
||
/// Appends opt-out instructions if not already present, then sends and logs the result.
|
||
/// </summary>
|
||
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
|
||
{
|
||
try
|
||
{
|
||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||
if (customer == null) return (false, "Customer not found.");
|
||
|
||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
|
||
|
||
var (_, company) = await GetCompanyAsync(job.CompanyId);
|
||
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
|
||
|
||
// Ensure TCPA-required opt-out language is present
|
||
const string stopSuffix = "Reply STOP to opt out.";
|
||
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
|
||
message = message.TrimEnd() + " " + stopSuffix;
|
||
|
||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Sms,
|
||
NotificationType = NotificationType.JobCompleted,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = GetCustomerDisplayName(customer),
|
||
Recipient = smsPhone,
|
||
Message = message,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
JobId = job.Id,
|
||
CompanyId = job.CompanyId
|
||
});
|
||
|
||
return (success, error);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
|
||
return (false, "An unexpected error occurred while sending the SMS.");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
|
||
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
|
||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||
/// standard "here is your invoice" message with no payment CTA.
|
||
/// </summary>
|
||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null)
|
||
{
|
||
try
|
||
{
|
||
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
|
||
if (customer == null) return;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var dueText = invoice.DueDate.HasValue
|
||
? $" Payment is due by {invoice.DueDate.Value:MMMM d, yyyy}."
|
||
: string.Empty;
|
||
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["invoiceNumber"] = invoice.InvoiceNumber,
|
||
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||
["invoiceDueDate"] = dueText
|
||
};
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
invoice.CompanyId, NotificationType.InvoiceSent, values,
|
||
$"Invoice {invoice.InvoiceNumber} from {companyName}");
|
||
|
||
// Append pay-now button if a payment link is available
|
||
if (!string.IsNullOrEmpty(paymentUrl))
|
||
{
|
||
htmlBody += $"""
|
||
<div style="margin:24px 0;text-align:center;">
|
||
<a href="{paymentUrl}" style="background:#198754;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
|
||
Pay Invoice Online
|
||
</a>
|
||
<p style="margin-top:8px;font-size:12px;color:#6c757d;">
|
||
This link expires in 5 days. You may also pay in person at our shop.
|
||
</p>
|
||
</div>
|
||
""";
|
||
}
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||
: StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
pdfAttachment, pdfFilename, "application/pdf",
|
||
replyToEmail, replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.InvoiceSent,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
InvoiceId = invoice.Id,
|
||
CompanyId = invoice.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyInvoiceSentAsync failed for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends a payment receipt to the customer when a manual (in-person/check) payment is recorded
|
||
/// by staff. This is distinct from <see cref="NotifyOnlinePaymentReceivedAsync"/> which handles
|
||
/// Stripe-originated payments and includes the Stripe paymentIntentId in the receipt.
|
||
/// </summary>
|
||
public async Task NotifyPaymentReceivedAsync(Invoice invoice, Payment payment)
|
||
{
|
||
try
|
||
{
|
||
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
|
||
if (customer == null) return;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var balanceText = invoice.BalanceDue > 0
|
||
? $" Remaining balance: {invoice.BalanceDue:C}."
|
||
: " Your account is paid in full.";
|
||
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["invoiceNumber"] = invoice.InvoiceNumber,
|
||
["paymentAmount"] = payment.Amount.ToString("C"),
|
||
["paymentDate"] = payment.PaymentDate.ToString("MMMM d, yyyy"),
|
||
["balanceDue"] = balanceText
|
||
};
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.PaymentReceived,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
InvoiceId = invoice.Id,
|
||
CompanyId = invoice.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived,
|
||
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyPaymentReceivedAsync failed for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends an overdue payment reminder. The <paramref name="daysOverdue"/> value is included
|
||
/// in the template values so the subject and body can express urgency appropriately
|
||
/// (e.g. "30 days overdue" vs "90 days overdue"). Called by the AR Aging report's
|
||
/// "Draft Follow-up Email" AI feature and by <see cref="SubscriptionExpiryBackgroundService"/>.
|
||
/// </summary>
|
||
public async Task NotifyPaymentReminderAsync(Invoice invoice, int daysOverdue)
|
||
{
|
||
try
|
||
{
|
||
var customer = invoice.Customer ?? await _context.Customers.FindAsync(invoice.CustomerId);
|
||
if (customer == null) return;
|
||
|
||
var (companyName, company) = await GetCompanyAsync(invoice.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
|
||
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
var dueDate = invoice.DueDate.HasValue
|
||
? invoice.DueDate.Value.ToString("MMMM d, yyyy")
|
||
: "N/A";
|
||
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["invoiceNumber"] = invoice.InvoiceNumber,
|
||
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||
["balanceDue"] = invoice.BalanceDue.ToString("C"),
|
||
["dueDate"] = dueDate,
|
||
["daysOverdue"] = daysOverdue.ToString()
|
||
};
|
||
|
||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||
|
||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||
var plainText = StripHtml(fullHtml);
|
||
|
||
var (success, error) = await _emailService.SendEmailAsync(
|
||
customer.Email, customerName, subject, plainText, fullHtml,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
|
||
await WriteLog(new NotificationLog
|
||
{
|
||
Channel = NotificationChannel.Email,
|
||
NotificationType = NotificationType.PaymentReminder,
|
||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||
RecipientName = customerName,
|
||
Recipient = customer.Email,
|
||
Subject = subject,
|
||
Message = plainText,
|
||
ErrorMessage = error,
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customer.Id,
|
||
InvoiceId = invoice.Id,
|
||
CompanyId = invoice.CompanyId
|
||
});
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(customer.Email))
|
||
{
|
||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder,
|
||
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "NotifyPaymentReminderAsync failed for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Notifies the COMPANY (not the customer) when a customer approves or declines a quote
|
||
/// via the online approval portal. The email goes to the company's EmailFromAddress so the
|
||
/// right staff member sees the response. This is an internal business alert, not a
|
||
/// customer-facing message. Also fires the in-app bell notification for immediate visibility.
|
||
/// </summary>
|
||
public async Task NotifyQuoteActedByCustomerAsync(Quote quote, bool approved, string? declineReason)
|
||
{
|
||
try
|
||
{
|
||
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
|
||
|
||
// Determine company notification email — use EmailFromAddress as recipient, falling back to configured SendGrid from email
|
||
var prefs = await _context.CompanyPreferences
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
|
||
|
||
var companyEmail = prefs?.EmailFromAddress
|
||
?? _configuration["SendGrid:FromEmail"]
|
||
?? string.Empty;
|
||
|
||
if (string.IsNullOrWhiteSpace(companyEmail))
|
||
{
|
||
_logger.LogWarning("No company email configured for QuoteActedByCustomer notification for quote {QuoteId}", quote.Id);
|
||
return;
|
||
}
|
||
|
||
// Determine customer/prospect display name
|
||
string customerName;
|
||
if (quote.Customer != null)
|
||
customerName = GetCustomerDisplayName(quote.Customer);
|
||
else if (quote.CustomerId.HasValue)
|
||
{
|
||
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
|
||
customerName = customer != null ? GetCustomerDisplayName(customer) : "Unknown Customer";
|
||
}
|
||
else
|
||
{
|
||
customerName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
|
||
? quote.ProspectContactName
|
||
: quote.ProspectCompanyName ?? "Prospect";
|
||
}
|
||
|
||
var responseText = approved ? "APPROVED" : "DECLINED";
|
||
var declineReasonSection = (!approved && !string.IsNullOrWhiteSpace(declineReason))
|
||
? $"<p><strong>Reason for declining:</strong><br/>{WebUtility.HtmlEncode(declineReason)}</p>"
|
||
: string.Empty;
|
||
|
||
var values = new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = customerName,
|
||
["quoteNumber"] = quote.QuoteNumber ?? string.Empty,
|
||
["response"] = responseText,
|
||
["declineReasonSection"] = declineReasonSection
|
||
};
|
||
|
||
var defaultSubject = approved
|
||
? $"Quote {quote.QuoteNumber} APPROVED — {customerName}"
|
||
: $"Quote {quote.QuoteNumber} DECLINED — {customerName}";
|
||
|
||
var 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(" ", " ")
|
||
.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<string, string> BuildQuoteSentValues(string companyName, string recipientName, Quote quote, string? baseUrl)
|
||
{
|
||
var expiry = quote.ExpirationDate.HasValue
|
||
? $" valid until {quote.ExpirationDate.Value:MMMM d, yyyy}"
|
||
: string.Empty;
|
||
|
||
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
|
||
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
|
||
: string.Empty;
|
||
|
||
return new Dictionary<string, string>
|
||
{
|
||
["companyName"] = companyName,
|
||
["customerName"] = recipientName,
|
||
["quoteNumber"] = quote.QuoteNumber ?? string.Empty,
|
||
["quoteTotal"] = quote.Total.ToString("C"),
|
||
["quoteExpiry"] = expiry,
|
||
["approvalUrl"] = approvalUrl
|
||
};
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Fallback default templates (used when company has no DB template)
|
||
// -----------------------------------------------------------------------
|
||
|
||
private static class DefaultTemplates
|
||
{
|
||
private static readonly Dictionary<(NotificationType, NotificationChannel), (string? Subject, string Body)> All
|
||
= new()
|
||
{
|
||
[(NotificationType.QuoteSent, NotificationChannel.Email)] = (
|
||
"Your Quote {{quoteNumber}} from {{companyName}}",
|
||
"<p>Dear {{customerName}},</p><p>Thank you for your interest. Quote <strong>{{quoteNumber}}</strong> for <strong>{{quoteTotal}}</strong>{{quoteExpiry}} has been prepared for your review.</p><p style=\"margin:24px 0;text-align:center;\"><a href=\"{{approvalUrl}}\" style=\"background:#4f46e5;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;display:inline-block;\">View & 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.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>"
|
||
),
|
||
};
|
||
|
||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||
=> All.TryGetValue((type, channel), out var t) ? t : null;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Email footer helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Appends CAN-SPAM required footer as HTML.
|
||
/// </summary>
|
||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
||
{
|
||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||
|
||
if (!hasUnsubscribeUrl && !hasAddress)
|
||
return htmlBody;
|
||
|
||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||
|
||
if (hasAddress)
|
||
{
|
||
var addressLine = BuildAddressLine(company!);
|
||
footer += $"{WebUtility.HtmlEncode(company!.CompanyName)} | {WebUtility.HtmlEncode(addressLine)}";
|
||
if (hasUnsubscribeUrl) footer += " | ";
|
||
}
|
||
|
||
if (hasUnsubscribeUrl)
|
||
{
|
||
var unsubscribeUrl = $"{baseUrl}/Unsubscribe/Email/{token}";
|
||
footer += $"<a href=\"{unsubscribeUrl}\" style=\"color: #888;\">Unsubscribe</a>";
|
||
}
|
||
|
||
footer += "</p>";
|
||
return htmlBody + footer;
|
||
}
|
||
|
||
private static string BuildAddressLine(Company company)
|
||
{
|
||
var parts = new List<string>();
|
||
if (!string.IsNullOrWhiteSpace(company.Address)) parts.Add(company.Address);
|
||
if (!string.IsNullOrWhiteSpace(company.City)) parts.Add(company.City);
|
||
if (!string.IsNullOrWhiteSpace(company.State)) parts.Add(company.State);
|
||
if (!string.IsNullOrWhiteSpace(company.ZipCode)) parts.Add(company.ZipCode);
|
||
return string.Join(", ", parts);
|
||
}
|
||
|
||
private async Task<string?> GetBaseUrlAsync()
|
||
{
|
||
var configured = await _platformSettings.GetAsync(PlatformSettingKeys.BaseUrl);
|
||
if (!string.IsNullOrWhiteSpace(configured))
|
||
return configured.TrimEnd('/');
|
||
|
||
var request = _httpContextAccessor.HttpContext?.Request;
|
||
if (request != null)
|
||
return $"{request.Scheme}://{request.Host}";
|
||
|
||
return null;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Shared helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Builds a NotificationLog row with Status = Skipped. Written when the customer has opted
|
||
/// out of a channel so the audit trail shows we attempted to notify but honoured their preference.
|
||
/// </summary>
|
||
private static NotificationLog SkippedLog(
|
||
NotificationChannel channel,
|
||
NotificationType type,
|
||
string name,
|
||
string recipient,
|
||
int companyId,
|
||
int? customerId = null,
|
||
int? jobId = null,
|
||
int? quoteId = null,
|
||
int? invoiceId = null)
|
||
{
|
||
return new NotificationLog
|
||
{
|
||
Channel = channel,
|
||
NotificationType = type,
|
||
Status = NotificationStatus.Skipped,
|
||
RecipientName = name,
|
||
Recipient = recipient,
|
||
Message = "Notification skipped — customer opted out.",
|
||
SentAt = DateTime.UtcNow,
|
||
CustomerId = customerId,
|
||
JobId = jobId,
|
||
QuoteId = quoteId,
|
||
InvoiceId = invoiceId,
|
||
CompanyId = companyId
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persists a NotificationLog to the database. Uses a dedicated try/catch so a logging
|
||
/// failure never bubbles up to the caller — a failed log write is bad, but not worth
|
||
/// crashing the operation that triggered the notification.
|
||
/// </summary>
|
||
private async Task WriteLog(NotificationLog log)
|
||
{
|
||
try
|
||
{
|
||
await _context.NotificationLogs.AddAsync(log);
|
||
await _context.SaveChangesAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to write notification log");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true only when all four gating tiers allow SMS for this company:
|
||
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
|
||
/// </summary>
|
||
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
|
||
{
|
||
var platformOn = string.Equals(
|
||
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
|
||
"true", StringComparison.OrdinalIgnoreCase);
|
||
if (!platformOn) return false;
|
||
|
||
if (company == null) return false;
|
||
if (company.SmsDisabledByAdmin) return false;
|
||
|
||
var planConfig = await _context.SubscriptionPlanConfigs
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||
if (planConfig?.AllowSms != true) return false;
|
||
|
||
return company.SmsEnabled;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Loads the company entity and its display name. Returns a safe fallback name
|
||
/// so templates never render blank company names even if the row is missing.
|
||
/// </summary>
|
||
private async Task<(string Name, Company? Entity)> GetCompanyAsync(int companyId)
|
||
{
|
||
var company = await _context.Companies.FindAsync(companyId);
|
||
return (company?.CompanyName ?? "Powder Coating", company);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the From email address and display name to use for outgoing emails.
|
||
/// Company preferences take priority; falls back to null (EmailService applies appsettings fallback).
|
||
/// </summary>
|
||
private async Task<(string? ReplyToEmail, string? ReplyToName)> GetEmailFromAsync(int companyId)
|
||
{
|
||
var prefs = await _context.CompanyPreferences
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||
|
||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends a payment receipt for a Stripe-originated online payment. Includes the Stripe
|
||
/// paymentIntentId as a reference number so the customer can match it to their card statement.
|
||
/// Also shows a processing fee line when a surcharge was applied (Stripe Connect pass-through).
|
||
/// Distinct from <see cref="NotifyPaymentReceivedAsync"/> which handles manually recorded payments.
|
||
/// </summary>
|
||
public async Task NotifyOnlinePaymentReceivedAsync(Invoice invoice, decimal amountPaid, decimal surchargePaid, string paymentIntentId)
|
||
{
|
||
try
|
||
{
|
||
var customer = invoice.Customer
|
||
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
||
if (customer == null || string.IsNullOrWhiteSpace(customer.Email)) return;
|
||
|
||
var customerName = GetCustomerDisplayName(customer);
|
||
var company = await _context.Companies.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
|
||
var companyName = company?.CompanyName ?? "Your Service Provider";
|
||
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
|
||
|
||
var subject = $"Payment Received — Invoice {invoice.InvoiceNumber}";
|
||
var surchargeLine = surchargePaid > 0 ? $"\n Processing Fee: {surchargePaid:C}" : string.Empty;
|
||
var balanceDue = Math.Max(0, invoice.BalanceDue);
|
||
|
||
var plain = $@"Hi {customerName},
|
||
|
||
We have received your payment. Thank you!
|
||
|
||
Invoice: {invoice.InvoiceNumber}
|
||
Amount Paid: {amountPaid:C}{surchargeLine}
|
||
Reference: {paymentIntentId}
|
||
Balance Due: {balanceDue:C}
|
||
|
||
{(balanceDue > 0 ? "A remaining balance is still outstanding on this invoice." : "This invoice is now fully paid. No further action is required.")}
|
||
|
||
Thank you for your business,
|
||
{companyName}";
|
||
|
||
var surchargeRow = surchargePaid > 0
|
||
? $"<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Processing Fee</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'>{surchargePaid:C}</td></tr>"
|
||
: string.Empty;
|
||
var balanceColor = balanceDue > 0 ? "#dc2626" : "#16a34a";
|
||
var balanceNote = balanceDue > 0
|
||
? "A remaining balance is still outstanding on this invoice."
|
||
: "This invoice is now <strong>fully paid</strong>. No further action is required.";
|
||
|
||
var html = $@"<div style='font-family:sans-serif;max-width:600px;margin:auto;'>
|
||
<h2 style='color:#4f46e5;'>Payment Received</h2>
|
||
<p>Hi {System.Net.WebUtility.HtmlEncode(customerName)},</p>
|
||
<p>We have received your payment. Thank you!</p>
|
||
<table style='width:100%;border-collapse:collapse;margin:16px 0;'>
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Invoice</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{invoice.InvoiceNumber}</strong></td></tr>
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Amount Paid</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{amountPaid:C}</strong></td></tr>
|
||
{surchargeRow}
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Reference</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;'>{paymentIntentId}</td></tr>
|
||
<tr><td style='padding:8px;color:#6b7280;'>Balance Due</td><td style='padding:8px;'><strong style='color:{balanceColor};'>{balanceDue:C}</strong></td></tr>
|
||
</table>
|
||
<p>{balanceNote}</p>
|
||
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
|
||
</div>";
|
||
|
||
await _emailService.SendEmailAsync(customer.Email, customerName, subject, plain, html,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to send online payment receipt for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
public async Task NotifyDepositReceivedAsync(Quote quote, decimal amountPaid, decimal surchargePaid, string paymentIntentId)
|
||
{
|
||
try
|
||
{
|
||
// Determine recipient — linked customer or prospect contact
|
||
string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail;
|
||
if (string.IsNullOrWhiteSpace(toEmail)) return;
|
||
|
||
string customerName;
|
||
if (quote.Customer != null)
|
||
customerName = GetCustomerDisplayName(quote.Customer);
|
||
else if (!string.IsNullOrWhiteSpace(quote.ProspectContactName))
|
||
customerName = quote.ProspectContactName;
|
||
else
|
||
customerName = "Valued Customer";
|
||
|
||
var company = await _context.Companies.AsNoTracking()
|
||
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
|
||
var companyName = company?.CompanyName ?? "Your Service Provider";
|
||
|
||
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
|
||
|
||
var depositRequired = Math.Round(quote.Total * (quote.DepositPercent / 100m), 2);
|
||
var remaining = Math.Max(0, depositRequired - quote.DepositAmountPaid);
|
||
var surchargeLine = surchargePaid > 0 ? $"\n Processing Fee: {surchargePaid:C}" : string.Empty;
|
||
|
||
var subject = $"Deposit Received — Quote {quote.QuoteNumber}";
|
||
|
||
var plain = $@"Hi {customerName},
|
||
|
||
We have received your deposit. Thank you!
|
||
|
||
Quote: {quote.QuoteNumber}
|
||
Deposit Paid: {amountPaid:C}{surchargeLine}
|
||
Reference: {paymentIntentId}
|
||
Deposit Balance Remaining: {remaining:C}
|
||
|
||
{(remaining > 0 ? "A remaining deposit balance is still outstanding." : "Your deposit is fully paid. We will be in touch to schedule your job.")}
|
||
|
||
Thank you for your business,
|
||
{companyName}";
|
||
|
||
var surchargeRow = surchargePaid > 0
|
||
? $"<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Processing Fee</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'>{surchargePaid:C}</td></tr>"
|
||
: string.Empty;
|
||
var remainingColor = remaining > 0 ? "#dc2626" : "#16a34a";
|
||
var remainingNote = remaining > 0
|
||
? "A remaining deposit balance is still outstanding."
|
||
: "Your deposit is <strong>fully paid</strong>. We will be in touch to schedule your job.";
|
||
|
||
var html = $@"<div style='font-family:sans-serif;max-width:600px;margin:auto;'>
|
||
<h2 style='color:#4f46e5;'>Deposit Received</h2>
|
||
<p>Hi {System.Net.WebUtility.HtmlEncode(customerName)},</p>
|
||
<p>We have received your deposit. Thank you!</p>
|
||
<table style='width:100%;border-collapse:collapse;margin:16px 0;'>
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Quote</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{quote.QuoteNumber}</strong></td></tr>
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Deposit Paid</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;'><strong>{amountPaid:C}</strong></td></tr>
|
||
{surchargeRow}
|
||
<tr><td style='padding:8px;border-bottom:1px solid #e5e7eb;color:#6b7280;'>Reference</td><td style='padding:8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;'>{paymentIntentId}</td></tr>
|
||
<tr><td style='padding:8px;color:#6b7280;'>Deposit Remaining</td><td style='padding:8px;'><strong style='color:{remainingColor};'>{remaining:C}</strong></td></tr>
|
||
</table>
|
||
<p>{remainingNote}</p>
|
||
<p style='color:#6b7280;font-size:14px;'>Thank you for your business,<br/><strong>{System.Net.WebUtility.HtmlEncode(companyName)}</strong></p>
|
||
</div>";
|
||
|
||
await _emailService.SendEmailAsync(toEmail, customerName, subject, plain, html,
|
||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to send deposit receipt for quote {QuoteId}", quote.Id);
|
||
}
|
||
}
|
||
|
||
public async Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason)
|
||
{
|
||
try
|
||
{
|
||
var prefs = await _context.CompanyPreferences.AsNoTracking()
|
||
.FirstOrDefaultAsync(p => p.CompanyId == invoice.CompanyId && !p.IsDeleted);
|
||
if (prefs == null || string.IsNullOrWhiteSpace(prefs.EmailFromAddress)) return;
|
||
|
||
var (replyToEmail, _) = await GetEmailFromAsync(invoice.CompanyId);
|
||
var subject = $"CHARGEBACK ALERT — Invoice {invoice.InvoiceNumber} disputed";
|
||
|
||
var plain = $@"A chargeback has been opened on invoice {invoice.InvoiceNumber}.
|
||
|
||
Dispute ID: {disputeId}
|
||
Amount: {amount:C}
|
||
Reason: {reason}
|
||
Invoice #: {invoice.InvoiceNumber}
|
||
|
||
Log in to your Stripe Dashboard to respond to this dispute. You typically have 7–21 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 7–21 days to respond before it is automatically decided against you.</p>
|
||
</div>";
|
||
|
||
await _emailService.SendEmailAsync(prefs.EmailFromAddress, "Shop Admin", subject, plain, html,
|
||
replyToEmail: replyToEmail, replyToName: null);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to send chargeback alert for invoice {InvoiceId}", invoice.Id);
|
||
}
|
||
}
|
||
|
||
private static string GetCustomerDisplayName(Customer customer)
|
||
{
|
||
if (!customer.IsCommercial)
|
||
{
|
||
var contact = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||
if (!string.IsNullOrEmpty(contact)) return contact;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(customer.CompanyName))
|
||
return customer.CompanyName;
|
||
|
||
return $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() is { Length: > 0 } n
|
||
? n
|
||
: "Valued Customer";
|
||
}
|
||
}
|