Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~58 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

Expand Down Expand Up @@ -234,6 +234,7 @@ action, or `authorize! :workshop, to: :summary?`).

- `EventRegistrationServices::ProcessConfirmation` β€” Registration confirmation flow
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling
- `EventRegistrationServices::TransferContinuingEducation` β€” Splits/relocates a registrant's CE when they transfer events (issue #1944): a simple forward transfer leaves a paid, zero-hours **stub** on the source (its payments count at the original event) and creates a **live** record on the destination carrying the hours and the outstanding balance; when the reg being transferred out is itself a transfer-in (a collapsing double transfer, or a transfer back to the origin) its live record is relocated forward β€” merging back into the origin's stub β€” instead of split again, so no third record appears. Runs inside the transfer transaction, after the destination is saved and before a collapsing middle reg is destroyed
- `EventRegistrationReadiness` β€” Computes a registration's lifecycle `status` (`:not_ready` β†’ `:ready` β†’ `:certificate_due` β†’ `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` β€” Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `BuiltinCalloutCards` β€” Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here β€” they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
Expand Down
24 changes: 22 additions & 2 deletions app/controllers/continuing_education_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def show

def new
authorize!
return if redirect_transferred_in_ce

@ce_registration = @event_registration.continuing_education_registrations.build(
professional_license: @event_registration.registrant.professional_licenses.first,
hours: @event_registration.event.ce_hours_offered,
Expand All @@ -30,6 +32,7 @@ def new

def create
authorize!
return if redirect_transferred_in_ce

@ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create)

Expand Down Expand Up @@ -95,6 +98,19 @@ def set_event_registration
redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration
end

# A transferred-in reg's CE record is created by the transfer itself (carried
# from the source), so admins don't add one manually β€” send them to the source,
# where any additional CE belongs. The transfer's system-created record is exempt
# (it's built by the service, not this controller). (#1944)
def redirect_transferred_in_ce
return false unless @event_registration.transferred_in?

redirect_to edit_event_registration_path(@event_registration.transferred_from_registration),
alert: "This registrant transferred in from another event β€” manage their CE on the original registration.",
status: :see_other
true
end

def license_for_create
@event_registration.registrant.professional_licenses.first ||
@event_registration.registrant.professional_licenses.build
Expand All @@ -107,8 +123,12 @@ def apply_ce_params(ce_registration)
expires_on: params.dig(:continuing_education_registration, :license_expires_on),
license_id: params.dig(:continuing_education_registration, :professional_license_id))
ce_registration.hours = params.dig(:continuing_education_registration, :hours)
cost = params.dig(:continuing_education_registration, :cost_dollars)
ce_registration.cost_cents = (cost.to_d * 100).round if cost.present?
# A transfer-created record's cost is snapshotted from the source's outstanding
# balance and admin-locked, so ignore any submitted cost for it. (#1944)
unless ce_registration.transfer_created?
cost = params.dig(:continuing_education_registration, :cost_dollars)
ce_registration.cost_cents = (cost.to_d * 100).round if cost.present?
end

comments = params.fetch(:continuing_education_registration, {})
.permit(comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ])[:comments_attributes]
Expand Down
94 changes: 92 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, :update_attendance ]
before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance, :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 @@ -195,6 +205,76 @@ def update_attendance
redirect_to attendance_report_path(date, reopen: true), status: :see_other
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])

# Enforce the same-format rule server-side, not just in the picker: an event
# only transfers to another of its own format (on-demand ↔ on-demand). (#1944)
unless transfer_destination_events.exists?(destination_event.id)
redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence),
alert: "You can only transfer to another #{@event_registration.event.on_demand? ? "on-demand" : "scheduled"} event.",
status: :see_other
return
end

# 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
)
# Collapse a double transfer (A→B→C) to two live regs: when the reg being
# transferred out is itself a transfer-in, its predecessor is the real origin,
# so the new reg points straight there and the middle stop is dropped. (#1944)
source = @event_registration.transferred_from_registration || @event_registration

if destination == source
# Transferring back to the origin event undoes the whole chain: restore the
# origin to the status it held before it was transferred out, instead of
# linking it to itself.
destination.status = destination.status_before_transfer.presence || "registered"
destination.status_before_transfer = nil
else
destination.transferred_from_registration = source
end

saved = ActiveRecord::Base.transaction do
next false unless destination.save
# Split/relocate CE before dropping a collapsing middle reg, so its record
# moves forward instead of being cascade-destroyed with the reg. (#1944)
EventRegistrationServices::TransferContinuingEducation.new(
transferred_out: @event_registration, destination: destination
).call
@event_registration.destroy! if @event_registration.transferred_in?
true
end

if saved
redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence),
notice: "Transfer recorded β€” #{source.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 @@ -382,6 +462,16 @@ def attendance_report_path(date, reopen: false)
edit: (cell if reopen), anchor: cell)
end

# Events a registrant can be transferred into: published events of the same
# format as the one they're leaving β€” an on-demand event only transfers to
# another on-demand event, and a scheduled (non-on-demand) event only to
# another scheduled event β€” excluding the source event, most recent first.
def transfer_destination_events
Event.where(published: true, on_demand: @event_registration.event.on_demand)
.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 @@ -433,7 +523,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 @@ -1175,7 +1175,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
14 changes: 14 additions & 0 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def new
@scholarship = Scholarship.new(recipient: @allocatable.registrant)
@grants = Grant.selectable_for(@scholarship)
authorize! @scholarship
return if redirect_transferred_in_scholarship
load_scholarship_submission
end

Expand All @@ -51,6 +52,7 @@ def create
@scholarship = Scholarship.new(scholarship_params.merge(recipient: @allocatable.registrant))
@scholarship.build_allocation(allocatable: @allocatable, amount: @scholarship.amount_cents.to_i)
authorize! @scholarship
return if redirect_transferred_in_scholarship

if @scholarship.save
redirect_to scholarship_save_path, notice: "Scholarship created."
Expand Down Expand Up @@ -259,6 +261,18 @@ def locate_allocatable
GlobalID::Locator.locate_signed(sgid) if sgid
end

# A transferred-in reg carries no scholarship of its own β€” its recognition comes
# from the source it transferred from (see EventRegistration#effective_scholarship)
# and the dollars stay there. The UI hides the add link, but block the URL too and
# send the admin to the source, where the scholarship belongs. (#1944)
def redirect_transferred_in_scholarship
return false unless @allocatable.is_a?(EventRegistration) && @allocatable.transferred_in?

redirect_to edit_event_registration_path(@allocatable.transferred_from_registration),
alert: "This registrant transferred in from another event β€” add the scholarship on their original registration."
true
end

def scholarship_params
params.require(:scholarship).permit(
:amount_dollars, :amount_cents, :tasks_completed, :agreement_signed, :grant_id, :recipient_id,
Expand Down
31 changes: 30 additions & 1 deletion app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class ContinuingEducationRegistration < ApplicationRecord
# value is nil, e.g. a blank expiry on a placeholder license).
attr_accessor :license_kind, :license_number, :license_issuing_state, :license_expires_on

# Set when the transfer flow creates the destination record with a deliberately
# snapshotted hours/cost (including a $0 cost), so #default_from_event doesn't
# overwrite them with the event's offering. (#1944)
attr_accessor :skip_event_defaults

before_validation :default_from_event, on: :create

validates :hours, numericality: { greater_than_or_equal_to: 0 }
Expand Down Expand Up @@ -60,9 +65,30 @@ def self.search_by_params(params)
# sign-ins/early sign-outs. You can't certify hours the sign-in sheet doesn't support.
ATTENDANCE_COVERAGE_THRESHOLD = 0.9

# This record was created by a transfer β€” it lives on a transferred-in reg,
# carrying the hours forward from the source event with a cost snapshotted from
# the source's outstanding balance. Its cost is admin-locked (payments received
# here settle that balance); certification happens at this event. (#1944)
def transfer_created?
event_registration&.transferred_in? || false
end

# The source reg's CE record this one was split from β€” the paid $0-hours "stub"
# left at the original event, matched by license. Drives the "paid on original β†’"
# link on a transfer-created record's card. Nil when the source has none. (#1944)
def origin_ce_registration
return unless transfer_created?

event_registration.transferred_from_registration
&.continuing_education_registrations
&.find { |c| c.professional_license_id == professional_license_id }
end

# CE certificate eligibility β€” its own rule (not shared): the event grants CE,
# the registrant attended, the training has ended, the CE balance is paid, and
# (when attendance was tracked) the logged time approximately covers the hours.
# Everything is judged at this record's own event/registration β€” after a transfer
# the hours ride on the destination reg's own record, so there's nothing to walk.
def certificate_available?
event = event_registration&.event
return false unless event&.ce_eligible?
Expand Down Expand Up @@ -140,8 +166,11 @@ def payment_status_label
private

# Snapshot the hours offered and total cost from the event when they aren't set
# explicitly.
# explicitly. Skipped for a transfer-created record, whose hours/cost are
# deliberately carried over from the source (a $0 cost is intentional there).
def default_from_event
return if skip_event_defaults

event = event_registration&.event
self.hours = event.ce_hours_offered if event&.ce_hours_offered && (hours.blank? || hours.zero?)
self.cost_cents = event.ce_hours_cost_cents if event&.ce_hours_cost_cents && (cost_cents.blank? || cost_cents.zero?)
Expand Down
Loading