PAYATHON 2026

如何设计 invoicing 和 subscriptions 的应用逻辑?

支付小周

结论

续订时,不要反复创建新的 subscriptions 记录,也不要因为逾期未付款就删除原记录。

建议按下面的方式设计:

  • subscriptions 表记录订阅关系和当前状态;
  • subscription_periods 记录各个实际服务周期;
  • invoices 和 invoice_items 记录账单及费用快照;
  • payments 记录付款事实,并通过 payment_allocations 关联 invoice;
  • 未付款时,将订阅状态改为 past_due、suspended 或 canceled,同时保留历史记录;
  • 定时任务只处理已经到期且条件明确的记录,并用唯一约束防止重复开票。

这种设计既保留了审计记录,也不必通过反复修改日期来推断历史状态。

为什么不应该删除或反复修改订阅记录

subscription 表示客户与某项服务之间持续存在的商业关系。续订只是这段关系进入新的计费周期,并不等于产生了一份全新的订阅。

如果每次续订都创建新的 subscriptions 记录,会产生不少问题:

  • 难以判断哪些记录属于同一次连续订阅;
  • 升级、降级、暂停和恢复的处理会更加复杂;
  • 查询当前订阅时,需要从大量历史记录中筛选;
  • 客户生命周期、续订率和流失率等统计容易失真;
  • invoice、payment 和服务权限之间缺少稳定的关联对象。

但只修改一个 end_date 同样不够。这个字段只能反映当前结果,无法回答以下问题:

  • 客户过去购买过哪些周期?
  • 某张 invoice 对应哪个服务周期?
  • 订阅何时逾期、暂停或恢复?
  • 工作人员手动登记的 payment 支付了哪张 invoice?
  • 调整 end_date 之前的值是什么?

因此,订阅记录本身应保持稳定,每个计费周期则单独保存。

建议的数据模型

下面是一个简化后的模型,具体字段可以根据业务需要扩充。

CREATE TABLE customers (
    id BIGINT PRIMARY KEY,
    billing_name VARCHAR(200),
    billing_address TEXT,
    phone_number VARCHAR(50),
    email VARCHAR(320)
);

CREATE TABLE subscriptions (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    status VARCHAR(30) NOT NULL,
    started_at TIMESTAMP NOT NULL,
    current_period_start DATE,
    current_period_end DATE,
    canceled_at TIMESTAMP NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

CREATE TABLE subscription_periods (
    id BIGINT PRIMARY KEY,
    subscription_id BIGINT NOT NULL,
    period_start DATE NOT NULL,
    period_end DATE NOT NULL,
    status VARCHAR(30) NOT NULL,
    price_amount DECIMAL(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id),
    UNIQUE (subscription_id, period_start, period_end)
);

CREATE TABLE invoices (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    subscription_id BIGINT NOT NULL,
    subscription_period_id BIGINT NOT NULL,
    status VARCHAR(30) NOT NULL,
    issued_at TIMESTAMP NOT NULL,
    due_at TIMESTAMP NOT NULL,
    total_amount DECIMAL(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    delivery_method VARCHAR(20) NOT NULL,
    paid_at TIMESTAMP NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id),
    FOREIGN KEY (subscription_period_id) REFERENCES subscription_periods(id),
    UNIQUE (subscription_period_id)
);

CREATE TABLE invoice_items (
    id BIGINT PRIMARY KEY,
    invoice_id BIGINT NOT NULL,
    description VARCHAR(500) NOT NULL,
    quantity DECIMAL(12, 4) 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,
    customer_id BIGINT NOT NULL,
    payment_method VARCHAR(30) NOT NULL,
    status VARCHAR(30) NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    received_at TIMESTAMP NULL,
    external_reference VARCHAR(200),
    recorded_by BIGINT NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

CREATE TABLE payment_allocations (
    payment_id BIGINT NOT NULL,
    invoice_id BIGINT NOT NULL,
    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)
);

如果业务能保证一笔 payment 永远只支付一张 invoice,可以直接在 payments 中保存 invoice_id。不过,wire transfer 经常涉及合并付款、部分付款或多付,单独设置 payment_allocations 通常更适合长期使用。

日期应该如何处理

使用半开区间

建议把服务周期定义为:

[period_start, period_end)

也就是包含 period_start,不包含 period_end。

例如:

period_start = 2010-12-31
period_end   = 2011-01-31

这表示服务从 2010-12-31 00:00:00 开始,在 2011-01-31 00:00:00 之前结束。下一个周期可以直接从 2011-01-31 开始,不会产生重叠或空缺。

明确“一个月”还是“30 天”

这两个概念不能混用:

2011-01-31 + 30 days = 2011-03-02
2011-01-31 + 1 calendar month ≠ 一个固定的日期规则

如果产品出售的是“30 天”,就按固定天数计算。如果出售的是“按月订阅”,则要明确月底如何处理。例如,月度订阅从 1 月 31 日开始,后续账单日可以固定在每月最后一天,也可以将账单锚点调整到 28 日。

规则确定后,代码和数据库必须采用同一套实现,不要让不同任务各自计算。

不要向后修改已经结算的周期

对于已经开票或已经提供服务的 subscription_periods,不要通过修改日期来重写历史。需要纠错时,可以采用以下方式:

  • 作废 invoice;
  • 创建 credit note;
  • 创建调整项;
  • 新增补偿周期;
  • 记录具体的人工调整原因。

推荐的业务流程

1. 创建订阅

客户注册并选择方案后:

  1. 创建 subscriptions;
  2. 创建第一个 subscription_periods;
  3. 根据收费模式创建 invoice;
  4. 发送 invoice 或发起 card payment。

在此之前,需要先明确采用哪种收费模式。

  • 预付:先付款,再提供下一个周期的服务;
  • 后付:周期结束后开票,客户在到期日前付款。

问题中的方案属于后付模式,因为 invoice 对应已经过去的服务周期。即使客户最终没有付款,服务也已经提供,不能通过删除 subscription 抹掉这段事实。

2. 开具 invoice

定时任务查找已经结束但尚未开票的周期:

SELECT sp.*
FROM subscription_periods sp
LEFT JOIN invoices i
    ON i.subscription_period_id = sp.id
WHERE sp.period_end <= CURRENT_DATE
  AND i.id IS NULL;

找到记录后,在同一个数据库事务中创建 invoice 和 invoice items:

BEGIN;

INSERT INTO invoices (
    id,
    customer_id,
    subscription_id,
    subscription_period_id,
    status,
    issued_at,
    due_at,
    total_amount,
    currency,
    delivery_method
)
VALUES (
    :invoice_id,
    :customer_id,
    :subscription_id,
    :period_id,
    'open',
    CURRENT_TIMESTAMP,
    :due_at,
    :total_amount,
    :currency,
    :delivery_method
);

INSERT INTO invoice_items (
    id,
    invoice_id,
    description,
    quantity,
    unit_price,
    amount
)
VALUES (
    :item_id,
    :invoice_id,
    :description,
    1,
    :price,
    :price
);

COMMIT;

这里的 UNIQUE (subscription_period_id) 非常重要。即使 cronjob 因网络错误或进程重启而再次运行,同一个周期也无法生成两张 invoice。

3. 登记 payment

card payment 应保存支付渠道返回的唯一交易标识。工作人员登记 wire transfer 时,也应记录:

  • 实际到账时间;
  • 金额和币种;
  • 银行流水号或备注;
  • 操作人员;
  • 对应的 invoice;
  • 是否为部分付款。

不要只设置 invoices.paid = true。付款是一项独立的财务事实,必须保留金额和来源。

invoice 状态可以根据已分配金额计算:

allocated_amount = 0                 -> open
0 < allocated_amount < total_amount  -> partially_paid
allocated_amount >= total_amount     -> paid

如果付款可能被撤销、退款或退单,payments.status 还应支持 pending、confirmed、failed、refunded 等状态。

4. 处理逾期

定时任务应根据各张 invoice 的 due_at 判断是否逾期,而不是在每月 15 日统一处理所有客户:

SELECT *
FROM invoices
WHERE status IN ('open', 'partially_paid')
  AND due_at < CURRENT_TIMESTAMP;

逾期后,可以按照业务规则依次调整状态:

active
  -> past_due
  -> suspended
  -> canceled

例如:

  • 到期未付时,标记为 past_due 并发送提醒;
  • 宽限期结束后,标记为 suspended 并停止服务;
  • 长期未付时,标记为 canceled;
  • 补缴成功后,恢复为 active,必要时创建下一个周期。

最好用事件表记录这些状态变化:

CREATE TABLE subscription_events (
    id BIGINT PRIMARY KEY,
    subscription_id BIGINT NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    occurred_at TIMESTAMP NOT NULL,
    metadata TEXT,
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

有了事件记录,就能查明订阅何时暂停、暂停原因是什么,以及由谁恢复。

5. 创建下一个周期

付款满足续订条件后,应创建新的 subscription_periods,而不是新的 subscription:

INSERT INTO subscription_periods (
    id,
    subscription_id,
    period_start,
    period_end,
    status,
    price_amount,
    currency
)
VALUES (
    :period_id,
    :subscription_id,
    :previous_period_end,
    :next_period_end,
    'scheduled',
    :price_amount,
    :currency
);

同时可以更新 subscriptions.current_period_start 和 current_period_end,以便快速查询。这两个字段相当于缓存,完整的历史记录仍以 subscription_periods 为准。

Invoice 必须保存快照

客户可能在开票后修改姓名、地址或邮箱。如果 invoice 在每次展示时都读取 customers 中的当前数据,旧 invoice 的内容也会跟着变化。

因此,invoice 应保存开票时的账单信息快照,例如:

ALTER TABLE invoices
ADD COLUMN billing_name VARCHAR(200),
ADD COLUMN billing_address TEXT,
ADD COLUMN billing_email VARCHAR(320);

价格、税率、商品描述和币种也要保存在 invoice_items 中,不能只引用产品的当前价格。即使产品以后涨价,历史 invoice 也不应改变。

定时任务需要满足的条件

cronjob 可以负责触发处理,但业务正确性不能建立在“任务每月只运行一次”这个假设上。生产环境中的任务可能重复执行,也可能执行到一半失败。

任务至少要满足以下条件:

  • 幂等性:重复执行不会造成重复开票或重复续期;
  • 唯一约束:由数据库阻止重复记录;
  • 事务:invoice、items 和周期状态要么全部成功,要么全部失败;
  • 锁或并发控制:避免两个 worker 同时处理同一个周期;
  • 失败重试:邮件发送失败不能导致 invoice 丢失;
  • 审计日志:记录自动操作和人工操作;
  • 时间统一:数据库优先保存 UTC 时间,展示时再转换为客户所在时区。

发送 invoice 和创建 invoice 也应分开处理。可以先提交 invoice,再将发送任务写入队列。否则,邮件服务的短暂故障可能导致整个开票事务失败。

什么时候才应该创建新的 subscription

以下情况通常只需修改现有 subscription 或新增 period:

  • 正常续订;
  • 暂停后恢复;
  • 续订周期从一个月改为三个月;
  • 价格调整;
  • payment method 变化。

只有原有商业关系确实结束,并且后来重新建立时,才适合创建新的 subscription。例如:

  • 原订阅已经正式取消,客户数月后重新购买;
  • 客户改购了完全不同且无法连续转换的产品;
  • 法律主体、合同或服务对象发生变化;
  • 业务要求将重新订阅计为新的生命周期。

即使创建了新的 subscription,旧记录也不应删除。

最终建议

设计时应把“当前状态”和“历史事实”分开保存:

  • subscriptions 表示持续的订阅关系;
  • subscription_periods 表示各次服务周期;
  • invoices 表示应收款;
  • payments 表示实际收到的款项;
  • 状态和事件记录订阅的变化过程。

续订时新增周期,不新增 subscription;逾期时改变状态,不删除数据;已经发生的周期和财务记录原则上不应回写。按这种方式处理,日期计算、人工收款、部分付款、补缴恢复和历史审计都会更清楚。

备注:内容仅供参考。