框架深潜

Odoo 支付为什么难点常常不在“收款成功”,而在令牌化、手工捕获与退款子交易链路

很多团队已经知道 Odoo Payment 有一套 transaction state machine,但线上最容易踩坑的往往不是 done / error,而是 token 什么时候创建、授权后何时 capture、部分捕获如何拆子交易、退款为什么会变成负金额 child transaction,以及 provider 关闭后旧 token 为什么会一起失活。

框架
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

如果你已经知道 Odoo Payment 有一套交易状态机,那下一步最该看懂的,不是“支付成功后变 done”这件事,而是一笔支付在平台内部会不会继续分裂成更多交易对象

/home/ubuntu/odoo-temp/addons/payment/models/payment_provider.pypayment_transaction.py,以及 payment_stripe 的实现来看,真正决定系统是否稳的,是下面几条链路:

  1. 用户这次付款是否要顺手生成可复用 token。
  2. 这笔钱是自动捕获,还是先授权、后 capture。
  3. capture / void / refund 会不会创建 child transaction。
  4. provider 被禁用或切换状态后,历史 token 是否仍然可继续用。

所以支付的重点并不只是“钱有没有收成功”,而是Odoo 如何把一次支付拆成可追踪、可后处理、可回滚的对象图谱。

令牌化不是“记住卡号”,而是支付对象生命周期延长

payment.provider 里有个字段:

allow_tokenization

帮助文本写得很清楚:token 不是卡信息本身,而是指向 provider 侧已保存支付方式的匿名链接

这意味着 Odoo 的 tokenization 思路不是“本地保存支付凭据”,而是:

  • Odoo 只保留一个可复用引用;
  • 真正敏感数据仍在 provider 侧;
  • 下次扣款时,Odoo 用 token 发起新的交易。

这条边界非常重要,因为它决定了你不能把 token 当成“只是个 UX 优化”。它其实在把一次性支付,变成后续多次支付可复用的支付关系

token 不是总会创建,而是满足条件后在处理阶段落地

payment.transaction 里,tokenize 只是一个布尔意图:

  • 这次交易希望创建 token;
  • 但真正创建是在 _process() 里,当交易状态进入 authorizeddone 后,才调用 _tokenize(payment_data)

这很关键。

为什么不能在交易一创建就生成 token?

因为那时用户还没真正完成授权,provider 也可能还没返回足够的支付方法信息。

所以 Odoo 把 token 创建放在:

  • 支付数据被 provider 回传之后;
  • 金额校验过之后;
  • 状态足够可靠之后。

这样设计的好处是,token 不会和一个失败或中途取消的交易过早绑定。

手工捕获的核心不是“晚点收钱”,而是把一笔授权拆成后续操作

payment.provider 里有:

  • capture_manually
  • support_manual_capture

这两个字段连在一起,说明 Odoo 区分的是:

  • 商家想不想手工捕获
  • provider 到底支不支持这种能力,以及支持全额还是部分。

这不是一个简单的 UI 开关。

为什么手工捕获这么关键?

因为很多业务不是“下单即确认可发货”。

例如:

  • 库存还没确认;
  • 风控还没通过;
  • 订单要人工审核;
  • 预授权后可能只发一部分货。

在这种情况下,交易先到 authorized,并不意味着业务已经结束。它只是说明:

客户付款工具已经授权,但商家还没真正把钱捕获为最终成交。

child transaction 才是 capture / void / refund 的真实骨架

源码里 _capture()_void()_refund() 都不是直接在原交易上“改个状态”就结束,而是先调用:

_create_child_transaction(...)

这一步非常值得重视。

Odoo 为什么要新建子交易,而不是原地改父交易?

因为 capture、void、refund 都是新的支付动作,不是原动作的注释。

比如:

  • 授权金额 100;
  • 先捕获 60;
  • 剩余 40 作废;
  • 后来再退款 20。

如果只在原交易上写几个字段,你很快就搞不清:

  • 谁先发生;
  • 哪部分是授权,哪部分是捕获;
  • 哪个 provider 请求对应哪次操作;
  • 哪条失败了,哪条成功了。

而 child transaction 让 Odoo 可以把这些后续动作都独立编号、独立状态化、独立写日志。

为什么退款子交易金额是负数

_create_child_transaction(..., is_refund=True) 会把:

  • reference 前缀改成 R-<原参考号>
  • amount 取负;
  • operation 设成 refund

很多人第一次调试时会觉得“为什么 provider 回的是正数,Odoo 里却是负数?”

源码其实写得非常明确:退款在 Odoo 内部是负金额交易,但 provider 通常返回正金额退款对象,所以在 _validate_amount() 时要反向处理。

这条边界很有价值,因为它把会计 / 业务语义和 provider API 语义分开了:

  • Odoo 内部:退款就是负向金额;
  • provider 外部:退款接口大多仍传正数金额字段。

部分捕获为什么必须靠兄弟交易重新汇总

action_void() 里有一段特别重要的逻辑:

  • 它会统计同一个 source transaction 下、已经 done 的 capture 子交易金额;
  • 再计算剩余可 void 的金额;
  • 最终只 void 尚未被 capture 的部分。

这说明在 Odoo 眼里,“父交易当前还剩多少可作废金额”不是一个现成字段,而是通过已完成的子交易回推出来的结果

也就是说:

  • capture 并不是给父交易写一个“已捕获 60”;
  • 而是创建一条 60 的子交易;
  • 之后 void 或状态更新,再根据兄弟交易总和回算父交易命运。

这种建模更复杂,但也更可追踪。

父交易什么时候才会被推成 done 或 cancel

_update_source_transaction_state() 是理解整条链路的关键。

它会看 source transaction 下面的子交易:

  • 只统计已经进入最终态的同类子交易;
  • 汇总金额;
  • 如果汇总值和父交易金额一致,才把父交易推到最终状态。

并且:

  • 如果全都是 cancel,父交易变 cancel
  • 否则变 done

这说明父交易很多时候更像一张“总授权单”或“原始支付意图单”,只有当后续分支操作把总额完全走完,它才会得到最终结论。

provider 关闭后,为什么旧 token 会一起失活

payment.provider.write() 在状态切换时有一个容易被忽视的动作:

_archive_linked_tokens()

只要 provider 从 enabled/test 切换状态,关联 token 可能会被归档。

源码注释甚至专门提醒:切换 provider 状态,会影响相关 token。

这背后的逻辑很合理:

  • token 依附于 provider;
  • provider 环境变了,token 的可用性也不一定还安全;
  • 特别是 test / enabled 切换,本质上可能对应不同真实支付环境。

所以 token 不是“用户资产永远有效”,而是依赖 provider 生命周期的可用引用

Stripe 实现为什么特别适合看懂这套模型

payment_stripe 里,你可以看到这套抽象如何落地:

  • 创建 PaymentIntent / SetupIntent;
  • token 交易会带 customerpayment_methodoff_session
  • 手工捕获走 payment_intents/<id>/capture
  • 作废走 .../cancel
  • 退款走 refunds
  • 每一步再回流成 Odoo 的 _process() 更新。

这说明标准 payment 模块不是在替每个 provider 写死流程,而是在定义统一骨架:

  • provider 负责 API 差异;
  • Odoo 负责交易对象、子交易、状态约束、日志与后处理。

实施里最容易踩的 6 个误区

1. 把 token 当成本地卡存档

其实它只是 provider 侧支付方式的引用。

2. 以为 authorized 就等于收款完成

授权只是可捕获,不是最终成交。

3. 不理解退款是独立子交易

结果报表、对账、日志都会看乱。

4. 部分捕获后还想“直接取消整单”

实际上只能 void 未捕获余额,已捕获部分要走退款。

5. 忽略 provider 状态切换对 token 的影响

测试环境切生产环境时尤其容易出问题。

6. 只盯 payment page,不盯 post-process

有些 provider 回调、异步确认、退款后处理,不是客户页面一跳转就全部完成的。

最后一句

Odoo 支付真正难的地方,往往不是“按钮点下去有没有成功”,而是:

  • 这次支付会不会生成可复用 token;
  • 是自动扣款还是先授权再 capture;
  • capture / void / refund 如何拆成 child transaction;
  • 父交易何时才算真正结束;
  • provider 生命周期变化时,历史 token 和支付方式怎么收口。

把这套对象关系看懂后,你会发现 Odoo 的支付框架并不是“多了几个状态字段”,而是在用交易树表达支付世界里最难追踪的那些后续动作。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。