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,105 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the recipient resolver that routes invoice / Storno /
|
||||||
|
* reminder emails to a bookkeeper address when one is configured,
|
||||||
|
* while keeping the decision-maker (primary email) on CC.
|
||||||
|
*
|
||||||
|
* Pure helper, no DB, no side effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
|
||||||
|
|
||||||
|
describe('resolveBillingRecipients', () => {
|
||||||
|
it('routes to the primary email when no billing_email is set', () => {
|
||||||
|
expect(resolveBillingRecipients({ email: '[email protected]' }, null))
|
||||||
|
.toEqual({ to: '[email protected]', cc: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to billing_email and CCs the primary when both are set', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
billing_email: '[email protected]',
|
||||||
|
}, null)).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: ['[email protected]'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds the per-document cc_pdf_email into the CC list', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
billing_email: '[email protected]',
|
||||||
|
}, '[email protected]')).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: ['[email protected]', '[email protected]'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cc_pdf_email alone when there is no billing_email', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
}, '[email protected]')).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: ['[email protected]'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not CC the primary onto itself when billing_email equals email', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
billing_email: '[email protected]',
|
||||||
|
}, null)).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive when deduping addresses', () => {
|
||||||
|
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
|
||||||
|
// practice every mail server treats them as insensitive — and the
|
||||||
|
// admin entering "[email protected]" in one field and
|
||||||
|
// "[email protected]" in another should not produce two copies.
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
billing_email: '[email protected]',
|
||||||
|
}, '[email protected]')).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: ['[email protected]'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace around the addresses', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: ' [email protected] ',
|
||||||
|
billing_email: ' [email protected]\n',
|
||||||
|
}, '\[email protected] ')).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: ['[email protected]', '[email protected]'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty-string billing_email as not set', () => {
|
||||||
|
expect(resolveBillingRecipients({
|
||||||
|
email: '[email protected]',
|
||||||
|
billing_email: '',
|
||||||
|
}, null)).toEqual({
|
||||||
|
to: '[email protected]',
|
||||||
|
cc: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty To when neither email nor billing_email is set', () => {
|
||||||
|
// Caller is responsible for surfacing this — emailProcessor's own
|
||||||
|
// validation will reject the empty recipient. The helper just
|
||||||
|
// refuses to crash.
|
||||||
|
expect(resolveBillingRecipients({}, null))
|
||||||
|
.toEqual({ to: '', cc: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a null customer without throwing', () => {
|
||||||
|
// Per-doc cc alone is never promoted to To: — it stays
|
||||||
|
// supplemental. A missing customer is a caller bug; we just refuse
|
||||||
|
// to crash and let emailProcessor reject the empty recipient.
|
||||||
|
expect(resolveBillingRecipients(null, '[email protected]'))
|
||||||
|
.toEqual({ to: '', cc: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { formatShortDate } = require('../utils/dateFormatter');
|
||||||
const businessProfileService = require('./businessProfileService');
|
const businessProfileService = require('./businessProfileService');
|
||||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||||
|
const { resolveBillingRecipients } = require('./_billingRecipients');
|
||||||
const pdfService = require('./pdfService');
|
const pdfService = require('./pdfService');
|
||||||
const emailProcessor = require('./emailProcessor');
|
const emailProcessor = require('./emailProcessor');
|
||||||
// Migration 119 line-item hierarchy helpers, shared with quoteService.
|
// 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(),
|
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,
|
invoice_number: invoice.invoice_number,
|
||||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||||
event_name: invoice.event_name || '',
|
event_name: invoice.event_name || '',
|
||||||
@@ -1889,7 +1891,7 @@ async function sendInvoice(id, adminId) {
|
|||||||
installment_label: invoice.installment_label || '',
|
installment_label: invoice.installment_label || '',
|
||||||
installment_index: invoice.installment_index + 1,
|
installment_index: invoice.installment_index + 1,
|
||||||
installment_total: invoice.installment_total,
|
installment_total: invoice.installment_total,
|
||||||
cc: invoice.cc_pdf_email || undefined,
|
cc: invoiceCc,
|
||||||
attachments: [{
|
attachments: [{
|
||||||
filename: `${invoice.invoice_number}.pdf`,
|
filename: `${invoice.invoice_number}.pdf`,
|
||||||
contentPath: pdfPath,
|
contentPath: pdfPath,
|
||||||
@@ -2200,13 +2202,14 @@ async function sendStorno(stornoId, adminId) {
|
|||||||
.select('invoice_number', 'issue_date').first()
|
.select('invoice_number', 'issue_date').first()
|
||||||
: null;
|
: 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,
|
storno_number: storno.invoice_number,
|
||||||
original_invoice_number: originalRow?.invoice_number || '',
|
original_invoice_number: originalRow?.invoice_number || '',
|
||||||
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
||||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
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),
|
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
|
||||||
cc: storno.cc_pdf_email || undefined,
|
cc: stornoCc,
|
||||||
attachments: [{
|
attachments: [{
|
||||||
filename: `${storno.invoice_number}.pdf`,
|
filename: `${storno.invoice_number}.pdf`,
|
||||||
contentPath: pdfPath,
|
contentPath: pdfPath,
|
||||||
@@ -2511,7 +2514,8 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
|||||||
+ Number(lateFeeMinor || 0)
|
+ Number(lateFeeMinor || 0)
|
||||||
- Number(invoice.paid_amount_minor || 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,
|
invoice_number: invoice.invoice_number,
|
||||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
|
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).
|
// (matches the quote_sent + invoice_sent templates).
|
||||||
due_date: formatShortDate(invoice.due_date),
|
due_date: formatShortDate(invoice.due_date),
|
||||||
days_overdue: daysOverdue,
|
days_overdue: daysOverdue,
|
||||||
cc: invoice.cc_pdf_email || undefined,
|
cc: reminderCc,
|
||||||
attachments: [{
|
attachments: [{
|
||||||
filename: `${invoice.invoice_number}.pdf`,
|
filename: `${invoice.invoice_number}.pdf`,
|
||||||
contentPath: pdfPath,
|
contentPath: pdfPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user