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
8 changes: 5 additions & 3 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class EventRegistration < ApplicationRecord

ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze
INACTIVE_STATUSES = %w[ cancelled no_show transferred_out ].freeze
# Money is tracked apart from attendance: a transferred-out reg left the event but
# its money stays on the event's books.
MONEY_STATUSES = (ACTIVE_STATUSES + %w[ transferred_out ]).freeze
ATTENDANCE_STATUSES = (ACTIVE_STATUSES + INACTIVE_STATUSES).freeze
# Attendance outcomes surfaced as their own participation buckets; every other
# status falls into "other" (registered, transfers, cancellations).
Expand Down Expand Up @@ -179,12 +182,11 @@ def self.person_name_join = :registrant
scope :event_title, ->(event_title) { joins(:event).where("LOWER(events.title LIKE ?)", "%#{event_title}%") }
scope :active, -> { where(status: ACTIVE_STATUSES) }
scope :inactive, -> { where(status: INACTIVE_STATUSES) }
scope :holding_money, -> { where(status: MONEY_STATUSES) }
scope :attended, -> { where(status: "attended") }
scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) }
scope :transferred_in, -> { where.not(transferred_from_registration_id: nil) }
# The billable basis for an event's financial reporting: a transferred-in reg's
# money lives on its source registration, so it owes nothing here and is
# excluded from this event's totals (issue #1944).
# Registrations that didn't arrive by transfer. (#1944)
scope :not_transferred_in, -> { where(transferred_from_registration_id: nil) }
# "other" = any status outside the named attendance outcomes; the virtual
# "transferred_in" (an FK-backed dimension, not a status) routes to the
Expand Down
149 changes: 81 additions & 68 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def attendance_registrants(*statuses)
end

def scholarship_total_cents
scholarships.sum(:amount_cents)
money_scholarships.sum(:amount_cents)
end

# Scholarship dollars drawn from an EXTERNAL funder/grant β€” money a grant pays
Expand Down Expand Up @@ -327,8 +327,14 @@ def scholarship_registrants
@scholarship_registrants ||= people_sorted(scholarships.distinct.pluck(:recipient_id))
end

# Everyone behind the scholarship dollars, which can include a transferred-out
# recipient who isn't a #scholarship_registrants person here.
def money_scholarship_registrants
@money_scholarship_registrants ||= people_sorted(money_scholarships.distinct.pluck(:recipient_id))
end

def scholarship_amounts_by_recipient
@scholarship_amounts_by_recipient ||= scholarships.group(:recipient_id).sum(:amount_cents)
@scholarship_amounts_by_recipient ||= money_scholarships.group(:recipient_id).sum(:amount_cents)
end

# Funded / unfunded scholarship dollars per recipient (Person id => cents) β€” the
Expand All @@ -345,8 +351,8 @@ def unfunded_scholarship_cents_by_recipient

# Scholarship recipients (Person records, name-sorted) with a funded / unfunded
# award β€” the people behind each split, for the scholarship report row's expander.
# Both reuse the recipients already loaded by #scholarship_registrants, so the
# split adds no extra Person query.
# Both reuse the recipients already loaded by #money_scholarship_registrants, so
# the split adds no extra Person query.
def funded_scholarship_recipients
@funded_scholarship_recipients ||= recipients_for(funded_scholarship_cents_by_recipient.keys)
end
Expand All @@ -371,35 +377,28 @@ def registration_paid_by_registrant
# Per-registrant cents still owed after payments and scholarships, keyed by
# Person id. Aggregates across a person's registrations; sums to outstanding_cents.
def registration_due_by_registrant
@registration_due_by_registrant ||= billable_registration_ids.each_with_object(Hash.new(0)) do |id, map|
due = [ event.cost_cents.to_i - allocated_by_registration.fetch(id, 0), 0 ].max
@registration_due_by_registrant ||= due_cents_by_registration.each_with_object(Hash.new(0)) do |(id, due), map|
next if due.zero?
registrant_id = registrant_id_by_registration[id]
map[registrant_id] += due if registrant_id
end
end

# Real money allocated to this event's registrations from payments.
# Real money allocated to this event's registrations from payments, wherever the
# registrant ended up: money counts at the event of the reg it sits on.
def received_cents
registration_allocations.where(source_type: "Payment").sum(:amount)
end

# Still owed across all billable registrations, after payments and scholarships.
# Still owed across active registrations, after payments and scholarships. A
# transfer's balance is owed here, at the event they attend, not at the one they left.
def outstanding_cents
billable_registration_ids.sum do |id|
[ event.cost_cents.to_i - allocated_by_registration.fetch(id, 0), 0 ].max
end
end

# Full-price value of all billable registrations (before scholarships/discounts).
def total_cents
event.cost_cents.to_i * billable_registration_ids.size
due_cents_by_registration.values.sum
end

# Registration-fee subtotal: money received plus money still owed. This is the
# cash side of registration fees (it excludes scholarship-covered cost), so it
# reconciles with the Paid + Due breakdown and the grand-total equation.
# Differs from total_cents, the full pre-scholarship price.
def registration_subtotal_cents
received_cents + outstanding_cents
end
Expand Down Expand Up @@ -434,13 +433,11 @@ def monies_made_cents
end

def paid_count
return billable_registration_ids.size if free?
billable_registration_ids.count { |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i }
paid_registration_ids.size
end

def unpaid_count
return 0 if free?
billable_registration_ids.size - paid_count
unpaid_registration_ids.size
end

# Registrants whose cost is fully covered (payments and/or completed
Expand All @@ -450,7 +447,7 @@ def paid_registrants
end

def unpaid_registrants
@unpaid_registrants ||= people_sorted(registrants_for(billable_registration_ids - paid_registration_ids))
@unpaid_registrants ||= people_sorted(registrants_for(unpaid_registration_ids))
end

# Registrants still owing who have paid some of it β€” listed under Paid as well so
Expand All @@ -464,8 +461,8 @@ def partially_paid_registrants
# separate from the registration fee. These mirror the registration-fee
# methods above: paid is CE cash collected, outstanding is CE cost still owed
# after every allocation, and the total (paid + outstanding) is the CE addend
# in the grand total. Scoped to CE registrations tied to an active event
# registration, so cancelled registrants' CE is ignored like their fees.
# in the grand total. Scoped to #money_registration_ids, so cancelled
# registrants' CE is ignored like their fees.
# Cash collected counts every CE; what's owed and who has CE count only
# ContinuingEducationRegistration.counted.

Expand Down Expand Up @@ -696,7 +693,7 @@ def ce_lapsed_count
# roster's CE column: its icon links to editing this record when present.
def ce_registration_by_registrant
@ce_registration_by_registrant ||= ce_registrations.each_with_object({}) do |ce_registration, map|
registrant_id = ce_registrant_id_by_registration[ce_registration.event_registration_id]
registrant_id = registrant_id_by_registration[ce_registration.event_registration_id]
map[registrant_id] ||= ce_registration if registrant_id
end
end
Expand Down Expand Up @@ -1018,12 +1015,22 @@ def linked_registration_ids
.pluck(:event_registration_id)
end

# The money basis: active registrations that owe THIS event, i.e. excluding
# transferred-in regs (whose balance lives on their source registration). Kept
# distinct from registrant_count/active ids β€” a transferred-in registrant still
# counts in the headcount and attendance, just not in the financial totals.
def billable_registration_ids
@billable_registration_ids ||= active_registrations.not_transferred_in.pluck(:id)
# The money basis, kept apart from the people figures: every registration of this
# event holding money, including a transferred-out reg no longer in the headcount.
# Each allocation counts at the event of the reg it sits on, so a transfer's money
# is counted once, split across its two events. (#2947)
def money_registrations
@money_registrations ||= event.event_registrations.holding_money
end

def money_registration_ids
@money_registration_ids ||= money_registrations.pluck(:id)
end

# The two halves of any transfer touching this event.
def transfer_registration_ids
@transfer_registration_ids ||= money_registrations.where(status: "transferred_out")
.or(money_registrations.transferred_in).pluck(:id)
end

def registrant_ids
Expand Down Expand Up @@ -1062,8 +1069,8 @@ def reference_date

# Scholarships to RECOGNIZE recipients at this event: awards on this event's own
# registrations, plus awards on the SOURCE registrations of anyone transferred
# in (their dollars stay on the source event β€” see #scholarships and the
# billable basis β€” but they're still a scholarship recipient here). Recognition
# in (their dollars stay on the source event β€” see #money_scholarships β€” but
# they're still a scholarship recipient here). Recognition
# only: drives the recipient "hat", the recipient count, and the recipients
# page; NOT the dollar totals. (#1944)
def recognized_scholarships
Expand Down Expand Up @@ -1188,27 +1195,25 @@ def dedupe_answers(answers)
end

def registration_allocations
Allocation.where(allocatable_type: "EventRegistration", allocatable_id: active_registration_ids)
Allocation.where(allocatable_type: "EventRegistration", allocatable_id: money_registration_ids)
end

def allocated_by_registration
@allocated_by_registration ||= registration_allocations.group(:allocatable_id).sum(:amount)
end

# CE money/counts follow the CE record, not the registration billing basis: they
# count every CE record on a registration of this event that wasn't cancelled or
# a no-show β€” including a transferred-out reg's paid stub (counted here, where it
# was paid) and a transferred-in reg's own carried record (counted at the event it
# now credits). (#1944)
def ce_basis_registration_ids
@ce_basis_registration_ids ||= event.event_registrations.where.not(status: %w[ cancelled no_show ]).pluck(:id)
# What each active registration still owes.
def due_cents_by_registration
@due_cents_by_registration ||= RegistrationBalances.new(active_registration_ids).due_cents_by_id
end

# Continuing-education registrations counted for this event β€” the basis for every
# CE money figure and for the CE registrant counts / pie.
# CE money figure and for the CE registrant counts / pie. Includes a
# transferred-out reg's paid stub (counted here, where it was paid) and a
# transferred-in reg's own carried record (counted at the event it now credits). (#1944)
def ce_registrations
@ce_registrations ||= ContinuingEducationRegistration
.where(event_registration_id: ce_basis_registration_ids)
.where(event_registration_id: money_registration_ids)
.to_a
end

Expand All @@ -1219,15 +1224,6 @@ def counted_ce_registrations
end
end

# Registrant (Person) id per registration in the CE basis. Distinct from
# #registrant_id_by_registration (active regs only) because CE also counts a
# transferred-out reg's stub, whose reg is inactive. (#1944)
def ce_registrant_id_by_registration
@ce_registrant_id_by_registration ||= event.event_registrations
.where(id: ce_registrations.map(&:event_registration_id).uniq)
.pluck(:id, :registrant_id).to_h
end

def ce_allocations
Allocation.where(allocatable_type: "ContinuingEducationRegistration", allocatable_id: ce_registrations.map(&:id))
end
Expand Down Expand Up @@ -1268,14 +1264,14 @@ def ce_paid_registrant_ids
end

def ce_registrant_ids_for(records)
records.filter_map { |ce_registration| ce_registrant_id_by_registration[ce_registration.event_registration_id] }.uniq
records.filter_map { |ce_registration| registrant_id_by_registration[ce_registration.event_registration_id] }.uniq
end

# Roll a per-CE-registration cents figure (from the block) up to a
# { Person id => cents } hash, dropping zeros.
def ce_cents_by_registrant(records = ce_registrations)
records.each_with_object(Hash.new(0)) do |ce_registration, map|
registrant_id = ce_registrant_id_by_registration[ce_registration.event_registration_id]
registrant_id = registrant_id_by_registration[ce_registration.event_registration_id]
next unless registrant_id
cents = yield(ce_registration)
map[registrant_id] += cents if cents.positive?
Expand All @@ -1296,19 +1292,28 @@ def bulk_payments
# display lookups still surface a declined award (badged). Money and counts run
# off #scholarships, which drops them.
def all_scholarships
@all_scholarships ||= begin
scope = Scholarship
.joins(:allocation)
.where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids })
scope = scope.where(grant_id: funder_grant_ids) if @scholarship_funder
scope
end
@all_scholarships ||= scholarships_on(active_registration_ids)
end

def scholarships
@scholarships ||= all_scholarships.not_declined
end

# The awards behind the dollar figures. Wider than #scholarships, which answers
# who's a recipient at this event: an award stays on the transferred-out reg, so
# its dollars count here though the recipient has moved on.
def money_scholarships
@money_scholarships ||= scholarships_on(money_registration_ids).not_declined
end

def scholarships_on(registration_ids)
scope = Scholarship
.joins(:allocation)
.where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids })
scope = scope.where(grant_id: funder_grant_ids) if @scholarship_funder
scope
end

# Ids of grants the scoped funder gave β€” used to narrow scholarships to one
# funder. Empty (so no scholarships match) when the funder gave none.
def funder_grant_ids
Expand All @@ -1317,12 +1322,12 @@ def funder_grant_ids

# Externally funded = backed by a grant whose funder isn't the org itself.
def funded_scholarships
scholarships.externally_funded(self_funded_grant_ids)
money_scholarships.externally_funded(self_funded_grant_ids)
end

# Org-subsidized = no grant, or a grant the org (AWBW) self-funded.
def unfunded_scholarships
scholarships.org_subsidized(self_funded_grant_ids)
money_scholarships.org_subsidized(self_funded_grant_ids)
end

# Ids of grants the org self-funded; memoized so the funded/unfunded split
Expand All @@ -1331,14 +1336,22 @@ def self_funded_grant_ids
@self_funded_grant_ids ||= Grant.self_funded_ids
end

# Nothing owed here. A transfer only lists at an event holding some of its money,
# so a reg paid in full at the old event doesn't read as paid at the new one too.
def paid_registration_ids
@paid_registration_ids ||= billable_registration_ids.select do |id|
allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i
@paid_registration_ids ||= money_registration_ids.select do |id|
next false if due_cents_by_registration[id].positive?

transfer_registration_ids.exclude?(id) || allocated_by_registration.fetch(id, 0).positive?
end
end

def unpaid_registration_ids
@unpaid_registration_ids ||= due_cents_by_registration.select { |_id, due| due.positive? }.keys
end

def registrant_id_by_registration
@registrant_id_by_registration ||= active_registrations.pluck(:id, :registrant_id).to_h
@registrant_id_by_registration ||= money_registrations.pluck(:id, :registrant_id).to_h
end

def registrants_for(registration_ids)
Expand All @@ -1350,13 +1363,13 @@ def people_sorted(person_ids)
end

# Recipient Person records for the given ids, name-sorted, drawn from the
# already-loaded scholarship_registrants so a funded/unfunded split reuses one query.
# already-loaded money_scholarship_registrants so a funded/unfunded split reuses one query.
def recipients_for(recipient_ids)
recipient_ids.filter_map { |id| scholarship_recipients_by_id[id] }.sort_by(&:name)
end

def scholarship_recipients_by_id
@scholarship_recipients_by_id ||= scholarship_registrants.index_by(&:id)
@scholarship_recipients_by_id ||= money_scholarship_registrants.index_by(&:id)
end

def organization_ids
Expand Down
Loading
Loading