自行处理多币种付款时 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)
);
典型流程如下:
- 创建 EUR 发票及其明细。
- 客户选择使用 USD 付款。
- 系统生成一份有有效期的 USD 报价。
- 客户改选 GBP 后,旧报价过期或被新报价替换。
- 客户真正发起支付时,创建 InvoicePayments 记录。
- 支付成功后,记录实际结算金额、汇率和时间。
- 将所有成功付款折算为发票币种,再据此计算剩余应付金额。
这样不会把用户在页面上切换币种误记为真实的资金交易。
如果系统需要保留失败、取消或超时的支付尝试,可以把这些记录保存在 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:实际发生了什么资金交易
这种设计能够支持部分付款、多币种付款、失败重试、汇率锁定、退款和财务审计,同时不会因为客户临时改变付款方式而产生重复发票。
备注:内容仅供参考。
