PAYATHON 2026

同时处理单次与 recurring charges 的数据库设计

支付小周

结论

建议保留两类数据,但不要放在同一张 Charges 表中:

  • Charges:记录已经发生、可以进入账单的费用。
  • Subscriptions:记录订阅规则,包括金额、周期和下一次扣费时间。
  • Invoices 与 InvoiceItems:保存已经出具的账单及明细。

单次购买直接生成一条 Charges 记录。定时任务扫描到期的 Subscriptions,再为每个计费周期生成对应的 Charges 记录。

这与现有方案的基本思路接近,但表的职责更明确。订阅规定未来如何产生费用,charge 记录已经发生的费用。两者的生命周期不同,不适合只用一张表管理。

为什么不建议只使用一张 Charges 表

单次费用通常只需要记录:

  • 向哪个客户收费;
  • 金额和币种;
  • 费用发生时间;
  • 是否已经开票。

订阅则还要记录:

  • 计费周期;
  • 下一次计费时间;
  • 生效和终止时间;
  • 当前状态;
  • 套餐数量、价格和续费规则。

如果把这些信息全部放进 Charges,单次费用会带有大量无用的周期字段,订阅记录也要同时承担规则和交易明细两种职责。以后处理暂停、取消、改价、按比例计费、重试和历史审计时,数据含义很容易变得含混。

更合适的关系是:

Customers
    ├── Subscriptions ──< Charges
    ├── Charges
    └── Invoices ──< InvoiceItems >── Charges

Charges.subscription_id 可以为空:

  • 为空时表示单次费用;
  • 不为空时表示该费用由某个订阅生成。

这样既可以统一查询所有费用,也不会把订阅规则和实际费用混在一起。

推荐的表结构

下面是一个简化的 PostgreSQL 示例。其他 SQL 数据库也可以使用同样的数据模型,只需调整字段类型和语法。

CREATE TABLE customers (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name            VARCHAR(200) NOT NULL
);

CREATE TABLE subscriptions (
    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id         BIGINT NOT NULL REFERENCES customers(id),
    status              VARCHAR(20) NOT NULL
                        CHECK (status IN ('active', 'paused', 'cancelled')),
    amount_minor        BIGINT NOT NULL CHECK (amount_minor >= 0),
    currency            CHAR(3) NOT NULL,
    interval_unit       VARCHAR(10) NOT NULL
                        CHECK (interval_unit IN ('day', 'week', 'month', 'year')),
    interval_count      INTEGER NOT NULL DEFAULT 1
                        CHECK (interval_count > 0),
    next_billing_at     TIMESTAMPTZ,
    starts_at           TIMESTAMPTZ NOT NULL,
    ends_at             TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (ends_at IS NULL OR ends_at > starts_at)
);

CREATE TABLE charges (
    id                  BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id         BIGINT NOT NULL REFERENCES customers(id),
    subscription_id     BIGINT REFERENCES subscriptions(id),
    charge_type         VARCHAR(20) NOT NULL
                        CHECK (charge_type IN ('one_time', 'recurring')),
    description         VARCHAR(500) NOT NULL,
    amount_minor        BIGINT NOT NULL,
    currency            CHAR(3) NOT NULL,
    service_period_from TIMESTAMPTZ,
    service_period_to   TIMESTAMPTZ,
    occurred_at         TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    invoice_id          BIGINT,
    status              VARCHAR(20) NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'invoiced', 'void')),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (
        (charge_type = 'one_time' AND subscription_id IS NULL)
        OR
        (charge_type = 'recurring' AND subscription_id IS NOT NULL)
    )
);

CREATE INDEX idx_subscriptions_due
    ON subscriptions (next_billing_at)
    WHERE status = 'active';

CREATE INDEX idx_charges_uninvoiced
    ON charges (customer_id, occurred_at)
    WHERE status = 'pending';

金额最好用最小货币单位的整数保存,例如人民币分或美元美分。amount_minor = 1099 表示 10.99,这样可以避免浮点数运算带来的舍入误差。如果业务需要比最小货币单位更高的精度,可以改用固定精度的 DECIMAL,但不要用 FLOAT 或 DOUBLE 保存金额。

即使可以从订阅表查到客户、金额、币种和描述,charges 仍应保存这些字段,不能只依赖实时关联。订阅以后可能改价或更换套餐,历史费用则必须保留生成时的信息。

防止重复生成 recurring charge

定时任务可能因超时、重启或多实例并发而重复执行。只做”先查询、再插入”并不可靠,应为每个订阅的计费周期设置数据库级唯一约束。

ALTER TABLE charges
ADD COLUMN billing_cycle_at TIMESTAMPTZ;

CREATE UNIQUE INDEX uq_recurring_charge_cycle
    ON charges (subscription_id, billing_cycle_at)
    WHERE subscription_id IS NOT NULL;

生成周期性费用时:

  1. 找出 next_billing_at <= current_timestamp 的有效订阅。
  2. 锁定订阅记录。
  3. 按当前 next_billing_at 创建费用。
  4. 将 next_billing_at 推进到下一个周期。
  5. 在同一个事务中提交这两项修改。

示意代码如下:

BEGIN;

SELECT *
FROM subscriptions
WHERE id = :subscription_id
  AND status = 'active'
  AND next_billing_at <= CURRENT_TIMESTAMP
FOR UPDATE;

INSERT INTO charges (
    customer_id,
    subscription_id,
    charge_type,
    description,
    amount_minor,
    currency,
    service_period_from,
    service_period_to,
    billing_cycle_at,
    occurred_at
)
VALUES (
    :customer_id,
    :subscription_id,
    'recurring',
    :description,
    :amount_minor,
    :currency,
    :period_start,
    :period_end,
    :billing_cycle_at,
    CURRENT_TIMESTAMP
)
ON CONFLICT DO NOTHING;

UPDATE subscriptions
SET next_billing_at = :next_billing_at,
    updated_at = CURRENT_TIMESTAMP
WHERE id = :subscription_id;

COMMIT;

这里的 :billing_cycle_at 是本次原定的计费时间,不是任务实际运行的时间。即使任务晚执行了几个小时,计费周期的标识也不会改变。

ON CONFLICT DO NOTHING 是 PostgreSQL 语法。在 MySQL、SQL Server 或 Oracle 中,需要换成相应的幂等写入方式。

不要简单地把日期加一个月

按月订阅需要处理月底问题。例如,直接给 1 月 31 日加一个月,不同数据库和日期库可能会得到不同结果。

业务应先明确以下规则:

  • 每月固定在哪一天计费;
  • 如果当月没有该日期,是否改为当月最后一天;
  • 后续月份是否仍按原始账单日计算。

因此,可以在订阅表中额外保存原始账单日和时区:

ALTER TABLE subscriptions
ADD COLUMN billing_day SMALLINT,
ADD COLUMN billing_timezone VARCHAR(64);

不要以上一次调整后的日期为基础继续递增。否则,1 月 31 日调整为 2 月 28 日后,后续账单日可能会一直变成每月 28 日。更可靠的方式是根据订阅起始日期、原始账单日和周期序号计算下一次时间。

如果任务停运一段时间后重新启动,还要决定是否补齐遗漏的周期。通常应循环生成所有已经到期的周期,而不是只生成一笔费用后直接把日期跳到未来:

while next_billing_at <= now:
    generate_charge(next_billing_at)
    next_billing_at = calculate_next_cycle(next_billing_at)

唯一约束能保证补偿过程即使重复执行,也不会为同一周期生成两笔费用。

Invoice 不应该只是实时汇总结果

如果每次查看历史账单时都重新执行 SUM(charges.amount_minor),后续的改价、作废或数据修正可能会改变已经发送给客户的账单。

invoice 应作为不能随意变化的业务记录保存:

CREATE TABLE invoices (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id     BIGINT NOT NULL REFERENCES customers(id),
    invoice_number  VARCHAR(50) NOT NULL UNIQUE,
    currency        CHAR(3) NOT NULL,
    period_from     TIMESTAMPTZ NOT NULL,
    period_to       TIMESTAMPTZ NOT NULL,
    status          VARCHAR(20) NOT NULL
                    CHECK (status IN ('draft', 'issued', 'paid', 'void')),
    total_minor     BIGINT NOT NULL DEFAULT 0,
    issued_at       TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE invoice_items (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    invoice_id      BIGINT NOT NULL REFERENCES invoices(id),
    charge_id       BIGINT NOT NULL REFERENCES charges(id),
    description     VARCHAR(500) NOT NULL,
    amount_minor    BIGINT NOT NULL,
    UNIQUE (charge_id)
);

ALTER TABLE charges
ADD CONSTRAINT fk_charges_invoice
FOREIGN KEY (invoice_id) REFERENCES invoices(id);

创建账单时,应在一个事务中完成以下操作:

  1. 创建 invoices 记录。
  2. 锁定该客户所有符合账期且尚未开票的 charges。
  3. 将费用复制到 invoice_items。
  4. 计算并保存 total_minor。
  5. 将相关费用标记为 invoiced。
  6. 提交事务。

账期范围建议使用半开区间:

occurred_at >= :period_from
AND occurred_at < :period_to

例如,8 月账期可以写成 [2026-08-01 00:00:00, 2026-09-01 00:00:00)。这种写法不依赖”当月最后一秒”的精度,也可以避免边界记录被重复计入账单。

取消、退款和历史修正

已经开票的费用通常不应直接修改或删除,更合适的做法是追加记录:

  • 取消尚未开票的费用时,将状态改为 void。
  • 冲销已经开票的费用时,创建金额为负数的 adjustment 或 credit charge。
  • 退款单独记录为 refund,并关联原始支付或费用。
  • 订阅改价只修改未来的计费规则,不回写历史 charge。

如果系统还要处理税费、折扣、退款和支付,可以继续拆分:

Subscriptions  -> 产生费用规则
Charges        -> 应收费用
Invoices       -> 对客户出具的账单
Payments       -> 实际收款
Refunds        -> 实际退款
Credits        -> 贷项或余额调整

这些概念不能只靠一个 status 字段混在一起。“费用已开票”不等于”账单已支付”,“退款成功”也不表示原始费用从未发生。

对现有设计的迁移方式

不必一次重写整个 billing system,可以分阶段迁移:

  1. 新建统一的 charges 表,把 StandardCharges 数据迁入,并标记为 one_time。
  2. 把 RecurringCharges 迁入 subscriptions。
  3. 为订阅生成的费用填写 subscription_id、billing_cycle_at,并将类型设为 recurring。
  4. 增加周期唯一索引和事务锁,保证定时任务可以幂等执行。
  5. 增加 invoices 和 invoice_items,不再依赖历史数据的动态汇总。
  6. 让新旧流程并行运行,并核对一个或多个完整账期,确认无误后再停用旧表。

最终设计仍然会统一存储所有已经发生的 charges,但 Charges 不再负责订阅调度。表的数量不是重点。未来的计费规则、已经发生的费用、已经出具的账单和实际收款应分别建模,才能保证并发安全和历史可追溯,也便于以后处理取消订阅、改价、补扣与退款。

备注:内容仅供参考。