How to validate that payment can never cause invoi

2019-09-04 19:58发布

问题:

I have this class:

class Payment < ActiveRecord::Base

  attr_accessible :amount, :invoice_id

  belongs_to :invoice

  validates :amount, :numericality => { :greater_than => 0, :less_than_or_equal_to => :maximum_amount }

  after_save    :update_amount_payable
  after_destroy :update_amount_payable

  private

  def maximum_amount
    invoice.amount_payable
  end

  def update_amount_payable
    invoice.update_amount_payable
  end

end

class Invoice < ActiveRecord::Base

  has_many :payments

  after_save :update_amount_payable

  def update_amount_payable
    update_column(:amount_payable_in_cents, new_amount_payable)
  end

  private

  def new_amount_payable
    (total - payments.map(&:amount).sum) * 100
  end

end

The code above works. But how can I validate that no payment amount can ever cause invoice.amount_payable to be less than 0?

Especially when multiple payments for the same invoice are possible, this turns out to be tricky.

I've been trying to get my head around this for hours, but to no avail. Maybe an after callback to rollback the database can be used here?

Thanks for any help.

回答1:

One cross-database solution that will work is to use optimistic locking. Essentially, it requires a special lock_version column, that is checked whenever an update is made. If the lock_version at the time an UPDATE is called is different than what the model is expecting, it throws an error noting that something outside of this model caused the record to change (thus invalidating the update). ActiveRecord supports this out of the box, and it will likely suffice for your needs if you don't mind blocking concurrent transactions altogether.

A case that it won't work is where you want to allow concurrent updates. In this case, you'll need to manually check the result during your update:

def update_amount_payable
  new_value = new_amount_payable
  raise "Payment amounts can't be greater than total invoice amount" if new_value < 0
  count = Invoice.where(id: id, amount_payable_in_cents: amount_payable_in_cents).
                  update_all(amount_payable_in_cents: new_value)
  raise ActiveRecord::StaleObjectError.new(self, 'update amount_payable_in_cents') if count != 1
end

private

def new_amount_payable
  (total - payments.sum(:amount)) * 100  # get the amount sum from the database
end


回答2:

I would change the field names. But given the current database schema try the following code:

app/models/invoice.rb

class Invoice < ActiveRecord::Base
  has_many :payments

  def still_open_amount
    self.amount_payable_in_cents - self.payments.sum('amount_in_cents')
  end
end

app/models/payment.rb

class Payment < ActiveRecord::Base
  belongs_to :invoice

  validates :amount_in_cents, :numericality => { :greater_than => 0 }

  before_validation :check_all_payments

  private
  def check_all_payments
    if self.new_record?
      if (self.invoice.payments.sum('amount_in_cents') + self.amount_in_cents) > self.invoice.amount_payable_in_cents
        errors.add(:amount, 'the invoice would be overpaid')
      end
    else
      if (self.invoice.payments.sum('amount_in_cents') - self.amount_in_cents_was + self.amount_in_cents) > self.invoice.amount_payable_in_cents
        errors.add(:amount, 'the invoice would be overpaid')
      end
    end
  end
end

This will through a validation error if you try to create a overpaying payment:

~/Desktop/testapp  ᐅ rails c
Loading development environment (Rails 4.0.0.beta1)
1.9.3-p286 :001 > i = Invoice.create(amount_payable_in_cents: 100)
   (0.1ms)  begin transaction
  SQL (6.8ms)  INSERT INTO "invoices" ("amount_payable_in_cents", "created_at", "updated_at") VALUES (?, ?, ?)  [["amount_payable_in_cents", 100], ["created_at", Mon, 13 May 2013 19:23:24 UTC +00:00], ["updated_at", Mon, 13 May 2013 19:23:24 UTC +00:00]]
   (0.8ms)  commit transaction
 => #<Invoice id: 1, amount_payable_in_cents: 100, created_at: "2013-05-13 19:23:24", updated_at: "2013-05-13 19:23:24"> 
1.9.3-p286 :003 > p1 = i.payments.create(amount_in_cents: 90)
   (0.1ms)  begin transaction
  Invoice Load (0.2ms)  SELECT "invoices".* FROM "invoices" WHERE "invoices"."id" = ? ORDER BY "invoices"."id" ASC LIMIT 1  [["id", 1]]
   (0.2ms)  SELECT SUM("payments"."amount_in_cents") AS sum_id FROM "payments" WHERE "payments"."invoice_id" = ?  [["invoice_id", 1]]
  SQL (0.4ms)  INSERT INTO "payments" ("amount_in_cents", "created_at", "invoice_id", "updated_at") VALUES (?, ?, ?, ?)  [["amount_in_cents", 90], ["created_at", Mon, 13 May 2013 19:24:10 UTC +00:00], ["invoice_id", 1], ["updated_at", Mon, 13 May 2013 19:24:10 UTC +00:00]]
   (1.0ms)  commit transaction
 => #<Payment id: 1, invoice_id: 1, amount_in_cents: 90, created_at: "2013-05-13 19:24:10", updated_at: "2013-05-13 19:24:10"> 
1.9.3-p286 :004 > p2 = i.payments.create(amount_in_cents: 20)
   (0.1ms)  begin transaction
  Invoice Load (0.2ms)  SELECT "invoices".* FROM "invoices" WHERE "invoices"."id" = ? ORDER BY "invoices"."id" ASC LIMIT 1  [["id", 1]]
   (0.1ms)  SELECT SUM("payments"."amount_in_cents") AS sum_id FROM "payments" WHERE "payments"."invoice_id" = ?  [["invoice_id", 1]]
   (0.1ms)  commit transaction
 => #<Payment id: nil, invoice_id: 1, amount_in_cents: 20, created_at: nil, updated_at: nil> 
1.9.3-p286 :005 > p2.errors
 => #<ActiveModel::Errors:0x007fd57b8e36d8 @base=#<Payment id: nil, invoice_id: 1, amount_in_cents: 20, created_at: nil, updated_at: nil>, @messages={:amount=>["the invoice would be overpaid"]}> 
1.9.3-p286 :006 >