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:
@@ -9,6 +9,7 @@ public class CustomerDto
|
||||
public string? ContactFirstName { get; set; }
|
||||
public string? ContactLastName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? BillingEmail { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
@@ -52,10 +53,13 @@ public class CreateCustomerDto : IValidatableObject
|
||||
public string? ContactLastName { get; set; }
|
||||
|
||||
[Display(Name = "Email")]
|
||||
[EmailAddress(ErrorMessage = "Please enter a valid email address")]
|
||||
[StringLength(200)]
|
||||
[StringLength(1000)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Display(Name = "Billing / Accounting Email")]
|
||||
[StringLength(1000)]
|
||||
public string? BillingEmail { get; set; }
|
||||
|
||||
[Display(Name = "Phone")]
|
||||
[Phone(ErrorMessage = "Please enter a valid phone number")]
|
||||
[StringLength(20)]
|
||||
@@ -143,6 +147,33 @@ public class CreateCustomerDto : IValidatableObject
|
||||
"Please provide at least one contact method (Email or Phone)",
|
||||
new[] { nameof(Email), nameof(Phone) });
|
||||
}
|
||||
|
||||
// Validate each address in comma-separated email fields
|
||||
foreach (var addr in SplitEmails(Email))
|
||||
{
|
||||
if (!IsValidEmail(addr))
|
||||
yield return new ValidationResult(
|
||||
$"'{addr}' is not a valid email address.",
|
||||
new[] { nameof(Email) });
|
||||
}
|
||||
foreach (var addr in SplitEmails(BillingEmail))
|
||||
{
|
||||
if (!IsValidEmail(addr))
|
||||
yield return new ValidationResult(
|
||||
$"'{addr}' is not a valid email address.",
|
||||
new[] { nameof(BillingEmail) });
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitEmails(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? []
|
||||
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try { _ = new System.Net.Mail.MailAddress(email); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface INotificationService
|
||||
/// Notify when a quote is created/sent. Handles both registered customers and prospects.
|
||||
/// Optionally attaches the quote PDF to the email.
|
||||
/// </summary>
|
||||
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
|
||||
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the quote approval link to the customer via SMS.
|
||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
||||
/// Notify customer when an invoice has been sent.
|
||||
/// Optionally includes an online payment link in the email body.
|
||||
/// </summary>
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null);
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||
|
||||
@@ -6,6 +6,7 @@ public class Customer : BaseEntity
|
||||
public string? ContactFirstName { get; set; }
|
||||
public string? ContactLastName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? BillingEmail { get; set; } // Accounting/invoicing email for commercial customers
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
|
||||
Generated
+9531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerBillingEmail : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BillingEmail",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BillingEmail",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Email" class="form-label">Email <span class="text-danger">*</span> <span class="text-muted fw-normal">(required if no phone number)</span></label>
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
|
||||
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -87,6 +87,14 @@
|
||||
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
|
||||
<span asp-validation-for="MobilePhone" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6" id="billingEmailRow" style="display:none;">
|
||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||
</label>
|
||||
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -372,4 +380,5 @@
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/js/customer-billing-email.js"></script>
|
||||
}
|
||||
|
||||
@@ -97,11 +97,37 @@
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Email</label>
|
||||
<p class="mb-0">
|
||||
<a href="mailto:@Model.Email" class="text-decoration-none">
|
||||
<i class="bi bi-envelope me-1"></i>@Model.Email
|
||||
</a>
|
||||
@if (!string.IsNullOrEmpty(Model.Email))
|
||||
{
|
||||
<a href="mailto:@Model.Email" class="text-decoration-none">
|
||||
<i class="bi bi-envelope me-1"></i>@Model.Email
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not provided</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@if (Model.IsCommercial)
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Billing / Accounting Email</label>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.BillingEmail))
|
||||
{
|
||||
<a href="mailto:@Model.BillingEmail" class="text-decoration-none">
|
||||
<i class="bi bi-envelope-at me-1"></i>@Model.BillingEmail
|
||||
</a>
|
||||
<span class="badge bg-info bg-opacity-10 text-info ms-2 small">Invoices</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Phone</label>
|
||||
<p class="mb-0">
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Email" class="form-label">Email</label>
|
||||
<input asp-for="Email" type="email" class="form-control" placeholder="name@example.com" />
|
||||
<input asp-for="Email" type="email" multiple class="form-control" placeholder="name@example.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="Email" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -91,6 +91,14 @@
|
||||
<input asp-for="MobilePhone" type="tel" class="form-control" placeholder="(555) 123-4567" />
|
||||
<span asp-validation-for="MobilePhone" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6" id="billingEmailRow" style="display:none;">
|
||||
<label asp-for="BillingEmail" class="form-label">Billing / Accounting Email
|
||||
<span class="text-muted fw-normal">(invoices sent here)</span>
|
||||
</label>
|
||||
<input asp-for="BillingEmail" type="email" multiple class="form-control" placeholder="accounting@company.com (comma-separate multiple)" />
|
||||
<span asp-validation-for="BillingEmail" class="text-danger"></span>
|
||||
<div class="form-text">When set, invoices are emailed here instead of the contact email.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,4 +444,5 @@
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/js/customer-billing-email.js"></script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
(function () {
|
||||
var select = document.getElementById('IsCommercial');
|
||||
var row = document.getElementById('billingEmailRow');
|
||||
if (!select || !row) return;
|
||||
|
||||
function toggle() {
|
||||
row.style.display = select.value === 'true' ? '' : 'none';
|
||||
}
|
||||
|
||||
toggle();
|
||||
select.addEventListener('change', toggle);
|
||||
})();
|
||||
Reference in New Issue
Block a user