PAYATHON 2026

如何验证 freemium 产品的 subscription service 有效性

支付阿杰

结论

不要只靠 paid_up = "y" 判断订阅是否有效,也不要用定时批处理来更新订阅状态。

更稳妥的做法是:

  1. 由支付平台管理 recurring billing。
  2. 系统通过 webhook 接收付款成功、付款失败、续订、取消和退款等事件,并验证签名。
  3. 在本地保存订阅状态和当前付费周期的结束时间,例如 subscription_status 和 current_period_end。
  4. 用户登录时正常创建 session,访问付费功能时再检查 entitlement(功能权限)是否有效。
  5. 定时任务用于对账和补偿遗漏的 webhook,不用于推测付款结果。

“最近一次成功扣款日期”可以作为审计信息,但不能单独用来判断订阅是否有效。订阅还可能涉及试用期、宽限期、退款、取消后到期、人工赠送时长或账单周期调整,单看扣款日期无法覆盖这些情况。

推荐的数据模型

不要使用只能表示”是或否”的 paid_up 字段。至少应保存以下信息:

CREATE TABLE subscriptions (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    provider VARCHAR(32) NOT NULL,
    provider_customer_id VARCHAR(255) NOT NULL,
    provider_subscription_id VARCHAR(255) NOT NULL UNIQUE,

    subscription_status VARCHAR(32) NOT NULL,
    current_period_start TIMESTAMP NULL,
    current_period_end TIMESTAMP NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,

    last_payment_succeeded_at TIMESTAMP NULL,
    last_provider_event_at TIMESTAMP NULL,

    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

subscription_status 可以使用系统内部定义的有限状态,例如:

  • trialing:试用中
  • active:订阅有效
  • past_due:扣款失败,处于催收或宽限期
  • canceled:已取消
  • expired:付费权益已到期
  • unpaid:账单未支付
  • refunded:已退款,需要按退款规则处理权益

具体名称应根据支付平台的实际 API 进行映射。不同平台即使使用相同或相似的状态名,含义也未必完全一致。

如何判断订阅是否有效

可以将”订阅记录”与”用户实际拥有的功能权限”分开处理。以后增加试用、优惠券、企业授权或人工赠送权限时,就不需要改动登录逻辑。

基础判断可以这样写:

function hasPremiumAccess(subscription, now = new Date()) {
  if (!subscription) {
    return false;
  }

  const periodEnd = subscription.current_period_end
    ? new Date(subscription.current_period_end)
    : null;

  if (!periodEnd || periodEnd <= now) {
    return false;
  }

  return ["trialing", "active"].includes(subscription.subscription_status);
}

如果业务允许用户在扣款失败后继续使用一段时间,可以明确加入宽限期规则:

function hasPremiumAccess(subscription, now = new Date()) {
  if (!subscription) {
    return false;
  }

  const periodEnd = new Date(subscription.current_period_end);

  if (["trialing", "active"].includes(subscription.subscription_status)) {
    return periodEnd > now;
  }

  if (
    subscription.subscription_status === "past_due" &&
    subscription.grace_period_end
  ) {
    return new Date(subscription.grace_period_end) > now;
  }

  return false;
}

宽限期属于产品策略,并不是支付平台状态自带的权限规则。比如,past_due 用户能否继续使用付费功能,需要由业务明确规定。

通过 webhook 更新状态

支付结果通常异步产生。此时用户可能没有登录,也可能已经关闭浏览器回调页面,所以不能依赖支付完成页面更新订阅。

下面是一段不绑定具体支付平台的示意代码:

app.post("/webhooks/billing", rawBodyMiddleware, async (req, res) => {
  let event;

  try {
    event = verifyWebhookSignature(
      req.rawBody,
      req.headers,
      process.env.BILLING_WEBHOOK_SECRET
    );
  } catch (error) {
    return res.status(400).send("Invalid signature");
  }

  const alreadyProcessed = await eventRepository.exists(event.id);

  if (alreadyProcessed) {
    return res.sendStatus(200);
  }

  await database.transaction(async (tx) => {
    switch (event.type) {
      case "subscription.updated":
      case "subscription.created":
        await subscriptionRepository.upsertFromProvider(
          tx,
          event.data.subscription
        );
        break;

      case "payment.succeeded":
        await subscriptionRepository.recordSuccessfulPayment(
          tx,
          event.data.subscriptionId,
          event.createdAt
        );
        break;

      case "payment.failed":
        await subscriptionRepository.markPastDue(
          tx,
          event.data.subscriptionId,
          event.createdAt
        );
        break;

      case "subscription.canceled":
        await subscriptionRepository.markCanceled(
          tx,
          event.data.subscriptionId,
          event.data.currentPeriodEnd
        );
        break;
    }

    await eventRepository.markProcessed(tx, event.id);
  });

  return res.sendStatus(200);
});

处理时需要注意:

  • 验证 webhook 签名。
  • 使用支付平台提供的唯一事件 ID,保证处理过程幂等。
  • webhook 事件可能重复、延迟或乱序到达。
  • 更新状态前比较事件时间或平台对象版本,防止旧事件覆盖新状态。
  • webhook 应尽快返回成功,复杂任务可以放入队列异步执行。
  • 不要信任浏览器提交的 subscription_status、金额或周期结束时间。

分开处理登录和功能鉴权

freemium 用户没有付费也需要登录,以便使用免费功能。因此,创建 session token 不应以 paid_up = "y" 为前提。

登录流程可以写成:

async function login(credentials) {
  const user = await authenticate(credentials);

  if (!user) {
    throw new Error("Invalid credentials");
  }

  return createSessionToken({
    sub: user.id
  });
}

用户访问付费功能时,再在服务端检查权限:

app.post("/api/premium/export", requireAuthentication, async (req, res) => {
  const subscription = await findSubscriptionByUserId(req.user.id);

  if (!hasPremiumAccess(subscription)) {
    return res.status(403).json({
      code: "PREMIUM_SUBSCRIPTION_REQUIRED"
    });
  }

  const result = await runPremiumExport(req.user.id);
  return res.json(result);
});

如果把订阅状态写入 JWT,还要考虑 token 在有效期内不会自动更新。用户取消订阅、收到退款或扣款失败后,旧 token 可能仍会显示其拥有付费权限。可以采用以下办法:

  • 执行付费操作时实时查询数据库或权限缓存。
  • 缩短 access token 的有效期。
  • 订阅更新后主动清理权限缓存。
  • 对高价值操作始终进行服务端实时检查。

是否还需要定时任务

可以保留定时任务,但应将它用于对账,而不是定期根据日期翻转字段。

例如,每天找出最近发生变化或即将到期的订阅,再与支付平台的状态核对:

async function reconcileSubscriptions() {
  const subscriptions = await findSubscriptionsNeedingReconciliation();

  for (const local of subscriptions) {
    try {
      const remote = await billingProvider.getSubscription(
        local.provider_subscription_id
      );

      await updateSubscriptionFromProvider(local.id, remote);
    } catch (error) {
      await logReconciliationFailure(local.id, error);
    }
  }
}

对账任务通常需要检查:

  • current_period_end 已过期,但本地状态仍为 active 的记录。
  • 很长时间没有收到平台事件的活跃订阅。
  • 处于 past_due 或其他异常状态的订阅。
  • webhook 处理失败或进入死信队列的事件。
  • 本地状态与支付平台状态不一致的记录。

调用支付平台 API 时,还要处理分页、限流、超时和重试。一次查询失败不应直接导致订阅失效。应先记录错误,稍后重试。

取消订阅时不要立即撤销权限

“取消自动续订”和”立即终止订阅”是两种不同的操作。

如果用户在付费周期中途取消自动续订,通常仍可继续使用付费功能,直到 current_period_end。此时可以保存:

subscription_status = active
cancel_at_period_end = true
current_period_end = 2026-09-30T12:00:00Z

付费周期结束且用户没有续费后,再终止其付费权益。退款、拒付或管理员强制停用是否需要立即撤销权限,应分别按照退款政策和风险规则处理。

注意事项

  • 数据库状态是本地鉴权的依据,支付平台是账单事实的最终来源。
  • 所有时间统一使用 UTC,并保存带时区的时间戳。
  • 不要保存完整信用卡号、CVV 等敏感卡片信息,只保存支付平台返回的 customer ID、subscription ID 等引用。
  • 购买接口也要使用幂等键,避免用户重复点击后创建多个订阅。
  • 套餐升降级、按比例计费、试用、退款和拒付都需要明确的权限规则。
  • webhook 处理日志应记录事件 ID、订阅 ID、处理结果和失败原因,但不能记录敏感支付信息。
  • 对账时如果支付平台暂时不可用,应重试或短暂保留原状态,避免大批用户被错误降级。

核心原则是:不要维护一个需要定时猜测真假的布尔字段。系统应保存订阅的生命周期状态和明确的权益截止时间,通过 webhook 实时更新,再用定时对账补偿异常。

备注:内容仅供参考。