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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user