Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/models/scholarship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Runs before save so create/update fail gracefully here — without it, an over-due amount reaches sync_allocation_amount's allocation.update! in after_update and raises RecordInvalid (500). The where.not(id: allocation.id) excludes this scholarship's own allocation so edits don't double-count.


def sync_allocation_amount
return unless allocation

Expand Down
3 changes: 3 additions & 0 deletions app/views/scholarships/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 text-sm">$</span>
<%= 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" } %>
</div>
<% if f.object.errors[:amount_dollars].any? %>
<p class="mt-1 text-sm text-red-600"><%= f.object.errors[:amount_dollars].to_sentence %></p>
<% end %>
</div>
<div data-scholarship-preview-target="owed" class="rounded-lg border px-4 py-3 <%= paid ? "border-gray-300 bg-gray-50" : "border-amber-300 bg-amber-50" %>">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400">Still owed</dt>
Expand Down
47 changes: 47 additions & 0 deletions spec/models/scholarship_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions spec/requests/scholarships_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading