代码 支付宝周期扣款上线踩的三个坑:CYCLE_PAY_AUTH 少个 p、扣款窗口只有 5 天、单笔封顶 100 元

2026-11-05 09:01:34

支付宝周期扣款上线踩的三个坑:CYCLE_PAY_AUTH 少个 p、扣款窗口只有 5 天、单笔封顶 100 元

做订阅制(会员月付、年付、SaaS 席位)时,国内做自动续费最常用的方案是支付宝周期扣款。文档写得很全,但内容分散:产品码、扣款时间窗、金额上限、解约状态分别写在四个页面,读漏一个,上线后就会收到 40004。

先给结论:周期扣款不是「签一次约、以后随时扣」。它是一份带时间窗和金额上限的授权,商户必须在窗口内、按上限、按次数去扣。超出任何一条,接口都不会给出容易理解的提示。

以下规则与错误码来自支付宝开放平台文档,未做生产实测;沙箱不可用,验证时用 0.01 元最小单跑通签约—扣款—解约。

一、两个产品码,差一个字母

签约和扣款用的不是同一个产品码:

  • 签约:personal_product_code = CYCLE_PAY_AUTH_P(末尾有 P,Personal)
  • 扣款:product_code = CYCLE_PAY_AUTH(末尾没有 P)

最容易出错的地方是:把带 P 的值放进扣款接口,或者把不带 P 的值放进签约参数。签约侧报 MERCHANT_AGREEMENT_IS_NOT_EXIST(商户协议不存在)时,提示看起来像「没有签代扣合同」,实际是参数错了。官方 FAQ 明确写了两种原因:商户未签约产品,或 personal_product_code / product_code 设置错误。

签约有两条路径:

  1. 独立签约:先调 alipay.user.agreement.page.sign,用户跳转到支付宝侧确认,回跳后再扣款。适合「先试用、后付费」。
  2. 支付并签约:首单用 alipay.trade.app.pay 带 agreement_sign_params,一次完成首付和签约。适合「开通即付费」。

支付并签约的 biz_content 大致如下:

{
  "out_trade_no": "202207151529dk01111",
  "total_amount": "0.01",
  "subject": "会员连续包月首月",
  "product_code": "CYCLE_PAY_AUTH",
  "timeout_express": "90m",
  "agreement_sign_params": {
    "personal_product_code": "CYCLE_PAY_AUTH_P",
    "sign_scene": "INDUSTRY|CARRENTAL",
    "external_agreement_no": "20220715220701",
    "sign_notify_url": "https://example.com/alipay/sign-notify",
    "access_params": { "channel": "ALIPAYAPP" },
    "period_rule_params": {
      "period_type": "MONTH",
      "period": "1",
      "execute_time": "2026-12-16",
      "single_amount": "15.00",
      "total_amount": "180.00",
      "total_payments": "12"
    }
  }
}

几个字段的实际含义和字面不太一样:

  • 外层 total_amount 是首单金额,不计入周期扣款总额(内层 total_amount)。
  • period_rule_params.execute_time 是「下次扣款日」,精确到日(yyyy-MM-dd),不是首单时间。
  • period_type=MONTH 时,execute_time 不允许传 28 日之后的日期(可以传 28),避免 2 月没有 29/30/31 号;period_type=DAY 时,period 不得小于 7。
  • external_agreement_no 是商户侧唯一签约号,同一用户可以有多套签约关系。签约失败后不要复用,每次签约保持唯一。
  • sign_notify_url 与 notify_url 分开时:支付结果发 notify_url,签约结果发 sign_notify_url。只设 notify_url 时,两条通知会挤到同一个地址,处理端必须靠 notify_type 区分。

二、坑一:签完约不等于随时能扣,窗口只有 5 天

接口调用日期并不等于可扣款范围。最常见的线上问题就是:本地能扣、线上扣不动。

官方规则:允许在约定日期及其之前 5 天发起扣款。约定 20 号扣款,只能在 15 号到 20 号之间发;21 号再发就是过期。

配套还有一条:一个周期内只能成功扣款一次。所以自动续费任务不能写成「每天扫一遍所有到期协议,谁到期就扣」;同一周期内重复扣款会被拒。如果第一次扣款返回「等待用户付款」(10003),第二天的重试仍落在同一周期,仍然只有一次成功机会。

可落地的做法是把扣款时间算成「窗口」,而不是「日期」:

// deductWindow 返回本次周期允许发起扣款的日期区间(含首尾)
func deductWindow(executeTime time.Time) (from, to time.Time) {
    to = time.Date(executeTime.Year(), executeTime.Month(), executeTime.Day(), 0, 0, 0, 0, time.Local)
    from = to.AddDate(0, 0, -5)
    return from, to
}

调度任务每天跑一次,只处理 today 落在 [from, to] 内的协议;窗口外直接跳过,不提前扣,也不延期扣。提前扣会被拒,延期扣同样会被拒,而且报错语义并不指向时间。

三、坑二:单笔 100 元封顶,年付订阅走不通

周期扣款的额度限制(官方 FAQ 标准):

  • 单笔不超过 100 元
  • 当日累计不超过 1000 元
  • 当月累计不超过 30000 元
  • 扣款周期最短 7 天,最长不限

也就是说,199 元/年的会员,周期扣款这条产品线本身就走不通。只把 single_amount 调大并不能解决问题:它只是你声明的单次上限,实际扣款还要落在产品额度内。

金额超限时要换产品:资金预授权(先冻结、后转支付),由商户在自己的周期内调授权转支付接口完成扣款。官方 FAQ 对额度超 100 元的建议很直接:核实预授权是否可用且满足业务需求。

选型判断:

场景可用产品说明
月付 ≤100 元、周期 ≥7 天周期扣款文档最全,接入成本最低
单笔 >100 元 或 周期性大额资金预授权 + 授权转支付需单独申请,冻结/解冻与退款链要自己管
一次性支付、不需要续费普通 alipay.trade.*别为了「以后可能加续费」提前上代扣

还有一处容易忽略:周期扣款不支持沙箱。本地联调只能请求生产环境,用最小金额(0.01)走完整链路,测试账号与真实账户分开。这一点和微信支付 v3 没有沙箱是同类问题,站内之前对比过四家的测试环境。

四、坑三:解约之后查不到 UNSIGN,通知是唯一信号

协议状态只有两个取值:NORMAL(正常)和 UNSIGN(解约)。

但 UNSIGN 只可能从异步通知里看到(notify_type=dut_user_unsign)。用户自己在支付宝里解约(我的 > 设置 > 支付设置 > 免密支付/自动扣款)之后,再调 alipay.user.agreement.query,返回的是 USER_AGREEMENT_NOT_EXIST(用户协议不存在),不会返回 status=UNSIGN。

这意味着两件事:

  1. 本地不能把「查不到协议」当成「数据错乱」,它等价于已解约,应该立即停止续费并降级权益。
  2. 如果 sign_notify_url 收不到(域名换了、证书过期、返回非 success),你就不会知道用户解约了,只能靠扣款失败反推。而扣款失败的 sub_code 是 ACQ.AGREEMENT_NOT_EXIST(协议不存在,可能原因:用户已解约或协议号传错),这两种原因在报错里分不开。

所以签约通知地址必须长期稳定,处理端要先把 status + notify_type 落库,再返回 success。返回非 success 会触发支付宝重发,但中间状态仍然会漏。

五、同步返回码怎么读

alipay.trade.pay 的 code 有四种状态:

code含义处理
10000支付成功落库,发权益
40004支付失败记 sub_code,按原因分类(协议/额度/参数)
10003等待用户付款等 5 分钟后调 alipay.trade.query 查
20000未知异常同上,先查单再决定

10003 和 20000 都要求「等 5 分钟后查单,查 4 次仍是 WAIT_BUYER_PAY 再决定」。此时有两条路:幂等重试代扣,或者调 alipay.trade.cancel 撤销这笔交易,再下一周期重来。不要在原单号上盲目连续请求;一个周期只允许一次成功扣款,重试要带同一个 out_trade_no 做幂等。

两个高频 sub_code:

  • ACQ.AGREEMENT_ERROR(协议信息异常):一是接口并发导致(文档建议报错后隔几秒再调),二是传的 auth_code 不属于该 product_code 下的协议号。扣款场景固定 scene=deduct_pay,auth_code=agreement_no。
  • MERCHANT_AGREEMENT_IS_NOT_EXIST(商户协议不存在):商户侧没签约周期扣款产品,或产品码写错(见第一节)。

六、上线前逐条对一遍

  1. 应用已上线、已绑定并签约「周期扣款」产品;用官方配置检测工具过一遍。
  2. 签约与扣款的产品码分别是 CYCLE_PAY_AUTH_P / CYCLE_PAY_AUTH,代码里不做字符串拼接。
  3. execute_time 按周期类型校验:MONTH 不超 28 号,DAY 的 period ≥7。
  4. 扣款任务按「窗口」调度(约定日 -5 到约定日),窗口外不发请求。
  5. 单笔金额 ≤100、单日 ≤1000、单月 ≤30000,超限的套餐换预授权。
  6. 签约号 external_agreement_no 每次签约全新生成,失败不复用。
  7. 签约通知与支付通知分开落库,靠 notify_type 区分,返回 success 前先持久化。
  8. 10003/20000 走「5 分钟 + 查单 4 次」,之后才幂等重试或撤销。
  9. 查协议返回 USER_AGREEMENT_NOT_EXIST 时按解约处理,立即停续费。

本文规则与错误码来自支付宝开放平台文档(周期扣款接入指南、如何查询 agreement_no、ACQ.AGREEMENT_ERROR、MERCHANT_AGREEMENT_IS_NOT_EXIST、周期扣款常见问题),金额与时间窗以接入时文档为准;文中未做生产实测,沙箱不可用,验证请用 0.01 元最小单跑通签约—扣款—解约三段。

参考文档:

推荐文章

程序员茄子在线接单