在 Rails 中创建 Payment 时如何更新关联的 Invoice?
结论
把付款金额作为”待分配余额”,按 invoice 的 id 从小到大遍历关联发票。每次分配当前付款余额和 invoice 未付金额中的较小值:
[payment_remaining, invoice.open_amount].min
每次扣减后检查付款余额。如果余额已经为 0,就结束遍历。整个流程应在数据库事务中执行,同时锁定相关 invoices,防止并发付款重复扣减 open_amount。
推荐实现:使用 Service Object
假设 Payment 和 Invoice 通过中间表建立多对多关联:
class Invoice < ActiveRecord::Base
has_many :invoice_payments
has_many :payments, through: :invoice_payments
end
class Payment < ActiveRecord::Base
has_many :invoice_payments
has_many :invoices, through: :invoice_payments
end
class InvoicePayment < ActiveRecord::Base
belongs_to :invoice
belongs_to :payment
end
可以用一个服务对象封装付款创建和金额分配:
class CreatePayment
def self.call(payment_attributes, invoice_ids)
new(payment_attributes, invoice_ids).call
end
def initialize(payment_attributes, invoice_ids)
@payment_attributes = payment_attributes
@invoice_ids = invoice_ids
end
def call
Payment.transaction do
payment = Payment.create!(@payment_attributes)
invoices = Invoice.where(id: @invoice_ids).order(:id).lock(true)
payment.invoices << invoices
remaining_amount = payment.amount
invoices.each do |invoice|
break if remaining_amount <= 0
allocated_amount = [remaining_amount, invoice.open_amount].min
invoice.open_amount -= allocated_amount
remaining_amount -= allocated_amount
invoice.save!
end
payment
end
end
end
调用方式:
payment = CreatePayment.call(
{ amount: BigDecimal("250.00") },
[3, 1, 2]
)
虽然传入的 ID 顺序是 [3, 1, 2],但查询使用了 order(:id),因此金额仍会按照 1、2、3 的顺序分配。
假设三个 invoice 的 open_amount 都是 100.00,执行结果如下:
Invoice 1: 0.00
Invoice 2: 0.00
Invoice 3: 50.00
分配逻辑
金额分配的核心代码是:
allocated_amount = [remaining_amount, invoice.open_amount].min
假设付款还剩 150,当前 invoice 的 open_amount 为 100:
allocated_amount = [150, 100].min
# => 100
此时当前 invoice 会被结清,付款还剩 50,程序继续处理下一张 invoice。
如果付款只剩 50,而 invoice 还有 100 未付,则只扣除 50:
allocated_amount = [50, 100].min
# => 50
扣减后付款余额变为 0,循环随即结束。
处理多余的付款金额
如果 payment 金额超过所有 invoices 的 open_amount 总和,就需要明确如何处理超出的部分。通常有两种做法。
第一种是拒绝超额付款:
if remaining_amount > 0
raise ActiveRecord::Rollback, "Payment amount exceeds total open amount"
end
更合适的方式是抛出业务异常,方便控制器显示错误:
class ExcessPaymentError < StandardError; end
然后在事务中检查剩余金额:
if remaining_amount > 0
raise ExcessPaymentError, "Payment amount exceeds total open amount"
end
第二种是允许未分配余额留在 payment 上,例如增加 unallocated_amount 字段:
payment.update!(unallocated_amount: remaining_amount)
具体采用哪种方式取决于实际业务,但不能直接丢弃未分配的金额。
为什么不建议直接放进 after_create
分配逻辑可以写在 Payment 的回调中,但通常不建议这样做。创建 payment 时,关联的 invoice 记录可能尚未写入中间表,因此 after_create 中的 payment.invoices 可能为空。模型回调还会让一次 payment 保存操作隐式修改多张 invoice,增加故障排查和测试的难度。
Service Object 可以清楚地描述整个流程:
- 创建 payment。
- 查询并锁定 invoices。
- 建立关联。
- 按顺序分配金额。
- 任一步骤失败时回滚所有修改。
注意事项
金额字段应使用数据库的 decimal 类型,不能使用 float,否则可能产生精度问题。例如:
t.decimal :amount, precision: 12, scale: 2
t.decimal :open_amount, precision: 12, scale: 2
还要保证 open_amount 不为 nil,且不能小于 0:
class Invoice < ActiveRecord::Base
validates :open_amount,
numericality: { greater_than_or_equal_to: 0 }
end
事务能保证某张 invoice 保存失败时,payment 和此前更新的 invoices 会一并回滚。lock(true) 会对查询到的记录加锁。具体行为取决于所使用的数据库,但在支持行级锁的数据库中,它能防止两个付款请求同时读取并扣减同一个旧的 open_amount。
不要依赖关联记录的默认返回顺序。即使当前数据库看起来总是按照 ID 返回,也应明确指定:
order(:id)
这样才能保证付款始终从 ID 最小的 invoice 开始分配。
备注:内容仅供参考。
