如何在 MERN 项目中生成嵌入 ZUGFeRD XML 的 PDF 发票?
结论
JavaScript 可以生成 ZUGFeRD 发票,但整个过程包含三个部分:
- 按指定的 ZUGFeRD Profile 生成合规 XML。
- 生成符合 PDF/A-3 要求的 PDF。
- 将 XML 作为关联文件嵌入 PDF,并写入正确的 PDF/A 和 ZUGFeRD 元数据。
在 Node.js 中,可以用 pdf-lib、xmlbuilder2 等通用库构建 XML、嵌入附件。不过,把 XML 附加到普通 PDF,并不代表得到了合规的 ZUGFeRD 发票。实现中的难点是完整校验 XML Schema、业务规则、PDF/A-3 和 XMP 元数据。
如果发票要进入正式的财务、税务或 EDI 流程,优先考虑经过验证的专业服务或库。自行实现也没有问题,但标准校验不能省略。
先确定 ZUGFeRD 版本和 Profile
ZUGFeRD 并不是一个固定的 XML 模板。开发前至少要确定以下事项:
- 使用哪个 ZUGFeRD 版本。
- 使用哪个 Profile,例如
BASIC、EN 16931或EXTENDED。 - 接收方要求使用什么嵌入文件名。
- 发票是否还要满足德国以外地区的税务规定。
- 是否需要支持贷项通知单、折扣、附加费、预付款等复杂业务。
常见的企业间电子发票一般会选择基于 EN 16931 的 Profile。各 Profile 对必填字段和业务规则有不同要求,因此不能只用 XML Schema 判断发票是否合法。
ZUGFeRD 2.x 与 Factur-X 采用相近的混合发票模型,但具体要求仍要以项目使用的规范版本为准。
推荐的处理流程
后端可以按以下顺序处理订单:
订单完成
→ 固化开票数据
→ 分配唯一发票号
→ 生成 ZUGFeRD XML
→ 校验 XML Schema 和业务规则
→ 生成可视化 PDF
→ 转换或生成 PDF/A-3
→ 嵌入 XML 并写入 XMP 元数据
→ 校验最终 PDF
→ 保存不可变的发票文件
不要直接读取可能发生变化的订单、客户或商品数据来重新生成历史发票。开票时应保存一份不可变的发票快照,其中包括:
- 买卖双方名称和地址
- 税号或 VAT ID
- 发票号和开票日期
- 交付日期或服务期间
- 币种
- 商品数量、单位和单价
- 税率、税额和含税/未税合计
- 折扣、附加费和舍入金额
- 付款方式、付款期限和银行信息
生成 ZUGFeRD XML
可以使用 xmlbuilder2 构造 XML:
npm install xmlbuilder2
下面的代码只演示生成逻辑的组织方式,并不是完整的 ZUGFeRD 模板:
import { create } from 'xmlbuilder2';
export function createInvoiceXml(invoice) {
const doc = create({ version: '1.0', encoding: 'UTF-8' })
.ele('rsm:CrossIndustryInvoice', {
'xmlns:rsm':
'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
'xmlns:ram':
'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
'xmlns:udt':
'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'
});
const context = doc.ele('rsm:ExchangedDocumentContext');
context
.ele('ram:GuidelineSpecifiedDocumentContextParameter')
.ele('ram:ID')
.txt(invoice.guidelineId)
.up()
.up();
const document = doc.ele('rsm:ExchangedDocument');
document.ele('ram:ID').txt(invoice.number).up();
document.ele('ram:TypeCode').txt('380').up();
document
.ele('ram:IssueDateTime')
.ele('udt:DateTimeString', { format: '102' })
.txt(invoice.issueDate)
.up()
.up();
/*
* 这里还必须添加:
* - SupplyChainTradeTransaction
* - 每个结算行项目
* - 卖方和买方
* - 交付信息
* - 税率和税额分组
* - 付款条件
* - 金额汇总
*/
return doc.end({ prettyPrint: true });
}
调用示例:
const xml = createInvoiceXml({
number: 'INV-2026-0001',
issueDate: '20260901',
guidelineId:
'urn:cen.eu:en16931:2017#compliant#urn:zugferd.de:2p3:en16931',
});
guidelineId 取决于使用的 ZUGFeRD 版本和 Profile。上面的值仅用于展示代码结构,投入生产前必须根据实际规范核对。
发票金额不要使用浮点数计算。例如:
// 不推荐
const total = 0.1 + 0.2;
应该使用十进制定点库,或者按最小货币单位计算:
const netCents = 1000;
const taxCents = Math.round(netCents * 19 / 100);
const grossCents = netCents + taxCents;
如果涉及单价精度、折扣分摊或多级舍入,可以使用 decimal.js 等十进制计算库,并遵循所选 Profile 的舍入规则。
生成并嵌入 PDF 附件
pdf-lib 可以创建 PDF,也支持添加附件:
npm install pdf-lib
示例:
import fs from 'node:fs/promises';
import {
AFRelationship,
PDFDocument,
StandardFonts
} from 'pdf-lib';
export async function createInvoicePdf(invoice, xml) {
const pdf = await PDFDocument.create();
const page = pdf.addPage([595.28, 841.89]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
page.drawText(`Invoice ${invoice.number}`, {
x: 50,
y: 790,
size: 18,
font
});
page.drawText(`Total: ${invoice.grossAmount} ${invoice.currency}`, {
x: 50,
y: 750,
size: 11,
font
});
await pdf.attach(
Buffer.from(xml, 'utf8'),
'factur-x.xml',
{
mimeType: 'application/xml',
description: 'ZUGFeRD invoice data',
creationDate: new Date(),
modificationDate: new Date(),
afRelationship: AFRelationship.Alternative
}
);
return pdf.save();
}
const pdfBytes = await createInvoicePdf(invoice, xml);
await fs.writeFile('invoice.pdf', pdfBytes);
实际项目应从数据库读取发票快照,不能直接信任客户端提交的金额。附件文件名也要根据 ZUGFeRD 版本和接收方要求确定。常见名称包括 factur-x.xml,旧版本或特定工作流可能使用其他名称。
不同版本的 pdf-lib 可能提供不同的导出项或附件选项,具体用法应以项目安装版本的 API 文档和 TypeScript 类型为准。
为什么上述 PDF 仍可能不合规
pdf-lib 虽然能嵌入附件,但它默认生成的 PDF 不一定符合 PDF/A-3。合规文件一般还需要处理以下内容:
- PDF/A-3 标识
- 嵌入或正确处理字体
- OutputIntent 和 ICC 色彩配置
- XML 附件的 MIME 类型
- 文件规范字典中的
AFRelationship - PDF Catalog 中的关联文件信息
- ZUGFeRD/Factur-X 要求的 XMP 扩展元数据
- XML 文件名、文档类型、版本和 Profile 的一致性
- 禁止使用 PDF/A 不允许的加密及其他特性
所以下面的代码只能生成带有 XML 附件的 PDF,不能证明该文件是合规的 ZUGFeRD 发票:
await pdf.attach(xmlBytes, 'factur-x.xml');
更稳妥的方式是先生成视觉 PDF,再交给明确支持 PDF/A-3 和 ZUGFeRD 元数据的组件处理。这个组件可以是受控的 Java/.NET 服务、商业 SDK,也可以是组织内部封装的命令行服务。MERN 架构并不要求所有组件都用 JavaScript 编写。Node.js 可以通过 HTTP 或任务队列调用专门的发票生成服务。
必须执行两类校验
XML 校验
至少需要进行以下检查:
- XML Schema 校验
- Schematron 业务规则校验
- EN 16931 规则校验
- 对应 ZUGFeRD Profile 的规则校验
- 金额、税额和舍入结果校验
通过 XSD 只能说明 XML 结构合法,不能证明发票的业务含义正确。例如,总税额与各税率分组不一致时,XML 结构仍可能合法,但不符合业务规则。
PDF 校验
最终文件应使用 PDF/A 验证器检查,主要确认以下事项:
- 文件确实符合目标 PDF/A-3 等级
- XML 附件可以提取
AFRelationship正确- XMP 元数据与附件内容一致
- 字体、颜色空间和文档结构符合要求
常见做法是使用 veraPDF 验证 PDF/A,再通过支持 ZUGFeRD/Factur-X 和 EN 16931 规则的验证器检查电子发票。验证器和规则包应固定版本并纳入自动化测试,防止规范更新后校验结果发生不易察觉的变化。
Node.js 项目的实际建议
如果发票类型比较简单,团队也有能力维护电子发票规范,可以使用:
xmlbuilder2生成 XMLdecimal.js处理金额- HTML 模板加 Chromium,或其他 PDF 库生成视觉 PDF
- 支持 PDF/A-3 的工具完成归档转换和附件关联
- veraPDF 与电子发票验证器完成最终校验
如果业务包含多税率、跨境交易、复杂折扣、贷项通知单,或者需要长期维护合规规则,更适合使用成熟的 ZUGFeRD/Factur-X SDK 或独立服务。即使这个服务由 Java 或 .NET 实现,也不会破坏 MERN 架构。它只负责接收结构化发票数据并返回最终 PDF。
不要把 XML 可以打开、PDF 中能看到附件当作验收标准。生产环境应以三项结果为准:XML 规则校验通过,PDF/A-3 校验通过,最终文件能够被目标会计或 EDI 系统正确导入。
备注:内容仅供参考。
