后续 invoice 记录是否都应引用原始 order_id?
明确结论
后续 recurring invoice 不应只根据原始 order_id 确定应收内容。
首次下单时,应根据订阅商品创建独立的 subscription 记录。之后生成 monthly invoices 时,直接引用 subscription_id。如果需要追溯订阅对应的首次订单,可以在 subscription 中保留 origin_order_id 或 origin_order_item_id,也可以在 invoice 中增加可选的 origin_order_id。
这种设计既保留了来源关系,也能避免把原始订单中的 one-off items 计入后续账单。
为什么不应直接按原始订单生成续费账单
order 通常表示一次购买行为,里面可能同时包含:
- 一次性商品;
- 一个或多个订阅商品;
- 只在首次购买时有效的折扣、运费和税费。
recurring invoice 表示某个计费周期内产生的应收款。生成账单时,需要考虑当前仍然有效的订阅、当前价格、计费周期和税费规则,而不是重新结算原始订单。
如果每次都通过 order_id 读取订单明细,容易产生以下问题:
- one-off items 可能被错误加入续费账单。
- 一个订单包含多个订阅时,很难分别处理各订阅的暂停、取消和续费日期。
- 原始商品价格或订单内容变化后,历史关系容易变得含糊。
- 升级、降级、按比例计费和优惠到期等场景难以处理。
因此,order 用于记录订阅最初是如何购买的,subscription 则记录后续如何持续计费。
推荐的关联方式
可以将关系设计为:
order
└── order_item
└── subscription
└── invoice
├── invoice_item
└── payment
各层的职责如下:
order_item区分一次性商品和订阅商品;subscription引用创建该订阅的order_item;invoice引用本次实际结算的subscription;invoice_item保存生成账单时的费用快照;payment记录 invoice 的支付情况。
如果一张 invoice 可以合并同一客户的多个订阅,就不应在 invoice 表中保存单一的 subscription_id。此时应由各条 invoice_item 分别引用对应的 subscription_id。
数据表设计示例
下面是一张 invoice 只对应一个 subscription 时的简化设计:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
item_type VARCHAR(20) NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id),
CHECK (item_type IN ('one_off', 'subscription'))
);
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
origin_order_item_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
billing_interval VARCHAR(20) NOT NULL,
current_price DECIMAL(12, 2) NOT NULL,
next_billing_at TIMESTAMP,
FOREIGN KEY (origin_order_item_id) REFERENCES order_items(id)
);
CREATE TABLE invoices (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
subscription_id BIGINT NOT NULL,
billing_period_start TIMESTAMP NOT NULL,
billing_period_end TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL,
currency CHAR(3) NOT NULL,
total_amount DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);
CREATE TABLE invoice_items (
id BIGINT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
description VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);
CREATE TABLE payments (
id BIGINT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
paid_at TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);
通过下面的路径,仍然可以从 invoice 找到原始订单:
invoice.subscription_id
→ subscription.origin_order_item_id
→ order_item.order_id
→ order.id
所以,invoice 不必为了追溯来源而强制保存 order_id。
一张账单包含多个订阅时
如果系统会将同一客户当月的多个订阅合并到一张 invoice,应把订阅关联放在明细层:
CREATE TABLE invoices (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
billing_period_start TIMESTAMP NOT NULL,
billing_period_end TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL,
currency CHAR(3) NOT NULL,
total_amount DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE invoice_items (
id BIGINT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
subscription_id BIGINT,
description VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);
subscription_id 可以为 NULL,因为 invoice 中可能还有不直接对应订阅的项目,例如税费、余额抵扣或人工调整。
生成续费账单的基本流程
到达 next_billing_at 后,可以按以下顺序处理:
- 查询状态有效且已到计费时间的
subscription。 - 根据订阅当前的价格、数量和计费周期生成 invoice。
- 将当时的描述、单价、税费和折扣写入
invoice_items。 - 尝试收款并新增 payment 记录。
- 根据支付结果更新 invoice 和 payment 的状态。
- 推进
next_billing_at,或者进入重试、逾期等流程。
账单金额应根据当前订阅和当前计费规则计算,不能直接复制原始订单中的全部 items。
是否还可以在 invoice 中保存 order_id
可以,但它应当是可选的追溯字段,而不是续费计算的依据。例如:
ALTER TABLE invoices
ADD COLUMN origin_order_id BIGINT NULL,
ADD CONSTRAINT fk_invoice_origin_order
FOREIGN KEY (origin_order_id) REFERENCES orders(id);
这个冗余字段可用于报表、客服查询,或者减少多表关联,但需要明确其含义。它表示创建相关订阅的首次订单,并不表示这张 invoice 会再次收取原订单中的全部内容。
如果一张 invoice 合并了来自多个原始订单的订阅,单个 origin_order_id 无法准确表示这种关系。此时应通过 invoice_items → subscriptions → order_items 追溯,或者另外建立关联表。
注意事项
invoice item 应保存快照
不要只保存 product_id 或 subscription_id,然后在展示历史 invoice 时读取商品的当前价格。商品名称、价格、税率和折扣以后都可能变化。
invoice_items 应保存开票时的:
- 描述;
- 数量;
- 单价;
- 折扣;
- 税额;
- 最终金额。
这样,历史账单不会随着商品数据的变化而改变。
不要直接修改已出具的 invoice
正式出具的 invoice 通常应视为不可变记录。如果发生退款、补差价或金额调整,应根据业务情况和当地财税要求使用 credit note、debit note 或新的调整明细,不要覆盖原记录。
具体处理方式取决于所在地区的会计和税务规则。在设计数据库前,应先确认实际合规要求。
payment 与 invoice 不一定是一对一
实际业务中可能出现:
- 一张 invoice 被分多次支付;
- 一次支付覆盖多张 invoice;
- 部分支付;
- 退款,以及支付失败后的重试。
如果需要将一次 payment 分摊到多张 invoice,可以增加分配表:
CREATE TABLE payment_allocations (
payment_id BIGINT NOT NULL,
invoice_id BIGINT NOT NULL,
allocated_amount DECIMAL(12, 2) NOT NULL,
PRIMARY KEY (payment_id, invoice_id),
FOREIGN KEY (payment_id) REFERENCES payments(id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);
原始订单应作为订阅的来源,subscription 是后续计费的主体,invoice 则保存某个计费周期的金额快照。即使原订单同时包含 one-off 和 subscription items,后续 recurring invoices 也只会计入相关的订阅费用。
备注:内容仅供参考。
