diff --git a/app/admin/applications.rb b/app/admin/applications.rb index 3e43eb7..3bc5eff 100644 --- a/app/admin/applications.rb +++ b/app/admin/applications.rb @@ -28,18 +28,34 @@ filter :subscription, as: :select filter :conf_year, as: :select + controller do + before_action :load_index_batch_data, only: [:index] + + def scoped_collection + end_of_association_chain.includes(:partner_registration) + end + + private + + def load_index_batch_data + raw = Payment + .where(transaction_status: '1') + .group(:user_id, :conf_year) + .sum(Arel.sql("total_amount::numeric")) + # Normalize keys to [Integer, Integer] so lookup matches regardless of DB return type + @payments_total_by_user_and_year = raw.transform_keys { |k| [k[0].to_i, k[1].to_i] } + @lodgings_by_description = Lodging.all.index_by(&:description) + end + end + index do selectable_column actions column :offer_status column "Balance Due" do |application| - users_current_payments = Payment.where(user_id: application.user_id, conf_year: application.conf_year) - ttl_paid = Payment.where(user_id: application.user_id, conf_year: application.conf_year, transaction_status: '1').pluck(:total_amount).map(&:to_f).sum / 100 - cost_lodging = Lodging.find_by(description: application.lodging_selection).cost.to_f - cost_partner = application.partner_registration.cost.to_f - total_cost = cost_lodging + cost_partner - balance_due = total_cost - ttl_paid - number_to_currency(balance_due) + payments_totals = controller.instance_variable_get(:@payments_total_by_user_and_year) || {} + lodgings_by_desc = controller.instance_variable_get(:@lodgings_by_description) || {} + number_to_currency(application.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc)) end column :first_name column :last_name @@ -49,16 +65,18 @@ column :workshop_selection3 column :lodging_selection column "partner_registration_id" do |application| - application.partner_registration.display_name + application.partner_registration&.display_name end column :birth_year end show do - users_current_payments = Payment.where(user_id: application.user_id, conf_year: application.conf_year) # Payment.current_conference_payments.where(user_id: application.user_id) ttl_paid = Payment.where(user_id: application.user_id, conf_year: application.conf_year, transaction_status: '1').pluck(:total_amount).map(&:to_f).sum / 100 cost_lodging = Lodging.find_by(description: application.lodging_selection).cost.to_f + if application.partner_registration.nil? + raise "Partner registration is missing for this application (id=#{application.id}); cannot compute balance. Fix data and retry." + end cost_partner = application.partner_registration.cost.to_f total_cost = cost_lodging + cost_partner balance_due = total_cost - ttl_paid @@ -66,14 +84,14 @@ table_for application.user.payments.where(conf_year: application.conf_year) do #application.user.payments.current_conference_payments column(:id) { |aid| link_to(aid.id, admin_payment_path(aid.id)) } column(:account_type) { |atype| atype.account_type.titleize } - column(:transaction_type) + column(:transaction_type) column(:transaction_date) {|td| Date.parse(td.transaction_date) } column(:total_amount) { |ta| number_to_currency(ta.total_amount.to_f / 100) } end text_node link_to("[Add Manual Payment]", new_admin_payment_path(:user_id => application)) end - + attributes_table do row :user row :conf_year @@ -100,7 +118,7 @@ row :workshop_selection3 row :lodging_selection row "partner_registration_id" do |app| - app.partner_registration.display_name + app.partner_registration&.display_name end row :partner_first_name row :partner_last_name @@ -161,7 +179,9 @@ column :lottery_position column :offer_status column "Balance Due" do |application| - users_current_payments = Payment.current_conference_payments.where(user_id: application.user_id) + if application.partner_registration.nil? + raise "Application #{application.id}: partner_registration is missing; cannot compute balance for CSV export." + end ttl_paid = Payment.current_conference_payments.where(user_id: application.user_id, transaction_status: '1').pluck(:total_amount).map(&:to_f).sum / 100 cost_lodging = Lodging.find_by(description: application.lodging_selection).cost.to_f cost_partner = application.partner_registration.cost.to_f @@ -178,7 +198,7 @@ column :workshop_selection3 column :lodging_selection column "partner_registration_id" do |app| - app.partner_registration.display_name + app.partner_registration&.display_name end column :birth_year column :street diff --git a/app/admin/dashboard.rb b/app/admin/dashboard.rb index 9826543..f736280 100644 --- a/app/admin/dashboard.rb +++ b/app/admin/dashboard.rb @@ -45,10 +45,14 @@ end column do panel "#{ApplicationSetting.get_current_app_year} Applicants who accepted their offer (#{Application.application_accepted.count})" do - table_for Application.application_accepted.sort.reverse do + applications = Application.application_accepted.includes(:partner_registration).sort.reverse + raw_payments = Payment.where(transaction_status: '1').group(:user_id, :conf_year).sum(Arel.sql("total_amount::numeric")) + payments_totals = raw_payments.transform_keys { |k| [k[0].to_i, k[1].to_i] } + lodgings_by_desc = Lodging.all.index_by(&:description) + table_for applications do column("Applicant") { |u| link_to(u.display_name, admin_application_path(u.id)) } column("Offer Date") { |od| od.offer_status_date } - column("Balance Due") { |a| number_to_currency a.balance_due } + column("Balance Due") { |a| number_to_currency(a.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc)) } end end end diff --git a/app/models/application.rb b/app/models/application.rb index 5b15d72..49e40f5 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -87,7 +87,7 @@ def total_user_has_paid end def lodging_cost - Lodging.find_by(description: self.lodging_selection).cost.to_f + Lodging.find_by(description: self.lodging_selection).cost.to_f end def partner_registration_cost @@ -102,6 +102,22 @@ def balance_due total_cost - total_user_has_paid end + # Balance due in dollars using preloaded data (avoids N+1). Payments total is in cents. + # Returns a rounded float suitable for number_to_currency. + # Raises if partner_registration or lodging is missing (data integrity: do not treat missing as $0). + def balance_due_with_batch(payments_totals:, lodgings_by_desc:) + raise "Application #{id}: partner_registration is missing; cannot compute balance." if partner_registration.nil? + lodging = lodgings_by_desc[lodging_selection] + raise "Application #{id}: lodging not found for selection '#{lodging_selection}'; cannot compute balance." if lodging.nil? + key = [user_id.to_i, conf_year.to_i] + ttl_paid_cents = (payments_totals[key] || 0).to_f + ttl_paid_dollars = ttl_paid_cents / 100 + cost_lodging = lodging.cost.to_f + cost_partner = partner_registration.cost.to_f + total_cost = cost_lodging + cost_partner + (total_cost - ttl_paid_dollars).round(2) + end + def first_workshop_instructor Workshop.find(workshop_selection1).instructor end diff --git a/spec/models/application_spec.rb b/spec/models/application_spec.rb index 84fed9f..c1aa56b 100644 --- a/spec/models/application_spec.rb +++ b/spec/models/application_spec.rb @@ -191,6 +191,50 @@ end end + describe '#balance_due_with_batch' do + it 'returns balance as total cost minus paid (payments in cents), rounded to 2 decimals' do + app = build(:application, user_id: 100, conf_year: 2026, lodging_selection: 'Standard') + allow(app).to receive(:partner_registration).and_return(double('PartnerRegistration', cost: 50.0)) + payments_totals = { [100, 2026] => 10_000 } # $100 in cents + lodgings_by_desc = { 'Standard' => double('Lodging', cost: 100.0) } + expect(app.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc)).to eq(50.0) + end + + it 'uses 0 for paid when key is missing in payments_totals' do + app = build(:application, user_id: 100, conf_year: 2026, lodging_selection: 'Standard') + allow(app).to receive(:partner_registration).and_return(double('PartnerRegistration', cost: 50.0)) + payments_totals = {} + lodgings_by_desc = { 'Standard' => double('Lodging', cost: 100.0) } + expect(app.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc)).to eq(150.0) + end + + it 'returns negative balance when paid exceeds total cost (overpayment)' do + app = build(:application, user_id: 100, conf_year: 2026, lodging_selection: 'Standard') + allow(app).to receive(:partner_registration).and_return(double('PartnerRegistration', cost: 50.0)) + payments_totals = { [100, 2026] => 20_000 } # $200 in cents + lodgings_by_desc = { 'Standard' => double('Lodging', cost: 100.0) } + expect(app.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc)).to eq(-50.0) + end + + it 'raises when partner_registration is missing (do not treat as $0)' do + app = build(:application, user_id: 100, conf_year: 2026, lodging_selection: 'Unknown') + allow(app).to receive(:partner_registration).and_return(nil) + payments_totals = { [100, 2026] => 0 } + lodgings_by_desc = {} + expect { app.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc) } + .to raise_error(/partner_registration is missing/) + end + + it 'raises when lodging is not found in lodgings_by_desc (do not treat as $0)' do + app = build(:application, user_id: 100, conf_year: 2026, lodging_selection: 'DeletedOrInvalidLodging') + allow(app).to receive(:partner_registration).and_return(double('PartnerRegistration', cost: 50.0)) + payments_totals = { [100, 2026] => 0 } + lodgings_by_desc = { 'Standard' => double('Lodging', cost: 100.0) } + expect { app.balance_due_with_batch(payments_totals: payments_totals, lodgings_by_desc: lodgings_by_desc) } + .to raise_error(/lodging not found for selection 'DeletedOrInvalidLodging'/) + end + end + describe '#first_workshop_instructor' do it 'returns the instructor of the first workshop' do workshop = double("Workshop", instructor: "John Smith") diff --git a/spec/requests/admin_applications_spec.rb b/spec/requests/admin_applications_spec.rb new file mode 100644 index 0000000..23aee5d --- /dev/null +++ b/spec/requests/admin_applications_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin Applications index', type: :request, no_application_mock: true do + let(:admin_user) { create(:admin_user) } + + describe 'GET /admin/applications' do + context 'when admin is not signed in' do + it 'redirects to admin sign in page' do + get admin_applications_path + expect(response).to redirect_to(new_admin_user_session_path) + end + end + + context 'when admin is signed in' do + before { sign_in admin_user } + + it 'renders successfully with default scope' do + get admin_applications_path + expect(response).to be_successful + end + + it 'renders successfully with scope=all (batch data loaded)' do + get admin_applications_path, params: { scope: 'all' } + expect(response).to be_successful + end + + it 'includes Balance Due column when applications exist' do + create(:lodging, description: 'Standard') + create(:application, lodging_selection: 'Standard') + get admin_applications_path + expect(response).to be_successful + expect(response.body).to include('Balance Due') + end + end + end +end diff --git a/spec/support/application_mock.rb b/spec/support/application_mock.rb index ed7936b..fcd2fec 100644 --- a/spec/support/application_mock.rb +++ b/spec/support/application_mock.rb @@ -1,5 +1,8 @@ RSpec.configure do |config| config.before(:each, type: :request) do + # Skip mock when testing admin applications index (needs real ActiveRecord chain) + next if RSpec.current_example.metadata[:no_application_mock] + # Create a mock for Application.active_conference_applications mock_active_applications = double("ActiveApplications") diff --git a/yarn.lock b/yarn.lock index 5937ba5..e8f2440 100644 --- a/yarn.lock +++ b/yarn.lock @@ -141,9 +141,9 @@ "@rails/actioncable" "^7.0" "@hotwired/turbo@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.2.tgz#c31cdadfe66b98983066a94073b26fc7e15835f0" - integrity sha512-3K6QZkwWfosAV8zuM5bY+kKF02jp1lMQGsWfSE6wXdZBRBP3ah+Vj26YNqYtkEomBwRWA0QKhZgyJP7xOQkVEg== + version "8.0.21" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.21.tgz#a3e80c01d70048200f64bbe3582b84f9bfac034e" + integrity sha512-fJTv3JnzFHeDxBb23esZSOhT4r142xf5o3lKMFMvzPC6AllkqbBKk5Yb31UZhtIsKQCwmO/pUQrtTUlYl5CHAQ== "@nodelib/fs.scandir@2.1.5": version "2.1.5"