diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb
index a54f1a30e6..85e8244de7 100644
--- a/app/controllers/scholarships_controller.rb
+++ b/app/controllers/scholarships_controller.rb
@@ -61,6 +61,7 @@ def create
redirect_to scholarship_save_path, notice: "Scholarship created."
else
@grants = Grant.selectable_for(@scholarship)
+ flash.now[:alert] = @scholarship.errors.full_messages.to_sentence
render :new, status: :unprocessable_content
end
end
@@ -87,6 +88,7 @@ def update
redirect_to scholarship_save_path, notice: "Scholarship updated."
else
@grants = Grant.selectable_for(@scholarship)
+ flash.now[:alert] = @scholarship.errors.full_messages.to_sentence
render :edit, status: :unprocessable_content
end
end
diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb
index 91f7d4e6b8..7deff65c5d 100644
--- a/app/models/scholarship.rb
+++ b/app/models/scholarship.rb
@@ -11,6 +11,7 @@ class Scholarship < ApplicationRecord
validates :amount_cents, numericality: { greater_than_or_equal_to: 0 }
validate :recipient_must_match_allocation_registrant
validate :within_grant_budget, if: :grant
+ validate :amount_within_event_registration_due
after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? }
after_create_commit :flag_event_registration_scholarship_requested
@@ -44,6 +45,18 @@ def recipient_must_match_allocation_registrant
end
end
+ def amount_within_event_registration_due
+ registration = allocation&.allocatable
+ return unless registration.is_a?(EventRegistration)
+ return if amount_cents.to_i <= 0
+
+ other_allocations_sum = registration.allocations.where.not(id: allocation.id).sum(:amount)
+ due_cents = registration.event.cost_cents.to_i - other_allocations_sum
+ return if amount_cents <= due_cents
+
+ errors.add(:amount_dollars, "cannot exceed the event registration amount due (#{MoneyFormatter.dollars_from_cents(due_cents)})")
+ end
+
def sync_allocation_amount
return unless allocation
diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb
index 2b2102909b..59f7dc0fe8 100644
--- a/app/views/scholarships/_form.html.erb
+++ b/app/views/scholarships/_form.html.erb
@@ -39,6 +39,9 @@
$
<%= f.number_field :amount_dollars, step: 0.01, min: 0, autocomplete: "off", class: "w-full rounded-lg border border-gray-300 bg-white pl-7 pr-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", data: { "scholarship-preview-target": "amount", action: "input->scholarship-preview#update", lpignore: "true", "1p-ignore": "true", bwignore: "true", "form-type": "other" } %>
+ <% if f.object.errors[:amount_dollars].any? %>
+
<%= f.object.errors[:amount_dollars].to_sentence %>
+ <% end %>
">
Still owed
diff --git a/spec/models/scholarship_spec.rb b/spec/models/scholarship_spec.rb
index 34fb9f2fe2..b0fc38bc88 100644
--- a/spec/models/scholarship_spec.rb
+++ b/spec/models/scholarship_spec.rb
@@ -53,6 +53,53 @@
expect(build(:scholarship, grant: nil, amount_cents: 9_999_999)).to be_valid
end
end
+
+ describe "amount_within_event_registration_due" do
+ let(:event) { create(:event, cost_cents: 5000) }
+ let(:person) { create(:person) }
+ let(:registration) { create(:event_registration, event:, registrant: person) }
+
+ it "is valid when the amount is less than the event registration due" do
+ scholarship = build(:scholarship, recipient: person, amount_cents: 4000)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ expect(scholarship).to be_valid
+ end
+
+ it "is valid when the amount equals the event registration due" do
+ scholarship = build(:scholarship, recipient: person, amount_cents: 5000)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ expect(scholarship).to be_valid
+ end
+
+ it "is invalid when the amount exceeds the event registration due" do
+ scholarship = build(:scholarship, recipient: person, amount_cents: 6000)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ expect(scholarship).not_to be_valid
+ expect(scholarship.errors[:amount_dollars])
+ .to include("cannot exceed the event registration amount due ($50)")
+ end
+
+ it "accounts for other allocations when computing the amount due" do
+ registration.allocations.create!(
+ source: create(:payment, amount_cents: 2000, amount_cents_remaining: 2000), amount: 2000
+ )
+ scholarship = build(:scholarship, recipient: person, amount_cents: 3500)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ expect(scholarship).not_to be_valid
+ expect(scholarship.errors[:amount_dollars])
+ .to include("cannot exceed the event registration amount due ($30)")
+ end
+
+ it "excludes the scholarship's own existing allocation from the due" do
+ scholarship = build(:scholarship, recipient: person, amount_cents: 5000)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ scholarship.save!
+ scholarship.allocation.update_column(:amount, 5000)
+
+ scholarship.amount_cents = 4500
+ expect(scholarship).to be_valid
+ end
+ end
end
describe "marking the event registration's scholarship_requested flag" do
diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb
index 95ed227895..65af3e4640 100644
--- a/spec/requests/scholarships_spec.rb
+++ b/spec/requests/scholarships_spec.rb
@@ -425,3 +425,56 @@
end
end
end
+
+RSpec.describe "Scholarships against an event registration", type: :request do
+ let(:admin) { create(:user, :with_person, super_user: true) }
+ let(:event) { create(:event, cost_cents: 5000) }
+ let(:person) { create(:person) }
+ let(:registration) { create(:event_registration, event:, registrant: person) }
+
+ before { sign_in admin }
+
+ describe "POST /scholarships" do
+ context "when the amount exceeds the event registration due" do
+ it "does not create the scholarship and shows flash and field errors" do
+ expect {
+ post scholarships_path(allocatable_sgid: registration.to_sgid.to_s),
+ params: { scholarship: { amount_dollars: "60" } }
+ }.not_to change(Scholarship, :count)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("cannot exceed the event registration amount due ($50)")
+ end
+ end
+
+ context "when the amount is within the event registration due" do
+ it "creates the scholarship" do
+ expect {
+ post scholarships_path(allocatable_sgid: registration.to_sgid.to_s),
+ params: { scholarship: { amount_dollars: "40" } }
+ }.to change(Scholarship, :count).by(1)
+
+ expect(response).to redirect_to(registrants_event_path(event))
+ end
+ end
+ end
+
+ describe "PATCH /scholarships/:id" do
+ let(:scholarship) do
+ s = build(:scholarship, recipient: person, amount_cents: 4000)
+ s.build_allocation(allocatable: registration, amount: 4000)
+ s.save!
+ s
+ end
+
+ context "when the updated amount exceeds the event registration due" do
+ it "does not update the scholarship and shows flash and field errors" do
+ patch scholarship_path(scholarship), params: { scholarship: { amount_dollars: "60" } }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("cannot exceed the event registration amount due ($50)")
+ expect(scholarship.reload.amount_cents).to eq(4000)
+ end
+ end
+ end
+end