StableOps

订阅

使用 StableOps 创建稳定币订阅套餐、为商户用户开通和管理订阅、自动生成账单,并让终端用户通过托管收银台或自有钱包流程付款;再根据已验证的订阅事件更新服务状态。完整覆盖套餐变更、取消、恢复、账单支付和付款状态查询,帮助业务可靠维护订阅生命周期。

StableOps 订阅把商户侧管理和终端用户支付分开:你的后端用密钥管理套餐、订阅、账单设置和 Portal 会话;终端用户拿 Portal 令牌查看自己的账单,并通过托管收银台或你的自有钱包支付流程完成付款。

生命周期

  1. 创建一个或多个套餐,指定周期和金额,套餐金额以美元计价。
  2. 为你的 merchantUserId 创建订阅。
  3. 读取首期未支付账单,并为该用户创建 Portal 会话。
  4. 在 Portal 上下文为账单创建收银台会话,跳转到收银台支付,或拿到支付指令,交给你自己的钱包支付。
  5. 以 Webhook 和账单状态为准完成业务逻辑。successUrl 只是浏览器返回路径。

结算币种

套餐和账单不绑定某一种稳定币。账单发起支付时,StableOps 会按账单金额生成一张收款单,发起支付请求传入 acceptedAssets,StableOps 会按这些链和资产组合分配收款地址。付款人在收银台里选择一条链和一种币,或你的钱包从返回的支付指令里选择一项;实际打出的那种币就成为账单的结算币种。在此之前,账单的 asset 为 null,表示还没有已结算币种。

acceptedAssets 需要与你已导入的地址池可收款范围一致。如果请求的链/资产没有可用地址,创建收款单会像单独创建 payment order 一样失败。

支付方式选择

托管收银台是最快的集成方式:调用 portal.invoices.checkoutSession(invoice.id, { acceptedAssets, ... }),把用户重定向到返回的 checkoutUrl。

如果你已经有自己的钱包支付流程,可以改为调用 portal.invoices.pay(invoice.id, { acceptedAssets })。响应里包含 paymentOrder.paymentInstructions,字段形状和单独创建 payment order 一致。你选择其中一条指令,让用户钱包在对应链和资产上把账单金额转到该地址,然后以 StableOps webhook 和账单状态作为最终结算依据。

结算模型

账单收银台会话包住的是账单自己的收款单。收款单元数据里有 invoice_id,账单记录也回写了 paymentOrderId。收款单到达 FINALIZED 后,StableOps 通过这对字段结算账单,把付款人实际打出的稳定币回填到账单的 asset,再把订阅状态推进到 active 或完成套餐升级。

续费、付款窗口与逾期

自动开票不等于自动扣款。 每期账单仍需要用户通过钱包主动付款。请在收到 end_user_invoice.open 后,通过你自己的通知渠道引导用户进入账单支付流程,不要假设平台能从用户钱包自动划款。

商户订阅设置按组织与环境隔离,可通过查询设置和更新设置管理:

设置默认值含义
renewal_lead_days3 天活跃且未安排期末取消的订阅,在周期结束前进入续费开票窗口
pay_window_days7 天从开票时刻计算账单的 due_at
grace_days3 天当前周期结束后允许续费的宽限期

账单付款窗口、订阅宽限期和支付订单有效期是三个不同概念。付款窗口不保证服务延长相同天数;应同时读取账单到期时间、订阅当前周期和状态。后台按周期检查,不保证在截止时间所在的那一秒立即变更状态。

  • 首次付款:未付款订阅处于 incomplete;首期账单到期且没有在途支付订单时,订阅转为 expired,未支付账单转为 uncollectible。
  • 试用结束:后台为到期试用开出首期账单,并将订阅推进到 past_due;后续按账期与宽限规则处理。
  • 续费未付:当前周期结束后,订阅在宽限期内进入 past_due;超过宽限期且没有在途付款时转为 expired。
  • 在途付款:正常逾期检查会暂缓处理关联 created、detected 或 confirmed 支付订单的账单,等待支付结果;这不保证付款最终成功,也不覆盖主动取消规则。

以查询接口返回的状态决定是否保留或暂停权益,并结合经过验签的订阅事件同步更新;不要仅靠浏览器返回或本地倒计时。

取消与恢复

取消订阅有两种行为,门户接口遵循相同规则:

  • immediate: false(默认):设置 cancel_at_period_end,不立即结束当前周期;后台期末处理时将适用订阅置为 expired,并将未支付账单置为 uncollectible。
  • immediate: true:立即置为 canceled,同时将未支付账单置为 uncollectible。已在链上发出的转账无法因此撤销,取消也不会自动退款。

恢复订阅只是清除非终态订阅的期末取消标记,不会替用户支付欠款,也不会复活已经 canceled 或 expired 的订阅。需要重新订阅时创建新订阅,并核对原账单及付款记录。

套餐变更

调用更改套餐后,请读取返回的订阅、账单及待生效套餐,而不是立即在业务系统切换权益。

  • 目标价格更高:创建 upgrade_proration 差额账单,当前实现收取目标套餐金额减去当前套餐金额,不按本周期剩余天数折算。差额付款达到 finalized 并结清账单后才切换套餐,当前周期起止时间不变。创建升级账单会清除期末取消标记;同一周期已有未付升级账单时,再次升级返回冲突。
  • 目标价格更低或相同:记录待生效套餐,用于后续续费开票,续费账单结清后切换;不会自动退还当前周期费用。已开出的续费账单不会因之后修改待生效套餐而自动重写,付款前应核对账单金额及目标套餐。

迟到付款与业务对账

账单已变为 uncollectible 后,关联支付订单仍可能完成。结算检查会产生 end_user_invoice.payment_late,不会把该账单直接结清或恢复订阅。商户应核实链上实际到账、账单和订阅状态,再决定人工补偿、新订阅或退款,避免重复开通权益。

订阅账单的底层支付订单会产生普通支付事件。不要单凭 payment.finalized 开通或升级订阅;查询账单是否为 paid,以及订阅是否进入预期状态。重复和乱序事件的处理见 Webhook。

角色

  • 后端或沙箱工具用密钥创建套餐、订阅和 Portal 会话。
  • 终端用户浏览器用 Portal 令牌读取自己的订阅和账单。
  • 控制台用于管理套餐、订阅、账单以及商户级订阅设置。

在线测试

下面的面板会在浏览器里使用你的沙箱 API 密钥,准备演示套餐、创建演示订阅、创建 Portal 会话,并打开账单对应的托管收银台。

开启时会在创建账单收银台会话前导入一个确定性 burner 地址,适合 org 还没有任何收款地址的场景。若只想使用自己管理的地址,请关闭。

订阅组件源码托管在 GitHub:github.com/StableOps/stableops-playground。

SDK 示例

import { StableOps } from '@stableops/api-sdk'

const stableops = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY!,
})

const plan = await stableops.merchantSubscriptions.plans.create(
  {
    code: 'starter',
    name: 'Starter',
    groupKey: 'starter',
    // 金额以美元计价;不指定币种,终端用户在收银台里选择 USDC 或 USDT。
    amount: '9.00',
    interval: 'month',
    intervalCount: 1,
  },
  { idempotencyKey: 'plan_starter' },
)

const created = await stableops.merchantSubscriptions.subscriptions.create(
  {
    planId: plan.id,
    merchantUserId: 'user_123',
  },
  { idempotencyKey: 'sub_user_123' },
)

const portalSession = await stableops.merchantSubscriptions.portalSessions.create({
  merchantUserId: 'user_123',
})

const portal = stableops.portal(portalSession.portalToken)
const invoice = created.invoice!
const acceptedAssets = [{ chain: 'base-sepolia', asset: 'USDC' }] as const
const checkout = await portal.invoices.checkoutSession(invoice.id, {
  acceptedAssets,
  // 可选。省略时默认 exact;auto 会在共享地址金额冲突时自动微调应付金额。
  amountMode: 'auto',
  successUrl: 'https://merchant.example/success',
  cancelUrl: 'https://merchant.example/cancel',
})

return Response.redirect(checkout.checkoutUrl, 303)

也可以使用你自己的钱包支付:

const payment = await portal.invoices.pay(invoice.id, {
  acceptedAssets,
  // 可选。省略时默认 exact;auto 时请始终使用返回的 paymentOrder.amount 发起转账。
  amountMode: 'auto',
})
const instruction = payment.paymentOrder.paymentInstructions[0]

// 交给你的钱包层:发送 payment.paymentOrder.amount 数量的
// instruction.asset 到 instruction.chain 上的 instruction.address。
await payWithYourWallet({
  chain: instruction.chain,
  asset: instruction.asset,
  amount: payment.paymentOrder.amount,
  to: instruction.address,
})

这篇文档怎么样?

最后更新

本页内容