我们把 Invocie 的合规引擎做成一份代码同时处理 MENA 的 clearance、欧盟的 Peppol,以及全球的 post-audit。架构经过三轮迭代才稳下来。下面是最终留下来的设计选择。
1. 在国家层面做 Strategy Pattern,不是区域层面
区域太粗了。沙特(clearance、ZATCA、15%)和阿联酋(interoperable、FTA、5%)都是「MENA」,但架构上完全不一样。我们先按 ISO 国家代码分发,再按区域,最后是默认兜底。
function pickStrategy(t: { country_code: string; tax_region: string }) {
if (MENA.includes(t.country_code)) return new MENAStrategy();
if (EU.includes(t.country_code)) return new EUStrategy();
if (t.tax_region === "MENA") return new MENAStrategy();
if (t.tax_region === "EU") return new EUStrategy();
return new DefaultStrategy();
}2. 永远 Decimal,永远不要 float
性能关键路径上我们用 BigInt 配合定点缩放(10000x,4 位小数精度),其他地方用 decimal.js,因为它的 SDK 更顺手。货币值唯一禁用的类型就是 JavaScript 的 number —— 上线前我们抓到三个舍入 bug,根因都是不小心冒出的 Number()。
3. 默认异步,例外时才同步
创建发票的 HTTP 路由从不阻塞在政府 API 上。它写库、入队、然后返回 201。worker 负责政府那边的提交,带重试(指数退避、最多 6 次、加抖动)。这把面向用户的 API 从政府侧延迟里隔离出来,也让重试在 ZATCA 状态不好的下午依然安全。
4. 把规范模型和线协议分开
我们内部的 Invoice 类型不论国家都长一个样。每种 strategy 的 buildArtifacts() 把它转换成各国的线格式(TLV QR、UBL 2.1 XML,或者只是默认的 JSON)。规范模型从不知道 XML 的存在,XML 也从不离开 strategy。这让以后加印度 IRP 变成单文件改动。
5. 日志是契约的一部分
每一次政府交互都向 ComplianceLog 写一行,带 prev_hash → hash 链式链接。审计师上门的时候(他们一定会来),你不会想从应用日志里重建发生了什么。数据库里的那一行就是收据。