部分付款后如何将 invoice 标记为 Paid
结论
不要直接执行 invoice.write({'state': 'paid'})。
在 Odoo 中,发票的付款状态取决于应收或应付会计分录是否完成核销。客户在优惠期内支付 95% 时,应将实际收到的 95% 和剩余 5% 的现金折扣一并核销。发票的 residual 归零后,系统会自动将其识别为 Paid。超过优惠期限后,则应按正常流程收取并核销 100% 的款项。
为什么直接修改 state 无效
action_validate_invoice_payment() 最终会创建付款分录并执行 reconciliation。发票状态通常根据以下信息重新计算:
- 应收或应付科目的未核销余额;
residual/amount_residual;- 付款分录与发票分录是否已完全核销。
即使暂时将 state 写成 paid,只要还有 5% 的余额未核销,后续计算仍可能把状态恢复为 Open。这样还会导致业务状态与总账不一致:界面显示发票已付款,应收账款却仍未结清。
正确的会计处理是:
实际收款 95% + 现金折扣 5% = 发票应收金额 100%
其中的 5% 应计入专门的现金折扣账户,不能直接消除余额。
推荐做法
旧版 Odoo 的 account.payment 通常已经提供付款差额处理字段:
payment_difference_handling = 'reconcile'
writeoff_account_id = discount_account.id
将 payment_difference_handling 设为 reconcile,表示把付款差额作为 write-off 处理,并完全核销发票。核销完成后,发票会自动进入 Paid 状态。
处理步骤如下:
- 判断付款日期是否早于或等于折扣截止日期;
- 计算可抵扣的 5%;
- 确认实际付款与折扣之和正好覆盖发票剩余余额;
- 将付款差额处理方式设为
reconcile; - 指定现金折扣会计科目;
- 调用标准付款过账方法,由 Odoo 创建分录并完成核销。
旧版 account.payment 示例
下面的示例适用于使用 account.invoice、post()、payment_difference_handling 和 writeoff_account_id 的 Odoo 版本。具体字段名需要按实际模块调整。
假设:
cash_discount_deadline是自定义的折扣截止日期字段;cash_discount_account_id是现金折扣科目;- 折扣按含税发票总额的 5% 计算;
- 每个 payment 只处理一张 invoice。
from odoo import api, models
from odoo.exceptions import UserError
class AccountPayment(models.Model):
_inherit = 'account.payment'
@api.multi
def post(self):
for payment in self:
if len(payment.invoice_ids) != 1:
continue
invoice = payment.invoice_ids
deadline = invoice.cash_discount_deadline
if not deadline or payment.payment_date > deadline:
continue
discount_account = invoice.cash_discount_account_id
if not discount_account:
raise UserError(
'A cash discount account must be configured.'
)
currency = invoice.currency_id
discount_amount = currency.round(
invoice.amount_total * 0.05
)
# 发票当前未付余额,应由本次付款和折扣共同结清。
expected_payment = currency.round(
invoice.residual - discount_amount
)
if currency.compare_amounts(
payment.amount, expected_payment
) != 0:
continue
payment.write({
'payment_difference_handling': 'reconcile',
'writeoff_account_id': discount_account.id,
})
return super(AccountPayment, self).post()
当付款金额是发票应付金额的 95%,并且付款日期仍在优惠期内时,标准付款流程会:
- 将 95% 计入银行或现金账户;
- 将 5% 的差额计入现金折扣账户;
- 将这两部分与发票应收分录完全核销;
- 把发票剩余金额降为零;
- 自动将发票识别为 Paid。
超过截止日期后,代码不会启用 write-off。此时只有付款金额覆盖全部剩余余额,标准核销流程才会将发票标记为 Paid。
已经发生部分付款时
如果一张发票可以分多次付款,就不能只判断本次付款是否等于发票总额的 95%。需要判断此前累计付款加上本次付款后,是否达到折后应付金额。
在旧版 account.invoice 中,可以根据当前 residual 计算本次应付金额:
discount_amount = currency.round(invoice.amount_total * 0.05)
expected_payment = currency.round(
invoice.residual - discount_amount
)
例如,发票金额为 1,000,之前已经支付 400,当前 residual 为 600。优惠期内还需实际支付:
600 - 50 = 550
这样累计现金付款为 950,剩余 50 作为现金折扣核销。
还需要防止以下异常情况:
if expected_payment <= 0:
raise UserError(
'The invoice balance is too small to apply this discount.'
)
如果折扣只适用于一次性付款,不适用于分期付款,还应检查发票此前是否已有付款或部分核销。
新版 Odoo 的区别
较新的 Odoo 已将发票迁移到 account.move。付款状态通常保存在 payment_state,付款过账入口通常是 action_post(),付款注册一般通过 account.payment.register wizard 完成。
不同版本的实现方式有所区别,但会计处理原则相同:
- 不直接写入
state或payment_state; - 为实际付款创建银行分录;
- 为 5% 的折扣创建 write-off 分录;
- 完全核销发票的 receivable/payable line;
- 由系统根据 residual 和 reconciliation 自动计算付款状态。
在新版 Odoo 中,更合适的做法是在 account.payment.register 中设置 write-off 行,或者扩展其生成付款差额分录的逻辑,而不是修改 account.move.payment_state。
注意事项
- 使用
currency.round()和currency.compare_amounts(),不要直接比较浮点数。 - 明确折扣基数是
amount_total、未税金额还是剩余金额。 - 现金折扣科目应由会计人员确认,不要随意使用普通费用科目。
- 外币发票还可能产生汇兑损益,不能将汇兑差额当作现金折扣。
- 退款、贷项通知单和负数发票需要单独处理符号。
- 一次付款关联多张发票时,应按发票分别计算折扣和 write-off,不能只设置一个总差额。
- 覆盖
post()或action_post()时,应先准备付款参数,最后调用super()。不要在标准核销完成后强制修改发票状态。 - 如果系统已经通过付款注册向导提供“付款差额处理/标记为全额支付”功能,应优先复用,避免重复实现会计分录和核销逻辑。
备注:内容仅供参考。
