PAYATHON 2026

如何在 MERN 项目中生成嵌入 ZUGFeRD XML 的 PDF 发票?

AI 小顾

结论

JavaScript 可以生成 ZUGFeRD 发票,但整个过程包含三个部分:

  1. 按指定的 ZUGFeRD Profile 生成合规 XML。
  2. 生成符合 PDF/A-3 要求的 PDF。
  3. 将 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 生成 XML
  • decimal.js 处理金额
  • HTML 模板加 Chromium,或其他 PDF 库生成视觉 PDF
  • 支持 PDF/A-3 的工具完成归档转换和附件关联
  • veraPDF 与电子发票验证器完成最终校验

如果业务包含多税率、跨境交易、复杂折扣、贷项通知单,或者需要长期维护合规规则,更适合使用成熟的 ZUGFeRD/Factur-X SDK 或独立服务。即使这个服务由 Java 或 .NET 实现,也不会破坏 MERN 架构。它只负责接收结构化发票数据并返回最终 PDF。

不要把 XML 可以打开、PDF 中能看到附件当作验收标准。生产环境应以三项结果为准:XML 规则校验通过,PDF/A-3 校验通过,最终文件能够被目标会计或 EDI 系统正确导入。

备注:内容仅供参考。