Posts LPSignal 的卡支付:免费试用、Managed Payments 和从 Stripe 回来按钮全灰
Post
Cancel

LPSignal 的卡支付:免费试用、Managed Payments 和从 Stripe 回来按钮全灰

  LPSignal 的卡支付在 09-30 凌晨提交(b4152af),10-01 修改过 Managed Payments 不接受的参数,10-02 的项目规范记下了 Stripe 已切到 live,同一天加入 Basic 七天免费试用。10-12 又出现 Managed Payments 与 API 版本不兼容的问题,线上结账返回 503;当天还修了从 Stripe 结账页返回后定价页按钮全部禁用的前端 bug。

结构

  没有用 Stripe SDK,stripeCall 直接调 REST,每个请求都固定 Stripe-Version: 2024-06-20。src/billing/stripe.ts 的注释记录了版本差异:2025-03-31.basil 将 current_period_end 移到订阅项(subscription item)上。请求显式带版本号,避免 Dashboard 默认版本升级改变读取到的字段。

  卡订阅状态以 Stripe 为准。webhook 根据事件类型和对象 id 触发处理,订阅、发票等对象用固定版本重新读取,不信任 payload 内的状态和金额。同步结果带版本号:开始同步时 sync_version + 1,写回时要求 sync_applied < 这次的版本,防止旧读取覆盖新结果。数据库事务里不调 Stripe。

  创建结账要先在账户行上认领(checkout_claim_at),拿到后才读最新状态。仍有效且套餐相同的 session 会复用;需要新建时,先确认旧 session 已过期或让它过期,发现旧 session 已完成则返回 409。认领超过 2 分钟可以被其他请求接管。新 session 写回时检查认领时间仍属于自己;若已被接管,就尝试让刚建的 session 过期,并返回 503 checkout_retry。过期失败会记日志,不能保证此时 session 已经作废。

  一个周期有没有付过钱,看的是买下这个周期的那张发票:最近一张 billing_reason 为 subscription_create 或 subscription_cycle 的发票(05faf6a)。改套餐产生的发票不能代替它,付了或作废一张改套餐发票,说明不了这个周期本身。void 不算 paid。发票列表按 has_more 翻页,某一页失败就抛错,不会因为读了一半就得出「没找到」。

10-01:Managed Payments 不接受写死的支付方式

  10-01 的 05faf6a 删除了创建 Checkout Session 时的 payment_method_types[0]=card。提交说明和代码注释记录,开启 Managed Payments(Stripe 作为记录商户)的账户会拒绝写死的支付方式列表。

  修法是不传 payment_method_types,也不传 managed_payments,由账户的 Dashboard 设置决定可用的支付方式和是否走 Managed Payments,和 outcometick、pmwallets 的做法一致。

10-02:七天免费试用

  试用按代码注释沿用 pmwallets 的方案,放在 BASIC_TRIAL 开关后面,默认关闭(cd348ca)。规则是:

  • 只有没订阅过 LPSignal(检查 Stripe 返回的各状态订阅,包括已取消的)、没用稳定币买过、没试用过的账户,第一次买 Basic 才有试用;
  • 结账时必须留卡(payment_method_collection=always),试用结束缺支付方式就取消;
  • 每个账户一次,每张卡也一次。卡用 Stripe 的 card fingerprint 识别,同一张卡在不同 customer 下指纹相同。

  卡的指纹要等订阅建出来才知道,所以检查放在同步里(checkTrial):往 plan_trial 插一行,subscription_id、account_id、fingerprint 各有唯一约束。同一个订阅已登记在当前账户下时通过,重复同步是幂等的;账户或卡已登记过其他试用时拒绝。拿不到指纹的支付方式也拒绝试用,客户仍可以购买无试用的订阅。检查通过之前,trialing 状态不给权益。

  被拒绝的试用由 cancelTrialNow 处理。取消前重新读取订阅,已不在试用中就不取消;仍在试用中则按剩余时间处理:

  • 离试用结束至少 15 分钟,直接删除订阅,再读回来。如果最新发票已付款且实付金额大于零,按取消与扣款发生竞争处理,发告警供人工核对退款;
  • 不到 15 分钟,就不删了,改成 cancel_at_period_end,试用结束时自然终止,不会续费。如果这一刻 Stripe 恰好在扣首月,那这个月照常生效,不需要退款。

  cancelTrialNow 的注释记录,pmwallets 的 portal 不允许换套餐,LPSignal 则允许。试用期间在 portal 里换成 Pro 会被拒绝(试用只给 Basic),这可能发生在临近试用结束时,所以需要区分剩余时间。取消失败由每小时任务重试。

  当天开关就打开了。CLAUDE.md 里记了一条:生产不能回滚到 cd348ca 之前,更早的代码看到 trialing 就给权益。

10-12:所有线上结账 503

  10-12 的项目笔记记录,线上结账全部失败,接口返回 503 stripe_unavailable,Stripe 实际返回的是 400。笔记保留的错误摘录是:

1
Managed Payments is not supported on API version 2024-06-20 … basil or greater

  503 是 checkout() 返回的:catch 把非 BillingError 异常统一包成 503 stripe_unavailable,Stripe 的 4xx 也在其中。这次 Stripe 能响应请求,拒绝的是 API 版本。

  可选的路有三条:

  1. 整体升级到 basil 及以后的版本。basil 把 current_period_end 挪到了订阅项上,发票和订阅之间的关联方式也变了,权益判断、宽限期、试用这些读取都要重写,是资金路径上的一次大改。
  2. 只在创建 Checkout Session 这一个请求上用 basil 版本,其余继续用 2024-06-20。前提是要先在 sandbox 里验证:用旧版本读 Managed Payments 产生的订阅和发票对象,字段是否完整。
  3. 在 Dashboard 关掉 Managed Payments。

  项目笔记记录,当天在 Dashboard 关闭 Managed Payments 后结账恢复。CLAUDE.md 也改成要求它保持关闭:它拒绝 2025-03-31.basil 以下的 API 版本,升级则需要调整周期和发票读取(59c3df6)。

10-12:从 Stripe 回来,按钮全是灰的

  同一天晚些时候修了定价页的一个问题。在定价页点「Choose Pro」或「Start 7-day free trial」,按钮变成「Opening Stripe…」并禁用,页面跳到 Stripe Checkout。用户在 Stripe 页面上点浏览器返回,回到定价页,所有套餐按钮还是禁用状态,显示「Opening Stripe…」,只有刷新页面才恢复。

  原因是 bfcache。跳去 Checkout 是整页导航,React 组件没有卸载,浏览器把整个页面连同 JS 堆一起放进了 back/forward cache。返回时页面直接从缓存恢复,busy 这个 state 还是离开前的值,没有任何代码会去清它。

  修法是监听 pageshow,触发时把 busy 清掉(5fd8855):

1
2
3
4
5
useEffect(() => {
  const onShow = () => setBusy(null);
  window.addEventListener('pageshow', onShow);
  return () => window.removeEventListener('pageshow', onShow);
}, []);

  没有判断 event.persisted。有的浏览器从缓存恢复时不带这个标记,而正常加载时 busy 本来就是 null,无条件清一次没有副作用。outcometick 的定价页之前已经是这么处理的,这次是照搬过来。

  测试在 web/test/billing-flows.test.tsx 里模拟了状态恢复:点 Pro,确认模拟的跳转函数 leaveTo 被调用,Pro 按钮显示「Opening Stripe…」且禁用,试用按钮也禁用;手动派发 pageshow 后,两者恢复可点,加载文案消失。这个测试覆盖 React 状态的恢复,没有实际启动浏览器走一次 bfcache 导航。

还没查清的

  项目笔记记录,10-09 到 10-12 出现过 stripe webhook rejected reason=bad_header。对应的代码分支是 Stripe-Signature 头里没有可解析的数字时间戳 t=,或没有合法的 64 位十六进制 v1= 签名。请求来源尚未查清。


LPSignal 工程笔记(4/4)

上一篇:LPSignal 的机会信号从 finalized 归档挪到链头

This post is licensed under CC BY 4.0 by the author.

盘口节流的丢帧与合并:存储量、重建误差和回测修正

改口径之后重建历史:标记价加权和 Polymarket Protocol V2 接入