Buchungskalender: PDF-Writer und Rechnungs-PDF

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:06:11 +00:00
1 parent d7b5b98187
commit 118be44847
3 files changed
+413

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { PdfDoc } from '../server/services/unterkunft/pdf.js'
import { rechnungPdf } from '../server/services/unterkunft/rechnungPdf.js'
// Buchungskalender Task 19: PDF-Writer und Rechnungs-PDF
const latin = (b) => b.toString('latin1')
function inhalt(extra = {}) {
return {
aussteller: { firma: 'Haus Seeblick', inhaber: 'Anna Muster', strasse: 'Seestr. 1', plz: '88662', ort: 'Ueberlingen', email: 'a@x.de', telefon: '0123', steuernummer: '12/345/67890', ustId: '', iban: 'DE02 1203 0000 0000 2020 51', bic: 'BYLADEM1001', bank: 'DKB' },
empfaenger: { name: 'Max Gast', firma: '', strasse: 'Weg 2', plz: '10115', ort: 'Berlin', land: 'Deutschland' },
unterkunft: { name: 'Ferienwohnung Süd' },
von: '2026-11-12', bis: '2026-11-15', naechte: 3, personen: 2,
posten: [{ text: 'Übernachtung (Frühstück), 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }],
summen: { bruttoCent: 32000, nettoCent: 29000, ust: [{ satz: 7, nettoCent: 25234, steuerCent: 1766 }, { satz: 19, nettoCent: 4202, steuerCent: 798 }], steuerart: 'regel' },
steuerart: 'regel', zahlart: 'vorort', bezugNummer: '', anfrageNummer: 'A-1', ...extra,
}
}
const kleinInhalt = () => inhalt({
steuerart: 'kleinunternehmer',
posten: [{ text: 'Übernachtung, 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 0 }],
summen: { bruttoCent: 27000, nettoCent: 27000, ust: [], steuerart: 'kleinunternehmer' },
})
test('PdfDoc: Struktur, xref, Kopf und Ende', () => {
const d = new PdfDoc()
d.text(50, 50, 'Hallo')
const b = d.toBuffer()
const s = latin(b)
assert.ok(s.startsWith('%PDF-1.4'))
assert.ok(s.endsWith('%%EOF\n'))
const start = Number(/startxref\n(\d+)\n/.exec(s)[1])
assert.equal(s.slice(start, start + 4), 'xref')
const eintraege = s.slice(start).split('\n').filter((z) => /^\d{10} 00000 n $/.test(z))
assert.ok(eintraege.length >= 6)
eintraege.forEach((z, k) => {
const off = Number(z.slice(0, 10))
assert.ok(s.slice(off).startsWith(`${k + 1} 0 obj`), `Objekt ${k + 1}`)
})
})
test('PdfDoc: CP1252, Euro, Escaping, Minus', () => {
const d = new PdfDoc()
d.text(10, 10, 'Übernachtung ß 5 € −3 (a) \\ ☃')
const s = latin(d.toBuffer())
assert.ok(s.includes('\xdcbernachtung \xdf 5 \x80 -3 \\(a\\) \\\\ ?'))
assert.ok(s.includes('/Encoding /WinAnsiEncoding'))
})
test('PdfDoc: Breiten und Umbruch', () => {
const d = new PdfDoc()
assert.equal(Math.round(d.breite('Hello', 10) * 100), 2278)
assert.ok(d.breite('Hello', 10, true) > d.breite('Hello', 10))
const z = d.umbrechen('eins zwei drei vier fünf sechs', 60, 10)
assert.ok(z.length > 1)
z.forEach((x) => assert.ok(d.breite(x, 10) <= 60))
assert.ok(d.umbrechen('Donaudampfschifffahrtsgesellschaft', 50, 10).length > 1)
assert.ok(d.breite('x', 10) > 0 && d.breite('€', 10) > 0)
})
test('Rechnung: Regelbesteuerung', () => {
const s = latin(rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }))
assert.ok(s.includes('RE-2026-0001'))
assert.ok(s.includes('Enthaltene USt 7 %'))
assert.ok(s.includes('Nettobetrag 19 %'))
assert.ok(s.includes('12.11.2026'))
assert.ok(s.includes('Beherbergung Ferienwohnung S\xfcd'))
assert.ok(s.includes('Zahlung vor Ort'))
assert.ok(!s.includes('Gem\xe4\xdf \xa7 19 UStG'))
assert.ok(s.includes('Steuernummer 12/345/67890'))
assert.ok(s.includes('IBAN'))
})
test('Rechnung: Kleinunternehmer und Online-Zahlung', () => {
const s = latin(rechnungPdf({ ...kleinInhalt(), zahlart: 'online' }, { art: 'rechnung', nummer: 'RE-2026-0002', datum: '2026-11-01' }))
assert.ok(s.includes('Gem\xe4\xdf \xa7 19 UStG wird keine Umsatzsteuer berechnet.'))
assert.ok(s.includes('Online-Zahlung'))
assert.ok(!s.includes('Enthaltene USt'))
})
test('Storno: Titel mit Bezug, negative Betraege', () => {
const neg = kleinInhalt()
neg.posten = neg.posten.map((p) => ({ ...p, einzelCent: -p.einzelCent }))
neg.summen = { ...neg.summen, bruttoCent: -27000, nettoCent: -27000 }
neg.bezugNummer = 'RE-2026-0002'
const s = latin(rechnungPdf(neg, { art: 'storno', nummer: 'RE-2026-0003', datum: '2026-11-02' }))
assert.ok(s.includes('Stornorechnung zu RE-2026-0002'))
assert.ok(s.includes('-270,00 \x80'))
})
test('Viele Posten: Seitenumbruch', () => {
const posten = Array.from({ length: 40 }, (_, k) => ({ text: `Posten ${k + 1}`, menge: 1, einzelCent: 1000, ustProzent: 0 }))
const s = latin(rechnungPdf({ ...kleinInhalt(), posten }, { art: 'rechnung', nummer: 'RE-2026-0004', datum: '2026-11-01' }))
assert.ok(s.includes('/Count 2'))
assert.ok(s.includes('Seite 2 von 2'))
})
test('Deterministisch', () => {
const o = { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }
assert.ok(rechnungPdf(inhalt(), o).equals(rechnungPdf(inhalt(), o)))
})
test('Beispiel-PDF schreiben (nur wenn Zielordner existiert)', async () => {
const { mkdirSync, writeFileSync } = await import('node:fs')
const dir = '/root/reports/buchungskalender-2026-10-07'
if (process.env.UK_BEISPIEL_PDF !== '1') return
mkdirSync(dir, { recursive: true })
writeFileSync(`${dir}/beispiel-rechnung.pdf`, rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }))
})