diff --git a/AGENTS.md b/AGENTS.md index 89340ea2a..97ccb152e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | @@ -238,6 +238,8 @@ 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 +- `EventRegistrationServices::RevertTransfer` — Undoes a transfer-out (issue #1944): restores the reg to the status it held before the transfer (via `status_before_transfer`, or "registered"), and when a destination was already recorded, unlinks it (it becomes a normal standalone reg, nothing deleted) and re-merges its split CE back onto the source (`TransferContinuingEducation#revert`). Backs the "Manage transfer" hub's undo action - `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) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index c4cd17be0..1e1e14acd 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -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, @@ -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) @@ -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 @@ -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] diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 0971c6394..da39d84c3 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, :update_attendance ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance, :transfer, :process_transfer, :revert_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 @@ -195,6 +205,108 @@ 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 + # Re-pointing a completed transfer to a different event: unlink the previously + # recorded destination (it becomes a standalone reg) and re-merge its CE back + # to the source before re-splitting to the newly chosen event. (#1944) + previous = @event_registration.transferred_to_registration + if previous && previous.event_id != destination_event.id + EventRegistrationServices::TransferContinuingEducation.new( + transferred_out: @event_registration, destination: previous + ).revert + previous.update!(transferred_from_registration: nil) + end + next false unless destination.save + # Carry the transferring reg's org links onto the destination so the new reg + # shares the same linked organizations — copied, not moved, so the source + # keeps its own. Read before the middle reg is dropped below. (#1944) + @event_registration.organizations.each do |organization| + destination.event_registration_organizations.find_or_create_by!(organization: organization) + end + # 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 + + # Undo a transfer-out: restore the reg to its pre-transfer status, and (when a + # destination was already recorded) unlink that destination and re-merge its CE + # back to the source. (#1944) + def revert_transfer + authorize! @event_registration, to: :transfer? + unless EventRegistrationServices::RevertTransfer.call(registration: @event_registration) + redirect_to edit_event_registration_path(@event_registration, return_to: params[:return_to].presence), + alert: "This registration isn't transferred out.", status: :see_other + return + end + + redirect_to edit_event_registration_path(@event_registration, return_to: params[:return_to].presence), + notice: "Transfer undone — #{@event_registration.registrant.full_name} is back to #{@event_registration.attendance_status_label.downcase} on #{@event_registration.event.title}.", + status: :see_other + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? @@ -382,6 +494,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) @@ -433,7 +555,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 f94cc93aa..360f8168b 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 79638d7a6..78f01a83c 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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 diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index c0cc63de2..3781cb601 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -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 @@ -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." @@ -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, diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 3d09de297..1450c2b15 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -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 } @@ -77,9 +82,30 @@ def self.parse_iso_date(value) # 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? @@ -157,8 +183,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?) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 41e966607..dc208b253 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -24,6 +24,14 @@ class EventRegistration < ApplicationRecord # registration is deleted (mirrors the FK's on_delete: :nullify). has_many :affiliations, dependent: :nullify, inverse_of: :event_registration + # 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 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? } # Staff correct/add attendance times on the CE edit form; a row with no sign-in @@ -35,10 +43,11 @@ class EventRegistration < ApplicationRecord accepts_nested_attributes_for :registrant before_create :generate_slug + before_save :capture_pre_transfer_status, if: :becoming_transferred_out? 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 @@ -48,11 +57,15 @@ class EventRegistration < ApplicationRecord # that defaults to something specific (the attendees index defaults to attended # registrations on trainings, so it needs a way to say "all of them"). FILTER_ALL = "all".freeze - # Attendance-outcome filter options, shared by the registrations index and the - # attendees index so the vocabulary can't drift between them. + # Sentinel filter value for the FK-backed "transferred in" dimension — an + # incoming reg keeps its own real status, so "transferred in" is a filter (routed + # through the attendance_status scope to the transfer FK), not a status. (#1944) + TRANSFERRED_IN_FILTER = "transferred_in".freeze + # Attendance-outcome filter options, shared by the registrations index, the + # attendees index, and the registrant roster so the vocabulary can't drift. ATTENDANCE_FILTER_OPTIONS = ( ATTENDANCE_STATUSES.map { |status| [ status.humanize, status ] } + - [ [ "Other (registered, transfers, cancellations)", "other" ] ] + [ [ "Transferred in", TRANSFERRED_IN_FILTER ], [ "Other (registered, transfers, cancellations)", "other" ] ] ).freeze # Event-type filter options, matching the .event_type scope's vocabulary and the # report suite's Event type select (events/_event_type_filter), so the same value @@ -122,7 +135,6 @@ 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" @@ -161,8 +173,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 @@ -313,30 +337,30 @@ def self.scholarship_allocatable_ids(scholarships) ) SQL } + # Payment-status filters evaluate the "billing" registration: a transferred-in + # reg carries no money of its own, so its paid status is the source reg's (they + # paid at the old event). For a normal reg the billing reg is itself. Both the + # amount applied and the cost owed are read from that billing reg. (#1944) + BILLING_REGISTRATION_ID_SQL = "COALESCE(event_registrations.transferred_from_registration_id, event_registrations.id)".freeze + BILLING_ALLOCATIONS_SUM_SQL = <<~SQL.squish.freeze + COALESCE(( + SELECT SUM(allocations.amount) FROM allocations + WHERE allocations.allocatable_type = 'EventRegistration' + AND allocations.allocatable_id = #{BILLING_REGISTRATION_ID_SQL} + ), 0) + SQL + BILLING_COST_CENTS_SQL = <<~SQL.squish.freeze + COALESCE(( + SELECT events.cost_cents FROM events + INNER JOIN event_registrations billing_reg ON billing_reg.id = #{BILLING_REGISTRATION_ID_SQL} + WHERE events.id = billing_reg.event_id + ), 0) + SQL scope :paid_in_full, -> { - where(<<~SQL.squish) - COALESCE(( - SELECT SUM(allocations.amount) FROM allocations - WHERE allocations.allocatable_type = 'EventRegistration' - AND allocations.allocatable_id = event_registrations.id - ), 0) >= COALESCE(( - SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id - ), 0) - SQL + where("#{BILLING_ALLOCATIONS_SUM_SQL} >= #{BILLING_COST_CENTS_SQL}") } scope :not_paid_in_full, -> { - where(<<~SQL.squish) - COALESCE(( - SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id - ), 0) > 0 - AND COALESCE(( - SELECT SUM(allocations.amount) FROM allocations - WHERE allocations.allocatable_type = 'EventRegistration' - AND allocations.allocatable_id = event_registrations.id - ), 0) < COALESCE(( - SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id - ), 0) - SQL + where("#{BILLING_COST_CENTS_SQL} > 0 AND #{BILLING_ALLOCATIONS_SUM_SQL} < #{BILLING_COST_CENTS_SQL}") } scope :payment_status, ->(value) { case value @@ -566,14 +590,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, @@ -596,12 +632,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" @@ -613,6 +655,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 @@ -653,6 +708,7 @@ def certificate_available? # An invoice (and receipt) only make sense for a paid event — free events have # nothing to bill or receipt. def invoice_available? + return transferred_from_registration.invoice_available? if transferred_in? event.cost_cents.to_i.positive? end @@ -676,6 +732,27 @@ def cost_cents event.cost_cents end + # A transferred-in reg carries no money of its own — its balance and payments + # live on the source registration (the old event, where they actually paid). Its + # remaining balance, paid status, and payment-on-file therefore mirror the + # source, so the ticket never re-bills a paid transfer and the invoice/receipt + # (built from the source) reflect the old cost. Reached through this ticket, but + # the money is the source's. (#1944) + def remaining_cost + return transferred_from_registration.remaining_cost if transferred_in? + super + end + + def paid_in_full? + return transferred_from_registration.paid_in_full? if transferred_in? + super + end + + def payment_received? + return transferred_from_registration.payment_received? if transferred_in? + super + end + # The registrant's currently-open attendance entry (signed in, not yet out) for # one day, or nil when they're not signed in that day. Drives which sign-in/out # button the CE callout shows. Deliberately day-scoped: an entry left open when @@ -786,25 +863,28 @@ def ce_license_provided? 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. Each reg certifies its + # own CE records now; after a transfer the hours ride on the destination reg's + # own record, so there's no cross-reg set to consult. (#1944) def ce_certificate_issued? - return false unless ce_registered? + return false unless continuing_education_registrations.any? continuing_education_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 + # toggle. For a registration that has CE 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). + # edit page); otherwise the registration's own certificate_sent_at. def certificate_issued? - ce_registered? ? ce_certificate_issued? : certificate_sent? + continuing_education_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) } + ce = continuing_education_registrations + if ce.any? + ce.each { |c| c.update!(certificate_sent_at: at) } else update!(certificate_sent_at: at) end @@ -870,6 +950,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) @@ -970,6 +1058,16 @@ def status_changed_to_cancelled? saved_change_to_status? && status == "cancelled" end + def becoming_transferred_out? + will_save_change_to_status? && status == "transferred_out" + end + + # Remember the status held just before a reg is transferred out, so a later + # transfer back to this event can restore it rather than leaving it "out". (#1944) + def capture_pre_transfer_status + self.status_before_transfer = status_was + end + # On cancellation, release any awarded scholarship back to its grant by zeroing # the amount (Scholarship#sync_allocation_amount zeroes the allocation to match). # scholarship_requested is left set on purpose: reactivating won't re-award, but diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c936cfdd3..c3e70f0b2 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -31,6 +31,13 @@ class RegistrationTicketCallout < ApplicationRecord # selected one (see BuiltinCalloutCards#card_for). APP_COLORED_BUILTIN_KEYS = %w[ payment scholarship ce_hours ].freeze + # Built-ins that represent a financial or credit *record* the registrant keeps + # even after withdrawing (their balance/invoice/receipt, scholarship award, CE + # credit, certificate). Everything else — videoconference, staff, handouts, FAQ, + # and admin-authored custom callouts — is event participation material a + # transferred-out registrant no longer needs, so the ticket hides those. (#1944) + FINANCIAL_RECORD_BUILTIN_KEYS = %w[ payment scholarship ce_hours certificate ].freeze + # Per-type fallbacks for the icon and colour. These are callout-specific (unlike # the generic colour swatches and palette, which live in DomainTheme so the whole # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). @@ -122,6 +129,13 @@ def behavioral_builtin? builtin? && CONTENT_BUILTIN_KEYS.exclude?(builtin_key) end + # A financial/credit record the registrant keeps after withdrawing (vs. event + # participation material the ticket hides once transferred out). Custom callouts + # (no builtin_key) are participation content, so they read as non-record. (#1944) + def financial_record? + builtin_key.in?(FINANCIAL_RECORD_BUILTIN_KEYS) + end + # Whether the row carries the inline CE config fields (hours offered / cost). def ce_config? CONFIG_BUILTIN_KEYS.include?(builtin_key.to_s) diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index 7a95e78e1..5030b3649 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/presenters/event_invoice.rb b/app/presenters/event_invoice.rb index f7f8a1fab..d989ce884 100644 --- a/app/presenters/event_invoice.rb +++ b/app/presenters/event_invoice.rb @@ -32,6 +32,10 @@ def details # the balance actually due. The registrant's snapshotted organization (if any) # is the bill-to; otherwise bill the person. def self.from_registration(registration) + # A transferred-in reg holds no money of its own — its balance and payments + # live on the source (the old event, where they paid). The invoice is reached + # through the new ticket but documents that source. (#1944) + registration = registration.transferred_from_registration if registration.transferred_in? event = registration.event registrant = registration.registrant organization = registration.organizations.first diff --git a/app/presenters/event_receipt.rb b/app/presenters/event_receipt.rb index 0ce473e91..14557149f 100644 --- a/app/presenters/event_receipt.rb +++ b/app/presenters/event_receipt.rb @@ -25,6 +25,10 @@ def amount_cents # settled it as a ledger entry, and a balance that reconciles to zero. The # snapshotted organization (if any) is the bill-to; otherwise bill the person. def self.from_registration(registration) + # A transferred-in reg holds no money of its own — its balance and payments + # live on the source (the old event, where they paid). The receipt is reached + # through the new ticket but documents that source. (#1944) + registration = registration.transferred_from_registration if registration.transferred_in? event = registration.event registrant = registration.registrant organization = registration.organizations.first diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index c427a1c7c..a69e1edc3 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -410,6 +410,7 @@ def ce_deadline_text(deadline) # so the card only appears once someone's been connected in the Event staff section. def staff_card return if config_gap?("staff") + return if registration.transferred_out? Card.new(icon_class: "fa-solid fa-people-group", color: "blue", title: "Meet the staff", subtitle: "The team for this event", @@ -417,9 +418,11 @@ def staff_card target: nil, trailing_icon: "fa-solid fa-arrow-right") end - # Shown only when the event has a videoconference URL set. + # Shown only when the event has a videoconference URL set. Hidden once the + # registrant has transferred out — they no longer attend this event. (#1944) def videoconference_card return if config_gap?("videoconference") + return if registration.transferred_out? Card.new(icon_class: "fa-solid fa-video", color: "blue", title: "Videoconference", subtitle: "Join details and add to calendar links", diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index c87326a8f..a8d3e3155 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 --------------------------------------------- @@ -646,7 +668,7 @@ def ce_registrant_count # roster's CE column: its icon links to editing this record when present. def ce_registration_by_registrant @ce_registration_by_registrant ||= ce_registrations.each_with_object({}) do |ce_registration, map| - registrant_id = registrant_id_by_registration[ce_registration.event_registration_id] + registrant_id = ce_registrant_id_by_registration[ce_registration.event_registration_id] map[registrant_id] ||= ce_registration if registrant_id end end @@ -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. @@ -1171,15 +1228,32 @@ def allocated_by_registration @allocated_by_registration ||= registration_allocations.group(:allocatable_id).sum(:amount) end - # Active continuing-education registrations for this event: those tied to an - # active event registration. The basis for every CE money figure and for the - # CE registrant counts / pie. + # CE money/counts follow the CE record, not the registration billing basis: they + # count every CE record on a registration of this event that wasn't cancelled or + # a no-show — including a transferred-out reg's paid stub (counted here, where it + # was paid) and a transferred-in reg's own carried record (counted at the event it + # now credits). (#1944) + def ce_basis_registration_ids + @ce_basis_registration_ids ||= event.event_registrations.where.not(status: %w[ cancelled no_show ]).pluck(:id) + end + + # Continuing-education registrations counted for this event — the basis for every + # CE money figure and for the CE registrant counts / pie. def ce_registrations @ce_registrations ||= ContinuingEducationRegistration - .where(event_registration_id: active_registration_ids) + .where(event_registration_id: ce_basis_registration_ids) .to_a end + # Registrant (Person) id per registration in the CE basis. Distinct from + # #registrant_id_by_registration (active regs only) because CE also counts a + # transferred-out reg's stub, whose reg is inactive. (#1944) + def ce_registrant_id_by_registration + @ce_registrant_id_by_registration ||= event.event_registrations + .where(id: ce_registrations.map(&:event_registration_id).uniq) + .pluck(:id, :registrant_id).to_h + end + def ce_allocations Allocation.where(allocatable_type: "ContinuingEducationRegistration", allocatable_id: ce_registrations.map(&:id)) end @@ -1211,7 +1285,7 @@ def ce_due_cents(ce_registration) def ce_unpaid_registrant_ids @ce_unpaid_registrant_ids ||= ce_registrations .select { |ce_registration| ce_due_cents(ce_registration).positive? } - .filter_map { |ce_registration| registrant_id_by_registration[ce_registration.event_registration_id] } + .filter_map { |ce_registration| ce_registrant_id_by_registration[ce_registration.event_registration_id] } .uniq end @@ -1225,7 +1299,7 @@ def ce_paid_registrant_ids # { Person id => cents } hash, dropping zeros. def ce_cents_by_registrant ce_registrations.each_with_object(Hash.new(0)) do |ce_registration, map| - registrant_id = registrant_id_by_registration[ce_registration.event_registration_id] + registrant_id = ce_registrant_id_by_registration[ce_registration.event_registration_id] next unless registrant_id cents = yield(ce_registration) map[registrant_id] += cents if cents.positive? @@ -1301,7 +1375,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 a53717f3e..799547ec0 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -92,6 +92,7 @@ def certificate_due_reason # under a "Not ready" badge), full description (tooltip) ]. One table keeps the # short and long forms in sync. EVENT_READY_CHECKS = [ + [ :transfer_incomplete?, "Transfer incomplete", "Transfer out has no destination recorded" ], [ :payment_due?, "Payment due", "Payment due" ], [ :organization_missing?, "Org validation", "No organization linked" ], [ :scholarship_uncreated?, "No scholarship", "Scholarship not created" ], @@ -138,7 +139,18 @@ def failed_event_ready_checks @failed_event_ready_checks ||= EVENT_READY_CHECKS.select { |predicate, _, _| send(predicate) } end + # A reg marked transferred-out but with no destination recorded yet is an + # unfinished admin task — the top pre-event issue. This is the one check that + # reads the reverse transfer link (not roster-preloaded), so it can query for a + # transferred-out row; active rows short-circuit on the status and never touch it. + def transfer_incomplete? + registration.transfer_destination_pending? + 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 +178,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 +188,20 @@ 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 certifies — its own records. After a transfer the + # hours ride on the destination reg's own record, so each reg certifies exactly + # what it holds. (issue #1944) + def certifiable_ce + registration.continuing_education_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 +214,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_registration_services/revert_transfer.rb b/app/services/event_registration_services/revert_transfer.rb new file mode 100644 index 000000000..90e3ad9ea --- /dev/null +++ b/app/services/event_registration_services/revert_transfer.rb @@ -0,0 +1,43 @@ +module EventRegistrationServices + # Undoes a transfer-out, restoring the registration to the status it held before + # it was marked transferred out (or "registered" if none was captured). + # + # Pending (no destination recorded yet): just restore the status. Completed (a + # destination reg already exists): also unlink that destination — it becomes a + # normal standalone registration, nothing deleted — and re-merge its split CE + # back onto this source before restoring the status. (#1944) + class RevertTransfer + def self.call(registration:) = new(registration:).call + + def initialize(registration:) + @registration = registration + end + + # Returns false (a no-op) if the reg isn't transferred out. + def call + return false unless @registration.transferred_out? + + ActiveRecord::Base.transaction do + # Query directly rather than through the has_one: loading the association + # would let the source's autosave re-link the destination when we update + # the source's status below, undoing the unlink. + destination = EventRegistration.find_by(transferred_from_registration_id: @registration.id) + if destination + TransferContinuingEducation.new( + transferred_out: @registration, destination: destination + ).revert + destination.update!(transferred_from_registration: nil) + end + + @registration.update!(status: restored_status, status_before_transfer: nil) + end + true + end + + private + + def restored_status + @registration.status_before_transfer.presence || "registered" + end + end +end diff --git a/app/services/event_registration_services/transfer_continuing_education.rb b/app/services/event_registration_services/transfer_continuing_education.rb new file mode 100644 index 000000000..7d9a60b51 --- /dev/null +++ b/app/services/event_registration_services/transfer_continuing_education.rb @@ -0,0 +1,83 @@ +module EventRegistrationServices + # Moves a registrant's CE credit when they transfer events, keeping two records + # so each event holds its own money (issue #1944): + # * the source keeps a paid $0-hours stub — its payments count at the original + # event, and it still surfaces in that event's CE searches; + # * the destination gets a live record carrying the hours and the outstanding + # balance, where new payments are received and the certificate is earned. + # 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 instead of split again, so no third record ever appears. + # + # Runs inside the transfer transaction, after the destination is saved and before + # a collapsing middle reg is destroyed (so its CE moves rather than cascades away). + class TransferContinuingEducation + def initialize(transferred_out:, destination:) + @transferred_out = transferred_out + @destination = destination + end + + def call + # Query the records directly rather than through the association: a collapsing + # middle reg is destroyed right after this, and a loaded has_many cache would + # make its dependent: :destroy sweep away the record we just moved forward. + records = ContinuingEducationRegistration.where(event_registration_id: @transferred_out.id).to_a + if @transferred_out.transferred_in? + records.each { |ce| relocate(ce) } + else + records.each { |ce| split(ce) } + end + end + + # Inverse of a split: fold each destination live record back into the source + # stub it was split from (matched by license), restoring the source's single + # record with its hours, full cost, and every payment. Destination records with + # no matching stub (independently created there) are left untouched. Must run + # while the destination is still linked, before RevertTransfer unlinks it. (#1944) + def revert + ContinuingEducationRegistration.where(event_registration_id: @transferred_out.id).each do |stub| + dest_ce = destination_ce_for(stub.professional_license_id) + merge_into(stub, dest_ce) if dest_ce + end + end + + private + + # Collapse / back-to-origin: the reg being dropped already holds the live + # record, so move it to the destination — merging into the destination's stub + # for that license when one exists (transferring back to the origin restores it). + def relocate(ce) + existing = destination_ce_for(ce.professional_license_id) + existing ? merge_into(existing, ce) : ce.update!(event_registration: @destination) + end + + # Split one source CE into a paid stub here (hours zeroed, cost = the amount + # already paid) plus a live record on the destination carrying the hours and + # the outstanding balance. Skips creation if the destination already carries a + # record for that license (the person was independently registered there). + def split(ce) + unless destination_ce_for(ce.professional_license_id) + @destination.continuing_education_registrations.create!( + professional_license_id: ce.professional_license_id, + hours: ce.hours, + cost_cents: ce.remaining_cost, + skip_event_defaults: true + ) + end + ce.update!(hours: 0, cost_cents: ce.allocations_sum) + end + + # Fold a relocated live record's hours, cost, and payments back into an existing + # stub, then drop the now-empty relocated record. Restores the original single + # record when transferring back to the origin. + def merge_into(stub, ce) + ce.allocations.each { |allocation| allocation.update!(allocatable: stub) } + stub.update!(hours: ce.hours, cost_cents: stub.cost_cents.to_i + ce.cost_cents.to_i) + ce.reload.destroy! + end + + def destination_ce_for(license_id) + @destination.continuing_education_registrations.detect { |ce| ce.professional_license_id == license_id } + end + end +end diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index 59743e38e..377f969ee 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/continuing_education_registrations/_payment_history.html.erb b/app/views/continuing_education_registrations/_payment_history.html.erb index 738af1ae0..cd0356d37 100644 --- a/app/views/continuing_education_registrations/_payment_history.html.erb +++ b/app/views/continuing_education_registrations/_payment_history.html.erb @@ -21,7 +21,7 @@
CE cost
- <% if params[:admin] == "true" %> + <% if params[:admin] == "true" && !ce_registration.transfer_created? %>
<%= number_field_tag "continuing_education_registration[cost_dollars]", (ce_registration.cost_cents.to_d / 100), @@ -30,6 +30,15 @@
<% else %>
<%= dollars_from_cents(cost_cents) %>
+ <%# A transfer-created record's cost is the balance carried from the source + and can't be edited here; it links back to the paid original. (#1944) %> + <% if ce_registration.transfer_created? && (origin = ce_registration.origin_ce_registration) %> +
+ + <%= link_to "From original registration", edit_continuing_education_registration_path(origin, return_to: "ce_registration"), + class: "font-medium underline", target: "_blank", rel: "noopener" %> +
+ <% end %> <% end %>
<%= link_to allocations_path(allocatable_sgid: ce_registration.to_sgid.to_s, return_to: "ce_registration"), diff --git a/app/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index 126cfaa74..da2957568 100644 --- a/app/views/event_registrations/_attendance_status_badge.html.erb +++ b/app/views/event_registrations/_attendance_status_badge.html.erb @@ -1,5 +1,6 @@ <% deco = registration.decorate %> -
+<% badge_return_to = local_assigns.fetch(:return_to, nil) %> +
<%= form_with model: registration, url: event_registration_path(registration), method: :patch, data: { turbo_frame: "_top" } do |f| %>
@@ -13,4 +14,24 @@
<% 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 %> + <%# A transferred-out reg links to the manage-transfer screen — amber with a + warning when no destination is recorded yet, purple once one is. %> + <% if registration.transferred_out? %> + <% pending = registration.transfer_destination_pending? %> + <%= link_to transfer_event_registration_path(registration, return_to: badge_return_to), + class: "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium #{pending ? "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100" : "border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100"}", + title: pending ? "Transfer incomplete — no destination event recorded yet — click to manage" : "Manage this transfer", + data: { turbo_frame: "_top" } do %> + text-[0.6rem]"> + Manage + <% end %> + <% end %>
diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 5c9cf62be..62ab99628 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. ---- %> -
+
@@ -11,7 +11,16 @@
- <% unless ce_registration %> + <% if ce_registration.nil? && event_registration.transferred_in? %> + <%# A transfer-in reg's CE is created by the transfer; if the source had none, + there's nothing here and admins add CE on the original reg. (#1944) %> +

+ No CE transferred in. + <%= link_to "Manage on the original registration", + edit_event_registration_path(event_registration.transferred_from_registration, return_to: "registrants"), + class: "font-medium text-teal-700 underline", target: "_blank", rel: "noopener" %> +

+ <% elsif ce_registration.nil? %> <%# Open the full new form (license/hours/cost) in a new tab and return here. %>
<%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), @@ -46,6 +55,32 @@ <% end %>

+ <%# Two-record CE after a transfer (issue #1944): the source keeps a paid, + zero-hours stub whose hours moved to the destination reg's live record; + the destination's record carries the hours with a cost snapshotted from + the source's outstanding balance and links back to the paid original. %> + <% if ce_registration.transfer_created? %> + <% origin = ce_registration.origin_ce_registration %> +

+ + Transferred from + <%= link_to event_registration.transferred_from_registration.event.title, + edit_event_registration_path(event_registration.transferred_from_registration, return_to: "registrants"), + class: "font-medium underline", target: "_blank", rel: "noopener" %> + <% if origin %>· + <%= link_to "paid on original", edit_continuing_education_registration_path(origin, return_to: "registration"), + class: "font-medium underline", target: "_blank", rel: "noopener" %> + <% end %> +

+ <% elsif (moved_to = event_registration.transferred_to_registration) %> +

+ + Hours moved to + <%= link_to moved_to.event.title, edit_event_registration_path(moved_to, 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 f5b965b51..353813b23 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,32 @@ 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" } %> + · + <%= link_to "Manage transfer", transfer_event_registration_path(f.object, 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,8 +183,15 @@
+ <%# 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_scholarship = !transferred_in && (f.object.event&.scholarship_eligible? || f.object.scholarships.any?) %> + <%# CE now lives on the transfer-in reg itself (its own record, carried by the + transfer), so its card shows here even for a transfer-in — payment/scholarship + stay on the source and appear in the read-only summary below. (#1944) %> <% show_ce = 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) %> @@ -253,22 +284,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 1e6a7fce3..4df06615a 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 a0679417e..e2cec56cf 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 %>
@@ -71,7 +72,7 @@
- <% if event_registration.event.autoshow_videoconference_link && event_registration.event.videoconference_url.present? && event_registration.joinable? %> + <% if event_registration.event.autoshow_videoconference_link && event_registration.event.videoconference_url.present? && event_registration.joinable? && !event_registration.transferred_out? %>
<%= render "events/videoconference_link", event: event_registration.event.decorate, joinable: event_registration.joinable? %>
@@ -126,6 +127,10 @@ real-ticket render doesn't issue a second query for the same rows. %> <% callouts = preview && show_all ? event_registration.event.registration_ticket_callouts : event_registration.event.registration_ticket_callouts.select(&:published?) %> <% callouts.each do |callout| %> + <%# A transferred-out registrant withdrew from this event: keep their financial/ + credit records (payment, scholarship, CE, certificate), hide participation + material (videoconference, staff, handouts, FAQ, custom callouts). (#1944) %> + <% next if event_registration.transferred_out? && !callout.financial_record? %> <% next if callout.payment_access_gated && !payment_access && !(preview && show_all) %> <% if callout.behavioral_builtin? %> <% card = builtin_cards.card_for(callout) %> 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 000000000..7b8134798 --- /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 000000000..3bb720144 --- /dev/null +++ b/app/views/event_registrations/_transferred_in_financials.html.erb @@ -0,0 +1,88 @@ +<%# ---- Transferred-in financials (issue #1944) — a read-only summary of the + scholarship / payment records that live on the SOURCE registration this person + transferred in from. (CE is carried onto this reg's own record — see its card + above — so it isn't summarized here.) 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 — their scholarship 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 %> +
+
+
diff --git a/app/views/event_registrations/transfer.html.erb b/app/views/event_registrations/transfer.html.erb new file mode 100644 index 000000000..0d9bde363 --- /dev/null +++ b/app/views/event_registrations/transfer.html.erb @@ -0,0 +1,123 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% + source = @event_registration + destination = source.transferred_to_registration + pending = destination.nil? + chained = source.transferred_in? + origin_reg = source.transferred_from_registration + # Where money/records already live — the source normally, or the true origin + # when this reg was itself transferred in. + records_event = (origin_reg || source).event + restore_status = source.status_before_transfer.presence || "registered" + restore_label = EventRegistration.new(status: restore_status).attendance_status_label.downcase + 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 %> +
+ +
+
+
+
+ +
+

Manage transfer

+
+ +
+
+
Registrant
+
<%= source.registrant.full_name %>
+
Transferred out of
+
<%= source.event.title %>
+
Destination
+
+ <% if destination %> + <%= link_to destination.event.title, edit_event_registration_path(destination), class: "font-medium text-gray-900 underline" %> + <% else %> + Not recorded yet + <% end %> +
+
+
+ + <%# ---- Record / change destination ---- %> +
+

<%= pending ? "Record the destination event" : "Change the destination event" %>

+

+ <%= pending ? "Create the registration where you'll track their attendance from here on." : "Move this transfer to a different event. The current destination becomes a normal standalone registration and its hours move back before re-splitting to the new event." %> +

+ + <%# Plain-language "here's what happens to their data" block so an admin + knowingly consents. The chain-collapse warning shows only when the reg + being transferred is itself a transfer-in. (#1944) %> +
+

What recording this transfer will do:

+
    +
  • <%= source.registrant.first_name %> keeps their <%= source.event.title %> registration as a record — it's marked "Transferred out," not deleted.
  • +
  • A new registration is created for the event you pick, where you'll track their attendance from here on.
  • +
  • Their payments and any scholarship stay with <%= records_event.title %> — the new event won't charge them again. Invoices and receipts still show the original cost.
  • +
  • Their linked organizations are copied to the new registration.
  • +
  • Continuing-education hours move with them: what they already paid stays counted at <%= records_event.title %>, and the remaining hours, balance, and their certificate move to the new event.
  • +
+ + <% if chained %> +
+

+ + Heads up — <%= source.registrant.first_name %> was already transferred in from <%= origin_reg.event.title %>. + Recording this transfer will: +

+
    +
  • Link the new registration straight back to <%= origin_reg.event.title %>, not to this one.
  • +
  • Remove this in-between registration for <%= source.event.title %> — its continuing-education record moves forward to the new event first, so nothing is lost.
  • +
  • Their money and history still trace back to <%= origin_reg.event.title %>.
  • +
+
+ <% end %> +
+ + <%= 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, destination&.event_id), + 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.on_demand? ? "other on-demand events" : "other scheduled (non-on-demand) events" %> are shown. +

+
+ +
+ <%= link_to "Back to registrants", roster_path, class: "btn btn-secondary-outline" %> + <%= submit_tag(pending ? "Record transfer" : "Change destination", class: "btn btn-primary ml-auto") %> +
+ <% end %> +
+ + <%# ---- Undo the transfer ---- %> +
+

<%= pending ? "They didn't transfer out?" : "Undo this transfer" %>

+

+ <% if pending %> + Put <%= source.registrant.first_name %> back to <%= restore_label %> on <%= source.event.title %> and clear the transfer. + <% else %> + This puts <%= source.registrant.first_name %> back to <%= restore_label %> on <%= source.event.title %>, turns their <%= destination.event.title %> registration into a normal standalone registration (nothing deleted), and moves their continuing-education hours back here. + <% end %> +

+ <%= link_to revert_transfer_event_registration_path(source, return_to: @return_to.presence), + class: "btn btn-danger", + data: { turbo_method: :patch, turbo_confirm: "Undo this transfer? #{source.registrant.first_name} goes back to #{restore_label} on #{source.event.title}." } do %> + + <%= pending ? "They didn't transfer out — undo" : "Undo transfer" %> + <% end %> +
+
+
diff --git a/app/views/events/_recipient_card.html.erb b/app/views/events/_recipient_card.html.erb index 06a8b66aa..6754a955e 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 34f3608db..2f788d4d3 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -62,7 +62,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 04b508fba..0839c840e 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -375,20 +375,29 @@ 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? %> + <% if ce_registration %> + <%# The transfer-in reg carries its own CE record now, so it + shows the normal status badge linking to that record. %> + <%= render "event_registrations/ce_status_badge", registration: registration, + href: edit_continuing_education_registration_path(ce_registration, return_to: "registrants") %> + <% elsif registration.transferred_in? %> + <%# No CE came across on transfer, and CE isn't created here — + it's managed on the original registration. %> + + <% else %> <%= 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 %> - <% 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 +409,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 %> @@ -492,7 +505,7 @@ <% end %> - <%= render "event_registrations/attendance_status_badge", registration: registration %> + <%= render "event_registrations/attendance_status_badge", registration: registration, return_to: "registrants" %> "><%= registration.created_at.strftime("%b %-d, %Y") %> <% readiness = @readiness[registration.id] %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 81eadc3ec..4a336ec2d 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -22,6 +22,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 4f3c44648..b96baae52 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 b0cca1fa0..af0bed73b 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 ca759940f..b64707051 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 412a3324c..b87e3565c 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/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 2a348c92e..d314490cf 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -245,7 +245,7 @@ <% when :attendance %> " data-sort-value="<%= registration.status %>"> - <%= render "event_registrations/attendance_status_badge", registration: registration %> + <%= render "event_registrations/attendance_status_badge", registration: registration, return_to: "onboarding" %> <% when :flagged_comments %> diff --git a/app/views/events/registrations/invoice.html.erb b/app/views/events/registrations/invoice.html.erb index 95e64e4d2..f80a6f8bc 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 7a9f89e4d..054fb3574 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 6a0cbfa1e..c8582b511 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -104,6 +104,9 @@ member do get :confirm post :process_confirm + get :transfer + post :process_transfer + patch :revert_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 000000000..c88d1da20 --- /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/migrate/20260816155204_add_status_before_transfer_to_event_registrations.rb b/db/migrate/20260816155204_add_status_before_transfer_to_event_registrations.rb new file mode 100644 index 000000000..0915ae0d1 --- /dev/null +++ b/db/migrate/20260816155204_add_status_before_transfer_to_event_registrations.rb @@ -0,0 +1,9 @@ +class AddStatusBeforeTransferToEventRegistrations < ActiveRecord::Migration[8.1] + def up + add_column :event_registrations, :status_before_transfer, :string + end + + def down + remove_column :event_registrations, :status_before_transfer, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f28a3f011..e01e5ca24 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -519,6 +519,8 @@ t.string "slug" t.boolean "someone_else_will_pay", default: false, null: false t.string "status", default: "registered", null: false + t.string "status_before_transfer" + 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" @@ -527,6 +529,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| @@ -1848,6 +1851,7 @@ add_foreign_key "event_registration_organizations", "event_registrations" add_foreign_key "event_registration_organizations", "form_submissions", on_delete: :nullify 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 97e2543a7..13e847616 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 2a53a2269..31b19a195 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -157,6 +157,48 @@ def ce_reg_for(event:, status:, cost_cents: 0) ce_reg.mark_certificate_sent! expect(ce_reg.certificate_sent?).to be(true) end + + describe "transfer creates a second record (two-record model, #1944)" do + let(:origin_event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 10_000, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:origin_reg) { create(:event_registration, event: origin_event, status: "attended") } + let(:license) { create(:professional_license, person: origin_reg.registrant) } + + it "carries hours/cost from the source without re-defaulting from the event" do + dest_reg = create(:event_registration, event: create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 10_000), + registrant: origin_reg.registrant, transferred_from_registration: origin_reg) + ce = dest_reg.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 0, skip_event_defaults: true) + + expect(ce.hours).to eq(6) + expect(ce.cost_cents).to eq(0) # the event's $100 default is skipped + expect(ce).to be_transfer_created + end + + it "certifies against its own (destination) event, not the source" do + future = create(:event, ce_hours_offered: 6, start_date: 10.days.from_now, end_date: 12.days.from_now) + dest_reg = create(:event_registration, event: future, registrant: origin_reg.registrant, + status: "attended", transferred_from_registration: origin_reg) + ce = dest_reg.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 0, skip_event_defaults: true) + + # Origin already ended, but the hours are earned at the destination event, + # which hasn't happened yet → not certifiable until that event ends. + expect(ce.certificate_available?).to be(false) + future.update!(start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce.reload.certificate_available?).to be(true) + end + + it "links a transfer-created record back to the paid original for the same license" do + origin_ce = origin_reg.continuing_education_registrations.create!( + professional_license: license, hours: 0, cost_cents: 10_000, skip_event_defaults: true) + dest_reg = create(:event_registration, event: create(:event, ce_hours_offered: 6), + registrant: origin_reg.registrant, transferred_from_registration: origin_reg) + ce = dest_reg.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 0, skip_event_defaults: true) + + expect(ce.origin_ce_registration).to eq(origin_ce) + 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 4afa56543..25166b37c 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -41,25 +41,149 @@ 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 "records the prior status when a reg is transferred out, so it can be restored" do + reg = create(:event_registration, status: "attended") + expect { reg.update!(status: "transferred_out") } + .to change { reg.status_before_transfer }.from(nil).to("attended") + 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 ".registrant_name" do @@ -88,6 +212,35 @@ end end + describe "CE certification (two-record model, #1944)" do + let(:origin_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:dest_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) } + let(:person) { create(:person) } + let(:license) { create(:professional_license, person: person) } + let!(:source) { create(:event_registration, event: origin_event, registrant: person, status: "transferred_out") } + let!(:destination) { create(:event_registration, event: dest_event, registrant: person, status: "attended", transferred_from_registration: source) } + + it "certifies each registration's own CE records" do + dest_ce = destination.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 0, skip_event_defaults: true) + + destination.mark_certificate_issued!(true) + expect(dest_ce.reload.certificate_sent?).to be(true) + expect(destination.reload.certificate_issued?).to be(true) + end + + it "does not reach across the transfer link to the other reg's CE" do + source_stub = source.continuing_education_registrations.create!( + professional_license: license, hours: 0, cost_cents: 0, skip_event_defaults: true) + dest_ce = destination.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 0, skip_event_defaults: true) + + destination.mark_certificate_issued!(true) + expect(dest_ce.reload.certificate_sent?).to be(true) + expect(source_stub.reload.certificate_sent?).to be(false) + end + end + describe "#sync_attendance_status_to_days!" do # A two-day event: start and end one day apart → day_count == 2. let(:event) { create(:event, start_date: 12.days.from_now, end_date: 13.days.from_now) } @@ -172,7 +325,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 @@ -440,6 +595,24 @@ def registration_with_scholarship end end + describe "payment-status scopes for a transferred-in reg" do + let(:new_event) { create(:event, cost_cents: 5000) } + + it "reads a transferred-in reg's paid status from its source, not its own event" do + # The new event costs $50 and the transfer holds no allocations, but its + # paid-in-full source means it belongs in .paid_in_full, not .not_paid_in_full. + from_paid = create(:event_registration, event: new_event, registrant: paid_reg.registrant, + transferred_from_registration: paid_reg) + from_unpaid = create(:event_registration, event: new_event, registrant: unpaid_reg.registrant, + transferred_from_registration: unpaid_reg) + + expect(EventRegistration.paid_in_full).to include(from_paid) + expect(EventRegistration.paid_in_full).not_to include(from_unpaid) + expect(EventRegistration.not_paid_in_full).to include(from_unpaid) + expect(EventRegistration.not_paid_in_full).not_to include(from_paid) + end + end + describe ".with_scholarship" do it "returns only registrations funded by a scholarship" do results = EventRegistration.with_scholarship @@ -1358,6 +1531,18 @@ def registration_for(person) expect(reg.reload.receipt_available?).to be(false) end + + it "mirrors the source for a transferred-in reg (no re-billing here)" do + payment = create(:payment, type: "CashPayment", amount_cents: 10_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: reg, amount: 10_000) + transferred_in = create(:event_registration, event: create(:event, cost_cents: 20_000), + registrant: reg.registrant, transferred_from_registration: reg) + + # The new event costs $200, but the source paid its balance in full, so the + # transfer owes nothing here — remaining is zero and the receipt is available. + expect(transferred_in.remaining_cost).to eq(0) + expect(transferred_in.receipt_available?).to be(true) + end end describe "#w9_available?" do diff --git a/spec/models/registration_ticket_callout_spec.rb b/spec/models/registration_ticket_callout_spec.rb index a86fec186..3d2e23114 100644 --- a/spec/models/registration_ticket_callout_spec.rb +++ b/spec/models/registration_ticket_callout_spec.rb @@ -89,6 +89,15 @@ expect(handouts.behavioral_builtin?).to be(false) expect(certificate.behavioral_builtin?).to be(true) end + + it "marks financial/credit records, but not participation or custom callouts" do + event = create(:event) + %w[ payment scholarship ce_hours certificate ].each do |key| + expect(create(:registration_ticket_callout, event:, builtin_key: key)).to be_financial_record + end + expect(create(:registration_ticket_callout, event:, builtin_key: "videoconference")).not_to be_financial_record + expect(create(:registration_ticket_callout, event:, builtin_key: nil)).not_to be_financial_record + end end describe "#published (inverse of hidden)" do diff --git a/spec/presenters/event_invoice_spec.rb b/spec/presenters/event_invoice_spec.rb index 3b57d3e98..154a971f9 100644 --- a/spec/presenters/event_invoice_spec.rb +++ b/spec/presenters/event_invoice_spec.rb @@ -78,6 +78,24 @@ expect(invoice.client_id).to eq(organization.id) end end + + context "for a transferred-in registration" do + let(:new_event) { create(:event, title: "On-Demand Follow-up", cost_cents: 40_000) } + + it "bills at the source event's cost with the source's applied credits" do + payment = create(:payment, type: "CashPayment", amount_cents: 40_000) + create(:allocation, source: payment, allocatable: registration, amount: 40_000) + transferred_in = create(:event_registration, event: new_event, registrant: registrant, + transferred_from_registration: registration) + + invoice = described_class.from_registration(transferred_in) + + expect(invoice.event).to eq(event) + expect(invoice.line_items.first.unit_price_cents).to eq(150_000) + expect(invoice.amount_applied_cents).to eq(40_000) + expect(invoice.balance_due_cents).to eq(110_000) + end + end end describe ".from_event" do diff --git a/spec/presenters/event_receipt_spec.rb b/spec/presenters/event_receipt_spec.rb index 0d9f0dede..583fcc16b 100644 --- a/spec/presenters/event_receipt_spec.rb +++ b/spec/presenters/event_receipt_spec.rb @@ -90,5 +90,23 @@ expect(receipt.client_id).to eq(organization.id) end end + + context "for a transferred-in registration" do + let(:new_event) { create(:event, title: "On-Demand Follow-up", cost_cents: 40_000) } + + it "documents the source event's charge and payments, not the new event's" do + payment = create(:payment, type: "CashPayment", amount_cents: 150_000) + create(:allocation, source: payment, allocatable: registration, amount: 150_000) + transferred_in = create(:event_registration, event: new_event, registrant: registrant, + transferred_from_registration: registration) + + receipt = described_class.from_registration(transferred_in) + + expect(receipt.line_items.first.description).to eq("AWBW 2-Day Art Facilitator Training") + expect(receipt.total_cents).to eq(150_000) + expect(receipt.amount_paid_cents).to eq(150_000) + expect(receipt.balance_cents).to eq(0) + end + end end end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 59b0665f9..aebb732c3 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -12,6 +12,43 @@ describe "as an admin" do before { sign_in admin } + describe "a transferred-in registration (two-record CE model, #1944)" do + let(:source) { create(:event_registration, event: event) } + let(:transferred_in) do + create(:event_registration, event: create(:event, ce_hours_offered: 6), + registrant: source.registrant, transferred_from_registration: source) + end + + it "blocks the manual new form, redirecting to the source registration" do + get new_continuing_education_registration_path(allocatable_sgid: transferred_in.to_sgid.to_s) + + expect(response).to redirect_to(edit_event_registration_path(source)) + end + + it "blocks manual create, redirecting to the source registration" do + expect { + post continuing_education_registrations_path, + params: { allocatable_sgid: transferred_in.to_sgid.to_s, + continuing_education_registration: { hours: "6", cost_dollars: "50", + license_kind: "LCSW", license_number: "123" } } + }.not_to change(ContinuingEducationRegistration, :count) + + expect(response).to redirect_to(edit_event_registration_path(source)) + end + + it "ignores a submitted cost when updating a transfer-created record (cost is locked)" do + license = create(:professional_license, person: source.registrant) + ce = transferred_in.continuing_education_registrations.create!( + professional_license: license, hours: 6, cost_cents: 6_000, skip_event_defaults: true) + + patch continuing_education_registration_path(ce), + params: { continuing_education_registration: { hours: "6", cost_dollars: "999", + license_kind: license.kind, license_number: license.number } } + + expect(ce.reload.cost_cents).to eq(6_000) + end + end + it "renders the index shell with the CE sign-ins menu" do ce_registration get continuing_education_registrations_path diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index de94e1528..62d1ae604 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,74 @@ 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 that the hours moved to the destination 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), skip_event_defaults: true) + + get edit_event_registration_path(source) + + expect(response.body).to include("Hours moved to") + expect(response.body).to include("Intended Training") + end + + it "shows the transferred-in reg's own CE card noting it transferred from the original" do + source_event = create(:event, title: "Origin Training", ce_hours_offered: 6) + source = create(:event_registration, event: source_event, status: "transferred_out") + incoming = create(:event_registration, event: create(:event, ce_hours_offered: 6), + registrant: source.registrant, transferred_from_registration: source) + incoming.continuing_education_registrations.create!(hours: 6, cost_cents: 0, skip_event_defaults: true, + professional_license: create(:professional_license, person: source.registrant)) + + get edit_event_registration_path(incoming) + + expect(response.body).to include("Transferred from") + expect(response.body).to include("Origin 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 @@ -547,6 +625,335 @@ 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-format events, excluding the source and the other format" do + # source's event defaults to on_demand: false (scheduled). + same_format = create(:event, title: "Destination Event", published: true, on_demand: false) + other_format = create(:event, title: "An On-Demand Event", published: true, on_demand: 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 format aren't offered as destinations. + expect(response.body).not_to include("