PAYATHON 2026

在 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 可以清楚地描述整个流程:

  1. 创建 payment。
  2. 查询并锁定 invoices。
  3. 建立关联。
  4. 按顺序分配金额。
  5. 任一步骤失败时回滚所有修改。

注意事项

金额字段应使用数据库的 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 开始分配。

备注:内容仅供参考。