From e4264fc0799be98823539c2db7bbeba9dc4511a2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 23 Jun 2026 07:02:32 -0400 Subject: [PATCH] Validate grant amount covers scholarships already issued A grant's funds are committed once scholarships are drawn against it, so lowering the grant amount below the total already awarded would leave those scholarships unbacked. Guard the amount from the grant side, mirroring Scholarship#within_grant_budget which guards each draw from the other side. Co-Authored-By: Claude Opus 4.8 --- app/models/grant.rb | 13 +++++++++++++ spec/models/grant_spec.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/models/grant.rb b/app/models/grant.rb index 330d600f25..489aab4240 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -10,6 +10,7 @@ class Grant < ApplicationRecord validates :name, presence: true validates :amount_cents, numericality: { greater_than_or_equal_to: 0 } validates :donor_type, inclusion: { in: DONOR_TYPES } + validate :amount_covers_scholarships_already_issued scope :by_deadline, -> { order(Arel.sql("application_deadline IS NULL, application_deadline ASC")) } @@ -94,6 +95,18 @@ def task_list private + # A grant's amount can't be lowered below what has already been awarded against + # it — the scholarships are committed, so the grant must at least cover them. + # Mirrors Scholarship#within_grant_budget from the other side of the ledger. + def amount_covers_scholarships_already_issued + return unless amount_cents + + issued = scholarships_total_cents + if amount_cents < issued + errors.add(:amount_cents, "can't be less than the #{MoneyFormatter.dollars_from_cents(issued)} already awarded in scholarships") + end + end + def text_to_list(text) text.to_s.split("\n").map(&:strip).reject(&:blank?) end diff --git a/spec/models/grant_spec.rb b/spec/models/grant_spec.rb index 68888adb9b..66eae6a3c0 100644 --- a/spec/models/grant_spec.rb +++ b/spec/models/grant_spec.rb @@ -19,6 +19,37 @@ it "is valid with a person donor" do expect(build(:grant, :donated_by_person)).to be_valid end + + describe "amount cannot drop below scholarships already issued" do + it "is invalid when the amount is reduced below the total awarded" do + grant = create(:grant, amount_cents: 100_000) + create(:scholarship, grant:, amount_cents: 60_000) + + grant.amount_cents = 50_000 + expect(grant).not_to be_valid + expect(grant.errors[:amount_cents]).to include("can't be less than the $600 already awarded in scholarships") + end + + it "is valid when the amount equals the total awarded" do + grant = create(:grant, amount_cents: 100_000) + create(:scholarship, grant:, amount_cents: 60_000) + + grant.amount_cents = 60_000 + expect(grant).to be_valid + end + + it "is valid when the amount stays above the total awarded" do + grant = create(:grant, amount_cents: 100_000) + create(:scholarship, grant:, amount_cents: 60_000) + + grant.amount_cents = 70_000 + expect(grant).to be_valid + end + + it "is valid for a new grant with no scholarships" do + expect(build(:grant, amount_cents: 0)).to be_valid + end + end end describe "money accessors" do