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 7630f258..e3fda871 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 b444dd64..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 @@ -12,8 +12,10 @@ import { negateInvoiceSnapshot, parseInvoiceSnapshot, renderAndUploadInvoice, + withServicePeriod, type InvoiceDocument, type RenderInvoiceResult, + type ServicePeriod, } from '../shared/invoice-service.js' import { invoiceFilesFolder } from '../shared/secure-invoice-folder.js' @@ -50,6 +52,7 @@ const ORDER_FIELDS = [ interface OrderContext { order: any conference: any + servicePeriod: ServicePeriod ticketCount: number documents: InvoiceDocument[] currentDocument: InvoiceDocument | null @@ -138,7 +141,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`) @@ -151,11 +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, ticketCount, invoicesService, }) @@ -171,6 +179,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) => return { order, conference, + servicePeriod, ticketCount, documents, currentDocument: findCurrentInvoiceDocument(documents), @@ -244,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) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) let rendered: RenderInvoiceResult if (current.type === 'correction') { @@ -326,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) + const snapshot = buildInvoiceSnapshot(ctx.order, ctx.conference.title, ctx.ticketCount, ctx.servicePeriod) const rendered = await renderAndUploadInvoice({ snapshot, invoiceNumber, @@ -407,7 +416,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.servicePeriod)) const rendered = await renderAndUploadInvoice({ snapshot, 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..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 @@ -5,6 +5,7 @@ import { ensureInvoiceDocuments, findCancellationFor, findCurrentInvoiceDocument, + formatServiceDate, InvalidTicketCountError, issueOriginalInvoice, markInvoiceSent, @@ -12,14 +13,17 @@ import { negateInvoiceSnapshot, parseInvoiceSnapshot, renderAndUploadInvoice, + withServicePeriod, type InvoiceDocument, type InvoiceOrder, 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', @@ -159,6 +163,67 @@ 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 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), + 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(/PB-CON26-001 has no Leistungsdatum/)) + expect(postSlackMessageMock).toHaveBeenCalledTimes(1) + 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 () => { + 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() + }) +}) + 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/__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-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 63a1ed86..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 @@ -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`). */ @@ -24,6 +25,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' @@ -45,6 +52,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 @@ -75,6 +88,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 @@ -100,7 +159,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) @@ -127,6 +187,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, @@ -217,9 +279,21 @@ 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) { + // 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 = `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}`) + }) + } + const baseData = { invoiceNumber, invoiceDate: formatInvoiceDate(invoiceDate), + serviceDate, purchaserName: snapshot.purchaser_name, purchaserEmail: snapshot.purchaser_email, companyName: snapshot.company_name, @@ -279,6 +353,7 @@ export async function renderAndUploadInvoice(params: RenderInvoiceParams): Promi export interface IssueOriginalInvoiceParams { order: InvoiceOrder conferenceTitle: string + servicePeriod?: ServicePeriod ticketCount: number invoiceNumber: string invoiceDate: Date @@ -304,7 +379,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, @@ -411,6 +486,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 } @@ -443,7 +519,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 4512ee05..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 @@ -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).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 0dca5107..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 @@ -112,9 +112,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) { @@ -199,6 +200,7 @@ export default defineHook(({ action }, hookContext) => { 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(),