feat(crm): route billing docs to billing_email when set
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.
Routing matrix:
- invoice / Storno / payment reminder
To: billing_email (fallback email when unset)
CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
- quote / contract / event reminder / gallery share
To: email (unchanged — decision-maker address)
- payment-check / paid-notification
To: admin contact (unchanged — internal flow)
A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Recipient resolution for billing-class outbound emails (invoice,
|
||||
* Storno, payment reminders).
|
||||
*
|
||||
* Customer accounts carry two email fields:
|
||||
* - `email` — primary contact, used for account auth,
|
||||
* gallery sharing, quote / contract sends, and
|
||||
* event reminders. The decision-maker address.
|
||||
* - `billing_email` — optional bookkeeper / accounts-payable
|
||||
* address. When set, billing documents go To:
|
||||
* here and the primary `email` is CC'd so the
|
||||
* decision-maker stays in the loop.
|
||||
*
|
||||
* Non-billing flows (quote, contract, gallery, event reminder, gallery
|
||||
* share) must NOT use this helper — they always route to the primary
|
||||
* `email` regardless of whether a billing_email is configured.
|
||||
*
|
||||
* The per-document `cc_pdf_email` field on invoices / Storno / quotes
|
||||
* is an additional CC the admin can set per save; this helper folds it
|
||||
* in alongside the billing/primary split and dedupes against the To:
|
||||
* address so the same address never appears twice on one envelope.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} customer a `customer_accounts` row; only
|
||||
* `email` + `billing_email` are read.
|
||||
* @param {string|null|undefined} perDocCcEmail the `cc_pdf_email`
|
||||
* column from the document being sent;
|
||||
* may be null/empty (no per-doc CC).
|
||||
* @returns {{ to: string, cc: string[] | undefined }}
|
||||
* `to` — single address (billing_email when set, else email).
|
||||
* `cc` — array of additional addresses, or undefined when there
|
||||
* are none. Always deduplicated against `to` and against
|
||||
* itself (case-insensitive).
|
||||
*/
|
||||
function resolveBillingRecipients(customer, perDocCcEmail) {
|
||||
const billing = String(customer?.billing_email || '').trim();
|
||||
const main = String(customer?.email || '').trim();
|
||||
const perDoc = String(perDocCcEmail || '').trim();
|
||||
|
||||
const to = billing || main;
|
||||
if (!to) {
|
||||
// No usable address at all. Caller will hit emailProcessor's own
|
||||
// validation; we just return a safe shape so callers don't crash.
|
||||
return { to: '', cc: undefined };
|
||||
}
|
||||
|
||||
const toKey = to.toLowerCase();
|
||||
const ccKeys = new Set();
|
||||
const ccList = [];
|
||||
const pushCc = (addr) => {
|
||||
if (!addr) return;
|
||||
const key = addr.toLowerCase();
|
||||
if (key === toKey || ccKeys.has(key)) return;
|
||||
ccKeys.add(key);
|
||||
ccList.push(addr);
|
||||
};
|
||||
|
||||
// Only CC the main email when billing_email actually took the To
|
||||
// slot — when billing is empty, `to` already IS the main email and
|
||||
// we don't want to CC self.
|
||||
if (billing && main) pushCc(main);
|
||||
if (perDoc) pushCc(perDoc);
|
||||
|
||||
return { to, cc: ccList.length > 0 ? ccList : undefined };
|
||||
}
|
||||
|
||||
module.exports = { resolveBillingRecipients };
|
||||
@@ -29,6 +29,7 @@ const { claimNextSequence } = require('../utils/documentSequences');
|
||||
const { formatShortDate } = require('../utils/dateFormatter');
|
||||
const businessProfileService = require('./businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
const { resolveBillingRecipients } = require('./_billingRecipients');
|
||||
const pdfService = require('./pdfService');
|
||||
const emailProcessor = require('./emailProcessor');
|
||||
// Migration 119 line-item hierarchy helpers, shared with quoteService.
|
||||
@@ -1880,7 +1881,8 @@ async function sendInvoice(id, adminId) {
|
||||
status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(),
|
||||
});
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, customer.email, 'invoice_sent', {
|
||||
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
event_name: invoice.event_name || '',
|
||||
@@ -1889,7 +1891,7 @@ async function sendInvoice(id, adminId) {
|
||||
installment_label: invoice.installment_label || '',
|
||||
installment_index: invoice.installment_index + 1,
|
||||
installment_total: invoice.installment_total,
|
||||
cc: invoice.cc_pdf_email || undefined,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
@@ -2200,13 +2202,14 @@ async function sendStorno(stornoId, adminId) {
|
||||
.select('invoice_number', 'issue_date').first()
|
||||
: null;
|
||||
|
||||
await emailProcessor.queueEmail(storno.event_id || null, customer.email, 'storno_issued', {
|
||||
const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email);
|
||||
await emailProcessor.queueEmail(storno.event_id || null, stornoTo, 'storno_issued', {
|
||||
storno_number: storno.invoice_number,
|
||||
original_invoice_number: originalRow?.invoice_number || '',
|
||||
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
|
||||
cc: storno.cc_pdf_email || undefined,
|
||||
cc: stornoCc,
|
||||
attachments: [{
|
||||
filename: `${storno.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
@@ -2511,7 +2514,8 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
+ Number(lateFeeMinor || 0)
|
||||
- Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, customer.email, templateKey, {
|
||||
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
|
||||
@@ -2523,7 +2527,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
// (matches the quote_sent + invoice_sent templates).
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
days_overdue: daysOverdue,
|
||||
cc: invoice.cc_pdf_email || undefined,
|
||||
cc: reminderCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
contentPath: pdfPath,
|
||||
|
||||
Reference in New Issue
Block a user