diff --git a/backend/__tests__/services/billingRecipients.test.js b/backend/__tests__/services/billingRecipients.test.js new file mode 100644 index 00000000..700d28e9 --- /dev/null +++ b/backend/__tests__/services/billingRecipients.test.js @@ -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: 'bride@example.com' }, null)) + .toEqual({ to: 'bride@example.com', cc: undefined }); + }); + + it('routes to billing_email and CCs the primary when both are set', () => { + expect(resolveBillingRecipients({ + email: 'bride@example.com', + billing_email: 'books@example.com', + }, null)).toEqual({ + to: 'books@example.com', + cc: ['bride@example.com'], + }); + }); + + it('folds the per-document cc_pdf_email into the CC list', () => { + expect(resolveBillingRecipients({ + email: 'bride@example.com', + billing_email: 'books@example.com', + }, 'advisor@example.com')).toEqual({ + to: 'books@example.com', + cc: ['bride@example.com', 'advisor@example.com'], + }); + }); + + it('uses cc_pdf_email alone when there is no billing_email', () => { + expect(resolveBillingRecipients({ + email: 'bride@example.com', + }, 'advisor@example.com')).toEqual({ + to: 'bride@example.com', + cc: ['advisor@example.com'], + }); + }); + + it('does not CC the primary onto itself when billing_email equals email', () => { + expect(resolveBillingRecipients({ + email: 'same@example.com', + billing_email: 'same@example.com', + }, null)).toEqual({ + to: 'same@example.com', + 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 "BRIDE@example.com" in one field and + // "bride@example.com" in another should not produce two copies. + expect(resolveBillingRecipients({ + email: 'BRIDE@example.com', + billing_email: 'books@example.com', + }, 'bride@example.com')).toEqual({ + to: 'books@example.com', + cc: ['BRIDE@example.com'], + }); + }); + + it('trims whitespace around the addresses', () => { + expect(resolveBillingRecipients({ + email: ' bride@example.com ', + billing_email: ' books@example.com\n', + }, '\tadvisor@example.com ')).toEqual({ + to: 'books@example.com', + cc: ['bride@example.com', 'advisor@example.com'], + }); + }); + + it('treats empty-string billing_email as not set', () => { + expect(resolveBillingRecipients({ + email: 'bride@example.com', + billing_email: '', + }, null)).toEqual({ + to: 'bride@example.com', + 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, 'a@b.com')) + .toEqual({ to: '', cc: undefined }); + }); +}); diff --git a/backend/src/services/_billingRecipients.js b/backend/src/services/_billingRecipients.js new file mode 100644 index 00000000..27a03c6a --- /dev/null +++ b/backend/src/services/_billingRecipients.js @@ -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 }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index e0b5879b..c50fe62f 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -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,