PAYATHON 2026

如何设计 recurring payments 与未来事件的会员系统架构

支付阿杰

结论

不要把 freeze_on、thaw_on、cancel_on 这类未来状态直接塞进 memberships 表,也不要在管理员安排事件时原地修改整张付款计划。

建议采用以下架构:

  1. 用独立记录保存已经确认的业务变更,例如 MembershipAdjustment。
  2. 保留当前生效的付款计划版本,不覆盖历史计划。
  3. 安排 freeze 或 future cancellation 后,立即生成 projected schedule,供管理员预览。
  4. 到达生效日期时,通过幂等任务正式应用变更,并激活对应的计划版本。
  5. 分开计算会员服务覆盖期和实际扣款日。
  6. 不改写已支付的 payment。未支付项目可以替换、取消,也可以生成调整项。
  7. refund 不属于 cancellation 的一种状态,应当作为独立的资金操作处理。

event queue 负责决定“什么时间发生什么”,但不能取代付款计划模型。事件、计划版本和实际交易需要分别建模。

不要让 Payment 同时承担三种职责

这类系统容易失控,往往是因为一条 recurring payment 同时代表了三件事:

  • 某段会员服务;
  • 某天应该扣款;
  • 一次实际发生的金融交易。

这三个概念需要拆开。

可以采用下面的模型:

Membership
├── MembershipTerm
├── MembershipAdjustment
│   ├── freeze
│   ├── cancellation
│   └── price_change
├── ScheduleVersion
│   └── Installment
└── PaymentTransaction
    └── RefundTransaction

各模型的职责如下:

  • MembershipTerm 表示会员资格的合同期限、续期规则和计费频率。
  • MembershipAdjustment 记录冻结、取消等业务决定。
  • ScheduleVersion 是某一时刻根据规则计算出的付款计划。
  • Installment 表示计划中的应收项目。
  • PaymentTransaction 记录真实扣款。
  • RefundTransaction 记录真实退款。

这样,即使重新计算付款计划,也不会改动已经发生的扣款历史。

Freeze 应当是一个区间事件

一次 freeze 在业务上是一个完整区间,不建议拆成互相独立的 freeze 和 thaw 事件。拆开后,可能出现 freeze 已经执行,但 thaw 被删除、执行失败或重复执行的情况。

更合适的记录方式是:

class MembershipAdjustment < ApplicationRecord
  belongs_to :membership

  enum :kind, {
    freeze: "freeze",
    cancellation: "cancellation",
    price_change: "price_change"
  }

  enum :status, {
    scheduled: "scheduled",
    applied: "applied",
    cancelled: "cancelled",
    failed: "failed"
  }

  validates :effective_on, presence: true
  validates :ends_on, presence: true, if: :freeze?
  validate :ends_after_effective_date

  private

  def ends_after_effective_date
    return unless freeze? && effective_on && ends_on
    errors.add(:ends_on, "must be after effective_on") if ends_on <= effective_on
  end
end

例如:

kind: freeze
effective_on: 2026-07-10
ends_on: 2026-07-24
status: scheduled

区间结束后自然恢复会员资格,不需要管理员再维护一条 thaw 事件。

如果需要取消已经安排的 freeze,也不要直接删除记录。将状态改为 cancelled,更方便后续审计和排查。

计划日期与扣款日期必须分开

每条 installment 至少应包含以下字段:

create_table :installments do |t|
  t.references :schedule_version, null: false
  t.date :service_starts_on, null: false
  t.date :service_ends_on, null: false
  t.date :due_on, null: false
  t.decimal :amount, precision: 12, scale: 2, null: false
  t.decimal :tax_amount, precision: 12, scale: 2, null: false
  t.string :status, null: false
  t.references :superseded_by
  t.timestamps
end

其中:

  • service_starts_on 和 service_ends_on 表示这笔费用对应的服务期;
  • due_on 表示允许发起扣款的日期;
  • status 表示该应收项当前所处的生命周期;
  • superseded_by 用于关联替代它的新项目。

这几个字段是正确处理 freeze 的基础。

假设某条 installment 覆盖以下时间:

service period: 2026-07-01...2026-07-15
due_on:        2026-07-01

会员在 2026-07-10...2026-07-24 freeze。系统应当先调整服务时间线,再根据企业的扣款规则计算 due_on,不能简单地把所有日期都加上 14 天。

Freeze 的计算顺序

建议将计算过程分为四步。

1. 计算暂停的服务时长

首先要明确日期区间采用半开区间:

[start_date, end_date)

也就是说:

2026-07-10...2026-07-24

这个区间包含 7 月 10 日,不包含 7 月 24 日,共 14 天。

Ruby 中可以直接计算:

freeze_days = (ends_on - effective_on).to_i

采用半开区间可以避免“冻结两周究竟是 14 天还是 15 天”这样的边界错误。

2. 处理与当前服务周期的交叉

如果 freeze 落在 installment 覆盖期中间,需要先确定合同规则。常见处理方式包括:

  • 整个 installment 延后;
  • 将 installment 拆成 freeze 前和 freeze 后两段;
  • 对交叉周期按日折算;
  • 当前周期保持不变,从下一个完整周期开始冻结。

技术层不能自行推断该采用哪种规则,必须将其定义为明确的 proration_policy。

如果采用拆分策略:

原服务期:  07-01 ───────────── 07-15
冻结期:             07-10 ───────────── 07-24

调整后:
第一段服务:07-01 ── 07-10
暂停:             07-10 ───────── 07-24
第二段服务:                         07-24 ── 07-29

后续周期从 07-29 继续,而不是沿用原来的 07-15。

3. 顺延后续服务期

后续 installment 的服务边界,应按照实际暂停的服务时长顺延:

new_service_starts_on = old_service_starts_on + freeze_days.days
new_service_ends_on   = old_service_ends_on + freeze_days.days

如果系统支持多次 freeze,不能直接累加每次 freeze 的天数。应当先合并重叠区间,否则同一天可能被重复顺延。

4. 将扣款日 snap 到允许日期

调整完服务期后,再通过独立的扣款日策略计算 due_on:

class SemiMonthlyCollectionPolicy
  def next_allowed_date(date)
    if date.day <= 1
      date.beginning_of_month
    elsif date.day <= 15
      date.change(day: 15)
    else
      date.next_month.beginning_of_month
    end
  end
end

调用方式:

candidate_due_on = installment.service_starts_on
due_on = collection_policy.next_allowed_date(candidate_due_on)

企业还需要明确两个问题:

  • 应当 snap 到当天之后的下一个允许日期,还是可以向前 snap?
  • due_on 能否早于 service_starts_on?

为避免未经授权的提前扣款,默认选择“不早于候选日期的下一个允许日期”通常更安全。不过,这仍然属于业务和支付授权规则,不能只由架构决定。

为什么 event queue 不能解决日期顺延

event queue 只负责执行下面这件事:

在 effective_on 到达后,尝试应用某个 adjustment

它并不知道:

  • freeze 与 installment 如何交叉;
  • 是否需要拆分周期;
  • 税费如何计算;
  • renewal date 如何顺延;
  • 日期如何 snap 到允许扣款日;
  • 已支付项目如何处理;
  • 多个未来事件如何组合。

这些规则应交给确定性的 schedule calculator:

ScheduleCalculator.call(
  membership: membership,
  base_schedule: active_schedule,
  adjustments: applicable_adjustments,
  as_of: calculation_date
)

相同的输入必须得到相同的输出。这样,同一套计算逻辑既能用于后台正式执行,也能用于管理员预览和自动化测试。

不要等到 freeze 当天才计算界面结果

未来事件不应提前改变 membership 当前的有效状态,但可以立即反映在 projected schedule 中。

因此,系统应同时保留两个视图:

  • current schedule:当前正式有效,可以用于实际扣款的计划;
  • projected schedule:纳入所有已安排未来事件后的预测计划。

界面可以明确显示:

当前有效计划
下一次扣款:2026-07-01

预计计划
已安排在 2026-07-10 至 2026-07-24 暂停会员资格。
预计下一次扣款将调整为 2026-08-01。
该日期可能因付款失败、计划变更或企业扣款规则而变化。

如果管理员取消 scheduled freeze,只需将 adjustment 标记为 cancelled,然后重新生成 projected schedule。

不要展示一张明知不准确、却没有任何提示的付款计划。

使用计划版本,不要原地移动所有记录

修改 schedule 时,更安全的方式是生成新版本:

class ScheduleVersion < ApplicationRecord
  belongs_to :membership
  belongs_to :derived_from, class_name: "ScheduleVersion", optional: true

  enum :status, {
    projected: "projected",
    active: "active",
    superseded: "superseded"
  }

  has_many :installments
end

典型流程如下:

  1. 当前正式计划为版本 1。
  2. 管理员安排 freeze。
  3. 系统以版本 1 和全部 scheduled adjustments 为输入,生成 projected 版本 2。
  4. 管理员修改 freeze 日期后,废弃版本 2,生成 projected 版本 3。
  5. freeze 生效时,在事务中验证版本 3 是否仍然适用。
  6. 将版本 1 标记为 superseded,将版本 3 标记为 active。

有了版本记录,系统就能回答这些实际问题:

  • 为什么某笔付款从 7 月 15 日变成了 8 月 1 日?
  • 哪位管理员安排了这次 freeze?
  • 修改前的计划是什么?
  • future cancellation 撤销后,计划如何恢复?
  • 当时向会员展示的是哪一版预测计划?

新版本不应改写已支付的 installment,只处理尚未结算的部分,并在需要时创建 credit、补款或 refund。

Event Processor 必须幂等

cron job 不能假设每个事件只会执行一次。任务可能重试,进程可能中断,也可能有两台 worker 同时处理同一事件。

一个简化的 Rails 实现如下:

class ApplyMembershipAdjustment
  def self.call(adjustment_id, processing_date: Date.current)
    adjustment = MembershipAdjustment.find(adjustment_id)

    MembershipAdjustment.transaction do
      adjustment.lock!

      return if adjustment.applied? || adjustment.cancelled?
      return if adjustment.effective_on > processing_date

      membership = adjustment.membership
      membership.lock!

      projected = ScheduleProjector.call(
        membership: membership,
        adjustments: membership.membership_adjustments.scheduled,
        as_of: processing_date
      )

      ScheduleActivator.call(
        membership: membership,
        projected_schedule: projected
      )

      adjustment.update!(
        status: :applied,
        applied_at: Time.current
      )
    end
  end
end

还应增加数据库约束或 idempotency key,防止同一个 adjustment 创建出两套正式计划。

任务查询应使用:

where(status: :scheduled)
  .where(effective_on: ..processing_date)

不要只查询 effective_on == Date.current。如果任务某天没有运行,使用等号查询会永久漏掉已经过期的事件。

Cancellation 与 Refund 应分开建模

Cancellation 改变的是服务权益:

会员资格在哪一天停止?
停止后是否仍有未消费余额?
是否需要取消未来应收项?

Refund 处理的是资金:

退哪几笔交易?
每笔退多少?
税费如何处理?
是否允许部分退款?
支付网关是否已受理?

推荐流程如下:

Cancellation
    ↓
计算服务终止和未消费金额
    ↓
取消或替换未来 installments
    ↓
根据退款政策生成 RefundRequest
    ↓
调用支付渠道
    ↓
记录 RefundTransaction

不能只把已支付 payment 的状态从 paid 改成 refunded。实际系统还可能遇到部分退款、多次退款、退款失败和异步确认。

应当保留原交易,并追加退款记录:

class RefundTransaction < ApplicationRecord
  belongs_to :payment_transaction

  enum :status, {
    requested: "requested",
    processing: "processing",
    succeeded: "succeeded",
    failed: "failed"
  }
end

自动续期也应使用同一套计算机制

自动续期不需要再维护一个特殊的 cron 分支。它可以视为在当前 term 结束时创建下一个 term:

当前 term 到期
    ↓
检查 auto_renew
    ↓
检查是否存在生效的 cancellation
    ↓
创建下一 MembershipTerm
    ↓
生成下一期 ScheduleVersion

固定一年、之后转为月度续期的会员,可以表示为:

{
  initial_term: 1.year,
  initial_billing_interval: 1.month,
  renewal_term: 1.month,
  renewal_billing_interval: 1.month,
  auto_renew: true
}

初始一年可以生成 12 条 installments,后续月度 term 则按滚动窗口生成。通常不需要无限生成未来记录,只要确保未来若干周期已经具备可执行的应收项即可。

实施顺序

可以按照以下顺序重构,以便控制风险:

  1. 为 installment 增加明确的服务起止日期,不再只依赖 payment date。
  2. 将 freeze 从 membership 字段迁移为区间型 adjustment。
  3. 把扣款日 snap 规则提取为独立策略对象。
  4. 编写纯计算的 ScheduleProjector,计算过程中不写数据库。
  5. 引入 ScheduleVersion,先用于 projected schedule。
  6. 改造界面,同时显示当前计划和预测计划。
  7. 使用幂等 processor,在生效日激活预测版本。
  8. 将 cancellation 接入同一套 adjustment 流程。
  9. 最后拆分 payment、refund 和 membership entitlement 的状态。

需要提前写进业务规则的边界

架构可以降低规则的实现成本,但不能替业务方作出决定。至少需要明确以下问题:

  • freeze 的开始日和结束日是否收费;
  • freeze 与已付款服务期交叉时,是拆分还是按日折算;
  • cancellation 当天是否仍然提供服务;
  • 取消日期落在周期中间时是否退款;
  • 是否允许多个 freeze 重叠;
  • freeze 与 future cancellation 冲突时哪个优先;
  • 付款失败是否影响会员有效期;
  • snap 后的扣款日是否允许跨月或跨 term;
  • 价格或税率变化后,旧的 projected schedule 如何失效;
  • 管理员手动修改 installment 后,重新投影时是否保留该修改。

最后一项需要特别处理。建议将人工修改保存为 adjustment 或 override,不要直接改变生成结果。否则,下次重新计算时很容易覆盖管理员的操作。

整体原则是:事件记录业务意图,计划版本保存计算结果,交易记录真实资金流。未来事件可以立即参与预测,但只有到达生效日期后才改变正式状态。这样既能保留 event queue 的简单性,也能保证付款计划准确、可追溯。

备注:内容仅供参考。