计费

由功能开关控制的 Creem 结账、权益门禁与许可证激活。

计费是 Flag-gated,默认关闭。保持 VITE_BILLING_ENABLED=false 时,Billing route、checkout actions、subscription reads 和 paywalls 都保持隐藏,因此模板可以作为 非商业桌面应用使用。

只有在 Supabase、billing migration、Creem products 和 Edge Function secrets 都配置好后, 再打开计费。

Flags

Flag默认值效果
VITE_BILLING_ENABLEDfalse启用 Billing page actions、Creem checkout、customer portal、subscription reads 与 feature gates。
VITE_BILLING_LICENSE_MODEfalse显示可选 license-key card,并启用 app/services/license/

下面内容都假设 VITE_BILLING_ENABLED=true

Price configuration

shared/price-config.ts 是 renderer 侧唯一价格映射。它定义三个 tier:

TierProduct shapeProduct env
Free无 Creem product
Pro月付和年付订阅VITE_CREEM_PRODUCT_PRO_MONTHLYVITE_CREEM_PRODUCT_PRO_YEARLY
Lifetime一次性购买VITE_CREEM_PRODUCT_LIFETIME

Product IDs 是 renderer-public 值,不是 secrets;秘密 Creem API key 只存在于 Supabase Edge secrets。

planTierForProductId(productId) 把已知 Creem product IDs 映射回 prolifetime。缺失或未知 ID 会解析成 free,所以未配置构建不会意外解锁付费功能。 它的形状与 soar-next 的 price-config 模型一致,方便同时维护 web 和 desktop 的团队复用 product IDs。

Checkout flow

桌面购买路径通过浏览器完成:

  1. 已登录用户打开 /billing
  2. PricingCard 把选中的 plan 解析成 Creem product ID。
  3. startCheckout(productId) 调用 JWT-verified create-checkout Edge Function。
  4. 函数创建 Creem checkout,并插入一条 status = 'paying'、带生成 order_nopayments 行。
  5. Renderer 校验返回 URL 是 https://*.creem.io,然后通过 window.conveyor.window.webOpenUrl 在系统浏览器中打开。
  6. 支付后 Creem 调用 public creem-webhook function。
  7. Webhook 使用 service-role key 更新 payments 行并 upsert 用户的 subscriptions 行。
  8. 用户返回 soar-electron://billing?status=success&orderId=...,或者只是重新聚焦 app;subscription store 两种情况下都会 refresh。

应用永远不会把浏览器返回视为购买凭证。权益来自经过验证的 webhook 写入的 Supabase subscriptions 行。

BILLING_SUCCESS_URL 是可选项。设置后,create-checkout 会让 Creem 跳转到一个托管 bounce page,并追加 status=success&orderId=<orderNo>。该 bounce page 应打开 soar-electron://billing?... 或你改名后的 scheme。

Entitlement state

app/stores/subscription-store.ts 读取账号绑定的 entitlement model:

State含义
disabledBilling 关闭;feature gates 放行所有功能,且不读取 Supabase。
unknownAuth、network 或 RLS 读取失败;gates 渲染中性 pending state。
free已登录用户没有 subscription 行。
active行为 active、trialing,或 canceled 但仍处于已付费 period 内。
expired行存在,但不再有权益。

Recurring plans 必须有未来的 period_end。一次性 lifetime rows 即使没有 recurring period 也保持 entitled。

Store 会在 auth-state changes、window focus 和 Billing page mount 时 refresh。桌面应用需要 window-focus refresh,因为浏览器 checkout 不一定在所有系统上可靠地把 deep link 交回 app。

Feature gates

app/lib/feature-gate.ts 定义付费功能矩阵:

Feature key解锁 tier
export_pdfPro、Lifetime
sync_cloudPro、Lifetime
unlimited_projectsPro、Lifetime
priority_supportPro、Lifetime
custom_brandingLifetime

使用 app/components/FeatureGate.tsx 中的 <FeatureGate>

<FeatureGate
  feature="custom_branding"
  pending={<MutedNotice />}
  fallback={<UpgradePrompt />}
>
  <CustomBrandingPanel />
</FeatureGate>

unknown 会渲染 pending,而不是 paywall。这样 app 离线或 entitlement 仍在读取时, 不会在用户已拥有的功能上闪出付费墙。

Customer portal

Manage action 调用 openCustomerPortal(),它会调用 JWT-verified customer-portal Edge Function。该函数:

  • 从 Supabase JWT 解析已登录用户;
  • 读取该用户的 subscriptions.customer_id
  • 向 Creem 请求 customer portal URL;
  • 只返回 https://*.creem.io URL。

Renderer 在外部打开前会再次校验 URL。

License mode

License mode 是可选功能。通过下面的配置启用:

VITE_BILLING_LICENSE_MODE=true

LicenseKeyCard 调用 app/services/license/,后者调用 JWT-verified activate-license Edge Function。激活时:

  • Edge Function 调用 Creem License Keys API 验证 key;
  • service-role Supabase writes 将用户的 subscriptions 行 upsert 为 product_type = 'one_time'status = 'active'period_end = '2999-12-31T00:00:00.000Z'
  • Renderer 通过 Electron safeStorage 把 license key 本地保存到 license-key secure slot;
  • 功能访问仍来自 Supabase subscription 行,而不是本地 key。

停用时,函数把 subscription 标记为 canceled,renderer 清除本地 secure-storage key。 即使 server-side deactivate 请求不可达,service 也会清除本地状态。

License mode 适合 offline-friendly 或手动发放的一次性产品。普通订阅应继续使用 checkout/webhook flow 作为 source of truth。

验证计费

  • 设置 VITE_BILLING_ENABLED=true 和真实 VITE_CREEM_PRODUCT_* IDs。
  • 部署 Edge Functions 并设置 Creem secrets。
  • 登录,打开 /billing,开始 sandbox checkout。
  • 确认浏览器打开 creem.io checkout URL。
  • 完成支付,确认 payments.status = 'success'
  • 确认同一个 user_idsubscriptions.status = 'active'
  • 通过 soar-electron://billing 返回或重新聚焦 app,确认 current plan 更新。
  • 如果启用了 license mode,激活测试 license,并先确认 subscription row 已写入,再依赖本地 secure storage。

相关页面

On this page