PAYATHON 2026

如何在桌面端 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 解锁付费功能

各部分的职责如下:

  1. 桌面应用负责登录、打开结账页面和查询权益。
  2. 后端负责创建结账会话、验证 Webhook,并维护订阅状态。
  3. 支付服务商负责采集银行卡等支付信息。
  4. 数据库只保存订阅状态和服务商返回的标识符,不保存银行卡数据。
  5. 功能是否可用,由后端签发或确认的 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 如何选择

关注点PSPMoR
交易费率通常相对较低通常相对较高
税率计算可提供工具通常包含
税务登记和申报多数情况下由开发者负责通常由 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 调用时重新校验权益。

适合独立开发者的落地方案

风险较低的起步方式如下:

  1. 选择一家支持订阅、托管结账、Webhook 和 customer portal 的服务商。
  2. 如果产品从一开始就面向全球销售,优先评估 MoR。
  3. 如果市场范围有限,并且能够自行处理税务,可以评估 PSP。
  4. 先提供一两个简单套餐,不急于实现复杂的按量计费。
  5. 由后端保存订阅映射和权益,客户端不保存任何支付密钥。
  6. 使用托管结账页面,不接触原始银行卡数据。
  7. 在测试环境中覆盖首次付款、续费、失败、取消、退款和 Webhook 重试。
  8. 上线前检查目标地区、公司注册地以及应用商店关于税务和自动续费的要求。

独立开发者通常不适合自行开发完整的支付系统。产品内部的用户、套餐和 entitlement 逻辑可以自行实现,资金采集、银行卡处理和支付网络接入则应交给专业支付服务商。

备注:内容仅供参考。