PAYATHON 2026

自行处理多币种付款时 Invoice 的数据库设计

支付老李

结论

建议采用方案 #a:保留一张 Invoice,另外建立 InvoicePayments,并在 InvoicePayments 中记录实际付款使用的货币。

Invoice 表中的货币表示发票的计价货币,例如 EUR;InvoicePayments 中的货币表示客户实际支付的货币,例如 USD 或 GBP。这是两个不同的概念。即使目前所有服务都以欧元定价,也不应共用一个字段。

客户更改付款货币时,通常不需要创建新 Invoice,也不应马上创建 InvoicePayments。只有真正发起支付或发生资金交易时,才创建付款记录。付款前选择的币种可以存放在 checkout session、payment intent 或 payment quote 等临时记录中。

为什么不应该复制 Invoice

Invoice 是一笔销售及其应收金额的业务凭证。客户把付款货币从美元改为英镑,并不代表发生了新的销售,所以不应再创建一张发票。

如果每次更改付款币种都复制 Invoice,容易产生以下问题:

  • 同一笔销售对应多张发票,无法直接判断哪张有效。
  • InvoiceItems 会被重复保存,或者需要在多张发票之间复制。
  • 应收账款、税务记录和订单状态可能重复计算。
  • 发票编号、取消状态和审计记录更难管理。
  • 部分付款、分次付款和退款难以准确关联。
  • 后续无法清楚区分“重新选择币种”和“真正重新开票”。

Invoice 和付款本来就是一对多关系。一张发票可能还没有付款,也可能对应一次付款、多次部分付款、失败的付款尝试,甚至多笔不同币种的付款。

推荐的数据关系

核心关系可以设计为:

Invoice
  ├── InvoiceItems
  └── InvoicePayments

各表的职责如下:

  • Invoice:记录发票编号、客户、计价货币、总金额和发票状态。
  • InvoiceItems:记录服务项目、数量、单价和税额等信息。
  • InvoicePayments:记录每笔实际支付的币种、金额、汇率、状态和外部交易号。

即使现在所有发票都以 EUR 计价,也建议在 Invoice 中保留 currency_code。这个字段明确了金额的单位,也为以后支持其他开票货币留出空间。

表结构示例

下面是一种简化设计:

CREATE TABLE invoices (
    id                  BIGINT PRIMARY KEY,
    invoice_number      VARCHAR(50) NOT NULL UNIQUE,
    customer_id         BIGINT NOT NULL,
    currency_code       CHAR(3) NOT NULL,
    subtotal_minor      BIGINT NOT NULL,
    tax_minor           BIGINT NOT NULL,
    total_minor         BIGINT NOT NULL,
    status              VARCHAR(20) NOT NULL,
    issued_at           TIMESTAMP NOT NULL,
    due_at              TIMESTAMP NULL,
    created_at          TIMESTAMP NOT NULL
);

CREATE TABLE invoice_items (
    id                  BIGINT PRIMARY KEY,
    invoice_id          BIGINT NOT NULL,
    description         VARCHAR(500) NOT NULL,
    quantity            DECIMAL(18, 6) NOT NULL,
    unit_price_minor    BIGINT NOT NULL,
    line_total_minor    BIGINT NOT NULL,

    CONSTRAINT fk_invoice_items_invoice
        FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

CREATE TABLE invoice_payments (
    id                          BIGINT PRIMARY KEY,
    invoice_id                  BIGINT NOT NULL,
    payment_reference           VARCHAR(100) NOT NULL UNIQUE,
    status                      VARCHAR(20) NOT NULL,

    payment_currency_code       CHAR(3) NOT NULL,
    payment_amount_minor        BIGINT NOT NULL,

    invoice_currency_code       CHAR(3) NOT NULL,
    invoice_amount_minor        BIGINT NOT NULL,

    exchange_rate               DECIMAL(24, 12) NULL,
    exchange_rate_observed_at   TIMESTAMP NULL,

    paid_at                     TIMESTAMP NULL,
    created_at                  TIMESTAMP NOT NULL,

    CONSTRAINT fk_invoice_payments_invoice
        FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

例如,一张总额为 100.00 EUR 的发票由客户使用 108.50 USD 支付,可以保存为:

invoices.total_minor = 10000
invoices.currency_code = 'EUR'

invoice_payments.payment_amount_minor = 10850
invoice_payments.payment_currency_code = 'USD'
invoice_payments.invoice_amount_minor = 10000
invoice_payments.invoice_currency_code = 'EUR'

exchange_rate 的换算方向必须定义清楚。例如,可以统一规定:

invoice_amount = payment_amount × exchange_rate

在这个例子中:

exchange_rate = 100.00 / 108.50

不要只保存汇率,还要保存换算后的发票金额。如果汇率精度、舍入规则或汇率来源发生变化,仅凭汇率重新计算出的结果可能与结算时不一致。

客户改变付款币种时如何处理

客户只是在付款页面切换币种时,可以更新或重新创建一条临时付款报价:

CREATE TABLE payment_quotes (
    id                          BIGINT PRIMARY KEY,
    invoice_id                  BIGINT NOT NULL,
    payment_currency_code       CHAR(3) NOT NULL,
    payment_amount_minor        BIGINT NOT NULL,
    exchange_rate               DECIMAL(24, 12) NOT NULL,
    expires_at                  TIMESTAMP NOT NULL,
    created_at                  TIMESTAMP NOT NULL,

    FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

典型流程如下:

  1. 创建 EUR 发票及其明细。
  2. 客户选择使用 USD 付款。
  3. 系统生成一份有有效期的 USD 报价。
  4. 客户改选 GBP 后,旧报价过期或被新报价替换。
  5. 客户真正发起支付时,创建 InvoicePayments 记录。
  6. 支付成功后,记录实际结算金额、汇率和时间。
  7. 将所有成功付款折算为发票币种,再据此计算剩余应付金额。

这样不会把用户在页面上切换币种误记为真实的资金交易。

如果系统需要保留失败、取消或超时的支付尝试,可以把这些记录保存在 InvoicePayments 中,并通过 status 区分:

pending
authorized
succeeded
failed
cancelled
refunded
partially_refunded

如果 InvoicePayments 只用于记录已经到账的款项,则应另建 payment_attempts。表名不是重点,重要的是明确每种记录代表“支付尝试”还是“已入账付款”。

发票状态不要简单等同于付款状态

一张发票可能对应多次付款,因此不能只根据最后一笔付款判断是否结清。余额应按照成功付款所覆盖的发票币种金额计算:

outstanding_amount =
    invoice_total
    - sum(successful_payment.invoice_amount)
    + sum(refund.invoice_amount)

再根据余额设置或计算发票状态:

  • 没有成功付款:open
  • 已支付一部分:partially_paid
  • 余额为零:paid
  • 发票作废:void
  • 已退款:根据业务规则设置为 refunded,或重新产生应收余额

如果存在并发支付,必须在事务中锁定余额或进行原子更新,避免两笔付款同时成功后产生超额支付。

金额和币种的注意事项

不要使用浮点数保存金额

不要用 FLOAT 或 DOUBLE 保存货币金额。可以把金额转换为最小货币单位后,以整数保存:

100.00 EUR -> 10000

也可以使用精度明确的 DECIMAL。采用最小单位整数时,不能假设所有货币都有两位小数。不同币种的最小单位位数可能不同,系统需要维护对应的币种元数据和舍入规则。

使用标准币种代码

通常使用 ISO 4217 三字符代码,例如:

EUR
USD
GBP
JPY

如果系统只需要保存代码,CHAR(3) 通常已经够用。只有在需要维护币种名称、最小单位位数和启用状态等元数据时,才有必要增加 Currency 表。

保存实际成交汇率

历史付款不能随着最新汇率变化。每笔付款至少要保存:

  • 客户支付的原始金额和币种;
  • 用于抵扣发票的金额和发票币种;
  • 实际采用的汇率;
  • 汇率时间及来源;
  • 舍入后的最终结果。

如果支付机构还会收取手续费,或使用另一种结算货币,还需要分别记录交易币种、发票币种和商户结算币种,不能用一个 currency_id 表示这三个概念。

退款最好单独记录

退款不应通过删除或覆盖原付款来处理。可以建立 payment_refunds 表,并关联原付款:

CREATE TABLE payment_refunds (
    id                      BIGINT PRIMARY KEY,
    payment_id              BIGINT NOT NULL,
    refund_reference        VARCHAR(100) NOT NULL UNIQUE,
    refund_currency_code    CHAR(3) NOT NULL,
    refund_amount_minor     BIGINT NOT NULL,
    invoice_amount_minor    BIGINT NOT NULL,
    status                  VARCHAR(20) NOT NULL,
    refunded_at             TIMESTAMP NULL,

    FOREIGN KEY (payment_id) REFERENCES invoice_payments(id)
);

同时保留原始付款和退款记录,才能形成完整且可审计的资金轨迹。

最终建议

Invoice 保留发票的计价货币,InvoicePayments 保存每笔付款实际使用的货币。客户切换付款币种不会改变销售事实,所以不应复制 Invoice。

具体来说,需要区分三个对象:

Invoice:客户欠多少钱,以及以什么货币计价
Payment Quote:客户当前选择如何付款
InvoicePayment:实际发生了什么资金交易

这种设计能够支持部分付款、多币种付款、失败重试、汇率锁定、退款和财务审计,同时不会因为客户临时改变付款方式而产生重复发票。

备注:内容仅供参考。