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 美元可被未来目录更新覆盖的引用结果
Product 定义产品,Plan 定义权益,Price 定义币种周期与版本,优惠覆盖价格,渠道映射负责执行,成交快照保存结果
目录、优惠、渠道和交易各自拥有一类事实;箭头表示引用,不表示下游可以改写上游。

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 时就同时提交 pricecurrencybilling_type,而且月付、年付和不同层级要建立不同 Product。若数据库直接照抄其中一家,切换渠道就会迫使 Product、Plan 或 Price 改变含义。

内部 Price 只保存业务需要长期解释的收费合同:币种、周期、金额、计价方式、版本和生效窗口,不拼接所有渠道字段的最大并集。Stripe 的 price_...、Paddle 的 pri_...、Lemon Squeezy 的 Variant/Price 身份或 Creem 的 prod_... 都属于执行投影,放在 Provider Mapping 中。服务商新增一个展示字段时,不会污染内部价格;更换服务商时,套餐和历史订单也不需要换身份。

应用内部始终使用稳定 ID,例如 product_routenestplan_pro_r1price_pro_usd_month_v1。公开名称可以改,外部对象可以退役,稳定 ID 仍负责连接订阅、订单、账单和审计事件。业务代码若到处比较 "Pro""monthly" 或某个渠道 ID,目录边界实际上还没有建立。

币种和周期共同定义一条可售价格

RouteNest Pro 同时销售美元和欧元的月付、年付时,需要四条 Price,不能把四组收费条件塞进 Plan 的可选字段。

内部 PricePlan币种周期金额当前用途
price_pro_usd_month_v1Pro r1USD每 1 个月$19美元月付新销售与旧订阅
price_pro_usd_year_v1Pro r1USD每 1 年$190美元年付
price_pro_eur_month_v1Pro r1EUR每 1 个月€18欧元月付
price_pro_eur_year_v1Pro r1EUR每 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 美元。

19 美元 Price v1 在切换点退出新销售但继续服务旧订阅,24 美元 Price v2 在渠道映射就绪后承接新 Checkout
改价切换的是新销售使用的版本;旧订阅是否迁移是另一项有合同与通知边界的任务。

在切换前已经生成、仍在有效期内的 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,不需要反过来规定内部层级。

服务商当前目录形状改价与停售边界内部映射的目标
StripeProduct → 一个或多个 Price;单个 Price 还可含多币种选项金额不能原地修改,创建新 Price;旧 Price 可归档Product ID + Price ID,必要时带渠道币种选项
PaddleProduct → 一个或多个 Price;Price 包含币种、一次性或周期和试用更新接口可改金额、币种与周期;Price 不删除,改为 archivedProduct ID + Price ID,并核对最终币种与周期
Lemon SqueezyProduct → Variant → Price;Price 关联 Variant,保存金额、模型和续费周期Variant 可停用,既有买家仍保留访问Product ID + Variant ID,必要时保存 Price ID
CreemProduct 同时包含金额、币种、billing type 和周期月付、年付和不同套餐分别创建 Product;测试与生产目录分离Product ID 本身就是该价格的执行制品

Stripe 的对象定义与改价规则见Products and Prices(在新标签页打开);Paddle 的一对多关系与字段见Prices(在新标签页打开)Update a Price(在新标签页打开)。Lemon Squeezy 的层级见Variants(在新标签页打开)Prices API(在新标签页打开)Creem 的 Product 创建请求(在新标签页打开)直接包含价格与计费字段,其当前 FAQ(在新标签页打开)要求月付、年付和不同套餐分别创建 Product。

同一内部 Price 分别映射到 Stripe Price、Paddle Price、Lemon Squeezy Variant 和 Price、Creem 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=RouteNestPlan=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_v2WELCOME20 r1$24 - $4.80 = $19.20下一周期按 v2 的 $24 续费,因为优惠只作用一次
10 月新客户购买 USD 年付price_pro_usd_year_v1基础价与实付均为 $190年付未参与本次改价,不由月付 v2 自动推导
10 月德国客户购买 EUR 月付price_pro_eur_month_v1WELCOME20 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、嵌入式支付和自建结账怎么选,决定买家怎样完成付款。