Skip to content
Merged
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
48 changes: 34 additions & 14 deletions app/admin/applications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,31 +65,33 @@
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
panel "Payment Activity -- [Balance Due: #{number_to_currency(balance_due)} Total Cost: #{number_to_currency(total_cost)}]" do
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions app/admin/dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/models/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions spec/models/application_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
38 changes: 38 additions & 0 deletions spec/requests/admin_applications_spec.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions spec/support/application_mock.rb
Original file line number Diff line number Diff line change
@@ -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")

Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down