计费
由功能开关控制的 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_ENABLED | false | 启用 Billing page actions、Creem checkout、customer portal、subscription reads 与 feature gates。 |
VITE_BILLING_LICENSE_MODE | false | 显示可选 license-key card,并启用 app/services/license/。 |
下面内容都假设 VITE_BILLING_ENABLED=true。
Price configuration
shared/price-config.ts 是 renderer 侧唯一价格映射。它定义三个 tier:
| Tier | Product shape | Product env |
|---|---|---|
| Free | 无 Creem product | 无 |
| Pro | 月付和年付订阅 | VITE_CREEM_PRODUCT_PRO_MONTHLY、VITE_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 映射回 pro 或
lifetime。缺失或未知 ID 会解析成 free,所以未配置构建不会意外解锁付费功能。
它的形状与 soar-next 的 price-config 模型一致,方便同时维护 web 和 desktop 的团队复用
product IDs。
Checkout flow
桌面购买路径通过浏览器完成:
- 已登录用户打开
/billing。 PricingCard把选中的 plan 解析成 Creem product ID。startCheckout(productId)调用 JWT-verifiedcreate-checkoutEdge Function。- 函数创建 Creem checkout,并插入一条
status = 'paying'、带生成order_no的payments行。 - Renderer 校验返回 URL 是
https://*.creem.io,然后通过window.conveyor.window.webOpenUrl在系统浏览器中打开。 - 支付后 Creem 调用 public
creem-webhookfunction。 - Webhook 使用 service-role key 更新
payments行并 upsert 用户的subscriptions行。 - 用户返回
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 | 含义 |
|---|---|
disabled | Billing 关闭;feature gates 放行所有功能,且不读取 Supabase。 |
unknown | Auth、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_pdf | Pro、Lifetime |
sync_cloud | Pro、Lifetime |
unlimited_projects | Pro、Lifetime |
priority_support | Pro、Lifetime |
custom_branding | Lifetime |
使用 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.ioURL。
Renderer 在外部打开前会再次校验 URL。
License mode
License mode 是可选功能。通过下面的配置启用:
VITE_BILLING_LICENSE_MODE=trueLicenseKeyCard 调用 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-keysecure 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.iocheckout URL。 - 完成支付,确认
payments.status = 'success'。 - 确认同一个
user_id的subscriptions.status = 'active'。 - 通过
soar-electron://billing返回或重新聚焦 app,确认 current plan 更新。 - 如果启用了 license mode,激活测试 license,并先确认 subscription row 已写入,再依赖本地 secure storage。
