很多人已经知道 Odoo 的 cash basis tax 不是“开票时立即进正式税额”,而是要等付款以后再转。
但如果你继续往 /home/ubuntu/odoo-temp/addons/account/models/account_tax.py、account_move.py、account_partial_reconcile.py 里读,会发现真正的设计中心不是“付款动作”,而是:
- transition account
- reconcile 事件
- 按 partial 比例迁移税与 base
现金制税的本质不是付款后补一笔税,而是先把税挂在可过渡、可核销的位置,等真实核销发生时,再按这次核销覆盖比例把税务口径迁移出去。
一、为什么一定要有 transition account
account.tax 上的 cash_basis_transition_account_id 帮助文本写得很直白:
- 发票未核销前,税额先停在 transition account;
- 到 reconcile 时,再从这里撤出,转到 regular tax account。
而源码还专门校验:
The cash basis transition account needs to allow reconciliation.
这句非常关键。
它说明 transition account 不是随便挑一个中间科目,而是一个带强约束的业务节点:
- 发票阶段,税额先暂存;
- 后续阶段,要能和 cash basis move 的对手行完成对应;
- 因为要表达“暂存”和“转正”之间的闭环,所以它必须允许 reconciliation。
误区 1:把 transition account 理解成普通过桥科目
不够准确。
普通过桥科目只是临时承接金额; 而 cash basis transition account 还是 后续税务迁移的定位点。
如果它不能 reconcile,系统就没法稳定表达“这部分税已从暂存状态转为正式状态”。
二、真正的触发器不是付款按钮,而是 account.partial.reconcile
在 /home/ubuntu/odoo-temp/addons/account/models/account_partial_reconcile.py 里,_collect_tax_cash_basis_values() 才是关键。
这段逻辑不是围绕 payment wizard 写的,而是围绕 partial reconcile 本身写的。
它会对每个 partial 做这些事:
- 找到 debit / credit 两端对应的 move;
- 收集这次 partial 覆盖的金额;
- 计算
percentage; - 推出
payment_rate; - 把结果放进 move 的 cash basis values 中,等待生成 caba entries。
这意味着:
cash basis tax 认可的不是“你登记了一笔付款”,而是“这笔付款和原应收 / 应付已经真的形成核销关系”。
所以你只创建 payment,还没和 invoice/bill 对应起来,不一定会得到完整的现金制税迁移语义。
三、为什么 partial reconcile 是核心,而不是例外
源码里最重要的一步,是按 partial 计算 percentage。
- 如果 move 以公司币为主,就用公司币金额比例;
- 如果 move 以外币为主,就用外币金额比例。
然后系统把这次 partial 对应的比例挂到:
- 本次应迁移多少 tax
- 本次应迁移多少 base
- 本次适用什么
payment_rate
这就说明 partial payment 在 cash basis 场景里根本不是边角问题,而是官方默认场景。
误区 2:以为现金制税总是“最后一笔付款才一次性转完”
不对。
官方设计明显是:
- 每次 partial reconcile 都能触发一部分迁移;
- 多次 partial 会累计形成完整迁移;
- full reconcile 只是 partial 全部跑完后的结果态。
四、payment_rate 暗示它同时在处理税与汇率口径
很多人把 cash basis 只当税问题,但源码里还有 payment_rate。
当 invoice 和 payment 的外币不一致时,系统会:
- 用 payment date 重新取 conversion rate;
- 或在 register payment 场景里接受强制 rate;
- 再用这个 rate 去换算本次 caba 迁移金额。
这说明 Odoo 非常清楚现实世界的复杂性:
- 业务单据可能是一种币;
- 实际结算可能是另一种币;
- cash basis 要回答的是“这次真实结清了多少税务金额”,而不是简单照搬开票日金额。
所以 cash basis move 和 exchange difference move 虽然常一起出现,但它们在逻辑上不是一回事。
五、base line 与 tax line 为什么都要被处理
在 account_partial_reconcile.py 里,系统既准备:
_prepare_cash_basis_base_line_vals()_prepare_cash_basis_tax_line_vals()
这说明迁移的不只是税行本身。
Odoo 的想法是:
- 税行需要从 transition / tax account 之间做迁移;
- 对应的 base 语义也要进入 cash basis 口径;
- tax tags、product tags、analytic_distribution 这些上下文要尽量保留。
所以现金制税 move 不是一条孤零零的税务修正,而是把“本次已实现的税务部分”按完整会计上下文重建出来。
六、为什么 product tags、tax tags、analytic distribution 也被带过去
你会在准备 cash basis line 的方法里看到:
tax_tag_idsanalytic_distributiondisplay_type- partner / currency / account
这说明官方很在意后续这些数据还能不能被报表、税表、分析维度正确识别。
换句话说,cash basis move 不是为了把数字凑平, 而是为了让“已经 exigible 的那部分金额”在后续:
- 税务报表
- 税标签统计
- 分析分摊
- 币种口径
里都能站得住。
七、为什么 unlink partial 时还要反转 CABA move
unlink() 里还能看到更强的设计信号:
- 找出 linked 的 tax cash basis entries;
- 找出 exchange difference entries;
- reverse 或 unlink 它们;
- 再更新 matching number。
这说明 Odoo 把 cash basis move 看成 reconcile 事实的衍生物。
只要 reconcile 被撤销,衍生出来的现金制税凭证也要跟着回滚。
误区 3:以为 CABA move 是独立事实,生成了就固定存在
不对。
它依附于 reconcile 事实。
核销撤销,相关现金制税逻辑就必须被撤回;否则税务口径会和真实结清状态脱节。
八、排查“为什么现金制税不对”时,更该查什么
建议按这个顺序:
- tax 是否真的是
tax_exigibility = 'on_payment'; cash_basis_transition_account_id是否配置且允许 reconcile;- 公司是否配置了 cash basis journal;
- 发票 / 付款之间是否真的形成了
account.partial.reconcile; - 是不是多次 partial 导致只迁移了一部分;
- 是否存在多币种与 payment rate 影响;
- 是否有人撤销过 reconcile,导致 caba move 被反转。
一句话记忆
Odoo 现金制税不是“付款后补税”,而是“先把税挂在可核销的 transition account 上,等 partial/full reconcile 发生时,再按本次覆盖比例与 payment rate 把税务口径迁移出去”。
DISCUSSION
评论区