如何设计 recurring payments 与未来事件的会员系统架构
结论
不要把 freeze_on、thaw_on、cancel_on 这类未来状态直接塞进 memberships 表,也不要在管理员安排事件时原地修改整张付款计划。
建议采用以下架构:
- 用独立记录保存已经确认的业务变更,例如
MembershipAdjustment。 - 保留当前生效的付款计划版本,不覆盖历史计划。
- 安排 freeze 或 future cancellation 后,立即生成 projected schedule,供管理员预览。
- 到达生效日期时,通过幂等任务正式应用变更,并激活对应的计划版本。
- 分开计算会员服务覆盖期和实际扣款日。
- 不改写已支付的 payment。未支付项目可以替换、取消,也可以生成调整项。
- 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。
- 管理员安排 freeze。
- 系统以版本 1 和全部 scheduled adjustments 为输入,生成 projected 版本 2。
- 管理员修改 freeze 日期后,废弃版本 2,生成 projected 版本 3。
- freeze 生效时,在事务中验证版本 3 是否仍然适用。
- 将版本 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 则按滚动窗口生成。通常不需要无限生成未来记录,只要确保未来若干周期已经具备可执行的应收项即可。
实施顺序
可以按照以下顺序重构,以便控制风险:
- 为 installment 增加明确的服务起止日期,不再只依赖 payment date。
- 将 freeze 从 membership 字段迁移为区间型 adjustment。
- 把扣款日 snap 规则提取为独立策略对象。
- 编写纯计算的
ScheduleProjector,计算过程中不写数据库。 - 引入
ScheduleVersion,先用于 projected schedule。 - 改造界面,同时显示当前计划和预测计划。
- 使用幂等 processor,在生效日激活预测版本。
- 将 cancellation 接入同一套 adjustment 流程。
- 最后拆分 payment、refund 和 membership entitlement 的状态。
需要提前写进业务规则的边界
架构可以降低规则的实现成本,但不能替业务方作出决定。至少需要明确以下问题:
- freeze 的开始日和结束日是否收费;
- freeze 与已付款服务期交叉时,是拆分还是按日折算;
- cancellation 当天是否仍然提供服务;
- 取消日期落在周期中间时是否退款;
- 是否允许多个 freeze 重叠;
- freeze 与 future cancellation 冲突时哪个优先;
- 付款失败是否影响会员有效期;
- snap 后的扣款日是否允许跨月或跨 term;
- 价格或税率变化后,旧的 projected schedule 如何失效;
- 管理员手动修改 installment 后,重新投影时是否保留该修改。
最后一项需要特别处理。建议将人工修改保存为 adjustment 或 override,不要直接改变生成结果。否则,下次重新计算时很容易覆盖管理员的操作。
整体原则是:事件记录业务意图,计划版本保存计算结果,交易记录真实资金流。未来事件可以立即参与预测,但只有到达生效日期后才改变正式状态。这样既能保留 event queue 的简单性,也能保证付款计划准确、可追溯。
备注:内容仅供参考。
