如何在桌面端 cross-platform app 中处理订阅计费?
结论
独立开发者应优先选择成熟的第三方支付服务,并使用服务商提供的托管结账页面,不要自行保存或处理银行卡号。
常见方案有两类:
- 支付服务商(PSP):例如 Stripe。开发者拥有较强的控制力,但通常也要承担更多税务申报、退款政策和账单合规责任。
- Merchant of Record(MoR):例如 Paddle、Lemon Squeezy。MoR 通常会代为处理销售税/VAT、付款、账单和部分退款事务。费率往往更高,但更适合缺少财税和支付经验的独立开发者。
如果客户分布在多个国家或地区,MoR 通常更适合作为起点。如果只面向少量明确的市场,并且已经具备税务和会计处理能力,PSP 可能节省一部分交易费用。
比较服务商时不能只看支付费率。税务申报、拒付处理、账单邮件、支付方式适配和后续维护时间,同样是实际成本。
推荐的整体架构
桌面应用不应自行判断订阅是否有效,也不能存放支付密钥。可以采用以下流程:
Desktop App
│
│ 登录并请求结账
▼
Your Backend
│
│ 创建 Checkout Session
▼
Hosted Checkout Page
│
│ 支付完成
▼
Payment Provider
│
│ Webhook
▼
Your Backend ──更新订阅状态──► Database
│
│ 返回 entitlement
▼
Desktop App 解锁付费功能
各部分的职责如下:
- 桌面应用负责登录、打开结账页面和查询权益。
- 后端负责创建结账会话、验证 Webhook,并维护订阅状态。
- 支付服务商负责采集银行卡等支付信息。
- 数据库只保存订阅状态和服务商返回的标识符,不保存银行卡数据。
- 功能是否可用,由后端签发或确认的 entitlement 决定。
实现步骤
1. 建立自己的用户账户体系
每笔订阅都必须关联到应用内的用户。至少需要保存以下数据:
users
- id
- email
- created_at
subscriptions
- user_id
- provider
- provider_customer_id
- provider_subscription_id
- status
- product_id
- current_period_end
- cancel_at_period_end
- updated_at
不要只靠邮箱匹配付款人与应用用户。邮箱可能发生变化,同一个邮箱也可能对应多条支付记录。创建结账会话时,应将自己的 user_id 写入服务商支持的 metadata 或 client reference 字段。
2. 在后端创建结账会话
下面用 Stripe 风格的 API 展示基本结构。具体字段可能随 SDK 版本变化,请以所选服务商的当前文档为准。
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post("/api/billing/checkout", requireUser, async (req, res) => {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: process.env.STRIPE_PRICE_ID,
quantity: 1,
},
],
client_reference_id: req.user.id,
customer_email: req.user.email,
success_url: "https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://example.com/billing/cancel",
metadata: {
user_id: req.user.id,
},
});
res.json({ checkoutUrl: session.url });
});
STRIPE_SECRET_KEY 只能保存在服务器端。Electron、Tauri、Qt、Flutter Desktop 或 .NET 桌面程序都无法安全保存服务器密钥,因为最终用户可以检查或修改本地程序。
桌面应用收到 checkoutUrl 后,应使用系统浏览器打开:
await openExternal(checkoutUrl);
这里的 openExternal 只是示意名称,实际使用时应替换为桌面框架提供的安全外链 API。
3. 使用 Webhook 更新订阅状态
用户打开支付成功页面,不代表订阅已经生效。页面跳转可以伪造,付款也可能仍在等待确认。订阅状态应以通过签名验证的 Webhook 为准。
app.post(
"/api/billing/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return res.status(400).send("Invalid webhook signature");
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await linkSubscriptionToUser({
userId: session.metadata?.user_id,
customerId: session.customer,
subscriptionId: session.subscription,
});
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await syncSubscription(subscription);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await markPaymentIssue(invoice.subscription);
break;
}
}
res.sendStatus(200);
}
);
Webhook 处理应满足以下要求:
- 验证签名;
- 按事件 ID 去重,保证幂等;
- 正确处理乱序到达的事件;
- 记录处理失败的事件,并支持重试;
- 不要在返回
2xx前执行耗时过长的任务; - 定期通过服务商 API 对账,修复遗漏的状态。
不能只处理 checkout.session.completed。续费失败、取消、退款、试用结束和拒付都可能改变用户权益。
4. 提供权益查询接口
桌面端不应直接依赖支付服务商定义的状态名称。后端需要把复杂的 billing 状态转换为应用可以直接使用的权益数据:
{
"plan": "pro",
"features": ["export", "cloud_sync"],
"active": true,
"validUntil": "2026-09-29T00:00:00Z"
}
例如:
app.get("/api/entitlements", requireUser, async (req, res) => {
const subscription = await findSubscription(req.user.id);
const active =
subscription &&
["active", "trialing"].includes(subscription.status) &&
subscription.current_period_end > new Date();
res.json({
plan: active ? "pro" : "free",
features: active ? ["export", "cloud_sync"] : [],
active,
validUntil: active ? subscription.current_period_end : null,
});
});
哪些状态仍可继续使用服务,应根据自己的宽限期、退款和欠费策略确定,不能直接照搬示例。
5. 提供自助管理入口
用户至少需要能够:
- 查看当前套餐和续费日期;
- 更新付款方式;
- 下载账单或收据;
- 取消自动续费;
- 查看取消的生效时间。
优先使用服务商提供的 customer portal。自行开发这些页面工作量较大,也容易遗漏身份验证和账单状态中的边界情况。
是否需要收集银行卡信息
使用托管结账页面后,银行卡号、有效期和安全码会由支付服务商直接采集。你的服务器通常只会收到:
- customer ID;
- subscription ID;
- payment method 的非敏感摘要;
- 卡品牌和末四位;
- 付款及订阅状态。
这些信息已经足以支持大多数业务逻辑。
即使使用嵌入式支付组件,也应选择服务商托管的 iframe、Web Component 或原生组件,让原始卡号直接发送给服务商,不经过自己的服务器。
以下做法应当避免:
- 将卡号写入数据库或日志;
- 通过普通 HTML 表单将卡号提交到自己的后端;
- 在客服工单、聊天或邮件中索取完整卡号;
- 保存 CVV/CVC;
- 将支付服务商的 secret key 打包进桌面应用。
合规要求
PCI DSS
只要接受银行卡付款,就需要考虑 PCI DSS。使用第三方托管结账通常能大幅缩小合规范围,但不代表商户完全没有义务。商户可能仍需完成相应的自我评估、维护安全配置,并确保结账页面和账户未被篡改。
适用的 SAQ 类型和责任范围取决于集成方式、收单机构及当前的 PCI DSS 要求,应向支付服务商或收单机构确认。为了节省少量手续费而自行处理银行卡数据,通常会带来高得多的安全和审计成本。
税务与发票
数字订阅可能涉及:
- VAT、GST 或 sales tax;
- 消费者所在地判断;
- 税率计算和税款代收;
- 税务登记与申报;
- 合规发票或收据;
- B2B 税号验证。
PSP 通常会提供税费计算和代收工具,但代收不一定意味着服务商会替你承担申报责任。MoR 通常以记录商户身份向客户销售,并承担更多税务和账单责任。具体覆盖范围需要查看合同,不能只依据产品宣传作出判断。
隐私与数据保护
隐私政策中需要说明:
- 使用了哪家支付服务商;
- 会向服务商传输哪些个人信息;
- 会保存哪些账单标识和交易记录;
- 数据保存期限;
- 用户如何请求访问或删除数据。
受税务、会计或反欺诈义务影响,账单记录可能无法立即删除。具体适用哪些规则,取决于开发者和客户所在地,应咨询当地会计师或律师。
自动续费与消费者保护
不同市场对自动续费的要求可能包括:
- 购买前清楚展示价格、周期和试用条件;
- 明确说明自动扣款的时间;
- 提供容易找到的取消方式;
- 在价格变化或续费前发送通知;
- 取消后确认服务终止时间;
- 避免默认勾选或使用误导性界面。
销售地区越多,需要处理的规则就越复杂。上线前应确认目标市场和桌面应用分发渠道的具体要求。
PSP 与 MoR 如何选择
| 关注点 | PSP | MoR |
|---|---|---|
| 交易费率 | 通常相对较低 | 通常相对较高 |
| 税率计算 | 可提供工具 | 通常包含 |
| 税务登记和申报 | 多数情况下由开发者负责 | 通常由 MoR 承担更多责任 |
| 结账控制力 | 较高 | 相对受限 |
| 上手难度 | 中等 | 较低 |
| 跨国销售维护量 | 较大 | 较小 |
| 客户账单上的商户 | 通常是开发者 | 通常是 MoR |
费率和服务范围会发生变化,不同国家、支付方式、退款和跨境交易也可能产生额外费用,因此不适合给出固定百分比。比较时,应按照预计客单价和客户分布计算总成本:
实际成本 =
交易手续费
+ 固定单笔费用
+ 跨境或货币转换费用
+ 退款与拒付成本
+ 税务和会计成本
+ 开发及维护时间成本
产品刚上线、收入规模尚不确定时,降低固定成本和合规风险通常比减少每笔交易费更重要。业务稳定后,可以再评估从 MoR 迁移到 PSP 是否划算。
支付客户和订阅数据未必能够完整迁移,因此在选择服务商之前,还要确认数据导出和迁移政策。
桌面应用特有的注意事项
如果应用通过 Microsoft Store、Mac App Store 等渠道分发,必须先查看对应商店关于数字功能、外部支付链接和应用内购买的现行规则。不同商店、地区和应用类型可能适用不同要求,不能默认网页支付一定被允许。
如果同时提供官网版本和商店版本,可以将 billing 抽象为统一的 entitlement 层:
interface EntitlementProvider {
getEntitlements(userId: string): Promise<Entitlements>;
}
官网购买、商店订阅和兑换码可以分别接入不同的 provider,桌面端只使用统一的权益结果。
离线使用也需要提前考虑。服务器可以签发短期且带签名的许可证,让应用在有限时间内离线验证:
license = {
userId,
features,
expiresAt,
signature
}
不要签发永久有效的本地布尔值,也不要只在配置文件中保存 isPro=true。客户端始终可能被修改。对于成本较高的服务端功能,还应在每次 API 调用时重新校验权益。
适合独立开发者的落地方案
风险较低的起步方式如下:
- 选择一家支持订阅、托管结账、Webhook 和 customer portal 的服务商。
- 如果产品从一开始就面向全球销售,优先评估 MoR。
- 如果市场范围有限,并且能够自行处理税务,可以评估 PSP。
- 先提供一两个简单套餐,不急于实现复杂的按量计费。
- 由后端保存订阅映射和权益,客户端不保存任何支付密钥。
- 使用托管结账页面,不接触原始银行卡数据。
- 在测试环境中覆盖首次付款、续费、失败、取消、退款和 Webhook 重试。
- 上线前检查目标地区、公司注册地以及应用商店关于税务和自动续费的要求。
独立开发者通常不适合自行开发完整的支付系统。产品内部的用户、套餐和 entitlement 逻辑可以自行实现,资金采集、银行卡处理和支付网络接入则应交给专业支付服务商。
备注:内容仅供参考。
