Add BillingEmail field for commercial customers; support comma-separated multi-email

- Customer entity + DTO: new BillingEmail field (accounting/invoicing address)
- Email fields now accept comma-separated lists; DTO validates each address individually
- NotificationService: SendToEmailListAsync helper fans out to all addresses in a list;
  NotifyQuoteSentAsync accepts optional overrideEmail so staff can send to an ad-hoc address
- Migration: AddCustomerBillingEmail
- Customer Create/Edit/Details views updated to show Billing Email field
- customer-billing-email.js: client-side helpers for billing email input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:46:53 -04:00
parent 12f784f34c
commit fb979bc88d
10 changed files with 9812 additions and 56 deletions
@@ -70,7 +70,7 @@ public class NotificationService : INotificationService
/// - 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)
public async Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null)
{
try
{
@@ -80,7 +80,8 @@ public class NotificationService : INotificationService
// Prospect quote (no customer record yet)
if (quote.CustomerId == null)
{
if (string.IsNullOrWhiteSpace(quote.ProspectEmail))
var prospectEmail = !string.IsNullOrWhiteSpace(overrideEmail) ? overrideEmail : quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(prospectEmail))
return;
var prospectName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
@@ -97,7 +98,7 @@ public class NotificationService : INotificationService
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
quote.ProspectEmail, prospectName, subject, plainText, fullHtml,
prospectEmail, prospectName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -107,7 +108,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = prospectName,
Recipient = quote.ProspectEmail,
Recipient = prospectEmail,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -124,7 +125,11 @@ public class NotificationService : INotificationService
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
// 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);
@@ -135,8 +140,8 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
var (success, error, recipientsLog) = await SendToEmailListAsync(
emailToUse, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -146,7 +151,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -156,10 +161,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (quoteEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteSent,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
customerName, string.Join(", ", quoteEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
@@ -195,7 +200,9 @@ public class NotificationService : INotificationService
if (quote.CustomerId == null)
{
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
// 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.");
@@ -279,7 +286,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(quote.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var approvedEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && approvedEmails.Count > 0)
{
var values = new Dictionary<string, string>
{
@@ -295,7 +303,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -305,7 +313,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.QuoteApproved,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -315,10 +323,10 @@ public class NotificationService : INotificationService
CompanyId = quote.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (approvedEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.QuoteApproved,
customerName, customer.Email, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
customerName, string.Join(", ", approvedEmails), quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
}
}
catch (Exception ex)
@@ -349,7 +357,8 @@ public class NotificationService : INotificationService
: NotificationType.JobStatusChanged;
// Email for all status changes
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
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.
@@ -377,7 +386,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -387,7 +396,7 @@ public class NotificationService : INotificationService
NotificationType = notifType,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -397,10 +406,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (statusEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobStatusChanged,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
customerName, string.Join(", ", statusEmails), job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
@@ -427,7 +436,8 @@ public class NotificationService : INotificationService
var customerName = GetCustomerDisplayName(customer);
// Email
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var completedEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && completedEmails.Count > 0)
{
var values = new Dictionary<string, string>
{
@@ -444,7 +454,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -454,7 +464,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -464,10 +474,10 @@ public class NotificationService : INotificationService
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (completedEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.JobCompleted,
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
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
@@ -611,7 +621,7 @@ public class NotificationService : INotificationService
/// (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)
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
{
try
{
@@ -622,7 +632,15 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
// 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}."
@@ -661,8 +679,8 @@ public class NotificationService : INotificationService
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
customer.Email, customerName, subject, plainText, fullHtml,
var (success, error, recipientsLog) = await SendToEmailListAsync(
invoiceEmail, customerName, subject, plainText, fullHtml,
pdfAttachment, pdfFilename, "application/pdf",
replyToEmail, replyToName);
@@ -672,7 +690,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.InvoiceSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -682,10 +700,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (invoiceEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -710,7 +728,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var paymentEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && paymentEmails.Count > 0)
{
var balanceText = invoice.BalanceDue > 0
? $" Remaining balance: {invoice.BalanceDue:C}."
@@ -733,7 +752,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -743,7 +762,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReceived,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -753,10 +772,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (paymentEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReceived,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", paymentEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -782,7 +801,8 @@ public class NotificationService : INotificationService
var (replyToEmail, replyToName) = await GetEmailFromAsync(invoice.CompanyId);
var customerName = GetCustomerDisplayName(customer);
if (customer.NotifyByEmail && !string.IsNullOrWhiteSpace(customer.Email))
var reminderEmails = ParseEmailList(customer.Email);
if (customer.NotifyByEmail && reminderEmails.Count > 0)
{
var dueDate = invoice.DueDate.HasValue
? invoice.DueDate.Value.ToString("MMMM d, yyyy")
@@ -806,7 +826,7 @@ public class NotificationService : INotificationService
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
var (success, error, recipientsLog) = await SendToEmailListAsync(
customer.Email, customerName, subject, plainText, fullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
@@ -816,7 +836,7 @@ public class NotificationService : INotificationService
NotificationType = NotificationType.PaymentReminder,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = customer.Email,
Recipient = recipientsLog,
Subject = subject,
Message = plainText,
ErrorMessage = error,
@@ -826,10 +846,10 @@ public class NotificationService : INotificationService
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.Email))
else if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.PaymentReminder,
customerName, customer.Email, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
customerName, string.Join(", ", reminderEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
catch (Exception ex)
@@ -1324,7 +1344,7 @@ public class NotificationService : INotificationService
{
var customer = invoice.Customer
?? await _context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
if (customer == null || string.IsNullOrWhiteSpace(customer.Email)) return;
if (customer == null || ParseEmailList(customer.Email).Count == 0) return;
var customerName = GetCustomerDisplayName(customer);
var company = await _context.Companies.AsNoTracking()
@@ -1374,7 +1394,7 @@ Thank you for your business,
<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,
await SendToEmailListAsync(customer.Email, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
@@ -1389,7 +1409,7 @@ Thank you for your business,
{
// Determine recipient — linked customer or prospect contact
string? toEmail = quote.Customer?.Email ?? quote.ProspectEmail;
if (string.IsNullOrWhiteSpace(toEmail)) return;
if (ParseEmailList(toEmail).Count == 0) return;
string customerName;
if (quote.Customer != null)
@@ -1448,7 +1468,7 @@ Thank you for your business,
<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,
await SendToEmailListAsync(toEmail, customerName, subject, plain, html,
replyToEmail: replyToEmail, replyToName: replyToName);
}
catch (Exception ex)
@@ -1500,6 +1520,52 @@ Log in to your Stripe Dashboard to respond to this dispute. You typically have 7
}
}
/// <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)