diff --git a/directus-cms/.env.example b/directus-cms/.env.example index 86328cd7..64772bd3 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -118,6 +118,26 @@ EMAIL_SENDMAIL_NEW_LINE="unix" # EMAIL_SMTP_USER="noreply@programmier.bar" # EMAIL_SMTP_PASSWORD="" +# Accounting mailbox: every invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) +# is mailed here as a PDF attachment, one per mail, for timely recording in the books. Leave empty +# to skip the accounting copy (e.g. locally). See docs/tickets.md. +ACCOUNTING_INVOICE_EMAIL="" + +# Dedicated SMTP transport for that copy, NOT the EMAIL_* transport above: the accounting mailbox +# only accepts mail whose envelope sender (MAIL FROM) is its registered sender address, and the +# transactional mail provider always uses its own return path. ACCOUNTING_MAIL_FROM must be that +# registered address (sender and envelope sender). ACCOUNTING_SMTP_SECURE works like +# EMAIL_SMTP_SECURE: "true" for port 465 (implicit TLS), "false" for 587 (STARTTLS); unset, it +# follows the port. Google Workspace: host smtp.gmail.com, port 465, secure true. Use an app +# password and never commit it. If ACCOUNTING_INVOICE_EMAIL is set but any of these is missing, no +# copy is sent and Slack is alerted. +ACCOUNTING_SMTP_HOST="" +ACCOUNTING_SMTP_PORT="" +ACCOUNTING_SMTP_SECURE="" +ACCOUNTING_SMTP_USER="" +ACCOUNTING_SMTP_PASSWORD="" +ACCOUNTING_MAIL_FROM="" + # Password Brevo's transactional webhook sends via Basic auth (POST /brevo-webhook, see # docs/newsletter.md). Without it the endpoint refuses every event (503). `openssl rand -hex 32` BREVO_WEBHOOK_SECRET="" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js index 3e4ef028..09f7af92 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/extension.config.js @@ -4,7 +4,8 @@ * Some dependencies (pdfkit, passkit-generator) use Node.js globals like * `__dirname` or native modules that don't work when bundled into ESM. * mjml is CommonJS with a large dependency tree (juice, cssnano, html-minifier) - * that is not meant to be bundled either. + * that is not meant to be bundled either; the same goes for nodemailer (CommonJS, used for + * the accounting-mail SMTP transport). * By marking them as external, they load from node_modules at runtime. */ export default { @@ -12,7 +13,7 @@ export default { { name: 'externalize-native-modules', resolveId(id) { - if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml') { + if (id === 'pdfkit' || id === 'passkit-generator' || id === 'mjml' || id === 'nodemailer') { return { id, external: true } } return null diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json index f2b96f3b..e1b8bb6f 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "mjml": "^5.4.1", + "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" @@ -12576,6 +12577,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json index 1b3a9390..dad3d0a9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json @@ -221,6 +221,7 @@ }, "dependencies": { "mjml": "^5.4.1", + "nodemailer": "^8.0.5", "passkit-generator": "^3.5.0", "pdfkit": "^0.16.0", "qrcode": "^1.5.4" diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index f13d8779..0c50e44c 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' -import { sendTemplatedEmail } from '../../shared/email-service.ts' +import { resetAccountingMailState } from '../../shared/accounting-mail.ts' +import { deliverTemplatedEmail, sendRawEmail, sendTemplatedEmail } from '../../shared/email-service.ts' +import { postSlackMessage } from '../../shared/postSlackMessage.ts' import registerEndpoint from '../index.ts' // The extensions SDK ships as ESM and is not transformed under Jest's CJS mode, @@ -11,13 +13,39 @@ jest.mock('@directus/extensions-sdk', () => ({ // `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. jest.mock('../../shared/museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) -// The lifecycle endpoint must NEVER send mail. Mocking the shared email service -// lets the tests assert that even if a future change wires it in, it is not called. +// Corrections and cancellations are mailed to the customer through the templated mail +// path (`deliverTemplatedEmail`), the accounting copy through its own SMTP transport. +// Mocking the shared email service lets the tests assert both and stay offline. jest.mock('../../shared/email-service.ts', () => ({ + deliverTemplatedEmail: jest.fn(), sendTemplatedEmail: jest.fn(), + sendRawEmail: jest.fn(), +})) +jest.mock('../../shared/postSlackMessage.ts', () => ({ + postSlackMessage: jest.fn(), +})) + +// The accounting copy goes through its own SMTP transport, never through MailService. +const mockSendMail = jest.fn<(mail: any) => Promise>() +jest.mock('nodemailer', () => ({ + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail: mockSendMail })) }, })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) +const deliverTemplatedEmailMock = jest.mocked(deliverTemplatedEmail) +const sendRawEmailMock = jest.mocked(sendRawEmail) +const postSlackMessageMock = jest.mocked(postSlackMessage) + +const ACCOUNTING_ENV = { + ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test', + ACCOUNTING_SMTP_HOST: 'smtp.example.test', + ACCOUNTING_SMTP_PORT: 465, + ACCOUNTING_SMTP_SECURE: true, + ACCOUNTING_SMTP_USER: 'sender@example.test', + ACCOUNTING_SMTP_PASSWORD: 'app-password', + ACCOUNTING_MAIL_FROM: 'sender@example.test', +} type RouteHandler = (req: any, res: any) => Promise @@ -61,7 +89,7 @@ const BASE_ORDER = { is_internal: false, } -const CONFERENCE = { id: 'conf-1', title: 'programmier.con 2026', start_on: '2026-03-01' } +const CONFERENCE = { id: 'conf-1', title: 'programmier.con 2026', start_on: '2026-03-01', end_on: '2026-03-02' } const ORIGINAL_SNAPSHOT = { order_id: 'order-1', @@ -101,7 +129,9 @@ function issuedOriginalRow(overrides: Record = {}) { } } -async function setup(options: { order?: Record; invoices?: any[]; conference?: any } = {}) { +async function setup( + options: { order?: Record; invoices?: any[]; conference?: any; env?: Record } = {} +) { const db: Db = { ticket_orders: [{ ...BASE_ORDER, ...options.order }], conferences: [options.conference ?? { ...CONFERENCE }], @@ -116,6 +146,8 @@ async function setup(options: { order?: Record; invoices?: any[]; c updates: [] as Array<{ collection: string; id: any; data: any }>, deletes: [] as Array<{ collection: string; id: any }>, uploads: [] as any[], + /** Bytes of each uploaded file, in upload order. */ + uploadedBytes: [] as Buffer[], accountabilities: [] as any[], } @@ -170,7 +202,10 @@ async function setup(options: { order?: Record; invoices?: any[]; c } class FakeFilesService { - async uploadOne(_stream: any, meta: any) { + async uploadOne(stream: any, meta: any) { + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + calls.uploadedBytes.push(Buffer.concat(chunks)) calls.uploads.push(meta) return `file-new-${++fileSeq}` } @@ -187,7 +222,7 @@ async function setup(options: { order?: Record; invoices?: any[]; c const context = { logger, services: { ItemsService: FakeItemsService, FilesService: FakeFilesService, MailService }, - env: {}, + env: options.env ?? {}, getSchema: async () => ({}), } @@ -217,6 +252,13 @@ async function setup(options: { order?: Record; invoices?: any[]; c beforeEach(() => { sendTemplatedEmailMock.mockReset() + deliverTemplatedEmailMock.mockReset() + deliverTemplatedEmailMock.mockResolvedValue('sent') + sendRawEmailMock.mockReset() + mockSendMail.mockReset() + mockSendMail.mockResolvedValue({ messageId: 'x' }) + resetAccountingMailState() + postSlackMessageMock.mockReset() }) describe('invoice-lifecycle endpoint', () => { @@ -285,6 +327,7 @@ describe('invoice-lifecycle endpoint', () => { invoice_number: 'PB-CON26-001', invoice_file: 'file-new-1', document_id: 'doc-original', + accounting_mail: 'skipped', // no ACCOUNTING_INVOICE_EMAIL in this setup }) // Document row updated in place: new file, refreshed snapshot, same number. @@ -296,10 +339,12 @@ describe('invoice-lifecycle endpoint', () => { const order = db.ticket_orders[0] expect(order.invoice_file).toBe('file-new-1') expect(order.invoice_number).toBe('PB-CON26-001') - // No new document row, nothing deleted, no email. + // No new document row, nothing deleted, no customer email, still not issued. expect(calls.creates).toHaveLength(0) expect(calls.deletes).toHaveLength(0) expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + expect(deliverTemplatedEmailMock).not.toHaveBeenCalled() + expect(doc.sent_at).toBeNull() }) test('is blocked once the invoice has been issued (sent)', async () => { @@ -375,22 +420,29 @@ describe('invoice-lifecycle endpoint', () => { corrects: 'PB-CON26-001', }) - // New row: correction, not yet sent, referencing the original. + // New row: correction referencing the original. const correction = db.ticket_invoices.find((r) => r.type === 'correction') expect(correction).toMatchObject({ order: 'order-1', invoice_number: 'PB-CON26-002', invoice_file: 'file-new-1', related_invoice: 'doc-original', - sent_at: null, }) // Snapshot taken from the order's CURRENT billing data. expect(correction.snapshot_json.company_name).toBe('Beispiel GmbH') + // Same service as the original: Leistungsdatum from the conference dates. + expect(correction.snapshot_json).toMatchObject({ + service_date_start: '2026-03-01', + service_date_end: '2026-03-02', + }) - // The original document row stays byte-for-byte untouched. + // The original document row stays byte-for-byte untouched; the only document + // update is the sent_at stamp on the new correction (see "customer mail"). const original = db.ticket_invoices.find((r) => r.id === 'doc-original') expect(original).toEqual(issuedOriginalRow()) - expect(calls.updates.filter((u) => u.collection === 'ticket_invoices')).toHaveLength(0) + expect( + calls.updates.filter((u) => u.collection === 'ticket_invoices' && u.id !== correction.id) + ).toHaveLength(0) expect(calls.deletes).toHaveLength(0) // Order pointers move to the correction (new authoritative invoice). @@ -399,9 +451,6 @@ describe('invoice-lifecycle endpoint', () => { // The correction PDF is named as a Rechnungsberichtigung. expect(calls.uploads[0].filename_download).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') - - // No email is ever sent from this path. - expect(sendTemplatedEmailMock).not.toHaveBeenCalled() }) test('mints the number across both collections (legacy orders included)', async () => { @@ -484,7 +533,6 @@ describe('invoice-lifecycle endpoint', () => { order: 'order-1', invoice_number: 'PB-CON26-002', related_invoice: 'doc-original', - sent_at: null, }) // Amounts mirror the ISSUED snapshot (old company name!), not current order data. expect(storno.snapshot_json).toMatchObject({ @@ -495,6 +543,9 @@ describe('invoice-lifecycle endpoint', () => { vat_amount_cents: -3610, total_gross_cents: -22610, ticket_count: 2, + // The referenced snapshot predates the Leistungsdatum: falls back to the conference dates. + service_date_start: '2026-03-01', + service_date_end: '2026-03-02', }) // The original document row stays untouched, nothing is deleted. @@ -510,10 +561,9 @@ describe('invoice-lifecycle endpoint', () => { expect(calls.updates.filter((u) => u.collection === 'tickets')).toHaveLength(0) expect(calls.uploads[0].filename_download).toBe('Stornorechnung-PB-CON26-002.pdf') - expect(sendTemplatedEmailMock).not.toHaveBeenCalled() }) - test('never instantiates the mail service', async () => { + test('never instantiates the mail service directly (all mail goes through the shared helpers)', async () => { const { call, MailService } = await setup({ invoices: [issuedOriginalRow()] }) await call('cancellation') expect(MailService).not.toHaveBeenCalled() @@ -587,4 +637,274 @@ describe('invoice-lifecycle endpoint', () => { } }) }) + + describe('accounting copy', () => { + test('a correction is mailed to accounting with the stored PDF', async () => { + const { call, calls, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] + expect(mail.to).toBe('accounting@example.test') + expect(mail.from).toBe('sender@example.test') + expect(mail.envelope).toEqual({ from: 'sender@example.test', to: 'accounting@example.test' }) + expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002') + expect(mail.text).toContain('zu Rechnung PB-CON26-001') + expect(mail.attachments).toHaveLength(1) + expect(mail.attachments![0]!.filename).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') + // Same bytes as the stored file. + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(mail.attachments![0]!.content.subarray(0, 4).toString()).toBe('%PDF') + + // The accounting copy is a separate mail; the customer mail is what issued the document. + expect(mail.to).not.toBe(BASE_ORDER.purchaser_email) + expect(db.ticket_invoices.find((r) => r.type === 'correction').sent_at).toEqual(expect.any(String)) + expect(deliverTemplatedEmailMock).toHaveBeenCalledTimes(1) + }) + + test('a cancellation is mailed to accounting', async () => { + const { call, calls } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + expect(mockSendMail).toHaveBeenCalledTimes(1) + const mail = mockSendMail.mock.calls[0]![0] + expect(mail.subject).toBe('Stornorechnung PB-CON26-002') + expect(mail.attachments![0]!.filename).toBe('Stornorechnung-PB-CON26-002.pdf') + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + }) + + test('a pre-issuance regenerate sends the new version, marked as replacing the previous one', async () => { + const { call, calls } = await setup({ + invoices: [issuedOriginalRow({ sent_at: null })], + env: ACCOUNTING_ENV, + }) + + const res = await call('regenerate') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('sent') + const mail = mockSendMail.mock.calls[0]![0] + expect(mail.subject).toBe('Rechnung PB-CON26-001 (ersetzt vorherige Fassung)') + expect(mail.attachments![0]!.filename).toBe('Rechnung-PB-CON26-001.pdf') + expect(mail.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + }) + + test('a regenerated correction keeps its document type and reference in the accounting mail', async () => { + const correction = issuedOriginalRow({ + id: 'doc-correction-1', + type: 'correction', + invoice_number: 'PB-CON26-002', + related_invoice: 'doc-original', + date_created: '2026-02-01T00:00:00.000Z', + sent_at: null, + }) + const { call } = await setup({ invoices: [issuedOriginalRow(), correction], env: ACCOUNTING_ENV }) + + const res = await call('regenerate') + + expect(res.statusCode).toBe(200) + const mail = mockSendMail.mock.calls[0]![0] + expect(mail.subject).toBe('Rechnungsberichtigung PB-CON26-002 (ersetzt vorherige Fassung)') + expect(mail.text).toContain('zu Rechnung PB-CON26-001') + }) + + test('is skipped with an info log when ACCOUNTING_INVOICE_EMAIL is not set', async () => { + const { call, logger } = await setup({ invoices: [issuedOriginalRow()] }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('skipped') + expect(mockSendMail).not.toHaveBeenCalled() + const infoLog = logger.info.mock.calls.map((c) => String(c[0])).join('\n') + expect(infoLog).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(logger.error).not.toHaveBeenCalled() + }) + + test('persistent failure: document is still created, Slack is alerted, endpoint answers 200', async () => { + mockSendMail.mockRejectedValue(new Error('550 sender rejected')) + const { call, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.accounting_mail).toBe('failed') + expect(mockSendMail).toHaveBeenCalledTimes(3) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('Stornorechnung PB-CON26-002') + // Issued by the customer mail, independent of the failed accounting copy. + expect(db.ticket_invoices.find((r) => r.type === 'cancellation')).toMatchObject({ + invoice_number: 'PB-CON26-002', + sent_at: expect.any(String), + }) + }) + + test('blocked operations and internal orders send nothing', async () => { + const blocked = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + expect((await blocked.call('regenerate')).statusCode).toBe(400) + + const internal = await setup({ order: { is_internal: true }, env: ACCOUNTING_ENV }) + expect((await internal.call('correction')).statusCode).toBe(400) + + expect(mockSendMail).not.toHaveBeenCalled() + expect(deliverTemplatedEmailMock).not.toHaveBeenCalled() + }) + }) + + describe('customer mail', () => { + function customerMailCall() { + expect(deliverTemplatedEmailMock).toHaveBeenCalledTimes(1) + const [options, emailContext] = deliverTemplatedEmailMock.mock.calls[0]! + return { options, emailContext: emailContext as any } + } + + test('a correction is mailed to the purchaser, CC billing_email, with the stored PDF — and issued', async () => { + const { call, calls, db } = await setup({ + order: { billing_email: 'billing@example.de' }, + invoices: [issuedOriginalRow()], + }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body.customer_mail).toBe('sent') + + const { options, emailContext } = customerMailCall() + expect(options).toMatchObject({ + templateKey: 'ticket_invoice_correction', + to: 'erika@example.de', + cc: 'billing@example.de', + data: { + purchaser_name: 'Erika Musterfrau', + conference_title: 'programmier.con 2026', + order_number: 'ORD-2026-ABC123', + invoice_number: 'PB-CON26-002', + reference_invoice_number: 'PB-CON26-001', + reference_invoice_date: '20.01.2026', + }, + }) + expect(options.data.invoice_date).toMatch(/^\d{2}\.\d{2}\.\d{4}$/) + expect(options.attachments).toHaveLength(1) + expect(options.attachments![0]!.filename).toBe('Rechnungsberichtigung-PB-CON26-002.pdf') + expect(options.attachments![0]!.contentType).toBe('application/pdf') + expect(options.attachments![0]!.content.equals(calls.uploadedBytes[0]!)).toBe(true) + // Through the customer mail path (Directus MailService), not the accounting SMTP. + expect(emailContext.services.MailService).toBeDefined() + expect(mockSendMail).not.toHaveBeenCalled() + + // Successful send stamps sent_at on the new document row only. + const correction = db.ticket_invoices.find((r) => r.type === 'correction') + expect(correction.sent_at).toEqual(expect.any(String)) + const stamps = calls.updates.filter((u) => u.collection === 'ticket_invoices') + expect(stamps).toEqual([ + { collection: 'ticket_invoices', id: correction.id, data: { sent_at: correction.sent_at } }, + ]) + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('a cancellation is mailed with its own template and without CC when billing_email is empty', async () => { + const { call, db } = await setup({ order: { billing_email: null }, invoices: [issuedOriginalRow()] }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.customer_mail).toBe('sent') + const { options } = customerMailCall() + expect(options.templateKey).toBe('ticket_invoice_cancellation') + expect(options.to).toBe('erika@example.de') + expect(options.cc).toBeUndefined() + expect(options.data).toMatchObject({ + invoice_number: 'PB-CON26-002', + reference_invoice_number: 'PB-CON26-001', + }) + expect(options.attachments![0]!.filename).toBe('Stornorechnung-PB-CON26-002.pdf') + expect(db.ticket_invoices.find((r) => r.type === 'cancellation').sent_at).toEqual(expect.any(String)) + }) + + test('send failure: endpoint still answers 200, Slack is alerted, sent_at stays null, accounting still gets its copy', async () => { + deliverTemplatedEmailMock.mockResolvedValue('failed') + const { call, db } = await setup({ invoices: [issuedOriginalRow()], env: ACCOUNTING_ENV }) + + const res = await call('correction') + + expect(res.statusCode).toBe(200) + expect(res.body).toMatchObject({ + invoice_number: 'PB-CON26-002', + customer_mail: 'failed', + accounting_mail: 'sent', + }) + expect(mockSendMail).toHaveBeenCalledTimes(1) // accounting copy, independent of the customer mail + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('Rechnungsberichtigung PB-CON26-002') + expect(slack).toContain('ORD-2026-ABC123') + expect(slack).toContain('sent_at') + const correction = db.ticket_invoices.find((r) => r.type === 'correction') + expect(correction).toMatchObject({ invoice_number: 'PB-CON26-002', sent_at: null }) + }) + + test('a throwing mail helper is contained as a failure as well', async () => { + deliverTemplatedEmailMock.mockRejectedValue(new Error('boom')) + const { call, db } = await setup({ invoices: [issuedOriginalRow()] }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.customer_mail).toBe('failed') + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(db.ticket_invoices.find((r) => r.type === 'cancellation').sent_at).toBeNull() + }) + + test('missing template: explicit failure naming the template, no fallback mail, sent_at stays null', async () => { + deliverTemplatedEmailMock.mockResolvedValue('template_missing') + const { call, db, logger } = await setup({ invoices: [issuedOriginalRow()] }) + + const res = await call('cancellation') + + expect(res.statusCode).toBe(200) + expect(res.body.customer_mail).toBe('template_missing') + expect(deliverTemplatedEmailMock).toHaveBeenCalledTimes(1) + expect(sendRawEmailMock).not.toHaveBeenCalled() + expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('ticket_invoice_cancellation') + expect(logger.error.mock.calls.map((c) => String(c[0])).join('\n')).toContain('ticket_invoice_cancellation') + expect(db.ticket_invoices.find((r) => r.type === 'cancellation').sent_at).toBeNull() + }) + + test('a sent correction counts as issued: regenerate is blocked, a further correction is possible', async () => { + const { call } = await setup({ invoices: [issuedOriginalRow()] }) + expect((await call('correction')).body.customer_mail).toBe('sent') + + const regenerate = await call('regenerate') + expect(regenerate.statusCode).toBe(400) + expect(regenerate.body.error).toContain('already been issued') + + const second = await call('correction') + expect(second.statusCode).toBe(200) + expect(second.body).toMatchObject({ invoice_number: 'PB-CON26-003', corrects: 'PB-CON26-002' }) + }) + + test('after a failed customer mail, regenerating the correction does not mail the customer either', async () => { + deliverTemplatedEmailMock.mockResolvedValueOnce('failed') + const { call, db } = await setup({ invoices: [issuedOriginalRow()] }) + expect((await call('correction')).body.customer_mail).toBe('failed') + deliverTemplatedEmailMock.mockClear() + + const res = await call('regenerate') + + expect(res.statusCode).toBe(200) + expect(res.body.invoice_number).toBe('PB-CON26-002') + expect(res.body.customer_mail).toBeUndefined() + expect(deliverTemplatedEmailMock).not.toHaveBeenCalled() + expect(db.ticket_invoices.find((r) => r.type === 'correction').sent_at).toBeNull() + }) + }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/invoice-actions-state.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/invoice-actions-state.test.ts index 5d26b025..bf70f18e 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/invoice-actions-state.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/invoice-actions-state.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from '@jest/globals' -import { deriveInvoiceActionsState, isExistingItemKey, type InvoiceDocumentRow } from '../invoice-actions-state.ts' +import { + deriveInvoiceActionsState, + describeActionResult, + isExistingItemKey, + type InvoiceDocumentRow, +} from '../invoice-actions-state.ts' function doc(overrides: Partial & Pick): InvoiceDocumentRow { return { @@ -106,3 +111,42 @@ describe('deriveInvoiceActionsState', () => { expect(state.showPostIssuanceActions).toBe(true) }) }) + +describe('describeActionResult', () => { + test('a correction that reached the customer is a success', () => { + const result = describeActionResult('correction', { + invoice_number: 'PB-CON26-002', + corrects: 'PB-CON26-001', + customer_mail: 'sent', + accounting_mail: 'sent', + }) + expect(result.type).toBe('success') + expect(result.text).toContain('Rechnungsberichtigung PB-CON26-002') + expect(result.text).toContain('An den Kunden verschickt.') + expect(result.text).toContain('Kopie an die Buchhaltung verschickt.') + }) + + test.each(['failed', 'template_missing', undefined])( + 'a cancellation whose customer mail is %s is a warning asking for manual dispatch', + (customerMail) => { + const result = describeActionResult('cancellation', { + invoice_number: 'PB-CON26-003', + cancels: 'PB-CON26-001', + customer_mail: customerMail, + accounting_mail: 'sent', + }) + expect(result.type).toBe('warning') + expect(result.text).toContain('Stornorechnung PB-CON26-003') + expect(result.text).toContain('manuell an den Kunden') + } + ) + + test('regenerate never reports a customer mail', () => { + const result = describeActionResult('regenerate', { + invoice_number: 'PB-CON26-001', + accounting_mail: 'skipped', + }) + expect(result.type).toBe('success') + expect(result.text).not.toContain('Kunden') + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts index a0a0c4a2..e2d4d8f7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/index.ts @@ -1,6 +1,8 @@ /// import { defineEndpoint } from '@directus/extensions-sdk' import type { SandboxEndpointRouter } from 'directus:api' +import { sendInvoiceToAccounting, type AccountingInvoiceDocument } from '../shared/accounting-mail.js' +import { sendInvoiceDocumentToCustomer, type CustomerInvoiceDocument } from '../shared/invoice-customer-mail.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { buildInvoiceSnapshot, @@ -12,6 +14,7 @@ import { negateInvoiceSnapshot, parseInvoiceSnapshot, renderAndUploadInvoice, + withServicePeriod, type InvoiceDocument, type RenderInvoiceResult, } from '../shared/invoice-service.js' @@ -29,6 +32,7 @@ const ORDER_FIELDS = [ 'purchaser_first_name', 'purchaser_last_name', 'purchaser_email', + 'billing_email', 'company_name', 'company_vat_id', 'billing_address_line1', @@ -72,9 +76,23 @@ interface OrderContext { * invoice's snapshot and an explicit reference to it. Touches neither Stripe nor * tickets nor the order status. * - * None of the routes sends an email, none deletes a file, and issued documents are - * never modified — every operation only ever adds a new document or (pre-issuance) - * swaps the PDF of the not-yet-sent one. + * None of the routes deletes a file, and issued documents are never modified — every + * operation only ever adds a new document or (pre-issuance) swaps the PDF of the + * not-yet-sent one. Every rendered PDF is copied to the accounting mailbox + * (`ACCOUNTING_INVOICE_EMAIL`, see shared/accounting-mail.ts); the response reports that + * as `accounting_mail: 'sent' | 'skipped' | 'misconfigured' | 'failed'`. + * + * A new correction or cancellation is also mailed to the customer, like the original + * invoice (shared/invoice-customer-mail.ts): To purchaser, CC billing_email, PDF + * attached; a successful send stamps `sent_at` (the document is issued). The response + * reports that as `customer_mail: 'sent' | 'failed' | 'template_missing'`; a failure + * never fails the route, leaves `sent_at` null and alerts Slack. + * + * Regenerate never mails the customer — for originals and corrections alike. It is + * only possible while `sent_at` is null, i.e. the customer mail did not go out: the + * admin regenerates if the data was wrong, then dispatches the PDF by hand and sets + * `sent_at`, exactly as for an original invoice whose confirmation mail failed. (A + * cancellation is never the current document, so it cannot be regenerated at all.) */ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger @@ -137,7 +155,9 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => } const conferencesService = new ItemsService('conferences', { schema, accountability }) - const conference = await conferencesService.readOne(order.conference, { fields: ['title', 'start_on'] }) + const conference = await conferencesService.readOne(order.conference, { + fields: ['title', 'start_on', 'end_on'], + }) if (!conference) { throw new HttpError(400, `Conference ${order.conference} not found`) @@ -155,6 +175,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => documents = await ensureInvoiceDocuments({ order, conferenceTitle: conference.title, + servicePeriod: conference, ticketCount, invoicesService, }) @@ -219,11 +240,40 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => return env.STORAGE_LOCATIONS?.split(',')[0] } + /** + * Copy a freshly rendered document to the accounting mailbox. Runs after the + * document row is stored and never throws, so a mail problem cannot turn a + * successfully created document into an error response (persistent failures are + * escalated to Slack inside). + */ + function sendToAccounting(ctx: OrderContext, doc: Omit) { + return sendInvoiceToAccounting( + { ...doc, orderNumber: ctx.order.order_number }, + { env, logger, source: ENDPOINT_NAME } + ) + } + + /** + * Mail a new correction/cancellation to the customer and stamp `sent_at` on success. + * Runs after the document row is stored and never throws (failures go to Slack). + */ + function sendToCustomer(ctx: OrderContext, doc: CustomerInvoiceDocument) { + return sendInvoiceDocumentToCustomer(doc, { + order: ctx.order, + conferenceTitle: ctx.conference.title, + invoicesService: ctx.invoicesService, + emailContext: { logger, services: context.services, getSchema: context.getSchema }, + logger, + source: ENDPOINT_NAME, + }) + } + /** * Regenerate: replace the PDF of the current (not yet issued) invoice document * in place. Keeps the invoice number and the original invoice date. The previous * file is intentionally NOT deleted. Once the document has been sent, this is * blocked — issued documents are immutable and must be corrected instead. + * Never mails the customer (see the endpoint comment above). */ registerRoute('regenerate', async (_req, ctx) => { const current = requireCurrentDocument(ctx) @@ -243,9 +293,10 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => throw new HttpError(400, `Invoice ${current.invoice_number} has been cancelled`) } - const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.conference) let rendered: RenderInvoiceResult + let correctedNumber: string | null = null if (current.type === 'correction') { // A regenerated correction must keep its printed reference to the corrected // invoice (§31 Abs. 5 UStDV) — without a resolvable predecessor the document @@ -257,6 +308,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `Correction ${current.invoice_number} does not reference a known invoice document — cannot regenerate it with the legally required reference` ) } + correctedNumber = predecessor.invoice_number rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber: current.invoice_number, @@ -266,6 +318,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => referenceDate: new Date(predecessor.invoice_date), filesService: ctx.filesService, storageLocation: storageLocation(), + logger, }) } else { rendered = await renderAndUploadInvoice({ @@ -275,6 +328,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => documentKind: 'invoice', filesService: ctx.filesService, storageLocation: storageLocation(), + logger, }) } @@ -289,7 +343,25 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Regenerated invoice ${current.invoice_number} for order ${ctx.order.order_number} (file: ${rendered.fileId})` ) - return { invoice_number: current.invoice_number, invoice_file: rendered.fileId, document_id: current.id } + // The earlier version was already copied to accounting when it was created + // (the paid hook does so even when the customer mail failed, which is exactly + // when a regenerate is possible). Send the replacement too, marked as such, so + // the books hold the version the customer will actually receive. + const accountingMail = await sendToAccounting(ctx, { + documentKind: current.type === 'correction' ? 'correction' : 'invoice', + invoiceNumber: current.invoice_number, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: correctedNumber, + replacesPreviousVersion: true, + }) + + return { + invoice_number: current.invoice_number, + invoice_file: rendered.fileId, + document_id: current.id, + accounting_mail: accountingMail, + } }) /** @@ -297,7 +369,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * current billing data with its own invoice number and today's date, explicitly * referencing the corrected invoice. The corrected document stays untouched; * the order's legacy pointers move to the correction (the new authoritative - * invoice). No email is sent. + * invoice). Mailed to the customer (which issues it) and copied to accounting. */ registerRoute('correction', async (_req, ctx) => { const current = requireCurrentDocument(ctx) @@ -321,7 +393,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const invoiceNumber = await generateInvoiceNumber(ctx.ordersService, ctx.invoicesService, conferenceYear) const invoiceDate = new Date() - const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.conference) const rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber, @@ -331,6 +403,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => referenceDate: new Date(current.invoice_date), filesService: ctx.filesService, storageLocation: storageLocation(), + logger, }) const documentId = await ctx.invoicesService.createOne({ @@ -354,11 +427,32 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created correction ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) + const accountingMail = await sendToAccounting(ctx, { + documentKind: 'correction', + invoiceNumber, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + }) + + const customerMail = await sendToCustomer(ctx, { + documentKind: 'correction', + documentId, + invoiceNumber, + invoiceDate, + referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, corrects: current.invoice_number, + accounting_mail: accountingMail, + customer_mail: customerMail, } }) @@ -366,8 +460,8 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => * Cancellation (Stornorechnung): create a NEW document with its own invoice * number, negative amounts mirroring the referenced invoice's immutable snapshot * and an explicit reference to it. Deliberately does NOT touch Stripe, tickets, - * the order status or the order's invoice pointers, and sends no email — it only - * records the cancellation document. + * the order status or the order's invoice pointers — it only records the + * cancellation document, mails it to the customer and copies it to accounting. */ registerRoute('cancellation', async (_req, ctx) => { const current = requireCurrentDocument(ctx) @@ -400,7 +494,9 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const invoiceNumber = await generateInvoiceNumber(ctx.ordersService, ctx.invoicesService, conferenceYear) const invoiceDate = new Date() - const snapshot = negateInvoiceSnapshot(referencedSnapshot) + // Same service as the referenced invoice, so the same Leistungsdatum; snapshots + // written before it was recorded fall back to the conference dates. + const snapshot = negateInvoiceSnapshot(withServicePeriod(referencedSnapshot, ctx.conference)) const rendered = await renderAndUploadInvoice({ snapshot, @@ -411,6 +507,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => referenceDate: new Date(current.invoice_date), filesService: ctx.filesService, storageLocation: storageLocation(), + logger, }) const documentId = await ctx.invoicesService.createOne({ @@ -428,11 +525,32 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => `${ENDPOINT_NAME}: Created cancellation ${invoiceNumber} for invoice ${current.invoice_number} on order ${ctx.order.order_number} (file: ${rendered.fileId})` ) + const accountingMail = await sendToAccounting(ctx, { + documentKind: 'cancellation', + invoiceNumber, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + }) + + const customerMail = await sendToCustomer(ctx, { + documentKind: 'cancellation', + documentId, + invoiceNumber, + invoiceDate, + referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, cancels: current.invoice_number, + accounting_mail: accountingMail, + customer_mail: customerMail, } }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions-state.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions-state.ts index 8b863e4f..6d30ad69 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions-state.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions-state.ts @@ -63,3 +63,62 @@ export function deriveInvoiceActionsState( showPostIssuanceActions: hasInvoice && isIssued && !isCancelled, } } + +export type InvoiceActionKey = 'regenerate' | 'correction' | 'cancellation' + +/** The fields of an `invoice-lifecycle` response the result notice reports on. */ +export interface InvoiceActionResponse { + invoice_number?: string + corrects?: string + cancels?: string + accounting_mail?: string + customer_mail?: string +} + +const ACCOUNTING_MAIL_TEXT: Record = { + sent: 'Kopie an die Buchhaltung verschickt.', + failed: 'Kopie an die Buchhaltung fehlgeschlagen — Hinweis ging an Slack, bitte manuell weiterleiten.', + skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', + misconfigured: + 'Keine Kopie an die Buchhaltung: SMTP-Zugang unvollständig konfiguriert (siehe Slack) — bitte manuell weiterleiten.', +} + +const MANUAL_DISPATCH = 'Bitte das PDF manuell an den Kunden schicken und danach sent_at am Rechnungsdokument setzen.' + +const CUSTOMER_MAIL_TEXT: Record = { + sent: 'An den Kunden verschickt.', + failed: `NICHT an den Kunden verschickt — der Versand ist fehlgeschlagen (siehe Slack). ${MANUAL_DISPATCH}`, + template_missing: `NICHT an den Kunden verschickt — die E-Mail-Vorlage fehlt in Directus (siehe Slack). ${MANUAL_DISPATCH}`, +} + +/** + * Result notice after a successful lifecycle call. A correction or cancellation that did + * not reach the customer is a warning: the document exists but still has to be sent. + */ +export function describeActionResult( + action: InvoiceActionKey, + data: InvoiceActionResponse +): { type: 'success' | 'warning'; text: string } { + const nr = data.invoice_number + const accounting = ACCOUNTING_MAIL_TEXT[data.accounting_mail ?? ''] ?? '' + + if (action === 'regenerate') { + return { + type: 'success', + text: `Rechnung ${nr} wurde neu generiert. Seite neu laden, um die neue Datei zu sehen. ${accounting}`.trim(), + } + } + + const created = + action === 'correction' + ? `Rechnungsberichtigung ${nr} wurde erstellt (berichtigt ${data.corrects}).` + : `Stornorechnung ${nr} wurde erstellt (storniert ${data.cancels}).` + const customerSent = data.customer_mail === 'sent' + const customer = + CUSTOMER_MAIL_TEXT[data.customer_mail ?? ''] ?? `Versand an den Kunden unbekannt. ${MANUAL_DISPATCH}` + + return { + type: customerSent ? 'success' : 'warning', + text: `${created} ${customer} ${accounting}`.trim(), + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue index c36ffad0..0310d3c7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/invoice-actions.vue @@ -1,19 +1,25 @@