diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb05cb5..44101dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Bump node version requirement to 20+ - Bump minimum supported browsers to Firefox 115, iOS/Safari 16 - Fix text with input x as null +- Added initial support for e-invoices (ZUGFeRD and Factur-X) ### [v0.18.0] - 2026-03-14 diff --git a/README.md b/README.md index 5847ce87..3b5fcd7c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ yarn add pdfkit - Encryption - Access privileges (printing, copying, modifying, annotating, form filling, content accessibility, document assembly) - Accessibility support (marked content, logical structure, Tagged PDF, PDF/UA) +- Long-term preservation of electronic documents (PDF/A) +- E-invoice PDFs (ZUGFeRD, Factur-X) ## Coming soon! diff --git a/docs/getting_started.md b/docs/getting_started.md index 0ae63d5d..9c47b142 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -318,6 +318,51 @@ In order to verify the generated document for PDF/A and its subsets conformance, Please note that PDF/A requires fonts to be embedded, as such the standard fonts PDFKit comes with cannot be used because they are in AFM format, which only provides neccessary metrics, without the font data. You should use `registerFont()` and use embeddable fonts such as `ttf`. +## E-invoices + +Electronic invoices are hybrid PDFs that are both human-readable and machine-processable, which is achieved by embedding structured XML documents. + +Currently, PDFKit aims to support ZUGFeRD v2.X and Factur-X, which both require the e-invoices to be embedded into a PDF/A-3 document. Make sure your `PDFDocument` is created with the `subset` set to either `PDF/A-3b` or `PDF/A-3a`: + + const doc = new PDFDocument({ subset: 'PDF/A-3b', pdfVersion: '1.7' }); + +Call `doc.einvoice(format, src, options = {})` after creating the document and pass the invoice XML as a `Buffer`, `ArrayBuffer` or base64 encoded `string` or path to file: + + // for ZUGFeRD + doc.einvoice('zugferd', '/invoices/invoice1234.xml'); + + // or for Factur-X + doc.einvoice('facturx', '/invoices/invoice1234.xml'); + +Note: only one e-invoice can be embedded per document and a second call will throw an error. + +The following options are supported for both ZUGFeRD and Factur-X: + +- `profile` a string indicating the conformance profile, defaults to 'EN 16931' and the value is case insensitive +- `documentType` a string indicating the type of document, e.g. 'INVOICE', 'ORDER'. Defaults to 'INVOICE'. See the ZUGFeRD/Factur-X specification for all accepted values. +- `version` a string to override the default version PDFKit chooses from the profile + +The following profiles are supported for ZUGFeRD: + +- `minimum` default version 2.4 +- `basic wl` default version 2.4 +- `basic` default version 2.4 +- `en 16931` default version 2.4 +- `extended` default version 2.4 +- `xrechnung` default version 3.0 + +The following profiles are supported for Factur-X: + +- `minimum` default version 1.0 +- `basic wl` default version 1.0 +- `basic` default version 1.0 +- `en 16931` default version 1.0 +- `extended` default version 1.0 + +PDFKit does not validate your XML against the selected profile, therefore please ensure you select the adequate profile based on your XML. + +Note: Unknown profiles fall back to 'EN 16931'. + ### Adding content Once you've created a `PDFDocument` instance, you can add content to the diff --git a/docs/guide.pdf b/docs/guide.pdf index 7c675884..49bebe1f 100644 Binary files a/docs/guide.pdf and b/docs/guide.pdf differ diff --git a/examples/einvoices/en16931.xml b/examples/einvoices/en16931.xml new file mode 100644 index 00000000..07823ecf --- /dev/null +++ b/examples/einvoices/en16931.xml @@ -0,0 +1,182 @@ + + + + + + urn:cen.eu:en16931:2017 + + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + + 1 + + + Test Product A + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + 100.00 + + + + + + 0 + + S-001 + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + 11111111111 + + + + B-001 + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + DE222222222 + + + + ORD-0001 + + + + + + + 20240101 + + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 21.85 + VAT + 115.00 + S + 19.00 + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + + diff --git a/examples/einvoices/zugferd-xrechnung.xml b/examples/einvoices/zugferd-xrechnung.xml new file mode 100644 index 00000000..d285b78c --- /dev/null +++ b/examples/einvoices/zugferd-xrechnung.xml @@ -0,0 +1,181 @@ + + + + + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + + + urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0 + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + 1 + + + Test Product A + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + 100.00 + + + + + 0 + + S-001 + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + 11111111111 + + + + B-001 + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + DE222222222 + + + + ORD-0001 + + + + + + 20240101 + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 21.85 + VAT + 115.00 + S + 19.00 + + + + 20240101 + + + 20240101 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + diff --git a/examples/facturx.js b/examples/facturx.js new file mode 100644 index 00000000..bdc51ac8 --- /dev/null +++ b/examples/facturx.js @@ -0,0 +1,246 @@ +const PDFDocument = require('../'); +const fs = require('fs'); +const path = require('path'); + +const invoice = { + number: 'RE-2024-0001', + date: '01.01.2024', + orderRef: 'ORD-0001', + currency: 'EUR', + note: 'Test invoice', + paymentTerms: 'Payable within 14 days without deduction', + + seller: { + name: 'Acme Media GmbH', + street: 'Test Street 1', + zip: '10115', + city: 'Berlin', + country: 'DE', + vat: 'DE111111111', + contact: 'Anna Seller', + phone: '030/11111111', + email: 'anna@seller.example', + iban: 'DE89 3704 0044 0532 0130 00', + }, + + buyer: { + name: 'Test Buyer GmbH', + street: 'Sample Avenue 2', + zip: '20095', + city: 'Hamburg', + country: 'DE', + vat: 'DE222222222', + }, + + lines: [ + { id: 1, description: 'Test Product A', qty: 1, unit: 'pcs', unitPrice: 100.00, vatRate: 19, total: 100.00 }, + ], + + allowances: [ + { label: 'Discount 30%', amount: -30.00, vatRate: 19 }, + { label: 'Agency commission 15%', amount: -15.00, vatRate: 19 }, + ], + + charges: [ + { label: 'Colour surcharge 60%', amount: 60.00, vatRate: 19 }, + ], + + summary: { + lineTotal: 100.00, + chargeTotal: 60.00, + allowanceTotal: 45.00, + taxBasis: 115.00, + vat: 21.85, + grandTotal: 136.85, + dueAmount: 136.85, + }, +}; + +const LEFT = 50; +const RIGHT = 545; +const WIDTH = RIGHT - LEFT; + +// helper functions +const fmt = (n) => n.toFixed(2).replace('.', ',') + ' €'; + +function rule(doc, y, weight = 0.5) { + doc.save() + .moveTo(LEFT, y).lineTo(RIGHT, y) + .lineWidth(weight).strokeColor('#CCCCCC').stroke() + .restore(); +} + +function tableRow(doc, y, cols, opts = {}) { + const { bold = false, bg = null, textColor = '#222222' } = opts; + if (bg) { + doc.save().rect(LEFT, y - 4, WIDTH, 18).fill(bg).restore(); + } + doc.fillColor(textColor).font(bold ? 'Palatino-Bold' : 'DejaVuSans').fontSize(9); + cols.forEach(([text, x, align = 'left', w = 80]) => { + doc.text(text, x, y, { width: w, align }); + }); +} + +// initialise document +const doc = new PDFDocument({ + subset: 'PDF/A-3b', + pdfVersion: '1.7', + size: 'A4', + margins: { top: 50, bottom: 50, left: LEFT, right: 50 }, + info: { + Title: `Invoice ${invoice.number}`, + Author: invoice.seller.name, + Subject: 'Factur-X e-invoice', + Keywords: 'invoice facturx en16931', + }, +}); + +doc.pipe(fs.createWriteStream(path.join(__dirname, 'facturx.pdf'))); + +doc.registerFont('Palatino-Bold', 'fonts/PalatinoBold.ttf'); +doc.registerFont('DejaVuSans', 'fonts/DejaVuSans.ttf'); + +// embed and declare e-invoice +doc.einvoice('facturx', path.join(__dirname, 'einvoices', 'en16931.xml')); + +// invoice header +doc.rect(LEFT, 40, WIDTH, 46).fill('#1A3A5C'); +doc.fillColor('#FFFFFF').font('Palatino-Bold').fontSize(18) + .text('INVOICE', LEFT + 10, 50, { width: 200 }); +doc.fillColor('#A8C4E0').font('DejaVuSans').fontSize(9) + .text(`No. ${invoice.number}`, LEFT + 10, 67, { width: 200 }); +doc.fillColor('#FFFFFF').font('DejaVuSans').fontSize(9) + .text(`Date: ${invoice.date}`, RIGHT - 150, 55, { width: 140, align: 'right' }) + .text(`Order ref: ${invoice.orderRef}`, RIGHT - 150, 67, { width: 140, align: 'right' }); + +// invoice addresses +let y = 100; + +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('FROM', LEFT, y); +doc.fillColor('#222222').font('Palatino-Bold').fontSize(10).text(invoice.seller.name, LEFT, y + 13); +doc.font('DejaVuSans').fontSize(9) + .text(invoice.seller.street, LEFT, y + 25) + .text(`${invoice.seller.zip} ${invoice.seller.city}, ${invoice.seller.country}`, LEFT, y + 36) + .text(`VAT: ${invoice.seller.vat}`, LEFT, y + 47) + .text(`Contact: ${invoice.seller.contact}`, LEFT, y + 58) + .text(`Phone: ${invoice.seller.phone}`, LEFT, y + 69) + .text(`Email: ${invoice.seller.email}`, LEFT, y + 80); + +const COL2 = LEFT + 270; +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('BILL TO', COL2, y); +doc.fillColor('#222222').font('Palatino-Bold').fontSize(10).text(invoice.buyer.name, COL2, y + 13); +doc.font('DejaVuSans').fontSize(9) + .text(invoice.buyer.street, COL2, y + 25) + .text(`${invoice.buyer.zip} ${invoice.buyer.city}, ${invoice.buyer.country}`, COL2, y + 36) + .text(`VAT: ${invoice.buyer.vat}`, COL2, y + 47); + +// invoice body (line items) +y = 215; +rule(doc, y - 5, 1); + +tableRow(doc, y, [ + ['#', LEFT, 'left', 20], + ['Description', LEFT + 25, 'left', 185], + ['Qty', LEFT + 215, 'right', 40], + ['Unit', LEFT + 260, 'center', 30], + ['Unit price', LEFT + 295, 'right', 70], + ['VAT %', LEFT + 370, 'right', 40], + ['Total', LEFT + 415, 'right', 80], +], { bold: true, textColor: '#1A3A5C' }); + +y += 18; +rule(doc, y - 3); + +invoice.lines.forEach((line, i) => { + const bg = i % 2 === 0 ? '#F5F8FB' : null; + tableRow(doc, y, [ + [String(line.id), LEFT, 'left', 20], + [line.description, LEFT + 25, 'left', 185], + [String(line.qty), LEFT + 215, 'right', 40], + [line.unit, LEFT + 260, 'center', 30], + [fmt(line.unitPrice), LEFT + 295, 'right', 70], + [`${line.vatRate}%`, LEFT + 370, 'right', 40], + [fmt(line.total), LEFT + 415, 'right', 80], + ], { bg }); + y += 18; +}); + +rule(doc, y - 2); + +// allowances, charges, adjustments etc. +y += 6; +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('ADJUSTMENTS', LEFT, y); +y += 14; + +const adjustments = [ + ...invoice.charges.map(c => ({ label: c.label, amount: c.amount, vatRate: c.vatRate, sign: '+', color: '#B45309' })), + ...invoice.allowances.map(a => ({ label: a.label, amount: Math.abs(a.amount), vatRate: a.vatRate, sign: '-', color: '#065F46' })), +]; + +adjustments.forEach((adj, i) => { + const bg = i % 2 === 0 ? '#F5F8FB' : null; + tableRow(doc, y, [ + [adj.label, LEFT + 25, 'left', 340], + [`${adj.vatRate}%`, LEFT + 370, 'right', 40], + [`${adj.sign} ${fmt(adj.amount)}`, LEFT + 415, 'right', 80], + ], { bg, textColor: adj.color }); + y += 18; +}); + +// totals +y += 4; +rule(doc, y, 1); +y += 10; + +const TLABEL = LEFT + 310; +const TVALUE = LEFT + 415; +const TWIDTH = RIGHT - TVALUE; + +const totalsRows = [ + ['Subtotal (lines)', fmt(invoice.summary.lineTotal)], + ['Charges', '+' + fmt(invoice.summary.chargeTotal)], + ['Allowances', '-' + fmt(invoice.summary.allowanceTotal)], + ['Tax basis', fmt(invoice.summary.taxBasis)], + ['VAT (19%)', fmt(invoice.summary.vat)], +]; + +totalsRows.forEach(([label, value]) => { + doc.fillColor('#444444').font('DejaVuSans').fontSize(9) + .text(label, TLABEL, y, { width: 100, align: 'left' }) + .text(value, TVALUE, y, { width: TWIDTH, align: 'right' }); + y += 14; +}); + +y += 2; +doc.rect(TLABEL - 8, y - 4, RIGHT - TLABEL + 8, 22).fill('#1A3A5C'); +doc.fillColor('#FFFFFF').font('Palatino-Bold').fontSize(11) + .text('TOTAL DUE', TLABEL - 4, y + 2, { width: 100, align: 'left' }) + .text(fmt(invoice.summary.dueAmount), TVALUE, y + 2, { width: TWIDTH, align: 'right' }); +y += 28; + +// payment details +y += 6; +rule(doc, y, 1); +y += 10; + +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('PAYMENT DETAILS', LEFT, y); +y += 14; +doc.fillColor('#222222').font('DejaVuSans').fontSize(9) + .text(`Bank transfer (SEPA) - IBAN: ${invoice.seller.iban}`, LEFT, y) + .text(`Account holder: ${invoice.seller.name}`, LEFT, y + 12) + .text(`Terms: ${invoice.paymentTerms}`, LEFT, y + 24); + +// footer +const PAGE_BOTTOM = doc.page.height - doc.page.margins.bottom; +doc.rect(LEFT, PAGE_BOTTOM - 36, WIDTH, 1).fill('#1A3A5C'); +doc.fillColor('#888888').font('DejaVuSans').fontSize(7.5) + .text( + `${invoice.seller.name} - ${invoice.seller.street}, ${invoice.seller.zip} ${invoice.seller.city} - VAT ${invoice.seller.vat} - ${invoice.seller.email}`, + LEFT, PAGE_BOTTOM - 32, { width: WIDTH, align: 'center' } + ); + +// compliance note +doc.fillColor('#AAAAAA').fontSize(6.5) + .text('Factur-X - EN 16931 - PDF/A-3b', LEFT, PAGE_BOTTOM - 12, { width: WIDTH, align: 'right' }); + +doc.end(); \ No newline at end of file diff --git a/examples/facturx.pdf b/examples/facturx.pdf new file mode 100644 index 00000000..23d7d811 Binary files /dev/null and b/examples/facturx.pdf differ diff --git a/examples/zugferd.js b/examples/zugferd.js new file mode 100644 index 00000000..1b72d14a --- /dev/null +++ b/examples/zugferd.js @@ -0,0 +1,246 @@ +const PDFDocument = require('../'); +const fs = require('fs'); +const path = require('path'); + +const invoice = { + number: 'RE-2024-0001', + date: '01.01.2024', + orderRef: 'ORD-0001', + currency: 'EUR', + note: 'Test invoice', + paymentTerms: 'Payable within 14 days without deduction', + + seller: { + name: 'Acme Media GmbH', + street: 'Test Street 1', + zip: '10115', + city: 'Berlin', + country: 'DE', + vat: 'DE111111111', + contact: 'Anna Seller', + phone: '030/11111111', + email: 'anna@seller.example', + iban: 'DE89 3704 0044 0532 0130 00', + }, + + buyer: { + name: 'Test Buyer GmbH', + street: 'Sample Avenue 2', + zip: '20095', + city: 'Hamburg', + country: 'DE', + vat: 'DE222222222', + }, + + lines: [ + { id: 1, description: 'Test Product A', qty: 1, unit: 'pcs', unitPrice: 100.00, vatRate: 19, total: 100.00 }, + ], + + allowances: [ + { label: 'Discount 30%', amount: -30.00, vatRate: 19 }, + { label: 'Agency commission 15%', amount: -15.00, vatRate: 19 }, + ], + + charges: [ + { label: 'Colour surcharge 60%', amount: 60.00, vatRate: 19 }, + ], + + summary: { + lineTotal: 100.00, + chargeTotal: 60.00, + allowanceTotal: 45.00, + taxBasis: 115.00, + vat: 21.85, + grandTotal: 136.85, + dueAmount: 136.85, + }, +}; + +const LEFT = 50; +const RIGHT = 545; +const WIDTH = RIGHT - LEFT; + +// helper functions +const fmt = (n) => n.toFixed(2).replace('.', ',') + ' €'; + +function rule(doc, y, weight = 0.5) { + doc.save() + .moveTo(LEFT, y).lineTo(RIGHT, y) + .lineWidth(weight).strokeColor('#CCCCCC').stroke() + .restore(); +} + +function tableRow(doc, y, cols, opts = {}) { + const { bold = false, bg = null, textColor = '#222222' } = opts; + if (bg) { + doc.save().rect(LEFT, y - 4, WIDTH, 18).fill(bg).restore(); + } + doc.fillColor(textColor).font(bold ? 'Palatino-Bold' : 'DejaVuSans').fontSize(9); + cols.forEach(([text, x, align = 'left', w = 80]) => { + doc.text(text, x, y, { width: w, align }); + }); +} + +// initialise document +const doc = new PDFDocument({ + subset: 'PDF/A-3b', + pdfVersion: '1.7', + size: 'A4', + margins: { top: 50, bottom: 50, left: LEFT, right: 50 }, + info: { + Title: `Invoice ${invoice.number}`, + Author: invoice.seller.name, + Subject: 'ZUGFeRD XRechnung e-invoice', + Keywords: 'invoice zugferd xrechnung', + }, +}); + +doc.pipe(fs.createWriteStream(path.join(__dirname, 'zugferd.pdf'))); + +doc.registerFont('Palatino-Bold', 'fonts/PalatinoBold.ttf'); +doc.registerFont('DejaVuSans', 'fonts/DejaVuSans.ttf'); + +// embed and declare e-invoice +doc.einvoice('zugferd', path.join(__dirname, 'einvoices', 'zugferd-xrechnung.xml'), { profile: 'xrechnung' }); + +// invoice header +doc.rect(LEFT, 40, WIDTH, 46).fill('#1A3A5C'); +doc.fillColor('#FFFFFF').font('Palatino-Bold').fontSize(18) + .text('INVOICE', LEFT + 10, 50, { width: 200 }); +doc.fillColor('#A8C4E0').font('DejaVuSans').fontSize(9) + .text(`No. ${invoice.number}`, LEFT + 10, 67, { width: 200 }); +doc.fillColor('#FFFFFF').font('DejaVuSans').fontSize(9) + .text(`Date: ${invoice.date}`, RIGHT - 150, 55, { width: 140, align: 'right' }) + .text(`Order ref: ${invoice.orderRef}`, RIGHT - 150, 67, { width: 140, align: 'right' }); + +// invoice addresses +let y = 100; + +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('FROM', LEFT, y); +doc.fillColor('#222222').font('Palatino-Bold').fontSize(10).text(invoice.seller.name, LEFT, y + 13); +doc.font('DejaVuSans').fontSize(9) + .text(invoice.seller.street, LEFT, y + 25) + .text(`${invoice.seller.zip} ${invoice.seller.city}, ${invoice.seller.country}`, LEFT, y + 36) + .text(`VAT: ${invoice.seller.vat}`, LEFT, y + 47) + .text(`Contact: ${invoice.seller.contact}`, LEFT, y + 58) + .text(`Phone: ${invoice.seller.phone}`, LEFT, y + 69) + .text(`Email: ${invoice.seller.email}`, LEFT, y + 80); + +const COL2 = LEFT + 270; +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('BILL TO', COL2, y); +doc.fillColor('#222222').font('Palatino-Bold').fontSize(10).text(invoice.buyer.name, COL2, y + 13); +doc.font('DejaVuSans').fontSize(9) + .text(invoice.buyer.street, COL2, y + 25) + .text(`${invoice.buyer.zip} ${invoice.buyer.city}, ${invoice.buyer.country}`, COL2, y + 36) + .text(`VAT: ${invoice.buyer.vat}`, COL2, y + 47); + +// invoice body (line items) +y = 215; +rule(doc, y - 5, 1); + +tableRow(doc, y, [ + ['#', LEFT, 'left', 20], + ['Description', LEFT + 25, 'left', 185], + ['Qty', LEFT + 215, 'right', 40], + ['Unit', LEFT + 260, 'center', 30], + ['Unit price', LEFT + 295, 'right', 70], + ['VAT %', LEFT + 370, 'right', 40], + ['Total', LEFT + 415, 'right', 80], +], { bold: true, textColor: '#1A3A5C' }); + +y += 18; +rule(doc, y - 3); + +invoice.lines.forEach((line, i) => { + const bg = i % 2 === 0 ? '#F5F8FB' : null; + tableRow(doc, y, [ + [String(line.id), LEFT, 'left', 20], + [line.description, LEFT + 25, 'left', 185], + [String(line.qty), LEFT + 215, 'right', 40], + [line.unit, LEFT + 260, 'center', 30], + [fmt(line.unitPrice), LEFT + 295, 'right', 70], + [`${line.vatRate}%`, LEFT + 370, 'right', 40], + [fmt(line.total), LEFT + 415, 'right', 80], + ], { bg }); + y += 18; +}); + +rule(doc, y - 2); + +// allowances, charges, adjustments etc. +y += 6; +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('ADJUSTMENTS', LEFT, y); +y += 14; + +const adjustments = [ + ...invoice.charges.map(c => ({ label: c.label, amount: c.amount, vatRate: c.vatRate, sign: '+', color: '#B45309' })), + ...invoice.allowances.map(a => ({ label: a.label, amount: Math.abs(a.amount), vatRate: a.vatRate, sign: '-', color: '#065F46' })), +]; + +adjustments.forEach((adj, i) => { + const bg = i % 2 === 0 ? '#F5F8FB' : null; + tableRow(doc, y, [ + [adj.label, LEFT + 25, 'left', 340], + [`${adj.vatRate}%`, LEFT + 370, 'right', 40], + [`${adj.sign} ${fmt(adj.amount)}`, LEFT + 415, 'right', 80], + ], { bg, textColor: adj.color }); + y += 18; +}); + +// totals +y += 4; +rule(doc, y, 1); +y += 10; + +const TLABEL = LEFT + 310; +const TVALUE = LEFT + 415; +const TWIDTH = RIGHT - TVALUE; + +const totalsRows = [ + ['Subtotal (lines)', fmt(invoice.summary.lineTotal)], + ['Charges', '+' + fmt(invoice.summary.chargeTotal)], + ['Allowances', '-' + fmt(invoice.summary.allowanceTotal)], + ['Tax basis', fmt(invoice.summary.taxBasis)], + ['VAT (19%)', fmt(invoice.summary.vat)], +]; + +totalsRows.forEach(([label, value]) => { + doc.fillColor('#444444').font('DejaVuSans').fontSize(9) + .text(label, TLABEL, y, { width: 100, align: 'left' }) + .text(value, TVALUE, y, { width: TWIDTH, align: 'right' }); + y += 14; +}); + +y += 2; +doc.rect(TLABEL - 8, y - 4, RIGHT - TLABEL + 8, 22).fill('#1A3A5C'); +doc.fillColor('#FFFFFF').font('Palatino-Bold').fontSize(11) + .text('TOTAL DUE', TLABEL - 4, y + 2, { width: 100, align: 'left' }) + .text(fmt(invoice.summary.dueAmount), TVALUE, y + 2, { width: TWIDTH, align: 'right' }); +y += 28; + +// payment details +y += 6; +rule(doc, y, 1); +y += 10; + +doc.fillColor('#1A3A5C').font('Palatino-Bold').fontSize(9).text('PAYMENT DETAILS', LEFT, y); +y += 14; +doc.fillColor('#222222').font('DejaVuSans').fontSize(9) + .text(`Bank transfer (SEPA) - IBAN: ${invoice.seller.iban}`, LEFT, y) + .text(`Account holder: ${invoice.seller.name}`, LEFT, y + 12) + .text(`Terms: ${invoice.paymentTerms}`, LEFT, y + 24); + +// footer +const PAGE_BOTTOM = doc.page.height - doc.page.margins.bottom; +doc.rect(LEFT, PAGE_BOTTOM - 36, WIDTH, 1).fill('#1A3A5C'); +doc.fillColor('#888888').font('DejaVuSans').fontSize(7.5) + .text( + `${invoice.seller.name} - ${invoice.seller.street}, ${invoice.seller.zip} ${invoice.seller.city} - VAT ${invoice.seller.vat} - ${invoice.seller.email}`, + LEFT, PAGE_BOTTOM - 32, { width: WIDTH, align: 'center' } + ); + +// compliance note +doc.fillColor('#AAAAAA').fontSize(6.5) + .text('ZUGFeRD / XRechnung - PDF/A-3b', LEFT, PAGE_BOTTOM - 12, { width: WIDTH, align: 'right' }); + +doc.end(); diff --git a/examples/zugferd.pdf b/examples/zugferd.pdf new file mode 100644 index 00000000..212eaa81 Binary files /dev/null and b/examples/zugferd.pdf differ diff --git a/lib/document.js b/lib/document.js index b3296f4c..124335c4 100644 --- a/lib/document.js +++ b/lib/document.js @@ -23,6 +23,7 @@ import LineWrapper from './line_wrapper'; import SubsetMixin from './mixins/subsets'; import TableMixin from './mixins/table'; import MetadataMixin from './mixins/metadata'; +import EInvoiceMixin from './mixins/einvoice'; class PDFDocument extends stream.Readable { constructor(options = {}) { @@ -387,6 +388,7 @@ mixin(AcroFormMixin); mixin(AttachmentsMixin); mixin(SubsetMixin); mixin(TableMixin); +mixin(EInvoiceMixin); PDFDocument.LineWrapper = LineWrapper; diff --git a/lib/metadata.js b/lib/metadata.js index ae218029..ff931d58 100644 --- a/lib/metadata.js +++ b/lib/metadata.js @@ -1,23 +1,17 @@ class PDFMetadata { constructor() { - this._metadata = ` - - - - `; + this._body = ''; + this._extraNamespaces = {}; + this._metadata = ''; } - _closeTags() { - this._metadata = this._metadata.concat(` - - - - `); + registerNamespace(prefix, uri) { + this._extraNamespaces[prefix] = uri; } append(xml, newline = true) { - this._metadata = this._metadata.concat(xml); - if (newline) this._metadata = this._metadata.concat('\n'); + this._body = this._body.concat(xml); + if (newline) this._body = this._body.concat('\n'); } getXML() { @@ -29,8 +23,19 @@ class PDFMetadata { } end() { - this._closeTags(); - this._metadata = this._metadata.trim(); + const extraNs = Object.entries(this._extraNamespaces) + .map(([p, u]) => `\n xmlns:${p}="${u}"`) + .join(''); + + this._metadata = ` + + + + ${this._body} + + + + `.trim(); } } diff --git a/lib/mixins/einvoice.js b/lib/mixins/einvoice.js new file mode 100644 index 00000000..6fa00e86 --- /dev/null +++ b/lib/mixins/einvoice.js @@ -0,0 +1,46 @@ +import zugferd from './zugferd'; +import facturx from './facturx'; + +export default { + _importFormat(format) { + Object.assign(this, format); + }, + + /** + * Attach an electronic invoice and its required metadata. + * @param {string} format invoice format identifier (e.g. 'zugferd', 'facturx') + * @param {Buffer | ArrayBuffer | string} src invoice data to embed (Buffer, ArrayBuffer, base64 encoded string or path to file) + * @param {object} options format-specific options, see documentation for each supported format for details on required and optional fields + */ + einvoice(format, src, options = {}) { + if (this._einvoiceEmbedded) { + throw new Error( + 'An e-invoice has already been embedded in this document', + ); + } + + if (!src) { + throw new Error('einvoice: src is required'); + } + + if (this.subset !== 3) { + const current = this.subset ? `PDF/A-${this.subset}` : 'none'; + console.warn( + `einvoice: PDF/A-3 is required for e-invoice compliance (current subset: ${current})`, + ); + } + + switch (format.toLowerCase()) { + case 'zugferd': + this._einvoiceEmbedded = true; + this._importFormat(zugferd); + return this.embedZugferd(src, options); + case 'facturx': + this._einvoiceEmbedded = true; + this._importFormat(facturx); + return this.embedFacturX(src, options); + default: + throw new Error(`Unsupported e-invoice format: "${format}"`); + } + }, +}; diff --git a/lib/mixins/facturx.js b/lib/mixins/facturx.js new file mode 100644 index 00000000..d0fffd12 --- /dev/null +++ b/lib/mixins/facturx.js @@ -0,0 +1,111 @@ +const FACTURX_NS = 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#'; + +const PROFILES = { + minimum: { + label: 'MINIMUM', + filename: 'factur-x.xml', + version: '1.0', + }, + 'basic wl': { + label: 'BASIC WL', + filename: 'factur-x.xml', + version: '1.0', + }, + basic: { label: 'BASIC', filename: 'factur-x.xml', version: '1.0' }, + 'en 16931': { + label: 'EN 16931', + filename: 'factur-x.xml', + version: '1.0', + }, + extended: { + label: 'EXTENDED', + filename: 'factur-x.xml', + version: '1.0', + }, +}; + +export default { + /** + * Embed a Factur-X XML invoice alongside the required XMP metadata. + * @param {Buffer | ArrayBuffer | string} src XML invoice data to embed (Buffer, ArrayBuffer, base64 data-URI or path to file) + * @param {object} options + * * options.profile: the conformance profile 'minimum' or 'basic wl' or 'basic' or 'en 16931' or 'extended' (default: 'en 16931') + * * options.version: override for spec version that is written to the metadata, each conformance profile has a built-in default + * * options.documentType: the document type written to the metadata 'INVOICE' or 'ORDER' or 'ORDER_RESPONSE' or 'ORDER_CHANGE' (default: 'INVOICE') + * @returns filespec reference + */ + embedFacturX(src, options = {}) { + const profile = + PROFILES[options.profile?.toLowerCase()] ?? PROFILES['en 16931']; + const version = options.version || profile.version; + const documentType = options.documentType || 'INVOICE'; + + this.registerXMPNamespace('fx', FACTURX_NS); + + const filespec = this.file(src, { + name: profile.filename, + type: 'text/xml', + relationship: 'Alternative', + }); + + this.appendXML( + _getSchema(profile.filename, profile.label, version, documentType), + ); + + return filespec; + }, +}; + +function _getSchema(filename, profile, version, documentType) { + return ` + + + + + Factur-X PDFA Extension Schema + ${FACTURX_NS} + fx + + + + DocumentFileName + Text + external + The name of the embedded XML document + + + DocumentType + Text + external + The type of the embedded XML document + + + Version + Text + external + The version of the Factur-X standard used + + + ConformanceLevel + Text + external + The conformance level of the Factur-X standard used + + + + + + + + + ${documentType} + ${filename} + ${version} + ${profile} + + `; +} diff --git a/lib/mixins/metadata.js b/lib/mixins/metadata.js index 67b2b6cd..7654781e 100644 --- a/lib/mixins/metadata.js +++ b/lib/mixins/metadata.js @@ -9,6 +9,10 @@ export default { this.metadata.append(xml, newline); }, + registerXMPNamespace(prefix, uri) { + this.metadata.registerNamespace(prefix, uri); + }, + _addInfo() { this.appendXML(` diff --git a/lib/mixins/zugferd.js b/lib/mixins/zugferd.js new file mode 100644 index 00000000..2496a7e0 --- /dev/null +++ b/lib/mixins/zugferd.js @@ -0,0 +1,116 @@ +const ZUGFERD_V2_NS = 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#'; + +const PROFILES = { + minimum: { + label: 'MINIMUM', + filename: 'zugferd-invoice.xml', + version: '2.4', + }, + 'basic wl': { + label: 'BASIC WL', + filename: 'zugferd-invoice.xml', + version: '2.4', + }, + basic: { label: 'BASIC', filename: 'zugferd-invoice.xml', version: '2.4' }, + 'en 16931': { + label: 'EN 16931', + filename: 'zugferd-invoice.xml', + version: '2.4', + }, + extended: { + label: 'EXTENDED', + filename: 'zugferd-invoice.xml', + version: '2.4', + }, + xrechnung: { + label: 'XRECHNUNG', + filename: 'xrechnung.xml', + version: '3.0', + }, +}; + +export default { + /** + * Embed a ZUGFeRD XML invoice alongside the required XMP metadata. + * @param {Buffer | ArrayBuffer | string} src XML invoice data to embed (Buffer, ArrayBuffer, base64 data-URI or path to file) + * @param {object} options + * * options.profile: the conformance profile 'minimum' or 'basic wl' or 'basic' or 'en 16931' or 'extended' or 'xrechnung' (default: 'en 16931') + * * options.version: override for spec version that is written to the metadata, each conformance profile has a built-in default + * * options.documentType: the document type written to the metadata 'INVOICE' or 'ORDER' or 'ORDER_RESPONSE' or 'ORDER_CHANGE' (default: 'INVOICE') + * @returns filespec reference + */ + embedZugferd(src, options = {}) { + const profile = + PROFILES[options.profile?.toLowerCase()] ?? PROFILES['en 16931']; + const version = options.version || profile.version; + const documentType = options.documentType || 'INVOICE'; + + this.registerXMPNamespace('fx', ZUGFERD_V2_NS); + + const filespec = this.file(src, { + name: profile.filename, + type: 'text/xml', + relationship: 'Alternative', + }); + + this.appendXML( + _getSchema(profile.filename, profile.label, version, documentType), + ); + + return filespec; + }, +}; + +function _getSchema(filename, profile, version, documentType) { + return ` + + + + + ZUGFeRD PDFA Extension Schema + ${ZUGFERD_V2_NS} + fx + + + + DocumentFileName + Text + external + The name of the embedded XML document + + + DocumentType + Text + external + The type of the embedded XML document + + + Version + Text + external + The version of the ZUGFeRD standard used + + + ConformanceLevel + Text + external + The conformance level of the ZUGFeRD standard used + + + + + + + + + ${documentType} + ${filename} + ${version} + ${profile} + + `; +} diff --git a/tests/unit/einvoices/basic-wl.xml b/tests/unit/einvoices/basic-wl.xml new file mode 100644 index 00000000..dc1c6453 --- /dev/null +++ b/tests/unit/einvoices/basic-wl.xml @@ -0,0 +1,143 @@ + + + + + + urn:factur-x.eu:1p0:basicwl + + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + 0 + + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + + + + + + 20240101 + + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 19.00 + VAT + 100.00 + S + 19.00 + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + + diff --git a/tests/unit/einvoices/basic.xml b/tests/unit/einvoices/basic.xml new file mode 100644 index 00000000..79900dab --- /dev/null +++ b/tests/unit/einvoices/basic.xml @@ -0,0 +1,171 @@ + + + + + + urn:factur-x.eu:1p0:basic + + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + + 1 + + + Test Product A + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + 100.00 + + + + + + 0 + + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + + + + + + 20240101 + + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 19.00 + VAT + 100.00 + S + 19.00 + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + + diff --git a/tests/unit/einvoices/en16931.xml b/tests/unit/einvoices/en16931.xml new file mode 100644 index 00000000..07823ecf --- /dev/null +++ b/tests/unit/einvoices/en16931.xml @@ -0,0 +1,182 @@ + + + + + + urn:cen.eu:en16931:2017 + + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + + 1 + + + Test Product A + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + 100.00 + + + + + + 0 + + S-001 + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + 11111111111 + + + + B-001 + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + DE222222222 + + + + ORD-0001 + + + + + + + 20240101 + + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 21.85 + VAT + 115.00 + S + 19.00 + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + + diff --git a/tests/unit/einvoices/extended.xml b/tests/unit/einvoices/extended.xml new file mode 100644 index 00000000..8cb5baad --- /dev/null +++ b/tests/unit/einvoices/extended.xml @@ -0,0 +1,228 @@ + + + + + + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended + + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + + 1 + + Test edition note + + + + 4000001000002 + Test Product A + Full page advertisement in test publication + + + + 100.00 + 1 + + + false + + 0 + 0.00 + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + + 20240101 + + + 20240131 + + + + 100.00 + + + + + + 0 + + S-001 + 4000001000001 + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + 11111111111 + + + + B-001 + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + DE222222222 + + + + ORD-0001 + + + + + + + 20240101 + + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + DE00000000000000000000 + + + + 19.00 + VAT + 100.00 + S + 19.00 + + + + 20240101 + + + 20240131 + + + + + false + + 30.00 + 100.00 + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + true + + 60.00 + 70.00 + 42.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 15.00 + 112.00 + 16.80 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + 20240115 + + + + 100.00 + 42.00 + 46.80 + 95.20 + 18.09 + 113.29 + 0.00 + 113.29 + + + + + diff --git a/tests/unit/einvoices/minimum.xml b/tests/unit/einvoices/minimum.xml new file mode 100644 index 00000000..b396ce34 --- /dev/null +++ b/tests/unit/einvoices/minimum.xml @@ -0,0 +1,62 @@ + + + + + + urn:factur-x.eu:1p0:minimum + + + + + RE-2024-0001 + 380 + + 20240101 + + + + + + 0 + + Acme Media GmbH + + 10115 + Test Street 1 + Berlin + DE + + + DE111111111 + + + + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + + + + + + EUR + + 100.00 + 19.00 + 119.00 + 119.00 + + + + + diff --git a/tests/unit/einvoices/zugferd-xrechnung.xml b/tests/unit/einvoices/zugferd-xrechnung.xml new file mode 100644 index 00000000..d285b78c --- /dev/null +++ b/tests/unit/einvoices/zugferd-xrechnung.xml @@ -0,0 +1,181 @@ + + + + + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + + + urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0 + + + + RE-2024-0001 + 380 + + 20240101 + + + Test invoice + + + + + + 1 + + + Test Product A + + + + 100.00 + 1 + + + + 1 + + + + VAT + S + 19 + + + 100.00 + + + + + 0 + + S-001 + Acme Media GmbH + + Anna Seller + + 030/11111111 + + + anna@seller.example + + + + 10115 + Test Street 1 + Berlin + DE + + + billing@seller.example + + + DE111111111 + + + 11111111111 + + + + B-001 + Test Buyer GmbH + + 20095 + Sample Avenue 2 + Hamburg + DE + + + accounts@buyer.example + + + DE222222222 + + + + ORD-0001 + + + + + + 20240101 + + + + + EUR + + 30 + SEPA credit transfer + + DE89370400440532013000 + Acme Media GmbH + + + + 21.85 + VAT + 115.00 + S + 19.00 + + + + 20240101 + + + 20240101 + + + + + true + + 60.00 + Colour surcharge 60% + + VAT + S + 19.00 + + + + + false + + 30.00 + Discount 30% + + VAT + S + 19.00 + + + + + false + + 15.00 + Agency commission 15% + + VAT + S + 19.00 + + + + Payable within 14 days without deduction + + + 100.00 + 60.00 + 45.00 + 115.00 + 21.85 + 136.85 + 136.85 + + + + diff --git a/tests/unit/facturx.spec.js b/tests/unit/facturx.spec.js new file mode 100644 index 00000000..ef814a89 --- /dev/null +++ b/tests/unit/facturx.spec.js @@ -0,0 +1,226 @@ +import PDFDocument from '../../lib/document'; +import { logData } from './helpers'; +import { readFileSync } from 'fs'; + +function makeDoc() { + return new PDFDocument({ + autoFirstPage: false, + compress: false, + pdfVersion: '1.7', + subset: 'PDF/A-3b', + }); +} + +function getMetadata(data) { + const buf = data.find( + (v) => + typeof v !== 'string' && + Buffer.isBuffer(v) && + v.toString().includes('x:xmpmeta'), + ); + return buf ? buf.toString() : null; +} + +function loadXML(name) { + return readFileSync(new URL(`./einvoices/${name}`, import.meta.url)); +} + +const XML = Buffer.from(''); + +describe('facturx', () => { + test('embeds the XML with AFRelationship Alternative', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML); + doc.end(); + + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toBeDefined(); + expect(filespec).toContain('/F (factur-x.xml)'); + + const embeddedFile = data.find( + (v) => typeof v === 'string' && v.includes('/Type /EmbeddedFile'), + ); + expect(embeddedFile).toContain('/Subtype /text#2Fxml'); + }); + + test('XMP contains pdfaExtension schema declaration', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('pdfaExtension:schemas'); + expect(xmp).toContain('Factur-X PDFA Extension Schema'); + expect(xmp).toContain( + 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#', + ); + expect(xmp).toContain('pdfaProperty:name>DocumentFileName'); + expect(xmp).toContain('pdfaProperty:name>ConformanceLevel'); + }); + + test('XMP contains fx invoice values', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML, { profile: 'BASIC', version: '1.0' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('INVOICE'); + expect(xmp).toContain( + 'factur-x.xml', + ); + expect(xmp).toContain('BASIC'); + expect(xmp).toContain('1.0'); + }); + + test('defaults to EN 16931 profile with version 1.0', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'EN 16931', + ); + expect(xmp).toContain('1.0'); + }); + + test('profile option sets ConformanceLevel in XMP', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML, { profile: 'EXTENDED' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'EXTENDED', + ); + expect(xmp).toContain( + 'factur-x.xml', + ); + }); + + test('version can be overridden via options', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML, { version: '1.0.07' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('1.0.07'); + }); + + test('documentType defaults to INVOICE', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('INVOICE'); + }); + + test('documentType can be overridden via options', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML, { documentType: 'ORDER' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('ORDER'); + }); + + test('AF catalog entry is present', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML); + doc.end(); + + const catalog = data.find( + (v) => typeof v === 'string' && v.includes('/Type /Catalog'), + ); + expect(catalog).toContain('/AF'); + }); + + describe('per-profile embed with real XML fixtures', () => { + test.each([ + ['minimum', 'MINIMUM', 'factur-x.xml', '1.0', 'minimum.xml'], + ['basic wl', 'BASIC WL', 'factur-x.xml', '1.0', 'basic-wl.xml'], + ['basic', 'BASIC', 'factur-x.xml', '1.0', 'basic.xml'], + ['en 16931', 'EN 16931', 'factur-x.xml', '1.0', 'en16931.xml'], + ['extended', 'EXTENDED', 'factur-x.xml', '1.0', 'extended.xml'], + ])( + 'profile "%s" : ConformanceLevel "%s", filename "%s", version "%s"', + (profile, label, filename, version, xmlFile) => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', loadXML(xmlFile), { profile }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + `${label}`, + ); + expect(xmp).toContain( + `${filename}`, + ); + expect(xmp).toContain(`${version}`); + + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toContain(`/F (${filename})`); + }, + ); + }); + + describe('case-insensitive profile lookup', () => { + test.each([ + ['MINIMUM', 'MINIMUM'], + ['Basic', 'BASIC'], + ['BASIC WL', 'BASIC WL'], + ['Basic Wl', 'BASIC WL'], + ['EN 16931', 'EN 16931'], + ['Extended', 'EXTENDED'], + ['EXTENDED', 'EXTENDED'], + ])( + 'profile input "%s" resolves to ConformanceLevel "%s"', + (input, label) => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', XML, { profile: input }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + `${label}`, + ); + }, + ); + }); + + test('unknown profile falls back to EN 16931', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('facturx', loadXML('en16931.xml'), { + profile: 'UNSUPPORTED_PROFILE', + }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'EN 16931', + ); + expect(xmp).toContain( + 'factur-x.xml', + ); + expect(xmp).toContain('1.0'); + }); +}); diff --git a/tests/unit/metadata.spec.js b/tests/unit/metadata.spec.js index b9c0c5b6..9c4478fc 100644 --- a/tests/unit/metadata.spec.js +++ b/tests/unit/metadata.spec.js @@ -6,14 +6,15 @@ describe('PDFMetadata', () => { metadata = new PDFMetadata(); }); - test('initialising metadata', () => { - expect(metadata._metadata).toBeDefined(); - expect(metadata.getLength()).toBeGreaterThan(0); - expect(typeof metadata._metadata).toBe('string'); + test('body is empty on initialisation', () => { + expect(metadata._body).toBe(''); + expect(metadata._metadata).toBe(''); + expect(metadata.getLength()).toBe(0); + expect(metadata.getXML()).toBe(''); }); - test('contains appended XML', () => { - let xml = ` + test('contains appended XML after end()', () => { + const xml = ` Test @@ -21,12 +22,52 @@ describe('PDFMetadata', () => { `; metadata.append(xml); + metadata.end(); expect(metadata.getXML()).toContain(xml); }); - test('closing tags', () => { - let length = metadata.getLength(); + test('end() produces a non-empty complete XML document', () => { + metadata.end(); + const xml = metadata.getXML(); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(metadata.getLength()).toBeGreaterThan(0); + }); + + test('registered namespaces appear on the rdf:RDF opening tag', () => { + metadata.registerNamespace( + 'fx', + 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#', + ); + metadata.end(); + const xml = metadata.getXML(); + // extract just the rdf:RDF opening tag (may span multiple lines) + const rdfOpenTag = xml.match(//)[0]; + expect(rdfOpenTag).toContain( + 'xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"', + ); + }); + + test('multiple registered namespaces all appear on rdf:RDF', () => { + metadata.registerNamespace( + 'fx', + 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#', + ); + metadata.registerNamespace('zf', 'urn:zugferd:example#'); + metadata.end(); + const rdfOpenTag = metadata.getXML().match(//)[0]; + expect(rdfOpenTag).toContain('xmlns:fx='); + expect(rdfOpenTag).toContain('xmlns:zf='); + }); + + test('no extra namespaces leaves rdf:RDF tag unchanged', () => { metadata.end(); - expect(metadata.getLength()).toBeGreaterThan(length); + const rdfLine = metadata + .getXML() + .split('\n') + .find((l) => l.includes('rdf:RDF')); + expect(rdfLine).not.toContain('xmlns:fx='); }); }); diff --git a/tests/unit/zugferd.spec.js b/tests/unit/zugferd.spec.js new file mode 100644 index 00000000..7a348a44 --- /dev/null +++ b/tests/unit/zugferd.spec.js @@ -0,0 +1,340 @@ +import PDFDocument from '../../lib/document'; +import { logData } from './helpers'; +import { readFileSync } from 'fs'; +import { vi } from 'vitest'; + +function makeDoc() { + return new PDFDocument({ + autoFirstPage: false, + compress: false, + pdfVersion: '1.7', + subset: 'PDF/A-3b', + }); +} + +function getMetadata(data) { + const buf = data.find( + (v) => + typeof v !== 'string' && + Buffer.isBuffer(v) && + v.toString().includes('x:xmpmeta'), + ); + return buf ? buf.toString() : null; +} + +function loadXML(name) { + return readFileSync(new URL(`./einvoices/${name}`, import.meta.url)); +} + +const XML = Buffer.from(''); + +describe('einvoice guards', () => { + test('throws on unsupported format', () => { + const doc = makeDoc(); + logData(doc); + expect(() => doc.einvoice('ubl', XML)).toThrow( + 'Unsupported e-invoice format: "ubl"', + ); + }); + + test('unsupported format does not lock the document', () => { + const doc = makeDoc(); + const data = logData(doc); + expect(() => doc.einvoice('ubl', XML)).toThrow(); + // should succeed after the failed attempt + expect(() => doc.einvoice('zugferd', XML)).not.toThrow(); + doc.end(); + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toBeDefined(); + }); + + test('throws when src is null', () => { + const doc = makeDoc(); + logData(doc); + expect(() => doc.einvoice('zugferd', null)).toThrow( + 'einvoice: src is required', + ); + }); + + test('throws when src is undefined', () => { + const doc = makeDoc(); + logData(doc); + expect(() => doc.einvoice('zugferd', undefined)).toThrow( + 'einvoice: src is required', + ); + }); + + test('throws when einvoice is called a second time', () => { + const doc = makeDoc(); + logData(doc); + doc.einvoice('zugferd', XML); + expect(() => doc.einvoice('zugferd', XML)).toThrow( + 'An e-invoice has already been embedded in this document', + ); + }); + + test('warns when subset is not PDF/A-3', () => { + const doc = new PDFDocument({ + autoFirstPage: false, + compress: false, + pdfVersion: '1.7', + subset: 'PDF/A-2b', + }); + logData(doc); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + doc.einvoice('zugferd', XML); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('PDF/A-3 is required'), + ); + warn.mockRestore(); + }); + + test('does not warn when subset is PDF/A-3b', () => { + const doc = makeDoc(); + logData(doc); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + doc.einvoice('zugferd', XML); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('does not warn when subset is PDF/A-3a', () => { + const doc = new PDFDocument({ + autoFirstPage: false, + compress: false, + pdfVersion: '1.7', + subset: 'PDF/A-3a', + }); + logData(doc); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + doc.einvoice('zugferd', XML); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('does not warn when subset is PDF/A-3 (no conformance suffix)', () => { + const doc = new PDFDocument({ + autoFirstPage: false, + compress: false, + pdfVersion: '1.7', + subset: 'PDF/A-3', + }); + logData(doc); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + doc.einvoice('zugferd', XML); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('returns the embedded filespec reference', () => { + const doc = makeDoc(); + logData(doc); + const filespec = doc.einvoice('zugferd', XML); + expect(filespec).toBeDefined(); + expect(filespec).not.toBeNull(); + }); +}); + +describe('zugferd', () => { + test('embeds the XML with AFRelationship Alternative', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML); + doc.end(); + + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toBeDefined(); + expect(filespec).toContain('/F (zugferd-invoice.xml)'); + + const embeddedFile = data.find( + (v) => typeof v === 'string' && v.includes('/Type /EmbeddedFile'), + ); + expect(embeddedFile).toContain('/Subtype /text#2Fxml'); + }); + + test('XMP contains pdfaExtension schema declaration', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('pdfaExtension:schemas'); + expect(xmp).toContain( + 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#', + ); + expect(xmp).toContain('pdfaProperty:name>DocumentFileName'); + expect(xmp).toContain('pdfaProperty:name>ConformanceLevel'); + }); + + test('XMP contains fx invoice values', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML, { profile: 'XRECHNUNG', version: '1.0' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('INVOICE'); + expect(xmp).toContain( + 'xrechnung.xml', + ); + expect(xmp).toContain( + 'XRECHNUNG', + ); + expect(xmp).toContain('1.0'); + }); + + test('defaults to EN 16931 profile', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'EN 16931', + ); + expect(xmp).toContain('2.4'); + }); + + test('XRECHNUNG profile uses xrechnung.xml filename and version 3.0', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML, { profile: 'XRECHNUNG' }); + doc.end(); + + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toContain('/F (xrechnung.xml)'); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'xrechnung.xml', + ); + expect(xmp).toContain('3.0'); + }); + + test('documentType defaults to INVOICE', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('INVOICE'); + }); + + test('documentType can be overridden via options', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML, { documentType: 'ORDER' }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain('ORDER'); + }); + + test('AF catalog entry is present', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML); + doc.end(); + + const catalog = data.find( + (v) => typeof v === 'string' && v.includes('/Type /Catalog'), + ); + expect(catalog).toContain('/AF'); + }); + + describe('per-profile embed with real XML fixtures', () => { + test.each([ + ['minimum', 'MINIMUM', 'zugferd-invoice.xml', '2.4', 'minimum.xml'], + ['basic wl', 'BASIC WL', 'zugferd-invoice.xml', '2.4', 'basic-wl.xml'], + ['basic', 'BASIC', 'zugferd-invoice.xml', '2.4', 'basic.xml'], + ['en 16931', 'EN 16931', 'zugferd-invoice.xml', '2.4', 'en16931.xml'], + ['extended', 'EXTENDED', 'zugferd-invoice.xml', '2.4', 'extended.xml'], + [ + 'xrechnung', + 'XRECHNUNG', + 'xrechnung.xml', + '3.0', + 'zugferd-xrechnung.xml', + ], + ])( + 'profile "%s" : ConformanceLevel "%s", filename "%s", version "%s"', + (profile, label, filename, version, xmlFile) => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', loadXML(xmlFile), { profile }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + `${label}`, + ); + expect(xmp).toContain( + `${filename}`, + ); + expect(xmp).toContain(`${version}`); + + const filespec = data.find( + (v) => + typeof v === 'string' && v.includes('/AFRelationship /Alternative'), + ); + expect(filespec).toContain(`/F (${filename})`); + }, + ); + }); + + describe('case-insensitive profile lookup', () => { + test.each([ + ['MINIMUM', 'MINIMUM'], + ['Basic', 'BASIC'], + ['BASIC WL', 'BASIC WL'], + ['Basic Wl', 'BASIC WL'], + ['EN 16931', 'EN 16931'], + ['Extended', 'EXTENDED'], + ['XRechnung', 'XRECHNUNG'], + ['XRECHNUNG', 'XRECHNUNG'], + ])( + 'profile input "%s" resolves to ConformanceLevel "%s"', + (input, label) => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', XML, { profile: input }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + `${label}`, + ); + }, + ); + }); + + test('unknown profile falls back to EN 16931', () => { + const doc = makeDoc(); + const data = logData(doc); + doc.einvoice('zugferd', loadXML('en16931.xml'), { + profile: 'UNSUPPORTED_PROFILE', + }); + doc.end(); + + const xmp = getMetadata(data); + expect(xmp).toContain( + 'EN 16931', + ); + expect(xmp).toContain( + 'zugferd-invoice.xml', + ); + expect(xmp).toContain('2.4'); + }); +});