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:
Luca
2026-05-27 14:04:15 +02:00
parent 3d37324080
commit 09c5110d2b
3 changed files with 183 additions and 6 deletions
@@ -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 };
+10 -6
View File
@@ -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,