From 0dcb4f7c526022eea7f502d32de4432429c5ce64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:55:05 +0000 Subject: [PATCH 1/4] Show Leistungsdatum on ticket invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoices, corrections and cancellations now print the conference date(s) as Leistungsdatum (§ 14 Abs. 4 Nr. 6 UStG) in the meta block. The dates are recorded in the invoice snapshot; cancellations of snapshots written before this change fall back to the conference's start_on/end_on. When no date is known the line is omitted and a warning is logged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../invoice-lifecycle/__tests__/index.test.ts | 10 ++- .../src/invoice-lifecycle/index.ts | 18 ++++- .../__tests__/invoice-generator.test.ts | 56 +++++++++++++ .../shared/__tests__/invoice-service.test.ts | 47 +++++++++++ .../src/shared/invoice-generator.ts | 23 ++++-- .../src/shared/invoice-service.ts | 78 ++++++++++++++++++- .../__tests__/index.test.ts | 10 ++- .../src/ticket-order-processing/index.ts | 7 +- 8 files changed, 232 insertions(+), 17 deletions(-) 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..0622e2b6 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 @@ -61,7 +61,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', @@ -386,6 +386,11 @@ describe('invoice-lifecycle endpoint', () => { }) // 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. const original = db.ticket_invoices.find((r) => r.id === 'doc-original') @@ -495,6 +500,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. 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..a4f7439e 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 @@ -12,6 +12,7 @@ import { negateInvoiceSnapshot, parseInvoiceSnapshot, renderAndUploadInvoice, + withServicePeriod, type InvoiceDocument, type RenderInvoiceResult, } from '../shared/invoice-service.js' @@ -137,7 +138,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 +158,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => documents = await ensureInvoiceDocuments({ order, conferenceTitle: conference.title, + servicePeriod: conference, ticketCount, invoicesService, }) @@ -243,7 +247,7 @@ 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 if (current.type === 'correction') { @@ -266,6 +270,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 +280,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => documentKind: 'invoice', filesService: ctx.filesService, storageLocation: storageLocation(), + logger, }) } @@ -321,7 +327,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 +337,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({ @@ -400,7 +407,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 +420,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({ diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts index b6133816..42a72ae7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-generator.test.ts @@ -4,10 +4,12 @@ import { describe, expect, jest, test } from '@jest/globals' import PDFDocument from 'pdfkit' import { fitHeadingFontSize, + generateInvoicePdf, HEADING_MAX_FONT_SIZE, HEADING_MIN_FONT_SIZE, HEADING_X, LOGO_RIGHT_EDGE, + type InvoiceData, } from '../invoice-generator.ts' // `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform. @@ -62,3 +64,57 @@ describe('invoice heading layout', () => { expect(fitHeadingFontSize(measure, 'x'.repeat(500), 100)).toBe(HEADING_MIN_FONT_SIZE) }) }) + +describe('invoice meta block', () => { + const BASE: InvoiceData = { + invoiceNumber: 'PB-CON26-001', + invoiceDate: '01.02.2026', + serviceDate: '25.–26.11.2026', + purchaserName: 'Erika Musterfrau', + purchaserEmail: 'erika@example.de', + companyName: null, + companyVatId: null, + billingAddressLine1: null, + billingAddressLine2: null, + billingCity: null, + billingPostalCode: null, + billingCountry: null, + conferenceTitle: 'programmier.con 2026', + ticketType: 'Regular', + ticketCount: 1, + unitPriceGrossCents: 11900, + subtotalCents: 10000, + discountAmountCents: 0, + vatAmountCents: 1900, + totalGrossCents: 11900, + } + + /** Render the PDF and return every string passed to `doc.text`. */ + async function renderedTexts(data: InvoiceData): Promise { + const spy = jest.spyOn(PDFDocument.prototype, 'text') + try { + await generateInvoicePdf(data) + return spy.mock.calls.map((call) => String(call[0])) + } finally { + spy.mockRestore() + } + } + + test.each([ + ['invoice', {}], + ['correction', { referenceNumber: 'PB-CON26-000', referenceDate: '15.01.2026' }], + ['cancellation', { referenceNumber: 'PB-CON26-000', referenceDate: '15.01.2026' }], + ] as const)('a %s shows the Leistungsdatum (§ 14 Abs. 4 Nr. 6 UStG)', async (kind, reference) => { + const texts = await renderedTexts({ ...BASE, documentKind: kind, ...reference } as InvoiceData) + + expect(texts).toContain('Rechnungsdatum: 01.02.2026') + expect(texts).toContain('Leistungsdatum: 25.–26.11.2026') + }) + + test('omits the Leistungsdatum line when no service date is known', async () => { + const texts = await renderedTexts({ ...BASE, serviceDate: null }) + + expect(texts.some((text) => text.startsWith('Leistungsdatum'))).toBe(false) + expect(texts).toContain('Fälligkeitsdatum: 01.02.2026') + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts index c6888b33..f554565a 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts @@ -5,6 +5,7 @@ import { ensureInvoiceDocuments, findCancellationFor, findCurrentInvoiceDocument, + formatServiceDate, InvalidTicketCountError, issueOriginalInvoice, markInvoiceSent, @@ -12,6 +13,7 @@ import { negateInvoiceSnapshot, parseInvoiceSnapshot, renderAndUploadInvoice, + withServicePeriod, type InvoiceDocument, type InvoiceOrder, type InvoiceSnapshot, @@ -159,6 +161,51 @@ describe('buildInvoiceSnapshot', () => { }) }) +describe('Leistungsdatum (service date)', () => { + const CONFERENCE = { start_on: '2026-11-25T09:00:00', end_on: '2026-11-26T18:00:00' } + + test.each([ + ['2026-11-25', '2026-11-26', '25.–26.11.2026'], + ['2026-11-25T09:00:00', '2026-11-25T18:00:00', '25.11.2026'], + ['2026-11-25', null, '25.11.2026'], + ['2026-11-30', '2026-12-01', '30.11.–01.12.2026'], + ['2026-12-31', '2027-01-01', '31.12.2026–01.01.2027'], + [null, null, null], + ])('formats %s / %s as %s', (start, end, expected) => { + expect(formatServiceDate(start, end)).toBe(expected) + }) + + test('buildInvoiceSnapshot records the conference dates', () => { + const snapshot = buildInvoiceSnapshot(ORDER, 'Conf', 2, CONFERENCE) + expect(snapshot.service_date_start).toBe('2026-11-25') + expect(snapshot.service_date_end).toBe('2026-11-26') + }) + + test('withServicePeriod fills older snapshots and keeps recorded dates', () => { + const legacy = buildInvoiceSnapshot(ORDER, 'Conf', 2) + expect(withServicePeriod(legacy, CONFERENCE)).toMatchObject({ + service_date_start: '2026-11-25', + service_date_end: '2026-11-26', + }) + + const recorded = buildInvoiceSnapshot(ORDER, 'Conf', 2, { start_on: '2025-11-20', end_on: '2025-11-21' }) + expect(withServicePeriod(recorded, CONFERENCE)).toBe(recorded) + }) + + test('renders without a Leistungsdatum (and warns) when no date is known', async () => { + const logger = { warn: jest.fn() } + const result = await renderAndUploadInvoice({ + snapshot: buildInvoiceSnapshot(ORDER, 'Conf', 2), + invoiceNumber: 'PB-CON26-001', + invoiceDate: new Date('2026-02-01T10:00:00Z'), + filesService: fakeFilesService(), + logger, + }) + expect(result.pdfBuffer.subarray(0, 4).toString()).toBe('%PDF') + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/Leistungsdatum.*PB-CON26-001/)) + }) +}) + describe('negateInvoiceSnapshot', () => { test('flips every monetary amount and keeps the rest', () => { const snapshot = buildInvoiceSnapshot(ORDER, 'Conf', 2) 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 6c97e0ed..7aadbabb 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 @@ -61,6 +61,11 @@ export type InvoiceDocumentKind = 'invoice' | 'correction' | 'cancellation' interface InvoiceDataBase { invoiceNumber: string invoiceDate: string // formatted DD.MM.YYYY + /** + * Leistungsdatum (§ 14 Abs. 4 Nr. 6 UStG): the conference date(s), e.g. + * "25.–26.11.2026". The line is omitted when no date is known. + */ + serviceDate?: string | null // Customer purchaserName: string purchaserEmail: string @@ -228,18 +233,24 @@ export function generateInvoicePdf(data: InvoiceData): Promise { const metaY = headingWrapped ? Math.ceil(50 + headingHeight + 6) : 75 doc.fontSize(9).fillColor('#666') - doc.text(`Rechnungsdatum: ${data.invoiceDate}`, 50, metaY, { align: 'right' }) + let metaLineY = metaY + const metaLine = (text: string) => { + doc.text(text, 50, metaLineY, { align: 'right' }) + metaLineY += 12 + } + metaLine(`Rechnungsdatum: ${data.invoiceDate}`) + if (data.serviceDate) { + metaLine(`Leistungsdatum: ${data.serviceDate}`) + } if (kind === 'invoice') { - doc.text(`Fälligkeitsdatum: ${data.invoiceDate}`, 50, metaY + 12, { align: 'right' }) + metaLine(`Fälligkeitsdatum: ${data.invoiceDate}`) } else { // referenceNumber/referenceDate are guaranteed by the guard above. - doc.text(`Bezug: Rechnung Nr. ${data.referenceNumber} vom ${data.referenceDate}`, 50, metaY + 12, { - align: 'right', - }) + metaLine(`Bezug: Rechnung Nr. ${data.referenceNumber} vom ${data.referenceDate}`) } // --- Seller info --- - let y = Math.max(115, metaY + 40) + let y = Math.max(115, metaLineY + 16) doc.fontSize(9).fillColor('#333') doc.text(SELLER.name, 50, y) y += 13 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 50c7ffef..3e323213 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 @@ -23,6 +23,12 @@ export interface InvoiceOrder { ticket_type: string } +/** Conference dates the Leistungsdatum is derived from (subset of `conferences`). */ +export interface ServicePeriod { + start_on?: string | null + end_on?: string | null +} + /** Document types stored in `ticket_invoices.type`. */ export type InvoiceDocumentType = 'original' | 'correction' | 'cancellation' @@ -44,6 +50,12 @@ export interface InvoiceSnapshot { billing_postal_code: string | null billing_country: string | null conference_title: string + /** + * Leistungsdatum (§ 14 Abs. 4 Nr. 6 UStG): first/last conference day as + * YYYY-MM-DD. Absent on snapshots written before the field was introduced. + */ + service_date_start?: string | null + service_date_end?: string | null ticket_type: string ticket_count: number unit_price_gross_cents: number @@ -74,6 +86,52 @@ export function formatInvoiceDate(date: Date): string { }) } +/** The YYYY-MM-DD part of a Directus date/dateTime value, or null. */ +function toIsoDate(value: string | null | undefined): string | null { + const match = typeof value === 'string' ? /^\d{4}-\d{2}-\d{2}/.exec(value) : null + return match ? match[0] : null +} + +/** + * Format the Leistungsdatum in German notation: "25.11.2026" for a single day, + * "25.–26.11.2026" for a range within one month, otherwise both full dates + * (shortened where the year is shared). Returns null when no date is known. + */ +export function formatServiceDate(start: string | null | undefined, end: string | null | undefined): string | null { + const from = toIsoDate(start) ?? toIsoDate(end) + if (!from) { + return null + } + const to = toIsoDate(end) + const [fromYear, fromMonth, fromDay] = from.split('-') + if (!to || to <= from) { + return `${fromDay}.${fromMonth}.${fromYear}` + } + const [toYear, toMonth, toDay] = to.split('-') + if (fromYear !== toYear) { + return `${fromDay}.${fromMonth}.${fromYear}–${toDay}.${toMonth}.${toYear}` + } + if (fromMonth !== toMonth) { + return `${fromDay}.${fromMonth}.–${toDay}.${toMonth}.${toYear}` + } + return `${fromDay}.–${toDay}.${toMonth}.${toYear}` +} + +/** + * Fill in the conference dates on a snapshot written before the Leistungsdatum + * was recorded. Snapshots that already carry a date are returned unchanged. + */ +export function withServicePeriod(snapshot: InvoiceSnapshot, servicePeriod?: ServicePeriod): InvoiceSnapshot { + if (snapshot.service_date_start || snapshot.service_date_end) { + return snapshot + } + return { + ...snapshot, + service_date_start: toIsoDate(servicePeriod?.start_on), + service_date_end: toIsoDate(servicePeriod?.end_on), + } +} + /** * Thrown by {@link buildInvoiceSnapshot} when the ticket count is not a positive * number. Dividing by 0 (or NaN) would silently bake Infinity/NaN amounts into the @@ -99,7 +157,8 @@ export class InvalidTicketCountError extends Error { export function buildInvoiceSnapshot( order: InvoiceOrder, conferenceTitle: string, - ticketCount: number + ticketCount: number, + servicePeriod?: ServicePeriod ): InvoiceSnapshot { if (!Number.isFinite(ticketCount) || ticketCount <= 0) { throw new InvalidTicketCountError(order.order_number, ticketCount) @@ -126,6 +185,8 @@ export function buildInvoiceSnapshot( billing_postal_code: order.billing_postal_code, billing_country: order.billing_country, conference_title: conferenceTitle, + service_date_start: toIsoDate(servicePeriod?.start_on), + service_date_end: toIsoDate(servicePeriod?.end_on), ticket_type: order.ticket_type, ticket_count: ticketCount, unit_price_gross_cents: grossPerTicket, @@ -173,6 +234,7 @@ interface RenderInvoiceBaseParams { invoiceDate: Date filesService: any storageLocation?: string + logger?: { warn: (message: string) => void } } /** @@ -213,9 +275,15 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi const { snapshot, invoiceNumber, invoiceDate, filesService } = params const kind: InvoiceDocumentKind = params.documentKind || 'invoice' + const serviceDate = formatServiceDate(snapshot.service_date_start, snapshot.service_date_end) + if (!serviceDate) { + params.logger?.warn(`No Leistungsdatum (conference date) known for ${invoiceNumber} — omitting it from the PDF`) + } + const baseData = { invoiceNumber, invoiceDate: formatInvoiceDate(invoiceDate), + serviceDate, purchaserName: snapshot.purchaser_name, purchaserEmail: snapshot.purchaser_email, companyName: snapshot.company_name, @@ -271,6 +339,7 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi export interface IssueOriginalInvoiceParams { order: InvoiceOrder conferenceTitle: string + servicePeriod?: ServicePeriod ticketCount: number invoiceNumber: string invoiceDate: Date @@ -278,6 +347,7 @@ export interface IssueOriginalInvoiceParams { invoicesService: any filesService: any storageLocation?: string + logger?: { warn: (message: string) => void } } export interface IssueOriginalInvoiceResult extends RenderInvoiceResult { @@ -294,7 +364,7 @@ export interface IssueOriginalInvoiceResult extends RenderInvoiceResult { export async function issueOriginalInvoice(params: IssueOriginalInvoiceParams): Promise { const { order, conferenceTitle, ticketCount, invoiceNumber, invoiceDate, ordersService, invoicesService } = params - const snapshot = buildInvoiceSnapshot(order, conferenceTitle, ticketCount) + const snapshot = buildInvoiceSnapshot(order, conferenceTitle, ticketCount, params.servicePeriod) const rendered = await renderAndUploadInvoice({ snapshot, @@ -303,6 +373,7 @@ export async function issueOriginalInvoice(params: IssueOriginalInvoiceParams): documentKind: 'invoice', filesService: params.filesService, storageLocation: params.storageLocation, + logger: params.logger, }) const documentId = await invoicesService.createOne({ @@ -399,6 +470,7 @@ export class MissingDatePaidError extends Error { export interface EnsureInvoiceDocumentParams { order: InvoiceOrder & { invoice_number?: string | null; invoice_file?: string | null; date_paid?: string | null } conferenceTitle: string + servicePeriod?: ServicePeriod ticketCount: number invoicesService: any } @@ -431,7 +503,7 @@ export async function ensureInvoiceDocuments(params: EnsureInvoiceDocumentParams } const issuedAt = new Date(order.date_paid) - const snapshot = buildInvoiceSnapshot(order, params.conferenceTitle, params.ticketCount) + const snapshot = buildInvoiceSnapshot(order, params.conferenceTitle, params.ticketCount, params.servicePeriod) await invoicesService.createOne({ order: order.id, 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 88e46ddb..eb3179d8 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 @@ -75,7 +75,13 @@ const ORDER = { is_internal: false, } -const CONFERENCE = { id: 'conf-1', title: 'programmier.con 2026', start_on: '2026-03-01', ticket_max_quantity: null } +const CONFERENCE = { + id: 'conf-1', + title: 'programmier.con 2026', + start_on: '2026-03-01', + end_on: '2026-03-02', + ticket_max_quantity: null, +} function setup(orderOverrides: Record = {}) { const order = { ...ORDER, ...orderOverrides } @@ -163,6 +169,8 @@ describe('ticket-order-processing hook', () => { const issueParams = issueOriginalInvoiceMock.mock.calls[0]![0] expect(issueParams.invoiceNumber).toBe('PB-CON26-001') expect(issueParams.ticketCount).toBe(2) + // The conference dates become the invoice's Leistungsdatum. + expect(issueParams.servicePeriod).toMatchObject({ start_on: '2026-03-01', end_on: '2026-03-02' }) expect(issueParams.invoicesService).toBe(serviceInstances['ticket_invoices']![0]) // The confirmation email carries the invoice PDF. 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 69ec471a..336ab0f8 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 @@ -111,9 +111,10 @@ export default defineHook(({ action }, hookContext) => { continue } - // Get conference details (title + start_on for invoice year + ticket limit) + // Get conference details (title, start_on for invoice year, start_on/end_on + // for the Leistungsdatum, ticket limit) const conference = await conferencesService.readOne(order.conference, { - fields: ['title', 'start_on', 'ticket_max_quantity'], + fields: ['title', 'start_on', 'end_on', 'ticket_max_quantity'], }) if (!conference) { @@ -198,6 +199,7 @@ export default defineHook(({ action }, hookContext) => { const invoiceResult = await issueOriginalInvoice({ order, conferenceTitle: conference.title, + servicePeriod: conference, ticketCount: attendees.length, invoiceNumber, invoiceDate: new Date(), @@ -205,6 +207,7 @@ export default defineHook(({ action }, hookContext) => { invoicesService, filesService, storageLocation: env.STORAGE_LOCATIONS?.split(',')[0], + logger, }) pdfBuffer = invoiceResult.pdfBuffer From 406d6aed6fecb945c84fc39dfd4c821287de2d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:33:19 +0000 Subject: [PATCH 2/4] Pass only the conference dates as servicePeriod Hand over just start_on/end_on instead of the whole conference row, like conferenceTitle already does for the title. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../src/invoice-lifecycle/index.ts | 13 +++++++++---- .../ticket-order-processing/__tests__/index.test.ts | 2 +- .../src/ticket-order-processing/index.ts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) 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 d1126ddc..9fe053b5 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 @@ -15,6 +15,7 @@ import { withServicePeriod, type InvoiceDocument, type RenderInvoiceResult, + type ServicePeriod, } from '../shared/invoice-service.js' import { invoiceFilesFolder } from '../shared/secure-invoice-folder.js' @@ -51,6 +52,7 @@ const ORDER_FIELDS = [ interface OrderContext { order: any conference: any + servicePeriod: ServicePeriod ticketCount: number documents: InvoiceDocument[] currentDocument: InvoiceDocument | null @@ -154,12 +156,14 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => ) } + const servicePeriod: ServicePeriod = { start_on: conference.start_on, end_on: conference.end_on } + let documents: InvoiceDocument[] try { documents = await ensureInvoiceDocuments({ order, conferenceTitle: conference.title, - servicePeriod: conference, + servicePeriod, ticketCount, invoicesService, }) @@ -175,6 +179,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => return { order, conference, + servicePeriod, ticketCount, documents, currentDocument: findCurrentInvoiceDocument(documents), @@ -248,7 +253,7 @@ 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, ctx.conference) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) let rendered: RenderInvoiceResult if (current.type === 'correction') { @@ -330,7 +335,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, ctx.conference) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) const rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber, @@ -413,7 +418,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => // 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 snapshot = negateInvoiceSnapshot(withServicePeriod(referencedSnapshot, ctx.servicePeriod)) const rendered = await renderAndUploadInvoice({ snapshot, 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 ec7ec2f6..c1e35702 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 @@ -170,7 +170,7 @@ describe('ticket-order-processing hook', () => { expect(issueParams.invoiceNumber).toBe('PB-CON26-001') expect(issueParams.ticketCount).toBe(2) // The conference dates become the invoice's Leistungsdatum. - expect(issueParams.servicePeriod).toMatchObject({ start_on: '2026-03-01', end_on: '2026-03-02' }) + expect(issueParams.servicePeriod).toEqual({ start_on: '2026-03-01', end_on: '2026-03-02' }) expect(issueParams.invoicesService).toBe(serviceInstances['ticket_invoices']![0]) // The confirmation email carries the invoice PDF. 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 d13b598e..96871c84 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 @@ -200,7 +200,7 @@ export default defineHook(({ action }, hookContext) => { const invoiceResult = await issueOriginalInvoice({ order, conferenceTitle: conference.title, - servicePeriod: conference, + servicePeriod: { start_on: conference.start_on, end_on: conference.end_on }, ticketCount: attendees.length, invoiceNumber, invoiceDate: new Date(), From 0799337a55c74cde29d084c740ef05a6df4d1908 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:50:34 +0000 Subject: [PATCH 3/4] Alert Slack when an invoice has no Leistungsdatum Rendering stays non-blocking, but a document without the mandatory service date needs a human: post the invoice number and the remedy (set the conference dates, issue a correction if already sent). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../shared/__tests__/invoice-service.test.ts | 22 +++++++++++++++++-- .../__tests__/secure-invoice-folder.test.ts | 2 ++ .../src/shared/invoice-service.ts | 9 +++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts index f554565a..9eebf9e9 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts @@ -19,9 +19,11 @@ import { type InvoiceSnapshot, type RenderInvoiceParams, } from '../invoice-service.ts' +import { 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(async () => undefined) })) const ORDER: InvoiceOrder & { order_number: string } = { id: 'order-1', @@ -192,7 +194,9 @@ describe('Leistungsdatum (service date)', () => { expect(withServicePeriod(recorded, CONFERENCE)).toBe(recorded) }) - test('renders without a Leistungsdatum (and warns) when no date is known', async () => { + test('renders without a Leistungsdatum and alerts Slack when no date is known', async () => { + const postSlackMessageMock = jest.mocked(postSlackMessage) + postSlackMessageMock.mockClear() const logger = { warn: jest.fn() } const result = await renderAndUploadInvoice({ snapshot: buildInvoiceSnapshot(ORDER, 'Conf', 2), @@ -202,7 +206,21 @@ describe('Leistungsdatum (service date)', () => { logger, }) expect(result.pdfBuffer.subarray(0, 4).toString()).toBe('%PDF') - expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/Leistungsdatum.*PB-CON26-001/)) + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/PB-CON26-001 has no Leistungsdatum/)) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + expect(postSlackMessageMock.mock.calls[0]![0]).toMatch(/Rechnung PB-CON26-001 has no Leistungsdatum/) + }) + + test('does not alert Slack when the snapshot carries the conference dates', async () => { + const postSlackMessageMock = jest.mocked(postSlackMessage) + postSlackMessageMock.mockClear() + await renderAndUploadInvoice({ + snapshot: buildInvoiceSnapshot(ORDER, 'Conf', 2, CONFERENCE), + invoiceNumber: 'PB-CON26-001', + invoiceDate: new Date('2026-02-01T10:00:00Z'), + filesService: fakeFilesService(), + }) + expect(postSlackMessageMock).not.toHaveBeenCalled() }) }) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/secure-invoice-folder.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/secure-invoice-folder.test.ts index 22ef64d7..4399c618 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/secure-invoice-folder.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/secure-invoice-folder.test.ts @@ -80,6 +80,8 @@ const SNAPSHOT = { billing_postal_code: '61231', billing_country: 'Deutschland', conference_title: 'programmier.con 2026', + service_date_start: '2026-11-25', + service_date_end: '2026-11-26', ticket_type: 'regular', ticket_count: 1, unit_price_gross_cents: 11900, 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 0bb0927a..58f590d8 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 @@ -1,5 +1,6 @@ import { Readable } from 'node:stream' import { generateInvoicePdf, ticketTypeLabel, type InvoiceData, type InvoiceDocumentKind } from './invoice-generator.js' +import { postSlackMessage } from './postSlackMessage.js' import { uploadWithInvoiceFolderFallback } from './secure-invoice-folder.js' /** Order fields required to assemble an invoice (subset of `ticket_orders`). */ @@ -280,7 +281,13 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi const serviceDate = formatServiceDate(snapshot.service_date_start, snapshot.service_date_end) if (!serviceDate) { - params.logger?.warn(`No Leistungsdatum (conference date) known for ${invoiceNumber} — omitting it from the PDF`) + // Non-blocking like the invoice folder fallback, but the document lacks a + // mandatory field (§ 14 Abs. 4 Nr. 6 UStG), so a human has to follow up. + const message = `${KIND_LABEL[kind]} ${invoiceNumber} has no Leistungsdatum because its conference has no start_on/end_on. Set the conference dates; if the document has already been sent, issue a Rechnungsberichtigung.` + params.logger?.warn(message) + postSlackMessage(`:rotating_light: *Invoices*: ${message}`).catch((error: any) => { + params.logger?.warn(`Could not send Slack alert for ${invoiceNumber}: ${error?.message ?? error}`) + }) } const baseData = { From c8a44bea54e39de167c36ef337a1188db1068942 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:52:27 +0000 Subject: [PATCH 4/4] Keep the Leistungsdatum alert independent of the label map Name the document by its number only, so the alert does not depend on the document-label constant that stacked branches rename. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw --- .../src/shared/__tests__/invoice-service.test.ts | 2 +- .../src/shared/invoice-service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts index 9eebf9e9..d95480c7 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/shared/__tests__/invoice-service.test.ts @@ -208,7 +208,7 @@ describe('Leistungsdatum (service date)', () => { expect(result.pdfBuffer.subarray(0, 4).toString()).toBe('%PDF') expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/PB-CON26-001 has no Leistungsdatum/)) expect(postSlackMessageMock).toHaveBeenCalledTimes(1) - expect(postSlackMessageMock.mock.calls[0]![0]).toMatch(/Rechnung PB-CON26-001 has no Leistungsdatum/) + expect(postSlackMessageMock.mock.calls[0]![0]).toMatch(/Invoice document PB-CON26-001 has no Leistungsdatum/) }) test('does not alert Slack when the snapshot carries the conference dates', async () => { 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 58f590d8..f212472f 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 @@ -283,7 +283,7 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi if (!serviceDate) { // Non-blocking like the invoice folder fallback, but the document lacks a // mandatory field (§ 14 Abs. 4 Nr. 6 UStG), so a human has to follow up. - const message = `${KIND_LABEL[kind]} ${invoiceNumber} has no Leistungsdatum because its conference has no start_on/end_on. Set the conference dates; if the document has already been sent, issue a Rechnungsberichtigung.` + const message = `Invoice document ${invoiceNumber} has no Leistungsdatum because its conference has no start_on/end_on. Set the conference dates; if the document has already been sent, issue a Rechnungsberichtigung.` params.logger?.warn(message) postSlackMessage(`:rotating_light: *Invoices*: ${message}`).catch((error: any) => { params.logger?.warn(`Could not send Slack alert for ${invoiceNumber}: ${error?.message ?? error}`)