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 %> -
+
<%= form_with model: registration, url: event_registration_path(registration), method: :patch, data: { turbo_frame: "_top" } do |f| %>
@@ -13,4 +13,12 @@
<% end %> + <%# Transferred in is a relationship, not a status, so it rides alongside the + registration's own attendance status rather than replacing it. %> + <% if registration.transferred_in? %> + + + In + + <% end %>
diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 04f3d56270..ad2e4f73cd 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -2,7 +2,7 @@ records are created on the dedicated CE form (license/hours/cost). This card links to that form when no record exists, or shows the record + Edit link once one does. Only rendered for CE-eligible events. ---- %> -
+
@@ -46,6 +46,18 @@ <% end %>

+ <%# After a transfer, the hours are certified at the destination event + while this record + its payment stay here (issue #1944). %> + <% if ce_registration.certified_elsewhere? %> + <% certified_at = ce_registration.certified_at_registration %> +

+ + 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. %>
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index f658ccb844..790f7fea90 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -17,7 +17,6 @@ "incomplete_attendance" => "text-amber-600", "cancelled" => "text-gray-500", "no_show" => "text-red-600", - "transferred_in" => "text-teal-600", "transferred_out" => "text-purple-600" } %> <% status_icons = { @@ -26,7 +25,6 @@ "incomplete_attendance" => "fa-clock", "cancelled" => "fa-ban", "no_show" => "fa-circle-xmark", - "transferred_in" => "fa-right-to-bracket", "transferred_out" => "fa-right-from-bracket" } %> <% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %> @@ -104,6 +102,30 @@ data: { "attendance-status-target": "select", action: "attendance-status#update" }, "aria-label": "Registration status" %>
+ + <%# ---- Transfer trail (issue #1944) — link the paired registration so + the historical in/out relationship stays visible from either end. ---- %> + <% if f.object.transferred_out? %> + <% destination = f.object.transferred_to_registration %> + <% if destination %> +

+ + 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 %>
@@ -159,9 +181,13 @@ + <%# A transferred-in registration's scholarship/CE/payments live on the + source registration, so it shows a read-only summary of those instead + of its own editable cards (issue #1944). %> + <% transferred_in = f.object.transferred_in? %> <%# The org card widens to absorb a column for each of the other two cards that's hidden. %> - <% show_scholarship = f.object.event&.scholarship_eligible? || f.object.scholarships.any? %> - <% show_ce = f.object.event&.ce_eligible? %> + <% show_scholarship = !transferred_in && (f.object.event&.scholarship_eligible? || f.object.scholarships.any?) %> + <% show_ce = !transferred_in && f.object.event&.ce_eligible? %> <% org_span = 1 + (show_scholarship ? 0 : 1) + (show_ce ? 0 : 1) %> <% org_span_class = { 1 => "sm:col-span-1", 2 => "sm:col-span-2", 3 => "sm:col-span-3" }.fetch(org_span) %> <% active_orgs = f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.sort_by(&:name) %> @@ -253,22 +279,26 @@ <% end %> - <% if f.object.payment_unresolved? %> -
- - Automated payment creation error for Stripe checkout -
- <% elsif f.object.checkout_session_id.present? && f.object.payment_unresolved.nil? %> -
- - Automated payment/allocation creation may still be in progress - <% if @checkout_payment_status.present? %> - - Stripe checkout status: - <%= @checkout_payment_status %> - <% end %> -
+ <% if transferred_in %> + <%= render "transferred_in_financials", event_registration: f.object %> + <% else %> + <% if f.object.payment_unresolved? %> +
+ + Automated payment creation error for Stripe checkout +
+ <% elsif f.object.checkout_session_id.present? && f.object.payment_unresolved.nil? %> +
+ + Automated payment/allocation creation may still be in progress + <% if @checkout_payment_status.present? %> + - Stripe checkout status: + <%= @checkout_payment_status %> + <% end %> +
+ <% end %> + <%= render "payment_history", event_registration: f.object %> <% end %> - <%= render "payment_history", event_registration: f.object %> <% if allowed_to?(:index?, with: NotificationPolicy) %> <%= render "notifications/communications", f: f, diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 88ce06e026..10ce504323 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -1,7 +1,7 @@ <%# ---- Scholarship — "Requested" is a plain flag saved with the form; it does not create or destroy an award. Awarding is a deliberate action via "Add scholarship". ---- %> -
+
diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index a0679417e6..a9ae00658e 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -1,6 +1,7 @@ <% preview = local_assigns.fetch(:preview, false) %> <% show_all = local_assigns.fetch(:show_all, false) %>
+ <%= render "event_registrations/transfer_notice", event_registration: event_registration %>
diff --git a/app/views/event_registrations/_transfer_notice.html.erb b/app/views/event_registrations/_transfer_notice.html.erb new file mode 100644 index 0000000000..7b81347980 --- /dev/null +++ b/app/views/event_registrations/_transfer_notice.html.erb @@ -0,0 +1,26 @@ +<%# Transfer notice for the registrant ticket + builtin callout pages (issue + #1944). Both the original and the new event's registration have their own + ticket; this explains, on each, where the money/scholarship/CE live and where + attendance + the certificate are earned — so attendee and staff see accurate + info on both. Takes `event_registration`. %> +<% if event_registration.transferred_in? && (source = event_registration.transferred_from_registration) %> +
+ +
+ You transferred into <%= event_registration.event.title %>. Your payment, + scholarship, and continuing-education records stay on your + <%= link_to "original registration", registration_ticket_path(source.slug), class: "font-semibold underline" %> + — your attendance here and your certificate for these hours are earned at this event. +
+
+<% elsif event_registration.transferred_out? && (destination = event_registration.transferred_to_registration) %> +
+ +
+ You transferred out of <%= event_registration.event.title %> to + <%= link_to "your new registration", registration_ticket_path(destination.slug), class: "font-semibold underline" %>. + Attend there and earn your certificate at that event; your payment, scholarship, and + continuing-education records remain here. +
+
+<% end %> diff --git a/app/views/event_registrations/_transferred_in_financials.html.erb b/app/views/event_registrations/_transferred_in_financials.html.erb new file mode 100644 index 0000000000..65fb7e6bc0 --- /dev/null +++ b/app/views/event_registrations/_transferred_in_financials.html.erb @@ -0,0 +1,119 @@ +<%# ---- Transferred-in financials (issue #1944) — a read-only summary of the + scholarship / CE / payment records that live on the SOURCE registration this + person transferred in from. Styled distinctly (teal) so it never reads like a + normal registration's editable cards, and every figure links back to the + matching section on the source reg. ---- %> +<% source = event_registration.transferred_from_registration %> +<% source_link = ->(anchor) { edit_event_registration_path(source, anchor: anchor) } %> +<% source_cost_cents = source.event.cost_cents.to_i %> +<% source_paid_cents = source.allocations_sum %> +<% source_due_cents = [ source_cost_cents - source_paid_cents, 0 ].max %> +
+
+ + + +
+

Financials on the original registration

+

+ This registrant transferred in — scholarship, CE, and payments stay on their + registration for <%= source.event.title %> and aren't billed to this event. +

+
+ <%= link_to edit_event_registration_path(source), + class: "ml-auto inline-flex shrink-0 items-center gap-1.5 rounded-md border border-teal-300 bg-white px-2.5 py-1 text-xs font-medium text-teal-700 hover:bg-teal-100", + target: "_blank", rel: "noopener" do %> + View original registration + + <% end %> +
+ +
+ <%# ---- Payment ---- %> +
+
+ + Payment + <%= link_to source_link.call("allocations-card"), + class: "ml-auto text-xs font-medium text-teal-700 hover:underline", + target: "_blank", rel: "noopener" do %> + View + <% end %> +
+ <% if source_cost_cents > 0 %> +
+
Cost
<%= dollars_from_cents(source_cost_cents) %>
+
Allocated
<%= dollars_from_cents(source_paid_cents) %>
+
Due
"><%= dollars_from_cents(source_due_cents) %>
+
+ <% else %> +

Free event — no payment.

+ <% end %> +
+ + <%# ---- Scholarship ---- %> +
+
+ + Scholarship + <% if source.scholarship? %> + <%= link_to source_link.call("scholarship-card"), + class: "ml-auto text-xs font-medium text-teal-700 hover:underline", + target: "_blank", rel: "noopener" do %> + View + <% end %> + <% end %> +
+ <% source_scholarship = source.scholarships.first %> + <% if source_scholarship %> + <%# A scholarship recipient here too — the award (and its dollars) stay on + the original registration, but they keep the recipient designation. %> + + Scholarship recipient + +

<%= 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 %> +
+ + <%# ---- Continuing education ---- %> +
+
+ + Continuing education + <% if source.ce_registered? %> + <%= link_to source_link.call("ce-card"), + class: "ml-auto text-xs font-medium text-teal-700 hover:underline", + target: "_blank", rel: "noopener" do %> + View + <% end %> + <% end %> +
+ <% if source.ce_registered? %> + <%# Hours are earned/certified HERE (this event); the CE record + payment + stay on the original registration. %> + + Hours earned at this event + +

<%= 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 %> +
+
+
diff --git a/app/views/event_registrations/transfer.html.erb b/app/views/event_registrations/transfer.html.erb new file mode 100644 index 0000000000..c64d85399a --- /dev/null +++ b/app/views/event_registrations/transfer.html.erb @@ -0,0 +1,58 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% + source = @event_registration + roster_path = registrants_event_path(source.event) +%> +
+ <%= link_to roster_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700" do %> + + <%= source.event.title %> registrants + <% end %> +
+ +
+
+
+
+ +
+

Where did they transfer to?

+
+ +
+
+
Registrant
+
<%= source.registrant.full_name %>
+
Transferred out of
+
<%= source.event.title %>
+
+
+ +

+ 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 %> + +
+ + <%= select_tag :destination_event_id, + options_from_collection_for_select(@events, :id, :time_title), + include_blank: "Select an event…", + required: true, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +

+ Only <%= source.event.facilitator_training? ? "other facilitator trainings" : "other (non-training) events" %> are shown. +

+
+ +
+ <%= link_to "Skip for now", roster_path, class: "btn btn-secondary-outline" %> + <%= submit_tag "Record transfer", class: "btn btn-primary ml-auto" %> +
+ <% end %> +
+
diff --git a/app/views/events/_recipient_card.html.erb b/app/views/events/_recipient_card.html.erb index ae63dd75b1..076affd6d0 100644 --- a/app/views/events/_recipient_card.html.erb +++ b/app/views/events/_recipient_card.html.erb @@ -152,6 +152,15 @@ <% end %> <% end %> + <%# Transferred in: the award (and its dollars) are billed to the original + event; they're recognized here as a recipient (issue #1944). %> + <% if dashboard.transferred_in_recipient?(person.id) %> + + + Billed to original event + + <% end %> <%= render "scholarships/tasks_status", scholarship: scholarship %> <% if allowed_to?(:edit?, scholarship) %> <%= link_to edit_scholarship_path(scholarship, return_to: "recipients", participant: participant_slug), diff --git a/app/views/events/_registrant_filters.html.erb b/app/views/events/_registrant_filters.html.erb index 070f6bcc41..c280d3d8a7 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -72,7 +72,7 @@ <% end %> <%= render "events/filter_select", param: :attendance_status, label: "Attendance", - options: EventRegistration::ATTENDANCE_STATUSES.map { |s| [ EventRegistration.new(status: s).attendance_status_label, s ] }, + options: EventRegistration::ATTENDANCE_FILTER_OPTIONS, selected: params[:attendance_status], blank: "All statuses", field_class: field_class %> <% if has_cost %> diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 0aadb4da90..731aa6e80d 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -374,21 +374,40 @@ <%# Canonical CE badge (Requested → License # needed → $X due → Pending → Issued). Each state links to the connected CE registration's edit page; "Create" opens the new CE form. %> - <% ce_registration = registration.continuing_education_registrations.first %> - <% if ce_registration.nil? %> - <%= render "shared/badge", - label: "Create", - classes: "bg-gray-50 text-gray-400 border-gray-200", - href: new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"), - title: "Add CE registration" %> + <% if registration.transferred_in? %> + <%# CE is certified here but the record + payment live on the + original registration — link there, don't offer "Create". %> + <% certified_ce = registration.certifiable_ce_registrations.first %> + <% if certified_ce %> + <%= render "shared/badge", + label: "CE (transferred)", + classes: "bg-teal-50 text-teal-700 border-teal-200", + href: edit_continuing_education_registration_path(certified_ce, return_to: "registrants"), + title: "CE hours certified here; record + payment on the original registration", + target: "_blank", rel: "noopener" %> + <% else %> + + <% end %> <% else %> - <%= render "event_registrations/ce_status_badge", registration: registration, - href: edit_continuing_education_registration_path(ce_registration, return_to: "registrants") %> + <% ce_registration = registration.continuing_education_registrations.first %> + <% if ce_registration.nil? %> + <%= render "shared/badge", + label: "Create", + classes: "bg-gray-50 text-gray-400 border-gray-200", + href: new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"), + title: "Add CE registration" %> + <% else %> + <%= render "event_registrations/ce_status_badge", registration: registration, + href: edit_continuing_education_registration_path(ce_registration, return_to: "registrants") %> + <% end %> <% end %> <% end %> - <% scholarship = registration.scholarships.first %> + <%# effective_scholarship resolves a transferred-in reg to the award on + its source registration, so it still "gets the hat" here. %> + <% scholarship = registration.effective_scholarship %> + <% transferred_scholarship = scholarship && registration.transferred_in? %> <% scholarship_sort = if scholarship&.tasks_completed? 0 elsif scholarship @@ -400,29 +419,33 @@ end %> " data-column-toggle-col="scholarship" data-sort-value="<%= scholarship_sort %>"> <% if (s = scholarship) %> + <%# A transferred-in reg's award lives on its source registration — + open it in a new tab and flag that it's on the original. %> + <% badge_opts = transferred_scholarship ? { title: "Awarded on the original registration", target: "_blank", rel: "noopener" } : {} %> <% if s.tasks_completed? %> - <%= render "shared/badge", - label: "Completed", + <%= render "shared/badge", **badge_opts, + label: transferred_scholarship ? "Completed (transferred)" : "Completed", classes: "bg-green-50 text-green-700 border-green-200", href: edit_scholarship_path(s, return_to: "registrants") %> <% else %> - <%= render "shared/badge", - label: "Recipient", + <%= render "shared/badge", **badge_opts, + label: transferred_scholarship ? "Recipient (transferred)" : "Recipient", classes: "bg-blue-50 text-blue-700 border-blue-200", href: edit_scholarship_path(s, return_to: "registrants") %> <% end %> + <% elsif registration.transferred_in? %> + <%# Transferred in with no scholarship on the source — nothing to award here. %> + + <% elsif registration.scholarship_requested? %> + <%= render "shared/badge", + label: "Requested", + classes: "bg-amber-50 text-amber-700 border-amber-200", + href: new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants") %> <% else %> - <% if registration.scholarship_requested? %> - <%= render "shared/badge", - label: "Requested", - classes: "bg-amber-50 text-amber-700 border-amber-200", - href: new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants") %> - <% else %> - <%= render "shared/badge", - label: "Create", - classes: "bg-gray-50 text-gray-400 border-gray-200", - href: new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants") %> - <% end %> + <%= render "shared/badge", + label: "Create", + classes: "bg-gray-50 text-gray-400 border-gray-200", + href: new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants") %> <% end %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 585acf77c5..c78c7612fc 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -15,6 +15,7 @@ end %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_label, **callout_eyebrow } do %> + <%= render "event_registrations/transfer_notice", event_registration: @event_registration %> <%# Requesting CE flips this frame in place: the POST redirects back here and Turbo swaps in the license-entry branch — no full-page reload. %> <%= turbo_frame_tag "ce_request_section" do %> diff --git a/app/views/events/callouts/certificate.html.erb b/app/views/events/callouts/certificate.html.erb index 554f59d221..48d5f9cd15 100644 --- a/app/views/events/callouts/certificate.html.erb +++ b/app/views/events/callouts/certificate.html.erb @@ -119,6 +119,7 @@
<% else %> <%= render layout: "events/callouts/callout_page", locals: { title: "Certificate of completion" } do %> + <%= render "event_registrations/transfer_notice", event_registration: @event_registration %> <%# Not yet unlocked: show each condition and which are met, like the videoconference page. %> <% ended = @event.end_date&.past? %> <% attended = @event_registration.attended? %> diff --git a/app/views/events/callouts/payment.html.erb b/app/views/events/callouts/payment.html.erb index 7982d6cd43..2bcb42de1d 100644 --- a/app/views/events/callouts/payment.html.erb +++ b/app/views/events/callouts/payment.html.erb @@ -1,6 +1,7 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Payment — #{@event.title}") %> <%= render layout: "events/callouts/callout_page", locals: { title: "Payment" } do %> + <%= render "event_registrations/transfer_notice", event_registration: @event_registration %> <% if @allocations.any? %>

Payment history

diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index ca759940f7..b647070510 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -8,6 +8,7 @@ <% eyebrow = from_scholarship_edit ? { back_path: edit_scholarship_path(@scholarship), back_label: "Back to scholarship" } : {} %> <%= render layout: "events/callouts/callout_page", locals: { title: "Scholarship", **eyebrow } do %> + <%= render "event_registrations/transfer_notice", event_registration: @event_registration %>
<% if @scholarship && allowed_to?(:edit?, @scholarship) %> <%# Admin-only jump to the management surface for this scholarship. Hidden from diff --git a/app/views/events/dashboard.html.erb b/app/views/events/dashboard.html.erb index c1651074fa..7d707e5f78 100644 --- a/app/views/events/dashboard.html.erb +++ b/app/views/events/dashboard.html.erb @@ -331,17 +331,27 @@ [ "incomplete_attendance", "fa-circle-half-stroke", "text-amber-500" ], [ "no_show", "fa-circle-xmark", "text-red-500" ], [ "cancelled", "fa-ban", nil ], - [ "transferred_in", "fa-arrow-right-to-bracket", nil ], [ "transferred_out", "fa-arrow-right-from-bracket", nil ], + [ "transferred_in", "fa-arrow-right-to-bracket", "text-teal-600" ], [ "registered", "fa-hourglass-half", nil ] ].each do |status, icon, active_color| %> - <% count = @dashboard.attendance_count_for(status) %> + <%# Transferred in is FK-based (an incoming reg keeps its own status), so + it's counted via the transfer link, not the status column. %> + <% if status == EventRegistration::TRANSFERRED_IN_FILTER %> + <% count = @dashboard.transferred_in_count %> + <% registrants = @dashboard.transferred_in_registrants %> + <% label = "Transferred in" %> + <% else %> + <% count = @dashboard.attendance_count_for(status) %> + <% registrants = @dashboard.attendance_registrants(status) %> + <% label = EventRegistration::ATTENDANCE_STATUS_LABELS.fetch(status) %> + <% end %> <%= render "attendance_stat_row", - label: EventRegistration::ATTENDANCE_STATUS_LABELS.fetch(status), + label: label, icon: icon, icon_color: (count.positive? && active_color) || "text-gray-400", count: count, - registrants: @dashboard.attendance_registrants(status), + registrants: registrants, filter_path: registrants_event_path(@event, attendance_status: status) %> <% end %>
diff --git a/app/views/events/registrations/invoice.html.erb b/app/views/events/registrations/invoice.html.erb index 95e64e4d21..f80a6f8bcf 100644 --- a/app/views/events/registrations/invoice.html.erb +++ b/app/views/events/registrations/invoice.html.erb @@ -10,5 +10,6 @@ end %> <%= render "events/invoices/actions", back_path: back_path, back_label: back_label %>
+
<%= render "event_registrations/transfer_notice", event_registration: @event_registration %>
<%= render "events/invoices/invoice", invoice: @invoice %>
diff --git a/app/views/events/registrations/receipt.html.erb b/app/views/events/registrations/receipt.html.erb index 7a9f89e4d0..054fb35749 100644 --- a/app/views/events/registrations/receipt.html.erb +++ b/app/views/events/registrations/receipt.html.erb @@ -10,5 +10,6 @@ end %> <%= render "events/invoices/actions", back_path: back_path, back_label: back_label %>
+
<%= render "event_registrations/transfer_notice", event_registration: @event_registration %>
<%= render "events/receipts/receipt", receipt: @receipt %>
diff --git a/config/routes.rb b/config/routes.rb index 7d0534d362..7a8fc084ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -100,6 +100,8 @@ member do get :confirm post :process_confirm + get :transfer + post :process_transfer get :link_organization post :select_organization post :create_organization diff --git a/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb b/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb new file mode 100644 index 0000000000..c88d1da20c --- /dev/null +++ b/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb @@ -0,0 +1,33 @@ +class AddTransferredFromRegistrationToEventRegistrations < ActiveRecord::Migration[8.1] + # Records where a registration was transferred *from*: the incoming ("in") + # registration points back at the outgoing ("out") one. Putting the FK on the + # in-record means an in is identifiable directly (its FK is set) without + # scanning every other row, while an out stays identifiable by its terminal + # "transferred_out" status. See issue #1944. + def up + unless column_exists?(:event_registrations, :transferred_from_registration_id) + add_column :event_registrations, :transferred_from_registration_id, :bigint + end + unless index_exists?(:event_registrations, :transferred_from_registration_id) + add_index :event_registrations, :transferred_from_registration_id + end + unless foreign_key_exists?(:event_registrations, column: :transferred_from_registration_id) + add_foreign_key :event_registrations, :event_registrations, + column: :transferred_from_registration_id, on_delete: :nullify + end + + # "transferred_in" is no longer an attendance status — the transfer link now + # records the "in" relationship, freeing the status to track real attendance. + # Existing transferred_in rows have no link to recover, so reset them to + # registered (their default) rather than leaving an invalid status. + execute("UPDATE event_registrations SET status = 'registered' WHERE status = 'transferred_in'") + end + + def down + # Best-effort inverse: rows still carrying a transfer link were the "in"s. + execute("UPDATE event_registrations SET status = 'transferred_in' WHERE transferred_from_registration_id IS NOT NULL") + remove_foreign_key :event_registrations, column: :transferred_from_registration_id, if_exists: true + remove_index :event_registrations, :transferred_from_registration_id, if_exists: true + remove_column :event_registrations, :transferred_from_registration_id, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b96f6d27d3..49571b7e24 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -501,6 +501,7 @@ t.string "slug" t.boolean "someone_else_will_pay", default: false, null: false t.string "status", default: "registered", null: false + t.bigint "transferred_from_registration_id" t.datetime "updated_at", null: false t.boolean "w9_requested", default: false, null: false t.index ["checkout_session_id"], name: "index_event_registrations_on_checkout_session_id" @@ -509,6 +510,7 @@ t.index ["registrant_id", "event_id"], name: "index_event_registrations_on_registrant_id_and_event_id", unique: true t.index ["registrant_id"], name: "index_event_registrations_on_registrant_id" t.index ["slug"], name: "index_event_registrations_on_slug", unique: true + t.index ["transferred_from_registration_id"], name: "index_event_registrations_on_transferred_from_registration_id" end create_table "event_staffs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -1801,6 +1803,7 @@ add_foreign_key "event_registration_checklist_completions", "users", column: "completed_by_id" add_foreign_key "event_registration_organizations", "event_registrations" add_foreign_key "event_registration_organizations", "organizations" + add_foreign_key "event_registrations", "event_registrations", column: "transferred_from_registration_id", on_delete: :nullify add_foreign_key "event_registrations", "events" add_foreign_key "event_registrations", "people", column: "registrant_id" add_foreign_key "event_staffs", "events" diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb index 97e2543a7e..13e847616f 100644 --- a/spec/decorators/event_registration_decorator_spec.rb +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -75,7 +75,8 @@ end it "is deletable (no reason) when transferred in with no allocations" do - reg = create(:event_registration, status: "transferred_in") + source = create(:event_registration, status: "transferred_out") + reg = create(:event_registration, status: "registered", transferred_from_registration: source) expect(reg.decorate.deletion_blocked_reason).to be_nil end diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index f341e4ae50..b671a7f79b 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -136,6 +136,37 @@ def ce_reg_for(event:, status:, cost_cents: 0) ce_reg.mark_certificate_sent! expect(ce_reg.certificate_sent?).to be(true) end + + describe "certified at a different event (transfer)" do + let(:home_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:intended_event) { create(:event, ce_hours_offered: 6, start_date: 10.days.from_now, end_date: 12.days.from_now) } + let(:home_reg) { create(:event_registration, event: home_event, status: "transferred_out") } + let(:ce_reg) do + create(:continuing_education_registration, event_registration: home_reg, cost_cents: 0, + professional_license: create(:professional_license, person: home_reg.registrant)) + end + + it "defaults certified_at_registration to the home reg when not transferred" do + solo = create(:event_registration, event: home_event, status: "attended") + ce = create(:continuing_education_registration, event_registration: solo, cost_cents: 0, + professional_license: create(:professional_license, person: solo.registrant)) + expect(ce.certified_at_registration).to eq(solo) + end + + it "certifies against the destination (intended) event, not the home event" do + # Same person transfers on to the intended event (derives home_reg.transferred_to). + intended_reg = create(:event_registration, event: intended_event, registrant: home_reg.registrant, + status: "attended", transferred_from_registration: home_reg) + + expect(ce_reg.reload.certified_at_registration).to eq(intended_reg) + # Home event already ended, but hours are certified at the intended event, + # which hasn't happened yet → not certifiable until that event ends. + expect(ce_reg.certificate_available?).to be(false) + + intended_event.update!(start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg.reload.certificate_available?).to be(true) + end + end end # Payment interface comes from Registerable, driven by the CE record's own diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index ffbc78926b..22d5c4cc71 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -41,25 +41,168 @@ reg = create(:event_registration, status: "transferred_out") expect(reg).not_to be_active end - - it "returns true for transferred_in status" do - reg = create(:event_registration, status: "transferred_in") - expect(reg).to be_active - end end describe ".active" do it "returns only registrations with active statuses" do active_reg = create(:event_registration, status: "registered") - transferred_in_reg = create(:event_registration, status: "transferred_in") cancelled_reg = create(:event_registration, status: "cancelled") no_show_reg = create(:event_registration, status: "no_show") transferred_out_reg = create(:event_registration, status: "transferred_out") results = EventRegistration.active - expect(results).to include(active_reg, transferred_in_reg) + expect(results).to include(active_reg) expect(results).not_to include(cancelled_reg, no_show_reg, transferred_out_reg) end + + it "includes a transferred-in registration, which keeps its own active status" do + source = create(:event_registration, status: "transferred_out") + incoming = create(:event_registration, status: "registered", transferred_from_registration: source) + + expect(EventRegistration.active).to include(incoming) + end + end + + describe "transfer trail" do + let(:source) { create(:event_registration, status: "transferred_out") } + let!(:incoming) { create(:event_registration, status: "registered", transferred_from_registration: source) } + + it "links the incoming registration back to the one it came from" do + expect(incoming.transferred_from_registration).to eq(source) + expect(source.reload.transferred_to_registration).to eq(incoming) + end + + it "identifies an in by the back-link, not by status" do + expect(incoming).to be_transferred_in + expect(incoming).not_to be_transferred_out + expect(create(:event_registration, status: "registered")).not_to be_transferred_in + end + + it "identifies an out by its terminal status" do + expect(source).to be_transferred_out + expect(source).not_to be_transferred_in + end + + it "reports a pending destination only while an out has no incoming record" do + pending = create(:event_registration, status: "transferred_out") + expect(pending).to be_transfer_destination_pending + expect(source).not_to be_transfer_destination_pending + expect(incoming).not_to be_transfer_destination_pending + end + + it "nullifies the back-link if the source is destroyed" do + source.update_column(:status, "registered") # bypass the deletion guard for the test + source.destroy + expect(incoming.reload.transferred_from_registration_id).to be_nil + end + + it "scopes .transferred_in to registrations with a back-link" do + plain = create(:event_registration, status: "registered") + expect(EventRegistration.transferred_in).to include(incoming) + expect(EventRegistration.transferred_in).not_to include(plain, source) + end + + it "routes the transferred_in filter value through .attendance_status to the FK" do + plain = create(:event_registration, status: "registered") + results = EventRegistration.attendance_status("transferred_in") + expect(results).to include(incoming) + expect(results).not_to include(plain, source) + end + + it "still filters real statuses through .attendance_status" do + expect(EventRegistration.attendance_status("transferred_out")).to include(source) + expect(EventRegistration.attendance_status("transferred_out")).not_to include(incoming) + end + + it "annotates the reporting label for an incoming registration" do + expect(incoming.attendance_status_report_label).to eq("Registered (transferred in)") + expect(source.attendance_status_report_label).to eq("Transferred out") + end + + it "offers a Transferred in filter option backed by the FK value" do + expect(EventRegistration::ATTENDANCE_FILTER_OPTIONS).to include([ "Transferred in", "transferred_in" ]) + end + + it "scopes .not_transferred_in to registrations without a back-link" do + plain = create(:event_registration, status: "registered") + expect(EventRegistration.not_transferred_in).to include(plain, source) + expect(EventRegistration.not_transferred_in).not_to include(incoming) + end + + describe "financials live on the source" do + let(:paid_event) { create(:event, cost_cents: 10_000) } + let(:source) { create(:event_registration, event: paid_event, status: "transferred_out") } + let!(:incoming) do + create(:event_registration, event: create(:event, cost_cents: 10_000), + status: "registered", transferred_from_registration: source) + end + + it "labels payment status as transferred in rather than Due" do + expect(incoming.payment_status_label).to eq("Transferred in") + end + + it "derives payment access from the source registration" do + expect(incoming.payment_access_granted?).to be(false) + + create(:allocation, allocatable: source, amount: 10_000, + source: create(:payment, person: source.registrant, amount_cents: 10_000, amount_cents_remaining: nil)) + expect(incoming.reload.payment_access_granted?).to be(true) + end + end + + describe "scholarship recognition across a transfer" do + let(:source) { create(:event_registration, event: create(:event, cost_cents: 5_000), status: "transferred_out") } + let!(:incoming) { create(:event_registration, status: "registered", transferred_from_registration: source) } + + it "designates a transferred-in reg a scholarship recipient via the source award" do + scholarship = create(:scholarship, recipient: source.registrant, amount_cents: 5_000) + create(:allocation, source: scholarship, allocatable: source, amount: 5_000) + + expect(incoming.effective_scholarship).to eq(scholarship) + expect(incoming).to be_scholarship_recipient + # ...without the award becoming one of its own (dollars stay on the source). + expect(incoming.scholarship?).to be(false) + end + + it "is not a recipient when the source has no scholarship" do + expect(incoming.effective_scholarship).to be_nil + expect(incoming).not_to be_scholarship_recipient + end + + it "uses a reg's own scholarship when present" do + own = create(:event_registration, event: create(:event, cost_cents: 3_000), status: "registered") + scholarship = create(:scholarship, recipient: own.registrant, amount_cents: 3_000) + create(:allocation, source: scholarship, allocatable: own, amount: 3_000) + + expect(own.effective_scholarship).to eq(scholarship) + expect(own).to be_scholarship_recipient + end + end + end + + describe "CE certification across a transfer" do + let(:home_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:intended_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:person) { create(:person) } + let!(:source) { create(:event_registration, event: home_event, registrant: person, status: "transferred_out") } + let!(:intended) { create(:event_registration, event: intended_event, registrant: person, status: "attended", transferred_from_registration: source) } + let!(:ce) do + create(:continuing_education_registration, event_registration: source, + professional_license: create(:professional_license, person: person)) + end + + it "lets the intended event issue the CE certificate for hours earned there" do + expect(intended.certifiable_ce_registrations).to eq([ ce ]) + intended.mark_certificate_issued!(true) + expect(ce.reload.certificate_sent?).to be(true) + expect(intended.reload.certificate_issued?).to be(true) + end + + it "does not issue the transferred-out hours from the source registration" do + expect(source.certifiable_ce_registrations).to be_empty + source.mark_certificate_issued!(true) + expect(ce.reload.certificate_sent?).to be(false) + end end describe "#sync_attendance_status_to_days!" do @@ -146,7 +289,9 @@ it "returns true for a transferred-in registration with no allocations" do # Transferred-in is an ordinary active registration here; the source event's # transferred_out record preserves the transfer history. - expect(create(:event_registration, status: "transferred_in")).to be_deletable + source = create(:event_registration, status: "transferred_out") + incoming = create(:event_registration, status: "registered", transferred_from_registration: source) + expect(incoming).to be_deletable end end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index d8bd3f93ed..124f06ca84 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -185,6 +185,16 @@ expect(query_count.call).to eq(baseline) end + it "annotates the CSV Status column for a transferred-in registration" do + source = create(:event_registration, status: "transferred_out") + create(:event_registration, event: new_event, registrant: source.registrant, status: "attended", transferred_from_registration: source) + + get event_registrations_path, params: { format: :csv } + + status_cells = CSV.parse(response.body).drop(1).map { |row| row[5] } + expect(status_cells).to include("Attended (transferred in)") + end + context "registration form icon" do let(:reg_form) { create(:form, :standalone, name: "Registration Form") } let(:person) { existing_registration.registrant } @@ -477,6 +487,60 @@ def toggle_certificate(value) expect(response.body).to include("financial records") expect(response.body).to include("reverted payments still count") end + + it "prompts to record the destination for a transferred-out registration" do + existing_registration.update!(status: "transferred_out") + + get edit_event_registration_path(existing_registration) + + expect(response.body).to include("Record where they transferred to") + end + + it "notes on the source CE card where the hours are certified after a transfer" do + source_event = create(:event, ce_hours_offered: 6) + source = create(:event_registration, event: source_event, status: "transferred_out") + intended = create(:event, title: "Intended Training") + create(:event_registration, event: intended, registrant: source.registrant, transferred_from_registration: source) + create(:continuing_education_registration, event_registration: source, + professional_license: create(:professional_license, person: source.registrant)) + + get edit_event_registration_path(source) + + expect(response.body).to include("Hours certified at") + expect(response.body).to include("Intended Training") + end + + it "shows the source event on a transferred-in registration" do + source = create(:event_registration, event: event, status: "transferred_out") + incoming = create(:event_registration, event: new_event, transferred_from_registration: source) + + get edit_event_registration_path(incoming) + + expect(response.body).to include("Transferred in from") + end + + it "shows a source-financials summary (not editable cards) for a transferred-in reg" do + paid_event = create(:event, cost_cents: 10_000) + source = create(:event_registration, event: paid_event, status: "transferred_out") + create(:allocation, source: create(:payment, person: source.registrant, amount_cents: 4_000, amount_cents_remaining: 4_000), + allocatable: source, amount: 4_000) + scholarship = create(:scholarship, recipient: source.registrant, amount_cents: 6_000) + create(:allocation, source: scholarship, allocatable: source, amount: 6_000) + incoming = create(:event_registration, event: new_event, transferred_from_registration: source) + + get edit_event_registration_path(incoming) + + # The distinct read-only summary, linking back to the source reg's sections. + expect(response.body).to include("Financials on the original registration") + expect(response.body).to include("#{edit_event_registration_path(source)}#allocations-card") + expect(response.body).to include("#{edit_event_registration_path(source)}#scholarship-card") + # Designated a scholarship recipient, linking to the actual award record. + expect(response.body).to include("Scholarship recipient") + expect(response.body).to include(edit_scholarship_path(scholarship)) + # NOT the incoming reg's own editable payment/scholarship cards. + expect(response.body).not_to include("Registration payments and allocations") + expect(response.body).not_to include("name=\"event_registration[scholarship_requested]\"") + end end describe "PATCH /event_registrations/:id" do @@ -533,6 +597,109 @@ def toggle_certificate(value) expect(existing_registration.reload.someone_else_will_pay).to be(true) end + + it "redirects to the transfer screen when newly marked transferred out" do + patch event_registration_path(existing_registration), + params: { event_registration: { status: "transferred_out" } } + + expect(response).to redirect_to(transfer_event_registration_path(existing_registration, return_to: nil)) + end + + it "also redirects when the status is flipped via the inline (Turbo) badge" do + patch event_registration_path(existing_registration), + params: { event_registration: { status: "transferred_out" } }, + as: :turbo_stream + + expect(response).to redirect_to(transfer_event_registration_path(existing_registration, return_to: nil)) + end + + it "does not redirect to the transfer screen once a destination is recorded" do + create(:event_registration, transferred_from_registration: existing_registration) + existing_registration.update!(status: "transferred_out") + + patch event_registration_path(existing_registration), + params: { event_registration: { fee_note: "Settled" } } + + expect(response).not_to redirect_to(transfer_event_registration_path(existing_registration, return_to: nil)) + end + end + + describe "transfer flow" do + let!(:source) { create(:event_registration, event: event, status: "transferred_out") } + + describe "GET /event_registrations/:id/transfer" do + it "offers same-kind events, excluding the source and the other kind" do + same_kind = create(:event, title: "Destination Event", published: true, facilitator_training: false) + other_kind = create(:event, title: "A Facilitator Training", published: true, facilitator_training: true) + + get transfer_event_registration_path(source) + + expect(response).to have_http_status(:success) + expect(response.body).to include("Destination Event") + # The source event and the opposite kind aren't offered as destinations. + expect(response.body).not_to include("