Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 : "",
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/events/bulk_payments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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])
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
118 changes: 102 additions & 16 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app/policies/event_registration_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading