RouteNest 准备把 Pro 月付从 19 美元调到 24 美元。如果系统只有一条 plan=pro, price=19, stripe_price_id=... 的可变记录,直接把 19 改成 24,旧订阅便失去原价依据。年付 190 是否一起变化、欧元价是否重算、WELCOME20 基于哪个金额,以及 Paddle 或 Creem 应使用哪个对象,也都没有稳定答案。
Product、Plan、Price 怎么建模,取决于这些事实各自何时变化:Product 定义卖什么,Plan 定义买家获得哪些功能和额度,Price 定义金额、币种、周期与版本。Promotion 只改变符合条件的成交价,Provider Mapping 只负责把内部 Price 投影到具体支付渠道。订单和订阅还要保存成交时的快照,不能事后读取“当前价格”解释过去。
产品收费方式、目标用户和套餐权益需要先确定,支付账户也应通过必要审核。收费指标仍不清楚时,先处理“按席位、用量还是功能收费”的产品决策;账户仍在补件时,先回到支付审核的可核验材料。RouteNest、对象 ID、金额和日期均为合成案例,不对应真实账户。
Product、Plan、Price 分别回答什么
Product、Plan 和 Price 不是三个不同名字的“套餐”。它们分别回答产品身份、服务承诺和收费条件,变化频率也不同。
| 对象 | 它拥有的事实 | RouteNest 示例 | 不应放进来 |
|---|---|---|---|
| Product | 产品身份、用途、品牌展示和归属 | RouteNest 团队排班软件 | 19 美元、月付、Stripe Price ID |
| Plan | 一组可售权益、额度和服务等级 | Pro:20 名成员、排班导出、审计记录 | 币种、促销码、渠道账户 |
| Price | 某个 Plan 的金额、币种、周期、计价方式和版本 | Pro / USD / 每月 / 19 美元 / v1 | 功能开关、客户资格、服务商密钥 |
| Promotion | 优惠类型、适用范围、资格、期限和兑换限制 | 首次付费订单享 20% 优惠 | 永久基础价、套餐权益 |
| Provider Mapping | 内部 Price 与指定渠道账户、环境和外部对象的关系 | v2 → Stripe Live 的某个 Price | 产品业务语义、成交金额真值 |
| Transaction Snapshot | 当次购买实际使用的产品、价格、优惠和最终金额 | 基础价 24 美元,优惠 4.80 美元,实付 19.20 美元 | 可被未来目录更新覆盖的引用结果 |
Plan 是内部服务承诺层,不必在每家支付服务商中找到同名对象。它可以关联权限与额度系统,但不能让“当前 Pro 有什么功能”成为所有历史订阅的唯一答案。若 Pro 从 20 名成员变为 50 名且老用户仍保留原权益,应创建新的 Plan 修订或新 Plan,并让旧订阅继续引用旧承诺;只改展示文案而不改变交付内容时,才适合原地更新名称或描述。
Price 的身份不能只有金额。至少要同时确定目标 Plan、币种、计费周期、计价方式和版本;19 既不知道是美元还是欧元,也不知道是每月、每年还是一次性。对于按量、阶梯或混合计费,Price 还要引用计量单位和价格公式,但原始用量事件与账单计算属于后续计费链路,不应塞回 Product 或 Plan。
Canonical 目录必须留在自己的系统里
内部目录是应用关于“当前允许卖什么”的权威答案。Checkout 应先解析内部 Product、Plan 和 Price,再选择支付渠道;不能先拿到一个 Stripe Price ID,反过来猜用户买了哪个套餐。
同一组商业事实到了不同服务商,会落在不同对象上:Stripe 和 Paddle 都以 Product 关联一个或多个 Price;Lemon Squeezy 先在 Product 下区分 Variant,再让 Price 关联 Variant 并保存金额、价格模型与续费周期;Creem 创建 Product 时就同时提交 price、currency、billing_type,而且月付、年付和不同层级要建立不同 Product。若数据库直接照抄其中一家,切换渠道就会迫使 Product、Plan 或 Price 改变含义。
内部 Price 只保存业务需要长期解释的收费合同:币种、周期、金额、计价方式、版本和生效窗口,不拼接所有渠道字段的最大并集。Stripe 的 price_...、Paddle 的 pri_...、Lemon Squeezy 的 Variant/Price 身份或 Creem 的 prod_... 都属于执行投影,放在 Provider Mapping 中。服务商新增一个展示字段时,不会污染内部价格;更换服务商时,套餐和历史订单也不需要换身份。
应用内部始终使用稳定 ID,例如 product_routenest、plan_pro_r1 和 price_pro_usd_month_v1。公开名称可以改,外部对象可以退役,稳定 ID 仍负责连接订阅、订单、账单和审计事件。业务代码若到处比较 "Pro"、"monthly" 或某个渠道 ID,目录边界实际上还没有建立。
币种和周期共同定义一条可售价格
RouteNest Pro 同时销售美元和欧元的月付、年付时,需要四条 Price,不能把四组收费条件塞进 Plan 的可选字段。
| 内部 Price | Plan | 币种 | 周期 | 金额 | 当前用途 |
|---|---|---|---|---|---|
price_pro_usd_month_v1 | Pro r1 | USD | 每 1 个月 | $19 | 美元月付新销售与旧订阅 |
price_pro_usd_year_v1 | Pro r1 | USD | 每 1 年 | $190 | 美元年付 |
price_pro_eur_month_v1 | Pro r1 | EUR | 每 1 个月 | €18 | 欧元月付 |
price_pro_eur_year_v1 | Pro r1 | EUR | 每 1 年 | €180 | 欧元年付 |
年付 190 美元可以在价格页展示为“比连续 12 个月节省 38 美元”,但这 38 美元是两个基础价格的比较结果,不是结账时临时套用的优惠。年付的续费周期、合同金额和未来改价时点都可能与月付不同,因此它本身就是一条 Price。
买家位于德国也不应触发系统把 19 美元实时换算成欧元后覆盖原记录。地区、账单地址和语言可以参与价格选择策略,最终必须解析到一条明确的 EUR Price;实时换汇形成的单次 Quote 只能服务当前交易,不能悄悄成为下一次自动续费的目录价。
Stripe 允许一个 Price 配置多个 currency_options(在新标签页打开),Paddle 则支持按国家设置价格覆盖(在新标签页打开)。这些能力可以减少渠道对象数量,但不适合作为跨服务商的内部主模型。自有目录仍建议每个币种、周期和版本使用独立 Price,再由映射层决定多条内部价格是否安全地指向同一个外部对象。这样 Webhook 反查、退款解释和迁移导出都保留相同粒度。
同一 Plan、币种和周期在同一销售时点只能解析到一个当前版本。计划生效时间可以决定 v1 与 v2 的切换,但时间窗口不能代替状态:一条草稿即使已到生效时间,也不能被 Checkout 选中;一条已退役价格也不能因为结束时间为空重新出现。
优惠是覆盖层,不是另一条永久低价
WELCOME20 表示首次付费订单享 20% 优惠时,基础 Price 仍是 24 美元。Promotion 记录优惠类型、适用 Product 或 Price、客户资格、开始与结束时间、作用次数、兑换上限和状态;客户输入的 Code 只是取得这项 Promotion 的入口。
一笔符合条件的新月付订单可以按最小货币单位计算:
base_amount = 2400 USD cents
discount_amount = 480 USD cents
final_amount = 1920 USD cents
订单同时保存三项金额,不能只留下 final_amount=1920。只有最终金额时,退款与客服无法判断客户买的是 19.20 美元基础套餐,还是 24 美元套餐叠加首单优惠;财务也无法按 Promotion 统计让利。
Stripe 把 Coupon 与面向客户输入的 Promotion Code 分开(在新标签页打开):Coupon 定义折扣,Promotion Code 增加客户、首次订单、最低金额、有效期和兑换次数等限制。Paddle 的 Discount(在新标签页打开)可以一次生效、按若干账期重复或永久重复,还区分目录优惠与仅用于特定交易的非目录优惠。Lemon Squeezy 的 Discount(在新标签页打开)也包含作用期限、开始与结束时间以及 Variant 范围。内部 Promotion 要表达自己的业务规则,再映射到渠道能够等价执行的优惠对象;渠道无法表达相同范围或期限时,不能用一个近似 Coupon 冒充。
优惠规则改变后应创建新的 Promotion 修订。把 WELCOME20 从首单 20% 改成前三个月 20%,会改变续费金额和客户承诺;原订单必须继续引用兑换时的修订。停用 Code 只阻止新兑换,已经写入订阅或账单的优惠是否继续生效,要由原规则和渠道行为共同决定。
改价要创建新版本,不要覆盖旧金额
2026 年 10 月 1 日起把 Pro 美元月付改为 24 美元时,先创建 price_pro_usd_month_v2,再为计划使用的支付渠道准备对应对象。v2 尚未具备可执行映射时保持草稿;切换时让 v2 成为新销售的 active 版本,同时把 v1 标为 retired。v1 的金额仍是 19 美元。
在切换前已经生成、仍在有效期内的 Quote 应继续使用它冻结的 v1;切换后的新 Checkout 才解析到 v2。否则用户在确认页看到 19 美元,提交付款时却被重新查询成 24 美元。Quote 过期后重新报价,才按当前可售版本计算。
旧订阅默认继续引用 v1 及其渠道对象。迁移到 v2 可能涉及用户通知、同意、下期生效、按比例计费和当地规则,不能由目录归档顺手完成。Stripe 明确要求改金额时创建新 Price 并停用旧 Price,使用旧 Price 的现有订阅会继续有效(在新标签页打开)。即使改用另一家服务商,内部 v1 也必须保持不变,否则旧订阅的金额事实仍会随渠道实现漂移。
Paddle 的更新接口(在新标签页打开)目前允许修改 unit_price、币种和 billing_cycle。即使外部 API 接受原地修改,已经用于销售的内部 v1 仍应映射到不再变化的外部对象;v2 创建新的 Paddle Price。否则 v1 和 v2 会同时指向一个已变成 24 美元的对象,历史订阅、Webhook 反查和回滚都无法确定当时的条件。
一次改价涉及多个币种或周期时,每个目标价格都有独立新版本。美元月付涨价不会自动改写美元年付或欧元价格;是否联动属于一项明确的商业变更,并应在同一个变更批次中预览。切换只有在需要对外销售的渠道映射都达到可用状态后才发生,不能让 Checkout 在请求途中临时创建目录价或渠道对象。
退役停止新销售,不等于删除历史
目录状态描述“还能否用于哪类动作”,不是简单的显示开关。RouteNest 的 Price 使用四个状态:
| 状态 | 新 Checkout | 旧订阅与历史引用 | 可以修改什么 |
|---|---|---|---|
| draft | 不可用 | 尚无正式引用 | 发布前的业务字段;外部供给失败可重试 |
| active | 可用 | 可引用 | 只改不影响商业含义的展示或内部备注 |
| retired | 不再用于新销售 | 继续续费、退款、反查和审计 | 状态、结束时间和运维备注;不改金额 |
| archived | 不在日常目录展示 | 仍可按 ID 读取 | 只保留历史与法定记录,不物理删除 |
Plan 还需要 grandfathered 语义:它不再接受新客户,但现有订阅仍获得原权益。Product 归档表示整项产品退出新销售;Price retired 只停止某一币种、周期或版本;Promotion 停用只停止新兑换。把这些动作合成一个 enabled=false,无法回答老用户还能否登录、能否续费或为何按旧金额退款。
渠道对象也有自己的生命周期,不能把本地 retired 自动翻译成“删除外部对象”。Stripe 归档 Price 后,现有订阅继续运行,但使用相关产品的现有 Payment Links 会被停用(在新标签页打开);Paddle 没有删除 Price 的操作,官方要求通过更新将其归档(在新标签页打开);Lemon Squeezy 停用 Variant 会阻止新购买但保留既有客户访问,删除 Variant 则会让既有买家无法取得所附文件(在新标签页打开)。因此,是否退役渠道对象要根据新销售入口、既有订阅和客户访问逐项判断,而不是由数据库状态机械触发。
退款、争议、Webhook 和财务留档仍可能在停售后到达。只要任一历史对象还引用 Product、Plan、Price、Promotion 或 Mapping,物理删除都会把可解释事实变成孤儿。日常列表可以隐藏 archived,按 ID 的审计读取必须保留。
四家服务商对象不同,都要映射回内部 Price
同一内部 price_pro_usd_month_v2 投影到四家服务商时,外部层级并不一致。服务商对象只需准确执行这条 Price,不需要反过来规定内部层级。
| 服务商 | 当前目录形状 | 改价与停售边界 | 内部映射的目标 |
|---|---|---|---|
| Stripe | Product → 一个或多个 Price;单个 Price 还可含多币种选项 | 金额不能原地修改,创建新 Price;旧 Price 可归档 | Product ID + Price ID,必要时带渠道币种选项 |
| Paddle | Product → 一个或多个 Price;Price 包含币种、一次性或周期和试用 | 更新接口可改金额、币种与周期;Price 不删除,改为 archived | Product ID + Price ID,并核对最终币种与周期 |
| Lemon Squeezy | Product → Variant → Price;Price 关联 Variant,保存金额、模型和续费周期 | Variant 可停用,既有买家仍保留访问 | Product ID + Variant ID,必要时保存 Price ID |
| Creem | Product 同时包含金额、币种、billing type 和周期 | 月付、年付和不同套餐分别创建 Product;测试与生产目录分离 | Product ID 本身就是该价格的执行制品 |
Stripe 的对象定义与改价规则见Products and Prices(在新标签页打开);Paddle 的一对多关系与字段见Prices(在新标签页打开)和Update a Price(在新标签页打开)。Lemon Squeezy 的层级见Variants(在新标签页打开)与Prices API(在新标签页打开);Creem 的 Product 创建请求(在新标签页打开)直接包含价格与计费字段,其当前 FAQ(在新标签页打开)要求月付、年付和不同套餐分别创建 Product。
Mapping 只有同时记录内部 Price、服务商、商户账户、测试或生产环境、执行模式、外部对象类型与 ID、状态和版本,才能用于 Checkout。它还要保存最近一次核验到的金额、币种和周期摘要,便于在发布前发现“内部 v2 为 24 美元,外部对象仍是 19 美元”这类漂移;摘要是核验结果,不是第二份价格真值。
ready 不能由“外部 ID 不为空”推出。系统要使用该 Mapping 指向的账户和环境读取外部对象,确认对象可用于目标执行模式,金额、币种和周期与内部 Price 一致,并证明反向索引只会得到一个内部版本。创建接口返回成功但读回失败、对象属于另一个账户、test 对象被写进 live 映射,或者外部金额仍是 19 美元时,状态都应保持 pending 或 failed。
Checkout 只消费已经核验的 ready Mapping。外部对象后来被手工修改或归档时,漂移检测应让这条映射退出新销售;不能静默退回 v1,也不能在付款请求里临时补建一个 24 美元对象。前者违背改价决定,后者会产生没有发布记录的新价格。
provider=stripe + environment=live + price_... 仍然不完整,同一团队可能有多个 Stripe 账户;测试与生产也不能共用 Mapping。反向索引必须用服务商、账户、环境和外部对象身份稳定找到唯一内部 Price,Webhook 才能把续费、退款或争议归回正确版本。
只有需要可复用渠道价格对象的执行模式才强制消费这类 Mapping。一次性动态 Quote 可以在创建 Checkout 时携带冻结金额,但它仍要引用内部 Price 或明确的报价来源;不能因为渠道允许传 price_data,就让每次请求随意创造无法治理的新目录价。
成交事实不能靠当前目录倒推
目录回答现在能卖什么,订单回答当时卖了什么。创建 Checkout 前,系统先解析 active Price,再验证 Promotion,形成带有效期的 Quote;订单提交后,把 Quote 的结果冻结到订单行和支付请求。后续目录改价、优惠停用或渠道映射退役都不能回写这份成交事实。
同一笔销售会先后出现三种金额,三者不能共用一条可变记录:
| 金额对象 | 有效范围 | 允许怎样变化 | RouteNest 示例 |
|---|---|---|---|
| Catalog Price | 多次销售可复用的基础收费条件 | 已投入销售后只通过新版本改变 | USD 月付 v2 的基础价为 $24 |
| Quote | 某位买家在短时间内确认的选择结果 | 到期后重新解析;有效期内不随目录切换 | v1 Quote 在切换后仍保持 $19,直到过期 |
| Transaction Snapshot | 已提交订单或账单的成交事实 | 不重算;退款和审计沿原金额处理 | 基础价 $24、优惠 $4.80、实付 $19.20 |
Quote 冻结所选 Price、数量与有效 Promotion,Transaction Snapshot 冻结最终提交的订单或账单。Catalog Price 生成 Quote,Quote 冻结为 Transaction Snapshot,后两者都不能回写前者,用户在确认页看到的结果才能与最终扣款一致。
一条订阅订单行至少要同时保留内部引用和可读快照:Product 与 Plan 身份及修订、Price ID 与版本、单位金额、币种、计费周期、数量或计价输入、Promotion 身份与修订、基础金额、优惠金额、最终金额,以及执行所用的服务商、Mapping 修订和外部对象身份。引用负责追溯来源,快照负责在来源变化后仍能解释交易。
只有引用没有快照,客服在 v2 生效后打开旧订单,看到的可能是 24 美元而不是客户实际同意的 19 美元;只有快照没有引用,又无法解释这笔金额来自哪次目录发布和优惠规则。两者缺一,退款、收入确认、客户争议与迁移都会依赖猜测。
订阅继续保存当前绑定的内部 Price 和渠道 Subscription 身份,每次续费账单再保存该周期的实际金额事实。Payment 只描述资金动作,Invoice 只描述当期应收与行项目;它们不能取代 Product、Plan 或 Price。稳定的 Price 与成交快照是订阅状态机和账单行的输入,不能再从 Payment 或 Invoice 反推。
价格、优惠和 Mapping 的状态变化还应记录操作者、时间、原因与前后版本。审计事件不需要复制整张表,但必须能定位到不可变版本;“管理员在某天把 price 改了”无法证明改的是哪一代金额,也无法圈定受影响的新 Checkout。
RouteNest 从 19 美元涨到 24 美元怎样落库
RouteNest 已有 Product=RouteNest、Plan=Pro r1,四条 v1 Price 分别承载 USD/EUR 月付年付。WELCOME20 r1 只适用于 Pro 的首次付费订单、作用一次,并在 2026 年 12 月 31 日结束。生产只为已经批准使用的渠道创建对象;四家对象模型的差异不构成同时接入四家的理由。
9 月 20 日,目录创建 USD 月付 v2:24 美元、计划在 10 月 1 日 00:00 UTC 生效、前一版本指向 v1。支付控制面随后为 v2 创建新的渠道对象并逐项核对金额、币种和周期;任一必需渠道仍处于 pending 或 failed 时,v2 不能进入该渠道的新销售。9 月 30 日仍解析 v1,已生成且未过期的 Quote 也继续使用 v1。
10 月 1 日切换后,结果可以按交易直接复算:
| 交易 | 解析到的目录对象 | 优惠 | 成交快照 | 后续行为 |
|---|---|---|---|---|
| 9 月已存在的 USD 月付订阅续费 | price_pro_usd_month_v1 | 无 | 基础价与实付均为 $19 | 继续使用 v1,除非另有获准的迁移安排 |
| 10 月新客户购买 USD 月付 | price_pro_usd_month_v2 | WELCOME20 r1 | $24 - $4.80 = $19.20 | 下一周期按 v2 的 $24 续费,因为优惠只作用一次 |
| 10 月新客户购买 USD 年付 | price_pro_usd_year_v1 | 无 | 基础价与实付均为 $190 | 年付未参与本次改价,不由月付 v2 自动推导 |
| 10 月德国客户购买 EUR 月付 | price_pro_eur_month_v1 | WELCOME20 r1 | €18 - €3.60 = €14.40 | 使用 EUR Price,不在结账时换算 USD v2 |
四笔交易可以同时存在,不构成数据冲突。v1、v2、年付和欧元价是四种明确商业条件;Promotion 只覆盖符合条件的首笔成交;每个订单保存自己的金额与版本。财务看到 19、19.20、190 和 14.40 时,不需要从当前价格页猜原因。
如果公司决定 2027 年起让全部旧订阅升级到 24 美元,那是新的订阅迁移,不是补改 v1。它要确定适用客户、通知与同意、切换账期、Proration、失败恢复和渠道 API 行为,并为每个订阅留下计划变更记录。保留 v1 让这项迁移可审计,也让未迁移或迁移失败的订阅仍有合法来源。
每种故障都能定位到一个对象
价格页显示 24 美元而 Checkout 仍收 19 美元,先检查新请求解析到的是 v1 还是 v2,再检查 v2 的渠道 Mapping 是否 ready;不要把 Plan 的金额也改成 24 试图“对齐”。旧订阅突然涨到 24 美元,则要检查是否原地修改了外部对象或批量替换了 Subscription Price。先停止错误迁移,再按旧 Price 恢复订阅,并核对受影响的续费与通知记录;不要把 v2 降回 19。恢复后创建一笔新 Checkout,并读取一条旧订阅:前者应使用 v2,后者仍应引用 v1。
WELCOME20 过期后仍能使用,责任在 Promotion 的有效期、资格判断或渠道优惠映射,不在基础 Price。停止新的错误兑换并修正对应规则后,重新生成 Quote;新 Quote 应恢复为 24 美元基础价,已经成交的优惠快照保持不变。
Webhook 收到外部 Price ID 却找不到内部订单时,先阻止事件继续推进订单状态,再用服务商、商户账户、环境和外部对象身份修复反向 Mapping,最后幂等重放原事件;创建一条同名 Product 不会补回已经丢失的版本关系。测试 Checkout 成功而生产报对象不存在,通常是把 test Mapping 带入 live。应在生产账户创建并核验新的 live Mapping,再用新 Checkout 复验,不能把测试对象的环境字段直接改成 live。
老客户后台突然显示新 Pro 权益,应恢复订阅绑定的 Plan 修订,并重新计算该订阅的权益;历史订单打开后显示 24 美元,应恢复订单行的原 Price 引用与成交快照。复验时,旧订阅应显示原权益,旧订单仍应显示 19 美元。把所有问题都归到一张 plans 表,只会让下一次变化再次互相覆盖。
变化发生在哪项事实,决定要创建或更新哪个对象:
| 发生变化的事实 | 正确动作 | 必须保留的旧关系 |
|---|---|---|
| 名称、说明或图片,产品承诺不变 | 更新 Product 的展示字段 | 稳定 Product ID 与历史快照 |
| 产品用途、交付类型或业务实质改变 | 建立新的 Product,并重新核对支付服务商适用范围 | 原 Product 及其订单、订阅和政策证据 |
| 功能、额度或服务等级改变 | 创建 Plan 修订,明确哪些订阅 grandfathered | 旧订阅引用的 Plan 修订与权益承诺 |
| 金额、币种、周期或计价方式改变 | 创建 Price 版本并准备新的渠道映射 | 旧 Price、旧 Mapping 与成交快照 |
| 优惠范围、期限、资格或次数改变 | 创建 Promotion 修订 | 已兑换优惠及其订单或订阅快照 |
| 服务商、商户账户、环境或外部对象改变 | 创建新的 Provider Mapping | 历史交易使用的 Mapping 修订和外部身份 |
完成建模后,19 美元不再是会被新价格覆盖的字段,而是退出新销售、继续解释旧订阅的 v1;24 美元则成为只承接新销售的 v2。两代价格拥有各自的渠道映射,每笔订单也保存自己的成交快照。目标渠道的 v2 Mapping 达到 ready 后,再进入Hosted Checkout、嵌入式支付和自建结账怎么选,决定买家怎样完成付款。
