diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 03838b5df0..5aa8e04f97 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -2,7 +2,7 @@ class EventRegistrationsController < ApplicationController require "csv" # show redirects to slug URL; kept for backwards compatibility - before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :transfer, :process_transfer ] def index authorize! @@ -94,6 +94,16 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save + # Marking transferred out — from the edit-form save OR the inline roster/ + # onboarding status chip (Turbo) — with no destination yet sends the admin + # to the transfer screen to create/link the incoming registration. Handled + # before respond_to so both the HTML and Turbo paths redirect (issue #1944). + if @event_registration.saved_change_to_status? && + @event_registration.transfer_destination_pending? && + allowed_to?(:transfer?, @event_registration) + return redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to]), status: :see_other + end + notice = "Registration was successfully updated." respond_to do |format| format.turbo_stream @@ -173,6 +183,43 @@ def toggle_certificate_issued end end + # Follow-up screen shown after a registration is marked "transferred out": + # pick the destination event so the incoming registration is created/linked + # and the transfer trail is preserved (issue #1944). + def transfer + authorize! @event_registration, to: :transfer? + @return_to = params[:return_to] + @events = transfer_destination_events + end + + def process_transfer + authorize! @event_registration, to: :transfer? + destination_event = Event.find(params[:destination_event_id]) + + # The registrant may already be registered for the destination event, which + # would collide with the (registrant, event) uniqueness rule — link that + # record as the transfer target instead of creating a duplicate. + destination = EventRegistration.find_or_initialize_by( + registrant_id: @event_registration.registrant_id, + event_id: destination_event.id + ) + destination.transferred_from_registration = @event_registration + + if destination.save + redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence), + notice: "Transfer recorded — #{@event_registration.registrant.full_name} is now registered for #{destination_event.title}.", + status: :see_other + else + @return_to = params[:return_to] + @events = transfer_destination_events + flash.now[:alert] = destination.errors.full_messages.to_sentence + render :transfer, status: :unprocessable_content + end + rescue ActiveRecord::RecordNotFound + redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence), + alert: "Select a destination event to transfer to.", status: :see_other + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? @@ -340,6 +387,16 @@ def set_event_registration @event_registration = EventRegistration.includes({ registrant: [ :user, { affiliations: :organization } ] }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end + # Events a registrant can be transferred into: published events of the same + # kind as the one they're leaving — a facilitator training only transfers to + # another facilitator training, and a non-training only to another + # non-training — excluding the source event, most recent first. + def transfer_destination_events + Event.where(published: true, facilitator_training: @event_registration.event.facilitator_training) + .where.not(id: @event_registration.event_id) + .order(start_date: :desc) + end + # Creates the audited completion row for a checklist step (recording who/when), # or removes it — so an unchecked step leaves no trace. def toggle_checklist_step(step, completed) @@ -391,7 +448,7 @@ def csv_export(registrations) r&.preferred_email.to_s, r&.phone_number.to_s, e&.title.to_s, - er.attendance_status_label, + er.attendance_status_report_label, er.scholarships.any? ? "Yes" : "No", er.scholarships.any?(&:tasks_completed?) ? "Yes" : "No", cost_required ? er.payment_status_label : "", diff --git a/app/controllers/events/bulk_payments_controller.rb b/app/controllers/events/bulk_payments_controller.rb index f94cc93aa4..360f8168bc 100644 --- a/app/controllers/events/bulk_payments_controller.rb +++ b/app/controllers/events/bulk_payments_controller.rb @@ -7,7 +7,7 @@ def index authorize! @event track_view("events.bulk_payments", { event_id: @event.id }) - @event_registrations = @event.event_registrations.active.includes(:registrant) + @event_registrations = @event.event_registrations.active.not_transferred_in.includes(:registrant) @submissions = @event.form_submissions .where(role: "bulk_payment") .includes(:person, form_answers: :form_field, payment: :allocations) @@ -18,7 +18,7 @@ def index def create authorize! @event - @event_registrations = @event.event_registrations.active.includes(:registrant) + @event_registrations = @event.event_registrations.active.not_transferred_in.includes(:registrant) @allocated_by_registration = allocated_cents_by_registration(@event_registrations) submission = @event.form_submissions.find(params[:submission_id]) @@ -149,13 +149,13 @@ def set_event def assign_allocation_card_data(payment) @payment = payment.reload @submission = @payment.form_submission - @event_registrations = @event.event_registrations.active.includes(:registrant) + @event_registrations = @event.event_registrations.active.not_transferred_in.includes(:registrant) @allocated_by_registration = allocated_cents_by_registration(@event_registrations) end def assign_bulk_payment_card_data(submission) @submission = submission.reload.decorate - @event_registrations = @event.event_registrations.active.includes(:registrant) + @event_registrations = @event.event_registrations.active.not_transferred_in.includes(:registrant) @allocated_by_registration = allocated_cents_by_registration(@event_registrations) end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 0c96910e5b..45abe642c1 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1017,7 +1017,7 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals (1..day_count).each do |day| row << (registration.public_send("completed_day_#{day}") ? "Yes" : "No") end - row << registration.attendance_status_label + row << registration.attendance_status_report_label row << registration.comments.map { |comment| comment.body.to_s.strip }.reject(&:blank?).join(" ::: ") row << (registration.comments.any?(&:flagged?) ? "Yes" : "No") row diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 4b5bee69b2..1d3d1aa836 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -40,13 +40,30 @@ class ContinuingEducationRegistration < ApplicationRecord # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. + # The registration whose event the hours are completed/certified at — the home + # reg, or the destination it transferred to. Record + payment stay on the home + # reg; only certification follows the person. Derived from the transfer link, so + # there's nothing to keep in sync. (issue #1944) + def certified_at_registration + event_registration.transferred_to_registration || event_registration + end + + # True when this record's hours are certified at a *different* event than the + # one it's billed to (i.e. the home reg transferred out). + def certified_elsewhere? + event_registration.transferred_to_registration.present? + end + # CE certificate eligibility — its own rule (not shared): the event grants CE, # the registrant attended, the training has ended, and the CE balance is paid. + # Attendance + the training-ended check run against the certified-at reg (the + # destination event after a transfer); payment stays this record's own balance. def certificate_available? - event = event_registration&.event + reg = certified_at_registration + event = reg&.event return false unless event&.ce_eligible? - event.end_date&.past? && event_registration.attended? && paid_in_full? + event.end_date&.past? && reg.attended? && paid_in_full? end # Point this registration at a license for the typed type + number. `license_id` diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index cdf1071664..ee6394959b 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -20,6 +20,20 @@ class EventRegistration < ApplicationRecord through: :allocations, source: :source, source_type: "Scholarship" has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy + # Event-transfer trail (issue #1944). The FK lives on the incoming record: an + # "in" points back at the "out" it came from, so an in is identifiable directly + # (transferred_from_registration_id present) without scanning other rows. The + # in keeps its own real attendance status; only the out is marked + # "transferred_out". Chained transfers form a linked list back to the original. + belongs_to :transferred_from_registration, class_name: "EventRegistration", optional: true + has_one :transferred_to_registration, class_name: "EventRegistration", + foreign_key: :transferred_from_registration_id, inverse_of: :transferred_from_registration, dependent: :nullify + # CE registrations completed/certified at THIS event because their home + # registration transferred in here — the source reg's CE. Record + payment stay + # on the source; only certification follows the person. (issue #1944) + has_many :certified_ce_registrations, through: :transferred_from_registration, + source: :continuing_education_registrations + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } # Lets the registration edit form edit the registrant's shout-out text (which @@ -30,7 +44,7 @@ class EventRegistration < ApplicationRecord after_update :release_scholarships, if: :status_changed_to_cancelled? after_commit :send_cancellation_emails, if: :status_changed_to_cancelled? - ACTIVE_STATUSES = %w[ registered attended incomplete_attendance transferred_in ].freeze + ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze INACTIVE_STATUSES = %w[ cancelled no_show transferred_out ].freeze ATTENDANCE_STATUSES = (ACTIVE_STATUSES + INACTIVE_STATUSES).freeze # Attendance outcomes surfaced as their own participation buckets; every other @@ -77,12 +91,21 @@ class EventRegistration < ApplicationRecord "registered" => "Registered", "attended" => "Attended", "incomplete_attendance" => "Incomplete attendance", - "transferred_in" => "Transferred in", "cancelled" => "Cancelled", "no_show" => "No show", "transferred_out" => "Transferred out" }.freeze + # Options for the attendance-status filter and dashboard drill-downs: the real + # statuses plus the FK-backed "transferred in" dimension. An incoming + # registration keeps its own real status, so "transferred in" is a filter, not + # a status — the value routes through the `attendance_status` scope to the FK. + TRANSFERRED_IN_FILTER = "transferred_in".freeze + ATTENDANCE_FILTER_OPTIONS = ( + ATTENDANCE_STATUS_LABELS.map { |value, label| [ label, value ] } + + [ [ "Transferred in", TRANSFERRED_IN_FILTER ] ] + ).freeze + # Manual onboarding checklist steps shown on the event's Onboarding tab. Each is # an audited boolean (stored as a row in event_registration_checklist_completions, # capturing who completed it and when). The keys double as the param/column keys @@ -113,8 +136,20 @@ class EventRegistration < ApplicationRecord scope :inactive, -> { where(status: INACTIVE_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). + 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 + # transfer scope; every real value filters the status column. scope :attendance_status, ->(status) { - status == "other" ? where.not(status: NAMED_OUTCOME_STATUSES) : where(status: status) + case status.to_s + when "other" then where.not(status: NAMED_OUTCOME_STATUSES) + when TRANSFERRED_IN_FILTER then transferred_in + else where(status: status) + end } # Registrations on facilitator-training events ("trainings", narrowable to the # "live"/"on_demand" delivery formats) vs everything else ("other"); any other @@ -490,14 +525,26 @@ def attendance_recorded? status.in?(%w[ attended incomplete_attendance no_show ]) end - # Transferred out to another event. The trail to where the registrant went is - # history worth keeping, so it blocks deletion. Transferred_in is deliberately - # excluded: it's an ordinary active registration here, and the source event's - # transferred_out record already preserves the transfer trail. + # Transferred out to another event. Terminal status, so an out is always + # identifiable from its status alone. The trail to where the registrant went is + # history worth keeping, so it blocks deletion. def transferred_out? status == "transferred_out" end + # Transferred in from another event's registration. Identified by the presence + # of the back-link (not by status), so an in keeps recording its own real + # attendance (registered/attended/…) without losing the transfer history. + def transferred_in? + transferred_from_registration_id.present? + end + + # A transferred-out registration whose destination hasn't been recorded yet. + # Drives the follow-up prompt to create/link the incoming registration. + def transfer_destination_pending? + transferred_out? && transferred_to_registration.nil? + end + # Safe to delete only when removing the record would not orphan financial data # or erase history. Allocations tie the registration to a financial source of # any kind (payments, scholarships, and others) and have no dependent: :destroy, @@ -520,12 +567,18 @@ def deletable? # Reporting surfaces (rosters, CSV exports, dashboard metrics) must keep using # `paid_in_full?` so they still reflect the real balance owed. def payment_access_granted? + # A transferred-in reg's payment lives on its source, so access to this + # event's paid content follows whatever the source registration grants. + return transferred_from_registration.payment_access_granted? if transferred_in? paid_in_full? || intends_to_pay? end # Human-readable payment status for rosters and CSV exports. Assumes the event # has a cost — callers show nothing for free events. def payment_status_label + # A transferred-in reg owes nothing here — its balance is tracked on the + # source registration — so it never reads as Paid/Due for this event. + return "Transferred in" if transferred_in? return "Paid" if paid_in_full? return "Intends to pay" if intends_to_pay? "Due" @@ -537,6 +590,19 @@ def scholarship? scholarships.any? end + # The scholarship that designates this registrant a recipient at THIS event: its + # own award, or — for a transferred-in reg — the award on the source + # registration it came from (walking the transfer chain). The dollars stay on + # the source (see EventDashboard's billable basis); this is recognition only, so + # a transferred-in registrant still "gets the hat" at the event they attend. (#1944) + def effective_scholarship + scholarships.first || transferred_from_registration&.effective_scholarship + end + + def scholarship_recipient? + effective_scholarship.present? + end + # Noun phrase distinguishing a scholarship-requested registration from a # standard one in email subjects and notification labels (e.g. # "event scholarship registration" vs "event registration"). Driven by the @@ -659,26 +725,38 @@ def ce_license_provided? continuing_education_registrations.all? { |c| c.professional_license&.number_known? } end + # The CE records this registration is responsible for certifying — its own, + # plus any that transferred in to be certified here. A transferred-out reg + # certifies none (its hours moved to the destination event, which certifies + # them). Distinct from continuing_education_registrations, the home set that + # owns the payment. (issue #1944) + def certifiable_ce_registrations + return [] if transferred_out? + (continuing_education_registrations.to_a + certified_ce_registrations.to_a).uniq + end + # True when CE is registered and every CE registration's certificate has been - # issued (sent) — the terminal state of the CE lifecycle. + # issued (sent) — the terminal state of the CE lifecycle. Based on the CE this + # reg certifies (earned here), not the home set. def ce_certificate_issued? - return false unless ce_registered? + return false unless certifiable_ce_registrations.any? - continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } + certifiable_ce_registrations.all? { |c| c.certificate_sent_at.present? } end # The registration's completion certificate, as shown by the registrants-roster - # toggle. For a CE-eligible registration that's the CE certificate - # (certificate_sent_at on its CE registrations, so it stays in sync with the CE - # edit page); otherwise the registration's own certificate_sent_at (Certifiable). + # toggle. For a registration that earns CE here that's the CE certificate + # (certificate_sent_at on its earned CE registrations, so it stays in sync with + # the CE edit page); otherwise the registration's own certificate_sent_at. def certificate_issued? - ce_registered? ? ce_certificate_issued? : certificate_sent? + certifiable_ce_registrations.any? ? ce_certificate_issued? : certificate_sent? end def mark_certificate_issued!(issued) at = issued ? Time.current : nil - if ce_registered? - continuing_education_registrations.each { |c| c.update!(certificate_sent_at: at) } + certifiable = certifiable_ce_registrations + if certifiable.any? + certifiable.each { |c| c.update!(certificate_sent_at: at) } else update!(certificate_sent_at: at) end @@ -735,6 +813,14 @@ def attendance_status_label ATTENDANCE_STATUS_LABELS.fetch(status, status.humanize) end + # Status label for reporting (CSV exports), annotated when the registration + # transferred in — an incoming reg keeps its own status, so the transfer trail + # would otherwise be invisible in exports. + def attendance_status_report_label + return attendance_status_label unless transferred_in? + "#{attendance_status_label} (transferred in)" + end + # The completion record for a checklist step, or nil. Reads from the loaded # association so the Onboarding matrix can preload completions and avoid N+1. def checklist_completion_for(step) diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index dc82897d61..ca886869eb 100644 --- a/app/policies/event_registration_policy.rb +++ b/app/policies/event_registration_policy.rb @@ -11,6 +11,8 @@ def show? = admin? def show_public? = true def confirm? = admin? def process_confirm? = admin? + def transfer? = admin? + def process_transfer? = admin? def link_organization? = admin? def select_organization? = admin? def create_organization? = admin? diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 7cfb89864f..3024c30d9e 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -63,6 +63,23 @@ def no_show_count attendance_count_for("no_show") end + # Registrations transferred in from another event. FK-backed (an incoming reg + # keeps its own real status), so it's counted via the transfer link rather than + # the status column — parallel to the status rows in the attendance breakdown. + def transferred_in_count + transferred_in_registrant_ids.size + end + + def transferred_in_registrants + people_sorted(transferred_in_registrant_ids) + end + + # Whether a registrant (Person id) transferred into this event — for surfaces + # that recognize them but flag that their money is billed to the source event. + def transferred_in_recipient?(person_id) + transferred_in_registrant_ids.include?(person_id) + end + # Registrations with an attendance outcome on record (attended / incomplete / # no-show). def attendance_outcome_count @@ -127,7 +144,7 @@ def unfunded_scholarship_count end def scholarship_recipient_count - scholarships.distinct.count(:recipient_id) + recognized_scholarships.map(&:recipient_id).uniq.size end # This event's registrants grouped by the city of the organization linked on @@ -164,8 +181,13 @@ def registrant_city_breakdown # Person ids of this event's scholarship recipients — the lightweight id list # behind #scholarship_applicants (no includes/sort), for scoping the recipients # charts frame, which only needs their ids. Public: the controller calls it. + # Includes registrants transferred in with an award on their source reg — they + # are recognized recipients here even though the dollars stay on the source. (#1944) def scholarship_applicant_ids - @scholarship_applicant_ids ||= active_registrations.where(scholarship_requested: true).pluck(:registrant_id) + @scholarship_applicant_ids ||= ( + active_registrations.where(scholarship_requested: true).pluck(:registrant_id) + + recognized_scholarships.map(&:recipient_id) + ).uniq end def scholarship_applicants @@ -261,7 +283,7 @@ def shoutouts # decide whether to flag a registrant as a scholarship recipient. First # scholarship wins if a person has several. def scholarship_by_recipient - @scholarship_by_recipient ||= scholarships.includes(grant: :funder).group_by(&:recipient_id).transform_values(&:first) + @scholarship_by_recipient ||= recognized_scholarships.group_by(&:recipient_id).transform_values(&:first) end # Active registration slug per registrant (Person id) — a stable, non-db @@ -345,7 +367,7 @@ 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 ||= active_registration_ids.each_with_object(Hash.new(0)) do |id, map| + @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 next if due.zero? registrant_id = registrant_id_by_registration[id] @@ -358,16 +380,16 @@ def received_cents registration_allocations.where(source_type: "Payment").sum(:amount) end - # Still owed across all active registrations, after payments and scholarships. + # Still owed across all billable registrations, after payments and scholarships. def outstanding_cents - active_registration_ids.sum do |id| + 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 active registrations (before scholarships/discounts). + # Full-price value of all billable registrations (before scholarships/discounts). def total_cents - event.cost_cents.to_i * registrant_count + event.cost_cents.to_i * billable_registration_ids.size end # Registration-fee subtotal: money received plus money still owed. This is the @@ -408,13 +430,13 @@ def monies_made_cents end def paid_count - return registrant_count if free? - active_registration_ids.count { |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i } + return billable_registration_ids.size if free? + billable_registration_ids.count { |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i } end def unpaid_count return 0 if free? - registrant_count - paid_count + billable_registration_ids.size - paid_count end # Registrants whose cost is fully covered (payments and/or completed @@ -424,7 +446,7 @@ def paid_registrants end def unpaid_registrants - @unpaid_registrants ||= people_sorted(registrants_for(active_registration_ids - paid_registration_ids)) + @unpaid_registrants ||= people_sorted(registrants_for(billable_registration_ids - paid_registration_ids)) end # --- Continuing-education fees --------------------------------------------- @@ -1003,6 +1025,14 @@ 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) + end + def registrant_ids @registrant_ids ||= active_registrations.pluck(:registrant_id) end @@ -1017,6 +1047,11 @@ def registrant_ids_by_status end end + # Registrant (Person) ids for registrations transferred in from another event. + def transferred_in_registrant_ids + @transferred_in_registrant_ids ||= event.event_registrations.transferred_in.pluck(:registrant_id) + end + # Facilitator status for one represented organization, used by the # program-status breakdown. Prefers a registrant's own active affiliation to # the org as the reference point, falling back to the org's earliest @@ -1058,6 +1093,28 @@ def reference_date @reference_date ||= (event.start_date || Date.current).to_date end + # 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 + # only: drives the recipient "hat", the recipient count, and the recipients + # page; NOT the dollar totals. (#1944) + def recognized_scholarships + @recognized_scholarships ||= scholarships.includes(grant: :funder).to_a + transferred_in_source_scholarships + end + + # The source registrations' scholarships for everyone transferred into this + # event — recognized here, but billed to the source event. + def transferred_in_source_scholarships + source_ids = active_registrations.transferred_in.pluck(:transferred_from_registration_id) + return [] if source_ids.empty? + + Scholarship.joins(:allocation) + .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: source_ids }) + .includes(grant: :funder) + .to_a + end + # Grouping key for an applicant's funder: the funder identity when the # scholarship is drawn from a grant (so a funder's grants share a bucket), else # the unfunded / no-scholarship bucket. @@ -1176,7 +1233,7 @@ def allocated_by_registration # CE registrant counts / pie. def ce_registrations @ce_registrations ||= ContinuingEducationRegistration - .where(event_registration_id: active_registration_ids) + .where(event_registration_id: billable_registration_ids) .to_a end @@ -1301,7 +1358,7 @@ def city_by_organization end def paid_registration_ids - @paid_registration_ids ||= active_registration_ids.select do |id| + @paid_registration_ids ||= billable_registration_ids.select do |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i end end diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index a53717f3ee..3089b568b7 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -139,6 +139,9 @@ def failed_event_ready_checks end def payment_due? + # A transferred-in reg's balance is tracked on its source registration, so it + # owes nothing for this event and never reads as "Payment due" here. + return false if registration.transferred_in? registration.event.cost_cents.to_i > 0 && !registration.paid_in_full? end @@ -166,7 +169,7 @@ def ce_license_missing? end def ce_certificate_pending? - registration.ce_registered? && !ce_certificate_sent? + certifiable_ce.any? && !ce_certificate_sent? end # Post-event criteria are only met by a full "attended". "incomplete_attendance" @@ -176,12 +179,21 @@ def attendance_issue registration.status == "incomplete_attendance" ? "Attendance incomplete" : "Did not attend" end - # The admin-created CE billing records for this registration (preloaded on the - # roster). Their payment + certificate state drives the CE readiness checks. + # The admin-created CE billing records homed on this registration (preloaded on + # the roster). Their PAYMENT + license state drives the CE money/license checks; + # these stay on the home reg after a transfer. def ce_registrations registration.continuing_education_registrations end + # The CE this registration is responsible for CERTIFYING — its own plus any + # transferred in to be certified here (a transferred-out reg certifies none). + # Certificate checks use this so certification follows the person to the + # intended event. (issue #1944) + def certifiable_ce + registration.certifiable_ce_registrations + end + # CE is paid once every CE registration is paid in full. A requested-but-not-yet # -created CE registration counts as unpaid (nothing to pay against yet). def ce_paid? @@ -194,9 +206,9 @@ def registration_certificate_sent? registration.certificate_sent? end - # CE certificates are sent once every CE registration's certificate has been - # sent. No CE registration yet means nothing has been issued. + # CE certificates are sent once every CE registration this reg certifies has + # been sent. No certifiable CE means nothing has been issued. def ce_certificate_sent? - ce_registrations.any? && ce_registrations.all?(&:certificate_sent?) + certifiable_ce.any? && certifiable_ce.all?(&:certificate_sent?) end end diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index 59743e38ed..377f969eeb 100644 --- a/app/services/event_revenue_figures.rb +++ b/app/services/event_revenue_figures.rb @@ -186,10 +186,13 @@ def build(event) # [ event_id, registration_id, registrant_id ] for every active registration in # the report — the basis for both the per-event grouping and the drilldowns' - # registrant lookup, loaded in one query. + # registrant lookup, loaded in one query. Transferred-in regs are excluded: + # their money lives on the source event, so counting them here would inflate the + # new event's totals (mirrors EventDashboard#billable_registration_ids). (#1944) def registration_rows @registration_rows ||= EventRegistration .active + .not_transferred_in .where(event_id: @events.map(&:id)) .pluck(:event_id, :id, :registrant_id) end diff --git a/app/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index 768ce79e32..a4f30d40ad 100644 --- a/app/views/event_registrations/_attendance_status_badge.html.erb +++ b/app/views/event_registrations/_attendance_status_badge.html.erb @@ -1,5 +1,5 @@ <% deco = registration.decorate %> -
+ + Hours certified at + <%= link_to certified_at.event.title, edit_event_registration_path(certified_at, return_to: "registrants"), + class: "font-medium underline", target: "_blank", rel: "noopener" %> +
+ <% end %> + <%# Pinned to the card's bottom so it lines up with the scholarship card's chip and the organizations card's "Connect organization" link. %>+ + Transferred to + <%= link_to destination.event.title, edit_event_registration_path(destination, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +
+ <% else %> ++ + <%= link_to "Record where they transferred to", transfer_event_registration_path(f.object, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +
+ <% end %> + <% elsif f.object.transferred_in? %> ++ + Transferred in from + <%= link_to f.object.transferred_from_registration.event.title, edit_event_registration_path(f.object.transferred_from_registration, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +
+ <% end %>+ This registrant transferred in — scholarship, CE, and payments stay on their + registration for <%= source.event.title %> and aren't billed to this event. +
+Free event — no payment.
+ <% end %> +<%= dollars_from_cents(source_scholarship.amount_cents) %>
++ <%= source_scholarship.agreement_signed? ? "Agreement signed" : "Agreement pending" %> · + <%= source_scholarship.tasks_completed? ? "Tasks complete" : "Tasks outstanding" %> +
+ <%= link_to "View scholarship record", edit_scholarship_path(source_scholarship, return_to: "registrants"), + class: "mt-1 inline-flex items-center gap-1 text-xs font-medium text-teal-700 hover:underline", + target: "_blank", rel: "noopener" %> + <% else %> +None.
+ <% end %> +<%= source.ce_status_label %>
++ <%= plain_number(source.ce_hours_total) || "0" %> hrs + <% if source.ce_amount_due_cents.positive? %>· <%= dollars_from_cents(source.ce_amount_due_cents) %> due<% end %> +
+ <% if source.ce_license_numbers.any? %> +License: <%= source.ce_license_numbers.join(", ") %>
+ <% end %> + <% else %> +None.
+ <% end %> ++ Recording the destination event creates (or links) this person's registration + there and keeps the transfer history. The new registration tracks its own + attendance — you can skip this now and record it later. +
+ + <%= form_with url: process_transfer_event_registration_path(source), method: :post, data: { turbo: false }, class: "space-y-4" do %> + <%= hidden_field_tag :return_to, @return_to %> + ++ Only <%= source.event.facilitator_training? ? "other facilitator trainings" : "other (non-training) events" %> are shown. +
+Payment history