Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
negateInvoiceSnapshot,
parseInvoiceSnapshot,
renderAndUploadInvoice,
withServicePeriod,
type InvoiceDocument,
type RenderInvoiceResult,
} from '../shared/invoice-service.js'
Expand Down Expand Up @@ -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`)
Expand All @@ -155,6 +158,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) =>
documents = await ensureInvoiceDocuments({
order,
conferenceTitle: conference.title,
servicePeriod: conference,
ticketCount,
invoicesService,
})
Expand Down Expand Up @@ -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') {
Expand All @@ -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({
Expand All @@ -275,6 +280,7 @@ export default defineEndpoint(async (router: SandboxEndpointRouter, context) =>
documentKind: 'invoice',
filesService: ctx.filesService,
storageLocation: storageLocation(),
logger,
})
}

Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string[]> {
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
ensureInvoiceDocuments,
findCancellationFor,
findCurrentInvoiceDocument,
formatServiceDate,
InvalidTicketCountError,
issueOriginalInvoice,
markInvoiceSent,
MissingDatePaidError,
negateInvoiceSnapshot,
parseInvoiceSnapshot,
renderAndUploadInvoice,
withServicePeriod,
type InvoiceDocument,
type InvoiceOrder,
type InvoiceSnapshot,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,18 +233,24 @@ export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
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
Expand Down
Loading
Loading