diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index f27aac74bc..c603aa0992 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -189,7 +189,8 @@ def registration_subtotal_cents end def grand_total_cents - registration_subtotal_cents + scholarship_total_cents + cont_ed_total_cents + unallocated_bulk_payment_cents + registration_subtotal_cents + scholarship_total_cents + cont_ed_total_cents + + unallocated_bulk_payment_cents + unallocated_direct_payment_cents end # Money received through this event's bulk payment submissions that hasn't been @@ -198,6 +199,14 @@ def unallocated_bulk_payment_cents bulk_payments.sum(:amount_cents_remaining) end + # Leftover cash on direct (non-bulk) payments applied to this event's + # registrations — e.g. a registrant who overpaid, so the payment still carries + # an unallocated balance. Excludes bulk payments, whose remainder is already + # counted by unallocated_bulk_payment_cents, so the two never double-count. + def unallocated_direct_payment_cents + direct_payments.sum(:amount_cents_remaining) + end + # Cash actually collected from registrants: registration payments received plus # continuing-education fees paid. Excludes scholarships, which are awarded, not # collected. With due_cents this sums to monies_made_cents. @@ -750,6 +759,16 @@ def bulk_payments ) end + # Direct (non-bulk) payments allocated to this event's active registrations + # that still carry an unallocated balance. Excludes bulk payments so their + # remainder isn't double-counted against unallocated_bulk_payment_cents. + def direct_payments + @direct_payments ||= Payment + .where(id: registration_allocations.where(source_type: "Payment").select(:source_id)) + .where("amount_cents_remaining > 0") + .where.not(id: bulk_payments.select(:id)) + end + def scholarships @scholarships ||= Scholarship .joins(:allocation) diff --git a/app/views/events/dashboard.html.erb b/app/views/events/dashboard.html.erb index 78ec2b70cc..00fb7a0a0e 100644 --- a/app/views/events/dashboard.html.erb +++ b/app/views/events/dashboard.html.erb @@ -83,6 +83,7 @@ $grand = $registration + $scholarships + $cont_ed. Left-aligned; the headline figure dominates while the addends stay muted, and the row wraps on mobile. %> <% show_bulk_payments = @dashboard.unallocated_bulk_payment_cents.positive? %> + <% show_direct_payments = @dashboard.unallocated_direct_payment_cents.positive? %> <%# Each equation term is colorless at rest and reveals its domain color on hover — a tinted fill + darker border, matching the amber bulk-payments term's look but per-domain. border-transparent reserves the border so @@ -134,8 +135,21 @@ <% end %> <% end %> + <% if show_direct_payments %> + + + <%# Same amber treatment as the bulk-payments term — leftover cash on + a registrant's direct payment still needs allocating. Links to the + payments index filtered to payments with an unallocated balance. %> + <%= link_to payments_path(has_remaining: "yes"), class: "whitespace-nowrap leading-tight rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 hover:bg-amber-100 transition-colors", title: "View unallocated payments" do %> +

+ + <%= dollars_from_cents(@dashboard.unallocated_direct_payment_cents) %> +

+ + <% end %> + <% end %> -

Registration fees + scholarships + CE fees<%= " + unallocated bulk payments" if show_bulk_payments %>

+

Registration fees + scholarships + CE fees<%= " + unallocated bulk payments" if show_bulk_payments %><%= " + unallocated direct payments" if show_direct_payments %>

<%# Cash view of the same money: what's collected (registration paid + diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 6366abd982..718e4958b4 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1140,6 +1140,26 @@ def submit_agency_name(name) expect(response.body).not_to include("Unallocated bulk payments") end + + it "shows unallocated direct payments in the equation, linking to the filtered payments index" do + payment = create(:payment, person: person, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: registration, amount: 3_000) + + get dashboard_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Unallocated direct payments") + expect(response.body).to include(payments_path(has_remaining: "yes")) + end + + it "omits the direct payments term when every payment is fully applied" do + payment = create(:payment, person: person, amount_cents: 3_000, amount_cents_remaining: 3_000) + create(:allocation, source: payment, allocatable: registration, amount: 3_000) + + get dashboard_event_path(event) + + expect(response.body).not_to include("Unallocated direct payments") + end end context "as non-admin non-owner" do diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 1c3d2a03e6..ec1dcce74d 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -64,7 +64,9 @@ create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), allocatable: cancelled_reg, amount: 5_000) - # Money: reg1 fully covered (payment + scholarship), reg2 partly paid. + # Money: reg1 fully covered (payment + scholarship), reg2 partly paid. Each + # payment is fully applied to its registration, so the allocation callback + # decrements its remaining to zero — no unallocated balance left over. create(:allocation, source: create(:payment, amount_cents: 6_000, amount_cents_remaining: 6_000), allocatable: reg1, amount: 6_000) scholarship = create(:scholarship, recipient: person1, amount_cents: 4_000, tasks_completed: true) @@ -144,11 +146,12 @@ expect(dashboard.registration_subtotal_cents).to eq(16_000) end - it "reports grand total as registration subtotal plus completed scholarships plus cont ed plus unallocated bulk payments" do + it "reports grand total as registration subtotal plus completed scholarships plus cont ed plus unallocated bulk and direct payments" do expect(dashboard.grand_total_cents).to eq(20_000) expect(dashboard.grand_total_cents).to eq( dashboard.registration_subtotal_cents + dashboard.scholarship_total_cents + - dashboard.cont_ed_total_cents + dashboard.unallocated_bulk_payment_cents + dashboard.cont_ed_total_cents + dashboard.unallocated_bulk_payment_cents + + dashboard.unallocated_direct_payment_cents ) end @@ -156,6 +159,10 @@ expect(dashboard.unallocated_bulk_payment_cents).to eq(0) end + it "reports no unallocated direct payments when every payment is fully applied" do + expect(dashboard.unallocated_direct_payment_cents).to eq(0) + end + it "is not free when the event has a cost" do expect(dashboard.free?).to be(false) end @@ -847,4 +854,65 @@ def opt_in(person, text:) expect(dashboard.unallocated_bulk_payment_cents).to eq(0) end end + + describe "unallocated direct payments" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:payer) { create(:person) } + let!(:registration) { create(:event_registration, event: event, registrant: payer, status: "registered") } + + it "sums the leftover balance on direct payments applied to the event's registrations" do + # Overpaid: a $5,000 payment with only $3,000 applied leaves $2,000 unallocated + # (the allocation callback decrements the payment's remaining). + payment = create(:payment, person: payer, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: registration, amount: 3_000) + + expect(dashboard.unallocated_direct_payment_cents).to eq(2_000) + end + + it "adds the leftover balance to the grand total" do + payment = create(:payment, person: payer, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: registration, amount: 3_000) + + expect(dashboard.grand_total_cents).to eq( + dashboard.registration_subtotal_cents + dashboard.scholarship_total_cents + + dashboard.cont_ed_total_cents + dashboard.unallocated_bulk_payment_cents + 2_000 + ) + end + + it "ignores fully-applied direct payments" do + payment = create(:payment, person: payer, amount_cents: 3_000, amount_cents_remaining: 3_000) + create(:allocation, source: payment, allocatable: registration, amount: 3_000) + + expect(dashboard.unallocated_direct_payment_cents).to eq(0) + end + + it "ignores payments allocated to other events' registrations" do + other_event = create(:event, cost_cents: 10_000) + other_registration = create(:event_registration, event: other_event, registrant: payer, status: "registered") + payment = create(:payment, person: payer, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: other_registration, amount: 3_000) + + expect(dashboard.unallocated_direct_payment_cents).to eq(0) + end + + it "ignores payments allocated only to inactive registrations" do + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") + payment = create(:payment, person: payer, amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: payment, allocatable: cancelled, amount: 3_000) + + expect(dashboard.unallocated_direct_payment_cents).to eq(0) + end + + it "does not double-count bulk payments that have been applied to registrations" do + bulk_form = create(:form) + create(:event_form, event: event, form: bulk_form, role: "bulk_payment") + submission = create(:form_submission, person: payer, form: bulk_form, event: event, role: "bulk_payment") + bulk_payment = create(:payment, person: payer, form_submission: submission, + amount_cents: 5_000, amount_cents_remaining: 5_000) + create(:allocation, source: bulk_payment, allocatable: registration, amount: 3_000) + + expect(dashboard.unallocated_direct_payment_cents).to eq(0) + expect(dashboard.unallocated_bulk_payment_cents).to eq(2_000) + end + end end