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 @@
+ 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. %>+ + 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. %>+ + 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 %>+ This registrant transferred in — their scholarship and payments stay on their + registration for <%= source.event.title %> and aren't billed to this event. +
+Free event — no payment.
+ <% end %> +<%= dollars_from_cents(source_scholarship.amount_cents) %>
++ <%= source_scholarship.agreement_signed? ? "Agreement signed" : "Agreement pending" %> · + <%= source_scholarship.tasks_completed? ? "Tasks complete" : "Tasks outstanding" %> +
+ <%= link_to "View scholarship record", edit_scholarship_path(source_scholarship, return_to: "registrants"), + class: "mt-1 inline-flex items-center gap-1 text-xs font-medium text-teal-700 hover:underline", + target: "_blank", rel: "noopener" %> + <% else %> +None.
+ <% end %> ++ <%= 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:
++ + Heads up — <%= source.registrant.first_name %> was already transferred in from <%= origin_reg.event.title %>. + Recording this transfer will: +
++ Only <%= source.event.on_demand? ? "other on-demand events" : "other scheduled (non-on-demand) events" %> are shown. +
++ <% 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 %> +Payment history