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.
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
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 >