PAYATHON 2026

后续 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 读取订单明细,容易产生以下问题:

  1. one-off items 可能被错误加入续费账单。
  2. 一个订单包含多个订阅时,很难分别处理各订阅的暂停、取消和续费日期。
  3. 原始商品价格或订单内容变化后,历史关系容易变得含糊。
  4. 升级、降级、按比例计费和优惠到期等场景难以处理。

因此,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 后,可以按以下顺序处理:

  1. 查询状态有效且已到计费时间的 subscription。
  2. 根据订阅当前的价格、数量和计费周期生成 invoice。
  3. 将当时的描述、单价、税费和折扣写入 invoice_items。
  4. 尝试收款并新增 payment 记录。
  5. 根据支付结果更新 invoice 和 payment 的状态。
  6. 推进 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 也只会计入相关的订阅费用。

备注:内容仅供参考。