订阅

了解账号绑定权益和订阅门禁。

Expo 模板带有 RevenueCat paywall、恢复购买、兑换码,以及账号绑定的权益模型。最重要的区别是:RevenueCat 负责卖产品,Supabase 决定账号能访问什么。

subscriptions 表是权益真相来源。RevenueCat CustomerInfo 只是购买状态和本地缓存;不要把它作为高级功能门禁。

提供什么

Surface状态说明
PurchasesProviderNative-only封装 RevenueCat,获取 offerings,购买/恢复,并用 Supabase user UUID 登录 RevenueCat
EntitlementProviderIntegrated通过 RLS 读取 subscriptions,暴露 useEntitlement().isEntitled
app/paywall.tsxNative-onlyModal paywall;从 RevenueCat 当前 offering 渲染 packages
app/redeem-code.tsxNative-onlyiOS 兑换面板;Android 打开 Google Play 兑换 URL;Web 显示说明
app/manage-subscription.tsxNative-only商店管理链接、恢复购买、来自 RevenueCat cache 的状态摘要
PremiumGateIntegrated用 Supabase entitlement boolean 锁住任意 UI
home-paywall / home-redeem-codeNative-only复用 modal 体验,但使用 tab header chrome 的 tab-context wrapper

未配置时购买会优雅降级。在 Web 上,或 iOS/Android 没有对应 EXPO_PUBLIC_REVENUECAT_*_KEY 时,isConfigured 为 false,不加载 offerings,购买/恢复调用返回友好错误。

Provider 行为

lib/purchases.tsx 按平台选择公开 RevenueCat SDK key:

Platform.OS === "ios"     -> EXPO_PUBLIC_REVENUECAT_IOS_KEY
Platform.OS === "android" -> EXPO_PUBLIC_REVENUECAT_ANDROID_KEY
Platform.OS === "web"     -> unconfigured

当存在已登录 Supabase 用户时,PurchasesProvider 调用:

Purchases.logIn(user.id);

这会让 RevenueCat app_user_id 等于 Supabase UUID,因此 webhook event 可以 upsert 同一个 subscriptions.user_id row。RevenueCat customer-info listener 触发时,应用也会刷新 Supabase entitlement query。

账号绑定流程

Store purchase
  -> RevenueCat webhook
  -> Supabase Edge Function
  -> public.subscriptions row
  -> EntitlementProvider
  -> useEntitlement().isEntitled
  -> PremiumGate / Home card / Me row / paywall owned state

当前 supabase/functions/_shared/revenuecat.ts 服务端 mapper 接受 APP_STOREMAC_APP_STORE events。PLAY_STORE events 目前会被忽略,所以 iOS 购买在随附后端中是端到端的;Android 需要先扩展 mapper 和数据库 enum,Google Play 购买才能授予共享 Supabase entitlement。

Entitlement 真值表

lib/entitlement.tsx 中的 isSubscriptionEntitled() 是唯一门禁:

subscriptions.status何时有权益
active / trialing + one_time永远
active / trialing + monthyearperiod_end 仍在未来
canceledperiod_end 仍在未来
unpaid / expired从不
unknown从不

未登录、loading、error 状态都不是 entitled。状态不确定时门禁应保持锁定。

跨 provider 冲突规则

subscriptions 表中每个用户只有一个当前 row。这让 starter 保持单 tier,但意味着 webhook 需要确定性的接管规则:

  1. 同 provider event 总是应用,因此 renewals、cancellations、expirations 会被反映。
  2. 不同 provider event 只有在 incoming period_end 晚于现有 row 时才应用。

这样 Apple 购买不会缩短仍有效的 Creem desktop plan,反之亦然。

Paywall packages

应用不带产品 ID 配置。app/paywall.tsx 渲染 offerings.current.availablePackages 中存在的 packages,并按以下顺序排序:

Monthly -> Annual -> Lifetime

Monthly 和 Annual 在 analytics 中标记为 tier: "pro";Lifetime 标记为 tier: "lifetime"classifyPurchase() 的值只用于 purchase completed analytics event,不参与门禁。

Paywall 会在 Annual 存在时默认选择 Annual;如果 RevenueCat 返回 price-per-month 数据,会显示年度节省比例。购买或恢复成功后会调用 entitlement.pollAfterPurchase(),让 UI 等待 webhook 写入。

单 tier 模型

starter 只有一个 Pro entitlement 和一个 boolean gate:

const { isEntitled } = useEntitlement();

如果要添加真正的 tiers,请先改数据模型:移除或放宽 subscriptions.user_id 唯一假设,引入 product-to-feature 映射,并从 EntitlementProvider 暴露 tier-aware helpers。不要一开始就在 UI 组件中按 RevenueCat product ID 分支;那会漏掉 desktop、manual 或未来的服务端 grant。

相关页面

On this page