diff --git a/directus-cms/.env.example b/directus-cms/.env.example index d2b808e4..c205e961 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -123,6 +123,12 @@ 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, through the regular EMAIL_* transport above +# (sender EMAIL_FROM). Leave empty to skip the accounting copy (e.g. locally; with Slack configured +# that is reported once per process start). See docs/tickets.md. +ACCOUNTING_INVOICE_EMAIL="" + # 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/src/invoice-lifecycle/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/invoice-lifecycle/__tests__/index.test.ts index e3fda871..59ac98c1 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 { 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,25 @@ 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. +// The lifecycle endpoint must NEVER mail the customer (no templated mail), only the +// plain accounting copy. Mocking the shared email service lets the tests assert both. jest.mock('../../shared/email-service.ts', () => ({ sendTemplatedEmail: jest.fn(), + sendRawEmail: jest.fn(), +})) +jest.mock('../../shared/postSlackMessage.ts', () => ({ + postSlackMessage: jest.fn(), + isSlackConfigured: jest.fn(() => false), })) const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) +const sendRawEmailMock = jest.mocked(sendRawEmail) +const postSlackMessageMock = jest.mocked(postSlackMessage) + +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +/** The accounting mail handed to the shared email service (n-th call). */ +const accountingMail = (n = 0) => sendRawEmailMock.mock.calls[n]![0] as any type RouteHandler = (req: any, res: any) => Promise @@ -101,7 +115,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 +132,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 +188,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 +208,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 +238,10 @@ async function setup(options: { order?: Record; invoices?: any[]; c beforeEach(() => { sendTemplatedEmailMock.mockReset() + sendRawEmailMock.mockReset() + sendRawEmailMock.mockResolvedValue(true) + resetAccountingMailState() + postSlackMessageMock.mockReset() }) describe('invoice-lifecycle endpoint', () => { @@ -285,6 +310,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. @@ -521,7 +547,7 @@ describe('invoice-lifecycle endpoint', () => { 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() @@ -595,6 +621,131 @@ 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(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = accountingMail() + expect(mail.to).toBe('accounting@example.test') + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001` + ) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') + expect(mail.html).toBeUndefined() // plain text, not a template + 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 customer is not mailed, and the accounting copy does not issue the document. + expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + expect(db.ticket_invoices.find((r) => r.type === 'correction').sent_at).toBeNull() + }) + + test('a cancellation is mailed to accounting with its negative amount', 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(sendRawEmailMock).toHaveBeenCalledTimes(1) + const mail = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Stornorechnung (Gutschrift) PB-CON26-002 zu PB-CON26-001` + ) + expect(mail.text).toMatch(/Bruttobetrag: -\d/) + expect(mail.attachments[0].filename).toBe('Stornorechnung-PB-CON26-002.pdf') + expect(mail.attachments[0].content.equals(calls.uploadedBytes[0]!)).toBe(true) + expect(sendTemplatedEmailMock).not.toHaveBeenCalled() + }) + + 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 = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} Ticket – 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 = accountingMail() + expect(mail.subject).toBe( + `Ausgangsrechnung ${CONFERENCE.title} Ticket – Rechnungsberichtigung PB-CON26-002 zu PB-CON26-001 (ersetzt vorherige Fassung)` + ) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-001 vom ') + }) + + 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(sendRawEmailMock).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 () => { + sendRawEmailMock.mockResolvedValue(false) + 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(sendRawEmailMock).toHaveBeenCalledTimes(3) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toContain('Stornorechnung PB-CON26-002') + expect(db.ticket_invoices.find((r) => r.type === 'cancellation')).toMatchObject({ + invoice_number: 'PB-CON26-002', + sent_at: null, + }) + }) + + 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(sendRawEmailMock).not.toHaveBeenCalled() + }) + }) }) // Invoice PDFs must not land in the public file library. The folder lookup itself 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 9fe053b5..0cb14f8d 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 type { EmailServiceContext } from '../shared/email-service.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { buildInvoiceSnapshot, @@ -76,15 +78,18 @@ 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 emails the customer, 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. 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' | 'failed'`. */ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => { const logger = context.logger const ItemsService = context.services.ItemsService const FilesService = context.services.FilesService const env = context.env + const emailContext: EmailServiceContext = { logger, services: context.services, getSchema: context.getSchema } class HttpError extends Error { status: number @@ -229,6 +234,19 @@ 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, emailContext, 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 @@ -256,6 +274,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) let rendered: RenderInvoiceResult + let predecessorRef: { invoice_number: string; invoice_date: 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 @@ -267,6 +286,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` ) } + predecessorRef = predecessor rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber: current.invoice_number, @@ -303,7 +323,28 @@ 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, + invoiceDate: new Date(current.invoice_date), + snapshot, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: predecessorRef?.invoice_number ?? null, + referenceDate: predecessorRef ? new Date(predecessorRef.invoice_date) : null, + replacesPreviousVersion: true, + }) + + return { + invoice_number: current.invoice_number, + invoice_file: rendered.fileId, + document_id: current.id, + accounting_mail: accountingMail, + } }) /** @@ -311,7 +352,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). The customer is not emailed; accounting gets a copy. */ registerRoute('correction', async (_req, ctx) => { const current = requireCurrentDocument(ctx) @@ -370,11 +411,23 @@ 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, + invoiceDate, + snapshot, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, corrects: current.invoice_number, + accounting_mail: accountingMail, } }) @@ -382,8 +435,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, and does not email the + * customer — it only records the cancellation document (accounting gets a copy). */ registerRoute('cancellation', async (_req, ctx) => { const current = requireCurrentDocument(ctx) @@ -448,11 +501,23 @@ 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, + invoiceDate, + snapshot, + pdfBuffer: rendered.pdfBuffer, + invoiceFileName: rendered.invoiceFileName, + referenceNumber: current.invoice_number, + referenceDate: new Date(current.invoice_date), + }) + return { invoice_number: invoiceNumber, invoice_file: rendered.fileId, document_id: documentId, cancels: current.invoice_number, + accounting_mail: accountingMail, } }) }) 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..efa20470 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 @@ -36,7 +36,8 @@ const ACTION_TEXT: Record = { + sent: 'Kopie an die Buchhaltung verschickt.', + failed: 'Kopie an die Buchhaltung fehlgeschlagen — bitte manuell weiterleiten.', + skipped: 'Keine Kopie an die Buchhaltung (Postfach nicht konfiguriert).', +} + let loadStateRunId = 0 async function loadState(): Promise { @@ -106,12 +115,13 @@ async function runAction(action: ActionKey): Promise { // server-side admin check runs against the logged-in user. const response = await api.post(`/invoice-lifecycle/${encodeURIComponent(props.primaryKey)}/${action}`) const nr = response.data.invoice_number - successMessage.value = + const message = action === 'regenerate' ? `Rechnung ${nr} wurde neu generiert. Seite neu laden, um die neue Datei zu sehen.` : action === 'correction' - ? `Rechnungsberichtigung ${nr} wurde erstellt (berichtigt ${response.data.corrects}). Sie wurde NICHT automatisch versendet.` - : `Stornorechnung ${nr} wurde erstellt (storniert ${response.data.cancels}). Sie wurde NICHT automatisch versendet.` + ? `Rechnungsberichtigung ${nr} wurde erstellt (berichtigt ${response.data.corrects}). Sie wurde NICHT automatisch an den Kunden versendet.` + : `Stornorechnung ${nr} wurde erstellt (storniert ${response.data.cancels}). Sie wurde NICHT automatisch an den Kunden versendet.` + successMessage.value = `${message} ${ACCOUNTING_MAIL_TEXT[response.data.accounting_mail] ?? ''}`.trim() confirmAction.value = null await loadState() } catch (error: any) { diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts new file mode 100644 index 00000000..7bc8e947 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/accounting-mail.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals' +import { + buildAccountingMail, + getAccountingInvoiceEmail, + resetAccountingMailState, + sendInvoiceToAccounting, + type AccountingInvoiceDocument, +} from '../accounting-mail.ts' +import { isSlackConfigured, postSlackMessage } from '../postSlackMessage.ts' + +// `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. +jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null })) +jest.mock('../postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), isSlackConfigured: jest.fn() })) + +const postSlackMessageMock = jest.mocked(postSlackMessage) +const isSlackConfiguredMock = jest.mocked(isSlackConfigured) + +// The real shared email service runs on top of a fake Directus MailService. +const mailSendMock = jest.fn<(mail: any) => Promise>() +class FakeMailService { + send = mailSendMock +} + +const NBSP = ' ' + +const DOC: AccountingInvoiceDocument = { + documentKind: 'invoice', + invoiceNumber: 'PB-CON26-012', + invoiceDate: new Date(2026, 9, 21), + snapshot: { + conference_title: 'programmier.con 2026', + service_date_start: '2026-11-25', + service_date_end: '2026-11-26', + total_gross_cents: 118900, + }, + pdfBuffer: Buffer.from('%PDF-fake'), + invoiceFileName: 'Rechnung-PB-CON26-012.pdf', + orderNumber: 'ORD-2026-ABC123', +} + +const CORRECTION: AccountingInvoiceDocument = { + ...DOC, + documentKind: 'correction', + invoiceNumber: 'PB-CON26-049', + invoiceFileName: 'Rechnungsberichtigung-PB-CON26-049.pdf', + referenceNumber: 'PB-CON26-040', + referenceDate: new Date(2026, 9, 1), +} + +const CANCELLATION: AccountingInvoiceDocument = { + ...CORRECTION, + documentKind: 'cancellation', + invoiceNumber: 'PB-CON26-050', + invoiceFileName: 'Stornorechnung-PB-CON26-050.pdf', + snapshot: { ...DOC.snapshot, total_gross_cents: -118900 }, +} + +const ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +function makeOptions(env: Record | undefined = ENV) { + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + const emailContext = { + logger: logger as any, + services: { ItemsService: class {}, MailService: FakeMailService }, + getSchema: async () => ({}), + } + return { logger, options: { env, emailContext, source: 'test-source' } } +} + +beforeEach(() => { + resetAccountingMailState() + mailSendMock.mockReset() + mailSendMock.mockResolvedValue({ messageId: 'x' }) + postSlackMessageMock.mockReset() + postSlackMessageMock.mockResolvedValue(undefined) + isSlackConfiguredMock.mockReset() + isSlackConfiguredMock.mockReturnValue(true) +}) + +describe('getAccountingInvoiceEmail', () => { + test.each([ + ['unset', {}], + ['empty', { ACCOUNTING_INVOICE_EMAIL: '' }], + ['whitespace', { ACCOUNTING_INVOICE_EMAIL: ' ' }], + ['not a string', { ACCOUNTING_INVOICE_EMAIL: true }], + ['no env at all', undefined], + ])('returns null when %s', (_label, env) => { + expect(getAccountingInvoiceEmail(env as any)).toBeNull() + }) + + test('returns the trimmed address', () => { + expect(getAccountingInvoiceEmail({ ACCOUNTING_INVOICE_EMAIL: ' accounting@example.test ' })).toBe( + 'accounting@example.test' + ) + }) +}) + +describe('buildAccountingMail', () => { + test('original invoice: classifiable subject and a fact sheet without customer data', () => { + const mail = buildAccountingMail(DOC) + expect(mail.subject).toBe('Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012') + expect(mail.text).toBe( + [ + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnung', + 'Veranstaltung: programmier.con 2026', + 'Rechnungsnummer: PB-CON26-012', + 'Rechnungsdatum: 21.10.2026', + 'Leistungszeitraum: 25.–26.11.2026', + `Bruttobetrag: 1.189,00${NBSP}€`, + 'Aussteller: Lotum media GmbH', + '', + 'Das Dokument liegt als PDF bei (Rechnung-PB-CON26-012.pdf).', + '', + ].join('\n') + ) + // No order number either: the body only describes the document. + expect(mail.text).not.toContain('ORD-2026-ABC123') + }) + + test('correction names the corrected invoice in subject and body', () => { + const mail = buildAccountingMail(CORRECTION) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040' + ) + expect(mail.text).toContain( + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung\n' + ) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') + }) + + test('cancellation is marked as Gutschrift with a negative amount', () => { + const mail = buildAccountingMail(CANCELLATION) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040' + ) + expect(mail.text).toContain( + 'Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift)\n' + ) + expect(mail.text).toContain(`Bruttobetrag: -1.189,00${NBSP}€\n`) + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026\n') + }) + + test('a replacement version is marked in subject and body', () => { + const mail = buildAccountingMail({ ...DOC, replacesPreviousVersion: true }) + expect(mail.subject).toBe( + 'Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012 (ersetzt vorherige Fassung)' + ) + expect(mail.text).toContain('Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.') + }) + + test('omits what is not known: conference title, Leistungszeitraum, reference date', () => { + const mail = buildAccountingMail({ + ...CORRECTION, + referenceDate: null, + snapshot: { conference_title: '', service_date_start: null, service_date_end: null, total_gross_cents: 0 }, + }) + expect(mail.subject).toBe('Ausgangsrechnung Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040') + expect(mail.text).not.toContain('Veranstaltung') + expect(mail.text).not.toContain('Leistungszeitraum') + expect(mail.text).toContain('Bezug auf Originalrechnung: PB-CON26-040\n') + }) +}) + +describe('sendInvoiceToAccounting', () => { + test('sends one plain-text mail with exactly the document PDF through the MailService', async () => { + const { options } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') + + expect(mailSendMock).toHaveBeenCalledTimes(1) + const mail = mailSendMock.mock.calls[0]![0] + const expected = buildAccountingMail(DOC) + // No `from`: the MailService uses EMAIL_FROM like for every other mail. + expect(mail).toEqual({ + to: 'accounting@example.test', + subject: expected.subject, + text: expected.text, + attachments: [ + { filename: 'Rechnung-PB-CON26-012.pdf', content: DOC.pdfBuffer, contentType: 'application/pdf' }, + ], + }) + expect(mail.attachments[0].content).toBe(DOC.pdfBuffer) // the very same bytes + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('retries a failed send and recovers without escalating', async () => { + mailSendMock.mockRejectedValueOnce(new Error('421 try again')).mockResolvedValueOnce({ messageId: 'x' }) + const { options } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('sent') + + expect(mailSendMock).toHaveBeenCalledTimes(2) + expect(postSlackMessageMock).not.toHaveBeenCalled() + }) + + test('persistent failure: alerts Slack with the invoice number and does not throw', async () => { + mailSendMock.mockRejectedValue(new Error('transport down')) + const { options, logger } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') + + expect(mailSendMock).toHaveBeenCalledTimes(3) + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('transport down') + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('PB-CON26-012') + expect(slack).toContain('ORD-2026-ABC123') + expect(slack).toContain('Rechnung-PB-CON26-012.pdf') + // The address is configuration, not something to broadcast. + expect(slack).not.toContain('accounting@example.test') + }) + + test('a failing Slack alert is swallowed too', async () => { + mailSendMock.mockRejectedValue(new Error('transport down')) + postSlackMessageMock.mockRejectedValue(new Error('slack down')) + const { options, logger } = makeOptions() + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('failed') + + expect(mailSendMock).toHaveBeenCalledTimes(3) + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') + }) + + describe('mailbox not configured', () => { + afterEach(() => { + expect(mailSendMock).not.toHaveBeenCalled() + }) + + test('skips every document with an info log and alerts Slack once per process', async () => { + const { options, logger } = makeOptions({}) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CORRECTION, options)).resolves.toBe('skipped') + + expect(logger.info).toHaveBeenCalledTimes(2) + expect(logger.info.mock.calls[0]![0]).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(logger.info.mock.calls[1]![0]).toContain('PB-CON26-049') + expect(logger.error).not.toHaveBeenCalled() + + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + const slack = postSlackMessageMock.mock.calls[0]![0] + expect(slack).toContain('ACCOUNTING_INVOICE_EMAIL') + expect(slack).toContain('PB-CON26-012') + }) + + test('an undelivered alert is retried with the next document', async () => { + postSlackMessageMock.mockRejectedValueOnce(new Error('slack down')) + const { options, logger } = makeOptions({}) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CORRECTION, options)).resolves.toBe('skipped') + await expect(sendInvoiceToAccounting(CANCELLATION, options)).resolves.toBe('skipped') + + expect(postSlackMessageMock).toHaveBeenCalledTimes(2) + expect(postSlackMessageMock.mock.calls[1]![0]).toContain('PB-CON26-049') + expect(logger.error.mock.calls.map((c) => c[0]).join('\n')).toContain('slack down') + }) + + test('stays quiet when Slack is not configured', async () => { + isSlackConfiguredMock.mockReturnValue(false) + const { options, logger } = makeOptions({ ACCOUNTING_INVOICE_EMAIL: '' }) + await expect(sendInvoiceToAccounting(DOC, options)).resolves.toBe('skipped') + + expect(postSlackMessageMock).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() + }) + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts new file mode 100644 index 00000000..bdcf9b9c --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/accounting-mail.ts @@ -0,0 +1,205 @@ +/** + * Accounting copy of every invoice document. + * + * Each generated invoice document (Rechnung, Rechnungsberichtigung, Stornorechnung) is + * mailed to the accounting mailbox, one PDF per mail, so it is recorded in the books + * promptly (GoBD). Accounting takes it from there into the bookkeeping system. This is a + * separate mail — never a BCC on the customer mail — and is independent of whether the + * customer mail went out: the document exists either way. + * + * It goes through the Directus MailService like every other mail (shared email service, + * sender `EMAIL_FROM`). Subject and plain-text body state what the document is + * (Ausgangsrechnung, document type, number, reference, amount) so it can be classified + * without opening the PDF. The body carries no customer data — the PDF has everything. + * + * The mailbox comes from `ACCOUNTING_INVOICE_EMAIL`. Without it the accounting copy is + * skipped (info log, plus one Slack alert per process so a missing production setting + * does not go unnoticed), and environments without bookkeeping keep working unchanged. + */ + +import { sendRawEmail, type EmailServiceContext } from './email-service.js' +import { INVOICE_ISSUER_NAME, type InvoiceDocumentKind } from './invoice-generator.js' +import { + formatInvoiceDate, + formatServiceDate, + INVOICE_DOCUMENT_LABEL, + type InvoiceSnapshot, +} from './invoice-service.js' +import { isSlackConfigured, postSlackMessage } from './postSlackMessage.js' +import { formatPrice } from './ticket-utils.js' + +export const ACCOUNTING_INVOICE_EMAIL_ENV = 'ACCOUNTING_INVOICE_EMAIL' + +/** How often to attempt sending the accounting copy before escalating to Slack. */ +export const ACCOUNTING_MAIL_ATTEMPTS = 3 + +/** Document type as named in the accounting mail (more specific than the PDF title). */ +export const ACCOUNTING_DOCUMENT_LABEL: Record = { + invoice: 'Rechnung', + correction: 'Rechnungsberichtigung', + cancellation: 'Stornorechnung (Gutschrift)', +} + +export interface AccountingInvoiceDocument { + documentKind: InvoiceDocumentKind + invoiceNumber: string + invoiceDate: Date + /** The snapshot the PDF was rendered from (conference, Leistungsdatum, gross amount). */ + snapshot: Pick< + InvoiceSnapshot, + 'conference_title' | 'service_date_start' | 'service_date_end' | 'total_gross_cents' + > + /** The exact bytes of the stored PDF. */ + pdfBuffer: Buffer + /** Same file name as the stored PDF, e.g. `Rechnung-PB-CON26-001.pdf`. */ + invoiceFileName: string + /** For logs and the Slack alert only — not part of the mail. */ + orderNumber?: string | null + /** The corrected/cancelled invoice (corrections and cancellations only). */ + referenceNumber?: string | null + referenceDate?: Date | null + /** + * The document was re-rendered under the same number (pre-issuance regenerate), so + * accounting may already hold an earlier version that this one replaces. + */ + replacesPreviousVersion?: boolean +} + +export type AccountingMailResult = 'sent' | 'skipped' | 'failed' + +export interface AccountingMailOptions { + /** The extension's env (hook/endpoint context `env`). */ + env: Record | undefined + /** Mail goes through the shared email service; its logger is used for all logs. */ + emailContext: EmailServiceContext + /** Hook or endpoint name, for logs and Slack alerts. */ + source: string +} + +/** The configured accounting mailbox, or null when the env var is unset or empty. */ +export function getAccountingInvoiceEmail(env: Record | undefined): string | null { + const value = env?.[ACCOUNTING_INVOICE_EMAIL_ENV] + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null +} + +// Process-wide: alert Slack about a missing mailbox once (again after a failed post). +let missingMailboxReported = false + +/** Reset the once-per-process alert flag (tests only). */ +export function resetAccountingMailState(): void { + missingMailboxReported = false +} + +/** Subject and plain-text body that identify the document for automatic classification. */ +export function buildAccountingMail(doc: AccountingInvoiceDocument): { subject: string; text: string } { + const conference = doc.snapshot.conference_title?.trim() + const label = ACCOUNTING_DOCUMENT_LABEL[doc.documentKind] + const reference = doc.referenceNumber ? ` zu ${doc.referenceNumber}` : '' + const replaces = doc.replacesPreviousVersion ? ' (ersetzt vorherige Fassung)' : '' + // " Ticket" names what was sold; the invoice number follows the type as the unique key. + const product = conference ? `${conference} Ticket` : 'Ticket' + const subject = `Ausgangsrechnung ${product} – ${label} ${doc.invoiceNumber}${reference}${replaces}` + + const serviceDate = formatServiceDate(doc.snapshot.service_date_start, doc.snapshot.service_date_end) + const lines = [ + `Dokumentart: Ausgangsrechnung ${product} – ${label}`, + ...(conference ? [`Veranstaltung: ${conference}`] : []), + `Rechnungsnummer: ${doc.invoiceNumber}`, + `Rechnungsdatum: ${formatInvoiceDate(doc.invoiceDate)}`, + ...(serviceDate ? [`Leistungszeitraum: ${serviceDate}`] : []), + ...(doc.referenceNumber + ? [ + `Bezug auf Originalrechnung: ${doc.referenceNumber}${doc.referenceDate ? ` vom ${formatInvoiceDate(doc.referenceDate)}` : ''}`, + ] + : []), + `Bruttobetrag: ${formatPrice(doc.snapshot.total_gross_cents)}`, + `Aussteller: ${INVOICE_ISSUER_NAME}`, + '', + `Das Dokument liegt als PDF bei (${doc.invoiceFileName}).`, + ] + if (doc.replacesPreviousVersion) { + lines.push('Diese Fassung ersetzt die zuvor übermittelte mit derselben Rechnungsnummer.') + } + + return { subject, text: `${lines.join('\n')}\n` } +} + +/** + * Mail the document to the accounting mailbox. Never throws: a failing accounting copy + * must not fail order processing or a lifecycle operation. Retries a few times and, on + * persistent failure, alerts Slack with the invoice number so a human forwards the PDF. + * + * Deliberately does not touch `sent_at` — that records the customer dispatch only. + */ +export async function sendInvoiceToAccounting( + doc: AccountingInvoiceDocument, + options: AccountingMailOptions +): Promise { + const { emailContext, source } = options + const { logger } = emailContext + const label = INVOICE_DOCUMENT_LABEL[doc.documentKind] + const order = doc.orderNumber ? ` zu Bestellung ${doc.orderNumber}` : '' + + try { + const to = getAccountingInvoiceEmail(options.env) + if (!to) { + logger.info( + `${source}: ${ACCOUNTING_INVOICE_EMAIL_ENV} is not set — skipping accounting copy of ${doc.invoiceNumber}` + ) + if (!missingMailboxReported && isSlackConfigured()) { + missingMailboxReported = true + try { + await postSlackMessage( + `:warning: *${source}*: ${ACCOUNTING_INVOICE_EMAIL_ENV} ist nicht gesetzt — Rechnungsdokumente gehen nicht an die Buchhaltung. Erstes betroffenes Dokument: ${label} ${doc.invoiceNumber}${order}. Falls diese Umgebung Rechnungen für die Buchhaltung erzeugt: Variable setzen, Directus neu starten und die seitdem erzeugten PDFs manuell weiterleiten (das Directus-Log nennt jede Nummer). Dieser Hinweis kommt einmal pro Prozessstart.` + ) + } catch (slackErr: any) { + // Not delivered: let the next document try again. + missingMailboxReported = false + logger.error(`${source}: Failed to send Slack notification: ${slackErr?.message || slackErr}`) + } + } + return 'skipped' + } + + const { subject, text } = buildAccountingMail(doc) + for (let attempt = 1; attempt <= ACCOUNTING_MAIL_ATTEMPTS; attempt++) { + // sendRawEmail never throws; it logs the transport error itself. + const sent = await sendRawEmail( + { + to, + subject, + text, + attachments: [ + { filename: doc.invoiceFileName, content: doc.pdfBuffer, contentType: 'application/pdf' }, + ], + }, + emailContext + ) + if (sent) { + logger.info(`${source}: Sent accounting copy of ${doc.invoiceNumber}`) + return 'sent' + } + logger.warn( + `${source}: Attempt ${attempt}/${ACCOUNTING_MAIL_ATTEMPTS} to send the accounting copy of ${doc.invoiceNumber} failed` + ) + } + + logger.error( + `${source}: Accounting copy of ${label} ${doc.invoiceNumber}${order} could not be sent after ${ACCOUNTING_MAIL_ATTEMPTS} attempts` + ) + try { + await postSlackMessage( + `:warning: *${source}*: ${label} ${doc.invoiceNumber}${order} konnte nach ${ACCOUNTING_MAIL_ATTEMPTS} Versuchen nicht an das Buchhaltungspostfach geschickt werden. Bitte das PDF (${doc.invoiceFileName}) aus Directus manuell an die Buchhaltung weiterleiten. Details im Directus-Log.` + ) + } catch (slackErr: any) { + logger.error(`${source}: Failed to send Slack notification: ${slackErr?.message || slackErr}`) + } + return 'failed' + } catch (err: any) { + // Last line of defence: nothing in here may break the caller. + logger.error( + `${source}: Unexpected error sending accounting copy of ${doc.invoiceNumber}: ${err?.message || err}` + ) + return 'failed' + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts index 5eaf0308..590b42c0 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/email-service.ts @@ -70,6 +70,16 @@ export interface SendEmailOptions { headers?: Record } +/** Attachments in the shape MailService expects (PDF unless stated otherwise). */ +function toMailAttachments(attachments: EmailAttachment[]) { + return attachments.map((a) => ({ + filename: a.filename, + content: a.content, + contentType: a.contentType || 'application/pdf', + ...(a.cid && { cid: a.cid }), + })) +} + /** * Get an email template from the email_templates collection. */ @@ -141,14 +151,7 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema ...(options.replyTo && { replyTo: options.replyTo }), ...(options.cc && { cc: options.cc }), ...(options.headers && { headers: options.headers }), - ...(options.attachments && { - attachments: options.attachments.map((a) => ({ - filename: a.filename, - content: a.content, - contentType: a.contentType || 'application/pdf', - ...(a.cid && { cid: a.cid }), - })), - }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to} using template '${options.templateKey}'`) @@ -161,19 +164,24 @@ export async function sendTemplatedEmail(options: SendEmailOptions, context: Ema /** * Send a raw email (without using a template from CMS). - * Useful for dynamically generated content. + * Useful for dynamically generated content. Without `html` the mail is plain text only. */ export async function sendRawEmail( options: { to: string subject: string - html: string - /** Plain-text alternative, for clients that don't render HTML. */ - text?: string replyTo?: string /** Extra mail headers (e.g. List-Unsubscribe); passed through MailService to nodemailer. */ headers?: Record - }, + attachments?: EmailAttachment[] + } & ( + | { + html: string + /** Plain-text alternative, for clients that don't render HTML. */ + text?: string + } + | { html?: undefined; text: string } + ), context: EmailServiceContext ): Promise { const { logger, services, getSchema } = context @@ -190,10 +198,11 @@ export async function sendRawEmail( await mailService.send({ to: options.to, subject: options.subject, - html: options.html, + ...(options.html !== undefined && { html: options.html }), ...(options.text && { text: options.text }), ...(options.replyTo && { replyTo: options.replyTo }), ...(options.headers && { headers: options.headers }), + ...(options.attachments && { attachments: toMailAttachments(options.attachments) }), }) logger.info(`Email sent to ${options.to}`) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts index 95f4680f..45439a66 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-generator.ts @@ -154,6 +154,9 @@ export function fitHeadingFontSize( return fontSize } +/** The issuing company as printed on every invoice document. */ +export const INVOICE_ISSUER_NAME = SELLER.name + function formatEur(cents: number): string { return new Intl.NumberFormat('de-DE', { style: 'currency', diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts index f212472f..cf21ba82 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/invoice-service.ts @@ -264,7 +264,8 @@ export interface RenderInvoiceResult { invoiceFileName: string } -const KIND_LABEL: Record = { +/** Human-readable document name, used for PDF file names, titles and mail subjects. */ +export const INVOICE_DOCUMENT_LABEL: Record = { invoice: 'Rechnung', correction: 'Rechnungsberichtigung', cancellation: 'Stornorechnung', @@ -333,7 +334,7 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi const pdfBuffer = await generateInvoicePdf(invoiceData) - const label = KIND_LABEL[kind] + const label = INVOICE_DOCUMENT_LABEL[kind] const invoiceFileName = `${label}-${invoiceNumber}.pdf` const fileId = await uploadWithInvoiceFolderFallback( (folder) => diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts index d704f8fb..f2365dd9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/postSlackMessage.ts @@ -3,6 +3,14 @@ import { WebClient } from '@slack/web-api' // Create Slack web client instance const webClient = new WebClient(process.env.SLACK_BOT_TOKEN as string) +/** + * Whether a bot token and channel are configured. Optional alerts (e.g. about a missing + * setting) check this first, so environments without Slack stay quiet. + */ +export function isSlackConfigured(): boolean { + return Boolean(process.env.SLACK_BOT_TOKEN && process.env.SLACK_CHANNEL_ID) +} + /** * A helper function that posts a Slack message to a specific channel. * diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts index c1e35702..6de11386 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/__tests__/index.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { sendInvoiceToAccounting } from '../../shared/accounting-mail.ts' import { sendTemplatedEmail } from '../../shared/email-service.ts' import { generateInvoiceNumber } from '../../shared/invoice-generator.ts' import { issueOriginalInvoice, markInvoiceSent } from '../../shared/invoice-service.ts' @@ -33,7 +34,14 @@ jest.mock('../../shared/postSlackMessage.ts', () => ({ postSlackMessage: jest.fn(), })) +// Building and sending the accounting mail is covered in shared/__tests__/accounting-mail.test.ts; +// here it only matters that the hook hands over the right document at the right time. +jest.mock('../../shared/accounting-mail.ts', () => ({ + sendInvoiceToAccounting: jest.fn(), +})) + const sendTemplatedEmailMock = jest.mocked(sendTemplatedEmail) +const sendInvoiceToAccountingMock = jest.mocked(sendInvoiceToAccounting) const postSlackMessageMock = jest.mocked(postSlackMessage) const issueOriginalInvoiceMock = jest.mocked(issueOriginalInvoice) const markInvoiceSentMock = jest.mocked(markInvoiceSent) @@ -83,7 +91,9 @@ const CONFERENCE = { ticket_max_quantity: null, } -function setup(orderOverrides: Record = {}) { +const ACCOUNTING_ENV = { ACCOUNTING_INVOICE_EMAIL: 'accounting@example.test' } + +function setup(orderOverrides: Record = {}, env: Record = {}) { const order = { ...ORDER, ...orderOverrides } const serviceInstances: Record = {} @@ -117,7 +127,7 @@ function setup(orderOverrides: Record = {}) { logger, services: { ItemsService: FakeItemsService, FilesService, MailService }, getSchema: async () => ({}), - env: {}, + env, } registerHook(register as any, hookContext as any) @@ -127,11 +137,13 @@ function setup(orderOverrides: Record = {}) { await flush() } - return { order, serviceInstances, actions, invoke, logger } + return { order, serviceInstances, actions, invoke, logger, hookContext } } beforeEach(() => { sendTemplatedEmailMock.mockReset() + sendInvoiceToAccountingMock.mockReset() + sendInvoiceToAccountingMock.mockResolvedValue('sent') issueOriginalInvoiceMock.mockReset() markInvoiceSentMock.mockReset() postSlackMessageMock.mockReset() @@ -145,7 +157,7 @@ beforeEach(() => { fileId: 'file-1', pdfBuffer: Buffer.from('%PDF-fake'), invoiceFileName: 'Rechnung-PB-CON26-001.pdf', - snapshot: {} as any, + snapshot: { conference_title: 'programmier.con 2026' } as any, }) sendTemplatedEmailMock.mockResolvedValue(true) }) @@ -273,6 +285,91 @@ describe('ticket-order-processing hook', () => { const templates = sendTemplatedEmailMock.mock.calls.map(([opts]) => (opts as any).templateKey) expect(templates).toEqual(['ticket_profile_invitation', 'ticket_profile_invitation']) }) + + describe('accounting copy', () => { + const invitations = () => + sendTemplatedEmailMock.mock.calls.filter( + ([opts]) => (opts as any).templateKey === 'ticket_profile_invitation' + ) + + test('hands the original invoice document to the accounting mail, separate from the customer mail', async () => { + const { invoke, hookContext } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) + const [doc, options] = sendInvoiceToAccountingMock.mock.calls[0]! + const issueParams = issueOriginalInvoiceMock.mock.calls[0]![0] + expect(doc).toEqual({ + documentKind: 'invoice', + invoiceNumber: 'PB-CON26-001', + // The very date printed on the invoice, and the snapshot it was rendered from. + invoiceDate: issueParams.invoiceDate, + snapshot: { conference_title: 'programmier.con 2026' }, + pdfBuffer: Buffer.from('%PDF-fake'), + invoiceFileName: 'Rechnung-PB-CON26-001.pdf', + orderNumber: 'ORD-2026-ABC123', + }) + expect(options.env).toBe(hookContext.env) + expect(options.source).toBe('ticket-order-processing') + // Goes through the shared email service with the hook's services. + expect(options.emailContext.services).toBe(hookContext.services) + + // Not piggybacked onto the customer mail. + const confirmation = sendTemplatedEmailMock.mock.calls.find( + ([opts]) => (opts as any).templateKey === 'ticket_order_confirmation' + )![0] as any + expect(confirmation.cc).toBeUndefined() + expect(confirmation.bcc).toBeUndefined() + }) + + test('is not awaited: tickets and customer mails do not wait for a slow accounting copy', async () => { + // An accounting copy that never settles (e.g. slow mail provider, retries, Slack). + sendInvoiceToAccountingMock.mockReturnValue(new Promise(() => {})) + const { invoke, serviceInstances } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) + expect(serviceInstances['tickets']![0].createOne).toHaveBeenCalledTimes(2) + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) // confirmation mail went out + expect(invitations()).toHaveLength(2) + }) + + test('an unexpected rejection of the accounting copy is logged, not thrown', async () => { + sendInvoiceToAccountingMock.mockRejectedValue(new Error('boom')) + const { invoke, logger } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(invitations()).toHaveLength(2) + expect(logger.error.mock.calls.map((c) => String(c[0])).join('\n')).toContain('boom') + }) + + test('is sent even when the customer confirmation mail fails — and never stamps sent_at', async () => { + sendTemplatedEmailMock.mockResolvedValue(false) + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendInvoiceToAccountingMock).toHaveBeenCalledTimes(1) + expect(markInvoiceSentMock).not.toHaveBeenCalled() + }) + + test('a failed accounting copy does not affect the customer flow', async () => { + sendInvoiceToAccountingMock.mockResolvedValue('failed') + const { invoke } = setup({}, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + // The customer still got their invoice, issuance is recorded from THAT mail, + // tickets exist and attendees were invited. + expect(markInvoiceSentMock).toHaveBeenCalledTimes(1) + expect(invitations()).toHaveLength(2) + }) + + test('internal orders have no invoice, so nothing goes to accounting', async () => { + const { invoke } = setup({ is_internal: true }, ACCOUNTING_ENV) + await invoke({ status: 'paid' }) + + expect(sendInvoiceToAccountingMock).not.toHaveBeenCalled() + }) + }) }) // The folder lookup itself (env var, unset warning + Slack alert) is covered in diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts index 96871c84..a6389906 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/ticket-order-processing/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { defineHook } from '@directus/extensions-sdk' +import { sendInvoiceToAccounting } from '../shared/accounting-mail.js' import { sendTemplatedEmail, type EmailServiceContext } from '../shared/email-service.js' import { generateInvoiceNumber } from '../shared/invoice-generator.js' import { issueOriginalInvoice, markInvoiceSent } from '../shared/invoice-service.js' @@ -197,13 +198,14 @@ export default defineHook(({ action }, hookContext) => { schema, }) + const invoiceDate = new Date() const invoiceResult = await issueOriginalInvoice({ order, conferenceTitle: conference.title, servicePeriod: { start_on: conference.start_on, end_on: conference.end_on }, ticketCount: attendees.length, invoiceNumber, - invoiceDate: new Date(), + invoiceDate, ordersService, invoicesService, filesService, @@ -219,6 +221,27 @@ export default defineHook(({ action }, hookContext) => { logger.info( `${HOOK_NAME}: Invoice ${invoiceNumber} generated and stored (file: ${invoiceResult.fileId}, document: ${invoiceResult.documentId})` ) + + // Accounting copy (GoBD: timely recording). The document exists now, + // so it goes out regardless of what happens below. Not awaited: its + // retries and Slack alert must not delay tickets or customer mails. + // Never throws; persistent failures are escalated to Slack inside. + void sendInvoiceToAccounting( + { + documentKind: 'invoice', + invoiceNumber, + invoiceDate, + snapshot: invoiceResult.snapshot, + pdfBuffer, + invoiceFileName, + orderNumber: order.order_number, + }, + { env, emailContext: context, source: HOOK_NAME } + ).catch((err: any) => + logger.error( + `${HOOK_NAME}: Accounting copy of ${invoiceNumber} failed unexpectedly: ${err?.message || err}` + ) + ) } else { logger.info(`${HOOK_NAME}: Skipping invoice for internal order ${order.order_number}`) } diff --git a/docs/tickets.md b/docs/tickets.md index 0cb7f33d..9872a874 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -31,6 +31,7 @@ The flow consists of four main phases: | `STORAGE_LOCATIONS` | Comma-separated storage location IDs (first is used for invoice PDFs) | Yes | | `INVOICE_FILES_FOLDER` | Id of the `directus_files` folder invoice PDFs are uploaded into; the Public policy must not read it (see [Invoice File Privacy](#invoice-file-privacy)). Unset or invalid: invoices still upload, but into the public library (warning + Slack alert) | Yes | | Email transport config | Directus email settings (`EMAIL_TRANSPORT`, `EMAIL_SMTP_HOST`, etc.) | Yes | +| `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent (Slack is alerted once per process start) | Production | ## External Services @@ -158,8 +159,9 @@ Key-value settings used by extension hooks. 1. Creates `tickets` records with unique codes and profile tokens 2. Generates invoice PDF (pdfkit) and uploads to Directus files 3. Updates order with `invoice_number` and `invoice_file` - 4. Sends `ticket_order_confirmation` email to purchaser (with invoice PDF attachment, CC to `billing_email` if set) - 5. Sends `ticket_profile_invitation` email to all attendees + 4. Sends a copy of the invoice PDF to the accounting mailbox (see [Accounting copy](#accounting-copy)) + 5. Sends `ticket_order_confirmation` email to purchaser (with invoice PDF attachment, CC to `billing_email` if set) + 6. Sends `ticket_profile_invitation` email to all attendees ### `ticket-profile-completion` @@ -176,6 +178,39 @@ Key-value settings used by extension hooks. - **Seller**: Lotum media GmbH, Am Goldstein 1, 61231 Bad Nauheim, USt-ID DE272856704 - **Line items**: Show netto (net) prices; VAT (19%) shown separately in totals +## Accounting Copy + +Every generated invoice document is mailed to `ACCOUNTING_INVOICE_EMAIL` (GoBD: timely recording), +from where accounting takes it into the bookkeeping system. It goes through the Directus MailService +like every other mail (`sendRawEmail` in `shared/email-service.ts`, sender `EMAIL_FROM`), as its own +plain-text mail with exactly one PDF attached — the same bytes and file name as the stored file. + +Subject and body say what the document is, so it can be classified without opening the PDF: + +``` +Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012 +Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040 +Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040 +``` + +The conference name is the order's conference title (just `Ticket` if it is empty); the invoice number +follows the document type as the unique key. The body lists Dokumentart, Rechnungsnummer, +Rechnungsdatum, Leistungszeitraum, the referenced original invoice (corrections and cancellations), +Bruttobetrag and Aussteller — no customer data; the PDF carries everything else. + +| Document | Sent by | +|----------|---------| +| Original invoice | `ticket-order-processing`, started right after the document is generated and not awaited — tickets and customer mails never wait for it | +| Rechnungsberichtigung / Stornorechnung | `invoice-lifecycle` endpoint, when the document is created | +| Regenerated (not yet issued) document | `invoice-lifecycle` endpoint, subject suffixed `(ersetzt vorherige Fassung)` — accounting already holds the earlier version under the same number | + +Internal orders have no invoice, so nothing is sent. The accounting copy never sets `sent_at` (that +records the customer dispatch). It is retried up to three times; if it still fails, a Slack alert +names the invoice number so the PDF can be forwarded by hand. If `ACCOUNTING_INVOICE_EMAIL` is unset, +each document is skipped with an info log, and Slack (when configured) is alerted once per process +start with the first affected invoice number. Order processing and the lifecycle endpoint carry on +either way. + ## Data Flow ```