先说结论
如果你已经知道 Odoo Payment 有一套交易状态机,那下一步最该看懂的,不是“支付成功后变 done”这件事,而是一笔支付在平台内部会不会继续分裂成更多交易对象。
从 /home/ubuntu/odoo-temp/addons/payment/models/payment_provider.py、payment_transaction.py,以及 payment_stripe 的实现来看,真正决定系统是否稳的,是下面几条链路:
- 用户这次付款是否要顺手生成可复用 token。
- 这笔钱是自动捕获,还是先授权、后 capture。
- capture / void / refund 会不会创建 child transaction。
- provider 被禁用或切换状态后,历史 token 是否仍然可继续用。
所以支付的重点并不只是“钱有没有收成功”,而是Odoo 如何把一次支付拆成可追踪、可后处理、可回滚的对象图谱。
令牌化不是“记住卡号”,而是支付对象生命周期延长
payment.provider 里有个字段:
allow_tokenization
帮助文本写得很清楚:token 不是卡信息本身,而是指向 provider 侧已保存支付方式的匿名链接。
这意味着 Odoo 的 tokenization 思路不是“本地保存支付凭据”,而是:
- Odoo 只保留一个可复用引用;
- 真正敏感数据仍在 provider 侧;
- 下次扣款时,Odoo 用 token 发起新的交易。
这条边界非常重要,因为它决定了你不能把 token 当成“只是个 UX 优化”。它其实在把一次性支付,变成后续多次支付可复用的支付关系。
token 不是总会创建,而是满足条件后在处理阶段落地
在 payment.transaction 里,tokenize 只是一个布尔意图:
- 这次交易希望创建 token;
- 但真正创建是在
_process()里,当交易状态进入authorized或done后,才调用_tokenize(payment_data)。
这很关键。
为什么不能在交易一创建就生成 token?
因为那时用户还没真正完成授权,provider 也可能还没返回足够的支付方法信息。
所以 Odoo 把 token 创建放在:
- 支付数据被 provider 回传之后;
- 金额校验过之后;
- 状态足够可靠之后。
这样设计的好处是,token 不会和一个失败或中途取消的交易过早绑定。
手工捕获的核心不是“晚点收钱”,而是把一笔授权拆成后续操作
payment.provider 里有:
capture_manuallysupport_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 交易会带
customer、payment_method、off_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
评论区