From fe70c66a0c8fac8fb48d092cf1d997b972d0d64a Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 2 Aug 2026 09:18:15 -0400
Subject: [PATCH 01/16] Track event-reg transfers via a back-link instead of a
status
Add transferred_from_registration_id (self-FK) so an incoming registration
points back at the one it transferred out of. The in-record keeps its own real
attendance status, fixing the loss of attendance data when it was marked
"transferred_in"; an out stays identifiable by its terminal status. Adds a
follow-up screen to record/link the destination after marking transferred out.
Closes #1944
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../event_registrations_controller.rb | 96 +++++++++++++++----
app/models/event_registration.rb | 32 +++++--
app/policies/event_registration_policy.rb | 2 +
app/views/event_registrations/_form.html.erb | 26 ++++-
.../event_registrations/transfer.html.erb | 55 +++++++++++
config/routes.rb | 2 +
...rom_registration_to_event_registrations.rb | 33 +++++++
db/schema.rb | 3 +
.../event_registration_decorator_spec.rb | 3 +-
spec/models/event_registration_spec.rb | 53 ++++++++--
spec/views/page_bg_class_alignment_spec.rb | 1 +
11 files changed, 269 insertions(+), 37 deletions(-)
create mode 100644 app/views/event_registrations/transfer.html.erb
create mode 100644 db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb
diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb
index d6c70cfd92..4e022fbd17 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 ]
def index
authorize!
@@ -98,27 +98,36 @@ def update
respond_to do |format|
format.turbo_stream
format.html {
- case params[:return_to]
- when "registrants" then redirect_to helpers.registrants_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
- when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other
- when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
- when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other
- when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
- when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other
- when "roster" then redirect_to roster_event_path(@event_registration.event), notice: notice, status: :see_other
- # Two ways back to the recipients page: the shout-outs section (the
- # feature-a-shout-out flow) or the recipient's own card (their name).
- when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other
- when "recipient_card" then redirect_to helpers.recipients_event_card_path(@event_registration.event, @event_registration.slug), notice: notice, status: :see_other
- when "attendance" then redirect_to attendance_event_path(@event_registration.event), notice: notice, status: :see_other
+ # Just marked transferred out with no destination on record yet? Send
+ # the admin straight to the transfer screen to create/link the
+ # incoming registration (issue #1944).
+ if @event_registration.saved_change_to_status? &&
+ @event_registration.transfer_destination_pending? &&
+ allowed_to?(:transfer?, @event_registration)
+ redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to]), status: :see_other
else
- # No explicit origin: keep admins in the management context (the
- # registrants list) rather than dropping them on the public
- # registration show.
- if allowed_to?(:manage?, with: EventRegistrationPolicy)
- redirect_to helpers.registrants_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
+ case params[:return_to]
+ when "registrants" then redirect_to helpers.registrants_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
+ when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other
+ when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
+ when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other
+ when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
+ when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other
+ when "roster" then redirect_to roster_event_path(@event_registration.event), notice: notice, status: :see_other
+ # Two ways back to the recipients page: the shout-outs section (the
+ # feature-a-shout-out flow) or the recipient's own card (their name).
+ when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other
+ when "recipient_card" then redirect_to helpers.recipients_event_card_path(@event_registration.event, @event_registration.slug), notice: notice, status: :see_other
+ when "attendance" then redirect_to attendance_event_path(@event_registration.event), notice: notice, status: :see_other
else
- redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
+ # No explicit origin: keep admins in the management context (the
+ # registrants list) rather than dropping them on the public
+ # registration show.
+ if allowed_to?(:manage?, with: EventRegistrationPolicy)
+ redirect_to helpers.registrants_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other
+ else
+ redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other
+ end
end
end
}
@@ -174,6 +183,7 @@ def toggle_certificate_issued
end
end
+<<<<<<< HEAD
# Inline correction of one registrant's sign-in/out times for one training day, from
# the event's attendance report. Rows carry clock times only — the day comes from the
# report section the editor was opened in — plus a blank row to add a session and a
@@ -193,6 +203,43 @@ def update_attendance
# they typed; the editor reopens on this cell prefilled with them.
flash[:attendance_rows] = rows.submitted
redirect_to attendance_report_path(date, reopen: true), status: :see_other
+=======
+ # Follow-up screen shown after a registration is marked "transferred out":
+ # pick the destination event so the incoming registration is created/linked
+ # and the transfer trail is preserved (issue #1944).
+ def transfer
+ authorize! @event_registration, to: :transfer?
+ @return_to = params[:return_to]
+ @events = transfer_destination_events
+ end
+
+ def process_transfer
+ authorize! @event_registration, to: :transfer?
+ destination_event = Event.find(params[:destination_event_id])
+
+ # The registrant may already be registered for the destination event, which
+ # would collide with the (registrant, event) uniqueness rule — link that
+ # record as the transfer target instead of creating a duplicate.
+ destination = EventRegistration.find_or_initialize_by(
+ registrant_id: @event_registration.registrant_id,
+ event_id: destination_event.id
+ )
+ destination.transferred_from_registration = @event_registration
+
+ if destination.save
+ redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence),
+ notice: "Transfer recorded — #{@event_registration.registrant.full_name} is now registered for #{destination_event.title}.",
+ status: :see_other
+ else
+ @return_to = params[:return_to]
+ @events = transfer_destination_events
+ flash.now[:alert] = destination.errors.full_messages.to_sentence
+ render :transfer, status: :unprocessable_content
+ end
+ rescue ActiveRecord::RecordNotFound
+ redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence),
+ alert: "Select a destination event to transfer to.", status: :see_other
+>>>>>>> 9f45a210d (Track event-reg transfers via a back-link instead of a status)
end
def confirm
@@ -371,6 +418,7 @@ def set_event_registration
@event_registration = EventRegistration.includes({ registrant: [ :user, { affiliations: :organization } ] }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id])
end
+<<<<<<< HEAD
# Back to the report in read mode, scrolled to the day cell that was edited, keeping
# whichever view the admin had open. `reopen:` puts that cell back into edit mode.
def attendance_report_path(date, reopen: false)
@@ -380,6 +428,14 @@ def attendance_report_path(date, reopen: false)
attendance_event_path(@event_registration.event,
ce: params[:ce].presence, group: params[:group].presence, return_to: params[:return_to].presence,
edit: (cell if reopen), anchor: cell)
+=======
+ # Events a registrant can be transferred into: any published event other than
+ # the one they're transferring out of, most recent first.
+ def transfer_destination_events
+ Event.where(published: true)
+ .where.not(id: @event_registration.event_id)
+ .order(start_date: :desc)
+>>>>>>> 9f45a210d (Track event-reg transfers via a back-link instead of a status)
end
# Creates the audited completion row for a checklist step (recording who/when),
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index 7d9f1b6160..c7bde1ca28 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -24,6 +24,15 @@ 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
@@ -38,7 +47,7 @@ class EventRegistration < ApplicationRecord
after_update :release_scholarships, if: :status_changed_to_cancelled?
after_commit :send_cancellation_emails, if: :status_changed_to_cancelled?
- ACTIVE_STATUSES = %w[ registered attended incomplete_attendance transferred_in ].freeze
+ ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze
INACTIVE_STATUSES = %w[ cancelled no_show transferred_out ].freeze
ATTENDANCE_STATUSES = (ACTIVE_STATUSES + INACTIVE_STATUSES).freeze
# Attendance outcomes surfaced as their own participation buckets; every other
@@ -122,7 +131,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"
@@ -561,14 +569,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,
diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb
index 7a95e78e1f..5030b3649e 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/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb
index f658ccb844..1fd56fd434 100644
--- a/app/views/event_registrations/_form.html.erb
+++ b/app/views/event_registrations/_form.html.erb
@@ -17,7 +17,6 @@
"incomplete_attendance" => "text-amber-600",
"cancelled" => "text-gray-500",
"no_show" => "text-red-600",
- "transferred_in" => "text-teal-600",
"transferred_out" => "text-purple-600"
} %>
<% status_icons = {
@@ -26,7 +25,6 @@
"incomplete_attendance" => "fa-clock",
"cancelled" => "fa-ban",
"no_show" => "fa-circle-xmark",
- "transferred_in" => "fa-right-to-bracket",
"transferred_out" => "fa-right-from-bracket"
} %>
<% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %>
@@ -104,6 +102,30 @@
data: { "attendance-status-target": "select", action: "attendance-status#update" },
"aria-label": "Registration status" %>
+
+ <%# ---- Transfer trail (issue #1944) — link the paired registration so
+ the historical in/out relationship stays visible from either end. ---- %>
+ <% if f.object.transferred_out? %>
+ <% destination = f.object.transferred_to_registration %>
+ <% if destination %>
+
+ Recording the destination event creates (or links) this person's registration
+ there and keeps the transfer history. The new registration tracks its own
+ attendance — you can skip this now and record it later.
+
diff --git a/config/routes.rb b/config/routes.rb
index 76743101a6..07a0454021 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -104,6 +104,8 @@
member do
get :confirm
post :process_confirm
+ get :transfer
+ post :process_transfer
get :link_organization
post :select_organization
post :create_organization
diff --git a/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb b/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb
new file mode 100644
index 0000000000..c88d1da20c
--- /dev/null
+++ b/db/migrate/20260802131259_add_transferred_from_registration_to_event_registrations.rb
@@ -0,0 +1,33 @@
+class AddTransferredFromRegistrationToEventRegistrations < ActiveRecord::Migration[8.1]
+ # Records where a registration was transferred *from*: the incoming ("in")
+ # registration points back at the outgoing ("out") one. Putting the FK on the
+ # in-record means an in is identifiable directly (its FK is set) without
+ # scanning every other row, while an out stays identifiable by its terminal
+ # "transferred_out" status. See issue #1944.
+ def up
+ unless column_exists?(:event_registrations, :transferred_from_registration_id)
+ add_column :event_registrations, :transferred_from_registration_id, :bigint
+ end
+ unless index_exists?(:event_registrations, :transferred_from_registration_id)
+ add_index :event_registrations, :transferred_from_registration_id
+ end
+ unless foreign_key_exists?(:event_registrations, column: :transferred_from_registration_id)
+ add_foreign_key :event_registrations, :event_registrations,
+ column: :transferred_from_registration_id, on_delete: :nullify
+ end
+
+ # "transferred_in" is no longer an attendance status — the transfer link now
+ # records the "in" relationship, freeing the status to track real attendance.
+ # Existing transferred_in rows have no link to recover, so reset them to
+ # registered (their default) rather than leaving an invalid status.
+ execute("UPDATE event_registrations SET status = 'registered' WHERE status = 'transferred_in'")
+ end
+
+ def down
+ # Best-effort inverse: rows still carrying a transfer link were the "in"s.
+ execute("UPDATE event_registrations SET status = 'transferred_in' WHERE transferred_from_registration_id IS NOT NULL")
+ remove_foreign_key :event_registrations, column: :transferred_from_registration_id, if_exists: true
+ remove_index :event_registrations, :transferred_from_registration_id, if_exists: true
+ remove_column :event_registrations, :transferred_from_registration_id, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8da6a717f6..292b77e4e8 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -519,6 +519,7 @@
t.string "slug"
t.boolean "someone_else_will_pay", default: false, null: false
t.string "status", default: "registered", null: false
+ t.bigint "transferred_from_registration_id"
t.datetime "updated_at", null: false
t.boolean "w9_requested", default: false, null: false
t.index ["checkout_session_id"], name: "index_event_registrations_on_checkout_session_id"
@@ -527,6 +528,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|
@@ -1828,6 +1830,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 97e2543a7e..13e847616f 100644
--- a/spec/decorators/event_registration_decorator_spec.rb
+++ b/spec/decorators/event_registration_decorator_spec.rb
@@ -75,7 +75,8 @@
end
it "is deletable (no reason) when transferred in with no allocations" do
- reg = create(:event_registration, status: "transferred_in")
+ source = create(:event_registration, status: "transferred_out")
+ reg = create(:event_registration, status: "registered", transferred_from_registration: source)
expect(reg.decorate.deletion_blocked_reason).to be_nil
end
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index f6afe5b2db..98c297c4b0 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -41,25 +41,60 @@
reg = create(:event_registration, status: "transferred_out")
expect(reg).not_to be_active
end
-
- it "returns true for transferred_in status" do
- reg = create(:event_registration, status: "transferred_in")
- expect(reg).to be_active
- end
end
describe ".active" do
it "returns only registrations with active statuses" do
active_reg = create(:event_registration, status: "registered")
- transferred_in_reg = create(:event_registration, status: "transferred_in")
cancelled_reg = create(:event_registration, status: "cancelled")
no_show_reg = create(:event_registration, status: "no_show")
transferred_out_reg = create(:event_registration, status: "transferred_out")
results = EventRegistration.active
- expect(results).to include(active_reg, transferred_in_reg)
+ expect(results).to include(active_reg)
expect(results).not_to include(cancelled_reg, no_show_reg, transferred_out_reg)
end
+
+ it "includes a transferred-in registration, which keeps its own active status" do
+ source = create(:event_registration, status: "transferred_out")
+ incoming = create(:event_registration, status: "registered", transferred_from_registration: source)
+
+ expect(EventRegistration.active).to include(incoming)
+ end
+ end
+
+ describe "transfer trail" do
+ let(:source) { create(:event_registration, status: "transferred_out") }
+ let!(:incoming) { create(:event_registration, status: "registered", transferred_from_registration: source) }
+
+ it "links the incoming registration back to the one it came from" do
+ expect(incoming.transferred_from_registration).to eq(source)
+ expect(source.reload.transferred_to_registration).to eq(incoming)
+ end
+
+ it "identifies an in by the back-link, not by status" do
+ expect(incoming).to be_transferred_in
+ expect(incoming).not_to be_transferred_out
+ expect(create(:event_registration, status: "registered")).not_to be_transferred_in
+ end
+
+ it "identifies an out by its terminal status" do
+ expect(source).to be_transferred_out
+ expect(source).not_to be_transferred_in
+ end
+
+ it "reports a pending destination only while an out has no incoming record" do
+ pending = create(:event_registration, status: "transferred_out")
+ expect(pending).to be_transfer_destination_pending
+ expect(source).not_to be_transfer_destination_pending
+ expect(incoming).not_to be_transfer_destination_pending
+ end
+
+ it "nullifies the back-link if the source is destroyed" do
+ source.update_column(:status, "registered") # bypass the deletion guard for the test
+ source.destroy
+ expect(incoming.reload.transferred_from_registration_id).to be_nil
+ end
end
describe "#sync_attendance_status_to_days!" do
@@ -146,7 +181,9 @@
it "returns true for a transferred-in registration with no allocations" do
# Transferred-in is an ordinary active registration here; the source event's
# transferred_out record preserves the transfer history.
- expect(create(:event_registration, status: "transferred_in")).to be_deletable
+ source = create(:event_registration, status: "transferred_out")
+ incoming = create(:event_registration, status: "registered", transferred_from_registration: source)
+ expect(incoming).to be_deletable
end
end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index bd401c4af1..da04b2af8f 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -255,6 +255,7 @@
# ─── admin-only confirm/interstitial ───
"app/views/event_registrations/confirm.html.erb" => "admin-only bg-blue-100",
+ "app/views/event_registrations/transfer.html.erb" => "admin-only bg-blue-100",
"app/views/event_registrations/link_organization.html.erb" => "admin-only bg-blue-100",
"app/views/users/confirm_email_change.html.erb" => "admin-only bg-blue-100",
"app/views/users/confirm_email_manual.html.erb" => "admin-only bg-blue-100"
From cb89d00c4baedd56bb510715bd95d510240d498b Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 2 Aug 2026 09:23:00 -0400
Subject: [PATCH 02/16] Add specs for the transfer trail and follow-up flow
Cover the transferred_from/to associations, transferred_in?/transfer_destination_pending?,
the post-save redirect to the transfer screen, and the transfer create/link flow.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
spec/requests/event_registrations_spec.rb | 91 +++++++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index de94e15280..2444ede613 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -477,6 +477,23 @@ 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 "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
end
describe "PATCH /event_registrations/:id" do
@@ -547,6 +564,73 @@ 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 "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 "renders the destination picker, excluding the source event" do
+ other = create(:event, title: "Destination Event", published: true)
+
+ get transfer_event_registration_path(source)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("Destination Event")
+ # The source event isn't offered as a transfer destination.
+ expect(response.body).not_to include("
+ <%# After a transfer, the hours are certified at the destination event
+ while this record + its payment stay here (issue #1944). %>
+ <% if ce_registration.certified_elsewhere? %>
+ <% certified_at = ce_registration.certified_at_registration %>
+
+ <% 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/_transferred_in_financials.html.erb b/app/views/event_registrations/_transferred_in_financials.html.erb
index 3fee00ad0b..65fb7e6bc0 100644
--- a/app/views/event_registrations/_transferred_in_financials.html.erb
+++ b/app/views/event_registrations/_transferred_in_financials.html.erb
@@ -98,6 +98,11 @@
<% end %>
<% if source.ce_registered? %>
+ <%# Hours are earned/certified HERE (this event); the CE record + payment
+ stay on the original registration. %>
+
+ Hours earned at this event
+
<%= source.ce_status_label %>
<%= plain_number(source.ce_hours_total) || "0" %> hrs
diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb
index 180d29c3f0..268a3df0ba 100644
--- a/spec/models/continuing_education_registration_spec.rb
+++ b/spec/models/continuing_education_registration_spec.rb
@@ -157,6 +157,37 @@ def ce_reg_for(event:, status:, cost_cents: 0)
ce_reg.mark_certificate_sent!
expect(ce_reg.certificate_sent?).to be(true)
end
+
+ describe "certified at a different event (transfer)" do
+ let(:home_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) }
+ let(:intended_event) { create(:event, ce_hours_offered: 6, start_date: 10.days.from_now, end_date: 12.days.from_now) }
+ let(:home_reg) { create(:event_registration, event: home_event, status: "transferred_out") }
+ let(:ce_reg) do
+ create(:continuing_education_registration, event_registration: home_reg, cost_cents: 0,
+ professional_license: create(:professional_license, person: home_reg.registrant))
+ end
+
+ it "defaults certified_at_registration to the home reg when not transferred" do
+ solo = create(:event_registration, event: home_event, status: "attended")
+ ce = create(:continuing_education_registration, event_registration: solo, cost_cents: 0,
+ professional_license: create(:professional_license, person: solo.registrant))
+ expect(ce.certified_at_registration).to eq(solo)
+ end
+
+ it "certifies against the destination (intended) event, not the home event" do
+ # Same person transfers on to the intended event (derives home_reg.transferred_to).
+ intended_reg = create(:event_registration, event: intended_event, registrant: home_reg.registrant,
+ status: "attended", transferred_from_registration: home_reg)
+
+ expect(ce_reg.reload.certified_at_registration).to eq(intended_reg)
+ # Home event already ended, but hours are certified at the intended event,
+ # which hasn't happened yet → not certifiable until that event ends.
+ expect(ce_reg.certificate_available?).to be(false)
+
+ intended_event.update!(start_date: 3.days.ago, end_date: 1.day.ago)
+ expect(ce_reg.reload.certificate_available?).to be(true)
+ end
+ end
end
# Payment interface comes from Registerable, driven by the CE record's own
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index 52a84d54e0..de808cfd40 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -180,6 +180,31 @@
end
end
+ describe "CE certification across a transfer" do
+ let(:home_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) }
+ let(:intended_event) { create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) }
+ let(:person) { create(:person) }
+ let!(:source) { create(:event_registration, event: home_event, registrant: person, status: "transferred_out") }
+ let!(:intended) { create(:event_registration, event: intended_event, registrant: person, status: "attended", transferred_from_registration: source) }
+ let!(:ce) do
+ create(:continuing_education_registration, event_registration: source,
+ professional_license: create(:professional_license, person: person))
+ end
+
+ it "lets the intended event issue the CE certificate for hours earned there" do
+ expect(intended.certifiable_ce_registrations).to eq([ ce ])
+ intended.mark_certificate_issued!(true)
+ expect(ce.reload.certificate_sent?).to be(true)
+ expect(intended.reload.certificate_issued?).to be(true)
+ end
+
+ it "does not issue the transferred-out hours from the source registration" do
+ expect(source.certifiable_ce_registrations).to be_empty
+ source.mark_certificate_issued!(true)
+ expect(ce.reload.certificate_sent?).to be(false)
+ end
+ end
+
describe "#sync_attendance_status_to_days!" do
# 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) }
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index 08bc734a84..0c55e4e9b6 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -496,6 +496,20 @@ def toggle_certificate(value)
expect(response.body).to include("Record where they transferred to")
end
+ it "notes on the source CE card where the hours are certified after a transfer" do
+ source_event = create(:event, ce_hours_offered: 6)
+ source = create(:event_registration, event: source_event, status: "transferred_out")
+ intended = create(:event, title: "Intended Training")
+ create(:event_registration, event: intended, registrant: source.registrant, transferred_from_registration: source)
+ create(:continuing_education_registration, event_registration: source,
+ professional_license: create(:professional_license, person: source.registrant))
+
+ get edit_event_registration_path(source)
+
+ expect(response.body).to include("Hours certified at")
+ expect(response.body).to include("Intended Training")
+ end
+
it "shows the source event on a transferred-in registration" do
source = create(:event_registration, event: event, status: "transferred_out")
incoming = create(:event_registration, event: new_event, transferred_from_registration: source)
@@ -685,6 +699,20 @@ def toggle_certificate(value)
expect(response).to redirect_to(transfer_event_registration_path(source))
end
+
+ it "certifies the source reg's CE hours at the destination reg" do
+ ce = create(:continuing_education_registration, event_registration: source,
+ professional_license: create(:professional_license, person: source.registrant))
+
+ post process_transfer_event_registration_path(source),
+ params: { destination_event_id: destination_event.id }
+
+ incoming = EventRegistration.find_by(registrant: source.registrant, event: destination_event)
+ # Derived from the transfer link — no re-pointing needed.
+ expect(ce.reload.certified_at_registration).to eq(incoming)
+ expect(incoming.certifiable_ce_registrations).to include(ce)
+ expect(source.reload.certifiable_ce_registrations).to be_empty
+ end
end
end
From 9fa932d3df9517a83d70f475442272a6354b6920 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 10 Aug 2026 16:49:24 -0400
Subject: [PATCH 09/16] Note transferred-in recipients on the recipient card;
lock attendance exclusion
The event recipients card now flags a transferred-in scholarship recipient as
"Billed to original event" (recognized here, dollars on the source). Add a test
pinning that a transferred-out registration is excluded from the original
event's attendee count/attendance (it's an inactive status).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/views/events/_recipient_card.html.erb | 9 +++++++++
spec/requests/events_spec.rb | 1 +
spec/services/event_dashboard_spec.rb | 12 ++++++++++++
3 files changed, 22 insertions(+)
diff --git a/app/views/events/_recipient_card.html.erb b/app/views/events/_recipient_card.html.erb
index ae63dd75b1..34fa65ac59 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_registrant_ids.include?(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/spec/requests/events_spec.rb b/spec/requests/events_spec.rb
index df3d4c8513..58820d1f33 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -3200,6 +3200,7 @@ def ce_chip_text
get recipients_event_path(event)
expect(response.body).to include("Transo Ferredin")
+ expect(response.body).to include("Billed to original event")
end
it "shows a recipient city breakdown, grouped by the registration-linked org, in the lazy charts frame" do
diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb
index 0aeb1e7b13..12df1cdd26 100644
--- a/spec/services/event_dashboard_spec.rb
+++ b/spec/services/event_dashboard_spec.rb
@@ -1244,6 +1244,18 @@ def opt_in(person, text:)
expect(dashboard.registrant_count).to eq(2)
end
+ it "excludes a transferred-out registration from this event's attendee count" do
+ create(:event_registration, event: event, status: "transferred_out")
+
+ # Only paid_reg + the transferred-in incoming reg count; the transferred-out
+ # one withdrew and isn't an attendee here.
+ expect(dashboard.registrant_count).to eq(2)
+ expect(dashboard.expected_attendee_count).to eq(2)
+ expect(dashboard.attendance_registrants("attended", "registered")).not_to include(
+ an_object_having_attributes(id: EventRegistration.find_by(event: event, status: "transferred_out").registrant_id)
+ )
+ end
+
it "excludes the transferred-in registrant from every financial total" do
# Only the fully-paid registrant is billable here; the transferred-in one
# owes nothing to this event (its balance is on the source registration).
From 17d5492bcbcbc3fbcc4520bfe6036751014dc79f Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 10 Aug 2026 16:57:31 -0400
Subject: [PATCH 10/16] Surface CE certification at the intended event on
reporting/roster
Reflect where CE is certified rather than where it's billed:
- Readiness certificate checks (pending/sent) use the certifiable set (earned
here), so the intended event's roster shows the CE certificate pending and the
source no longer does; payment/license checks stay on the home reg.
- The registrants roster CE column shows a transferred-in reg's certified CE
(linking to the record on the original) instead of offering "Create".
- Add EventDashboard#transferred_in_recipient? (public) and use it for the
recipient-card "billed to original event" note (fixes a private-method call).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/services/event_dashboard.rb | 6 ++++
app/services/event_registration_readiness.rb | 21 ++++++++----
app/views/events/_recipient_card.html.erb | 2 +-
.../events/_registrants_results.html.erb | 34 ++++++++++++++-----
.../event_registration_readiness_spec.rb | 25 ++++++++++++++
5 files changed, 72 insertions(+), 16 deletions(-)
diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb
index 1fbdaebe4a..0288aea557 100644
--- a/app/services/event_dashboard.rb
+++ b/app/services/event_dashboard.rb
@@ -74,6 +74,12 @@ 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
diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb
index ceb2640648..3089b568b7 100644
--- a/app/services/event_registration_readiness.rb
+++ b/app/services/event_registration_readiness.rb
@@ -169,7 +169,7 @@ def ce_license_missing?
end
def ce_certificate_pending?
- registration.ce_registered? && !ce_certificate_sent?
+ certifiable_ce.any? && !ce_certificate_sent?
end
# Post-event criteria are only met by a full "attended". "incomplete_attendance"
@@ -179,12 +179,21 @@ def attendance_issue
registration.status == "incomplete_attendance" ? "Attendance incomplete" : "Did not attend"
end
- # The admin-created CE billing records for this registration (preloaded on the
- # roster). Their payment + certificate state drives the CE readiness checks.
+ # The admin-created CE billing records homed on this registration (preloaded on
+ # the roster). Their PAYMENT + license state drives the CE money/license checks;
+ # these stay on the home reg after a transfer.
def ce_registrations
registration.continuing_education_registrations
end
+ # The CE this registration is responsible for CERTIFYING — its own plus any
+ # transferred in to be certified here (a transferred-out reg certifies none).
+ # Certificate checks use this so certification follows the person to the
+ # intended event. (issue #1944)
+ def certifiable_ce
+ registration.certifiable_ce_registrations
+ end
+
# CE is paid once every CE registration is paid in full. A requested-but-not-yet
# -created CE registration counts as unpaid (nothing to pay against yet).
def ce_paid?
@@ -197,9 +206,9 @@ def registration_certificate_sent?
registration.certificate_sent?
end
- # CE certificates are sent once every CE registration's certificate has been
- # sent. No CE registration yet means nothing has been issued.
+ # CE certificates are sent once every CE registration this reg certifies has
+ # been sent. No certifiable CE means nothing has been issued.
def ce_certificate_sent?
- ce_registrations.any? && ce_registrations.all?(&:certificate_sent?)
+ certifiable_ce.any? && certifiable_ce.all?(&:certificate_sent?)
end
end
diff --git a/app/views/events/_recipient_card.html.erb b/app/views/events/_recipient_card.html.erb
index 34fa65ac59..076affd6d0 100644
--- a/app/views/events/_recipient_card.html.erb
+++ b/app/views/events/_recipient_card.html.erb
@@ -154,7 +154,7 @@
<% 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_registrant_ids.include?(person.id) %>
+ <% if dashboard.transferred_in_recipient?(person.id) %>
diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb
index 6eda46416d..731aa6e80d 100644
--- a/app/views/events/_registrants_results.html.erb
+++ b/app/views/events/_registrants_results.html.erb
@@ -374,16 +374,32 @@
<%# Canonical CE badge (Requested → License # needed → $X due →
Pending → Issued). Each state links to the connected CE
registration's edit page; "Create" opens the new CE form. %>
- <% ce_registration = registration.continuing_education_registrations.first %>
- <% if ce_registration.nil? %>
- <%= render "shared/badge",
- label: "Create",
- classes: "bg-gray-50 text-gray-400 border-gray-200",
- href: new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"),
- title: "Add CE registration" %>
+ <% if registration.transferred_in? %>
+ <%# CE is certified here but the record + payment live on the
+ original registration — link there, don't offer "Create". %>
+ <% certified_ce = registration.certifiable_ce_registrations.first %>
+ <% if certified_ce %>
+ <%= render "shared/badge",
+ label: "CE (transferred)",
+ classes: "bg-teal-50 text-teal-700 border-teal-200",
+ href: edit_continuing_education_registration_path(certified_ce, return_to: "registrants"),
+ title: "CE hours certified here; record + payment on the original registration",
+ target: "_blank", rel: "noopener" %>
+ <% else %>
+ —
+ <% end %>
<% else %>
- <%= render "event_registrations/ce_status_badge", registration: registration,
- href: edit_continuing_education_registration_path(ce_registration, return_to: "registrants") %>
+ <% ce_registration = registration.continuing_education_registrations.first %>
+ <% if ce_registration.nil? %>
+ <%= render "shared/badge",
+ label: "Create",
+ classes: "bg-gray-50 text-gray-400 border-gray-200",
+ href: new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"),
+ title: "Add CE registration" %>
+ <% else %>
+ <%= render "event_registrations/ce_status_badge", registration: registration,
+ href: edit_continuing_education_registration_path(ce_registration, return_to: "registrants") %>
+ <% end %>
<% end %>
<% end %>
diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb
index 23f3338514..c57dba6047 100644
--- a/spec/services/event_registration_readiness_spec.rb
+++ b/spec/services/event_registration_readiness_spec.rb
@@ -177,6 +177,31 @@ def award_scholarship(reg, tasks_completed:, amount: 1000)
end
end
+ describe "CE certificate across a transfer" do
+ let(:source) { create(:event_registration, event: create(:event, ce_hours_offered: 6, cost_cents: 0), status: "transferred_out") }
+ let(:intended) do
+ create(:event_registration, event: create(:event, ce_hours_offered: 6, cost_cents: 0),
+ registrant: source.registrant, status: "attended", transferred_from_registration: source)
+ end
+ let!(:ce) do
+ create(:continuing_education_registration, event_registration: source, cost_cents: 0,
+ professional_license: create(:professional_license, person: source.registrant))
+ end
+
+ it "flags the intended event's roster with the certified CE still pending" do
+ expect(described_class.new(intended).completion_issues).to include("CE certificate not sent")
+ end
+
+ it "clears once the certified CE has been issued" do
+ ce.mark_certificate_sent!
+ expect(described_class.new(intended).completion_issues).not_to include("CE certificate not sent")
+ end
+
+ it "does not flag the certified CE on the source (transferred-out) registration" do
+ expect(described_class.new(source).certificate_issues).not_to include("CE certificate not sent")
+ end
+ end
+
describe "#status" do
it "is :not_ready when a pre-event condition is outstanding" do
# default registrant is unpaid on a paid event
From 313579e0bc29e52f19d1729149b82a745253eac6 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 10 Aug 2026 17:01:48 -0400
Subject: [PATCH 11/16] Add transfer notices to the ticket and builtin callout
pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both the original and the new event's registration have their own ticket, so a
shared _transfer_notice partial explains on each where the money/scholarship/CE
records live and where attendance + the certificate are earned. Rendered on the
ticket and the payment, CE, scholarship, certificate, invoice, and receipt
pages — surfaced to attendee and staff, linking to the paired registration's
ticket.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../event_registrations/_ticket.html.erb | 1 +
.../_transfer_notice.html.erb | 26 +++++++++++++++++++
app/views/events/callouts/ce.html.erb | 1 +
.../events/callouts/certificate.html.erb | 1 +
app/views/events/callouts/payment.html.erb | 1 +
.../events/callouts/scholarship.html.erb | 1 +
.../events/registrations/invoice.html.erb | 1 +
.../events/registrations/receipt.html.erb | 1 +
spec/requests/events/registrations_spec.rb | 21 +++++++++++++++
9 files changed, 54 insertions(+)
create mode 100644 app/views/event_registrations/_transfer_notice.html.erb
diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index a0679417e6..a9ae00658e 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -1,6 +1,7 @@
<% preview = local_assigns.fetch(:preview, false) %>
<% show_all = local_assigns.fetch(:show_all, false) %>
diff --git a/app/views/event_registrations/_transfer_notice.html.erb b/app/views/event_registrations/_transfer_notice.html.erb
new file mode 100644
index 0000000000..7b81347980
--- /dev/null
+++ b/app/views/event_registrations/_transfer_notice.html.erb
@@ -0,0 +1,26 @@
+<%# Transfer notice for the registrant ticket + builtin callout pages (issue
+ #1944). Both the original and the new event's registration have their own
+ ticket; this explains, on each, where the money/scholarship/CE live and where
+ attendance + the certificate are earned — so attendee and staff see accurate
+ info on both. Takes `event_registration`. %>
+<% if event_registration.transferred_in? && (source = event_registration.transferred_from_registration) %>
+
+
+
+ You transferred into <%= event_registration.event.title %>. Your payment,
+ scholarship, and continuing-education records stay on your
+ <%= link_to "original registration", registration_ticket_path(source.slug), class: "font-semibold underline" %>
+ — your attendance here and your certificate for these hours are earned at this event.
+
+ 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/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb
index b6c496c3df..60184239c6 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 4f3c446481..b96baae52a 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 b0cca1fa0e..af0bed73bb 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? %>
diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb
index 2db435d453..36d901b1c0 100644
--- a/spec/requests/events/registrations_spec.rb
+++ b/spec/requests/events/registrations_spec.rb
@@ -18,6 +18,27 @@
get registration_ticket_path(registration.slug)
expect(response).to have_http_status(:success)
end
+
+ it "notes a transfer out and links to the new registration's ticket" do
+ destination = create(:event_registration, event: create(:event), registrant: user.person)
+ registration.update!(status: "transferred_out")
+ destination.update!(transferred_from_registration: registration)
+
+ get registration_ticket_path(registration.slug)
+
+ expect(response.body).to include("You transferred out")
+ expect(response.body).to include(registration_ticket_path(destination.slug))
+ end
+
+ it "notes a transfer in and links back to the original registration's ticket" do
+ source = create(:event_registration, event: create(:event), registrant: user.person, status: "transferred_out")
+ registration.update!(transferred_from_registration: source)
+
+ get registration_ticket_path(registration.slug)
+
+ expect(response.body).to include("You transferred into")
+ expect(response.body).to include(registration_ticket_path(source.slug))
+ end
end
context "as an admin" do
From 5153d0ee59deeff21883b08cc8cf2d2678bc1e12 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 16 Aug 2026 07:41:37 -0400
Subject: [PATCH 12/16] Collapse a double transfer to two live regs, not three
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A transfer-in is a temporary middle stop: when it's transferred out
again (A→B→C), keep only the endpoints. Point the new reg at the
original source and destroy the middle, so attendance and the transfer
trail stay on the two real registrations. (#1944)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../event_registrations_controller.rb | 16 +++++++++++++---
spec/requests/event_registrations_spec.rb | 18 ++++++++++++++++++
2 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb
index 629a784469..8cb58a1d5d 100644
--- a/app/controllers/event_registrations_controller.rb
+++ b/app/controllers/event_registrations_controller.rb
@@ -225,11 +225,21 @@ def process_transfer
registrant_id: @event_registration.registrant_id,
event_id: destination_event.id
)
- destination.transferred_from_registration = @event_registration
+ # Collapse a double transfer (A→B→C) to two live regs: when the reg being
+ # transferred out is itself a transfer-in, it's a temporary middle stop —
+ # point the new reg straight at the original source and drop the middle. (#1944)
+ source = @event_registration.transferred_from_registration || @event_registration
+ destination.transferred_from_registration = source
+
+ saved = ActiveRecord::Base.transaction do
+ next false unless destination.save
+ @event_registration.destroy! if @event_registration.transferred_in?
+ true
+ end
- if destination.save
+ if saved
redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence),
- notice: "Transfer recorded — #{@event_registration.registrant.full_name} is now registered for #{destination_event.title}.",
+ notice: "Transfer recorded — #{source.registrant.full_name} is now registered for #{destination_event.title}.",
status: :see_other
else
@return_to = params[:return_to]
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index 0c55e4e9b6..69cbfa256c 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -694,6 +694,24 @@ def toggle_certificate(value)
expect(existing.reload.transferred_from_registration).to eq(source)
end
+ it "collapses a double transfer, pointing the new reg at the original and dropping the middle" do
+ original = create(:event_registration, status: "transferred_out")
+ middle = create(:event_registration, registrant: original.registrant,
+ status: "transferred_out", transferred_from_registration: original)
+
+ # One reg created, the middle destroyed — net zero.
+ expect {
+ post process_transfer_event_registration_path(middle),
+ params: { destination_event_id: destination_event.id }
+ }.not_to change(EventRegistration, :count)
+
+ final = EventRegistration.find_by(registrant: middle.registrant, event: destination_event)
+ expect(final.transferred_from_registration).to eq(original)
+ expect(EventRegistration.exists?(middle.id)).to be(false)
+ expect(original.reload.transferred_to_registration).to eq(final)
+ expect(response).to redirect_to(edit_event_registration_path(final))
+ end
+
it "sends the admin back to pick an event when none was chosen" do
post process_transfer_event_registration_path(source)
From 358c64db98a152aab9b4a04ff708e415832226b6 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 16 Aug 2026 12:23:04 -0400
Subject: [PATCH 13/16] Scope transfers by on-demand format; add recipient-card
billed note
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Transfers now match on-demand ↔ on-demand and scheduled ↔ scheduled (was
facilitator-training kind). Picker hint + tests updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../event_registrations_controller.rb | 23 ++++++---
app/models/event_registration.rb | 11 +++++
.../event_registrations/transfer.html.erb | 2 +-
..._before_transfer_to_event_registrations.rb | 9 ++++
db/schema.rb | 3 +-
spec/models/event_registration_spec.rb | 6 +++
spec/requests/event_registrations_spec.rb | 48 ++++++++++++++-----
7 files changed, 80 insertions(+), 22 deletions(-)
create mode 100644 db/migrate/20260816155204_add_status_before_transfer_to_event_registrations.rb
diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb
index 8cb58a1d5d..605cc0a6a7 100644
--- a/app/controllers/event_registrations_controller.rb
+++ b/app/controllers/event_registrations_controller.rb
@@ -226,10 +226,19 @@ def process_transfer
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, it's a temporary middle stop —
- # point the new reg straight at the original source and drop the middle. (#1944)
+ # 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
- destination.transferred_from_registration = source
+
+ if destination == source
+ # Transferring back to the origin event undoes the whole chain: restore the
+ # origin to the status it held before it was transferred out, instead of
+ # linking it to itself.
+ destination.status = destination.status_before_transfer.presence || "registered"
+ destination.status_before_transfer = nil
+ else
+ destination.transferred_from_registration = source
+ end
saved = ActiveRecord::Base.transaction do
next false unless destination.save
@@ -440,11 +449,11 @@ def attendance_report_path(date, reopen: false)
end
# Events a registrant can be transferred into: published events of the same
- # kind as the one they're leaving — a facilitator training only transfers to
- # another facilitator training, and a non-training only to another
- # non-training — excluding the source event, most recent first.
+ # 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, facilitator_training: @event_registration.event.facilitator_training)
+ Event.where(published: true, on_demand: @event_registration.event.on_demand)
.where.not(id: @event_registration.event_id)
.order(start_date: :desc)
end
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index d5545517a8..e6310f1fdf 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -49,6 +49,7 @@ 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?
@@ -1045,6 +1046,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/views/event_registrations/transfer.html.erb b/app/views/event_registrations/transfer.html.erb
index c64d85399a..ba5e664311 100644
--- a/app/views/event_registrations/transfer.html.erb
+++ b/app/views/event_registrations/transfer.html.erb
@@ -45,7 +45,7 @@
required: true,
class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
- Only <%= source.event.facilitator_training? ? "other facilitator trainings" : "other (non-training) events" %> are shown.
+ Only <%= source.event.on_demand? ? "other on-demand events" : "other scheduled (non-on-demand) events" %> are shown.
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 0000000000..0915ae0d19
--- /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 292b77e4e8..be464f8d77 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_15_114533) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_16_155204) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -519,6 +519,7 @@
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
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index de808cfd40..d6c23ef80a 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -90,6 +90,12 @@
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
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index 69cbfa256c..ed952b9895 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -642,29 +642,30 @@ def toggle_certificate(value)
let!(:source) { create(:event_registration, event: event, status: "transferred_out") }
describe "GET /event_registrations/:id/transfer" do
- it "offers same-kind events, excluding the source and the other kind" do
- same_kind = create(:event, title: "Destination Event", published: true, facilitator_training: false)
- other_kind = create(:event, title: "A Facilitator Training", published: true, facilitator_training: true)
+ 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 kind aren't offered as destinations.
+ # The source event and the opposite format aren't offered as destinations.
expect(response.body).not_to include("
Date: Sun, 16 Aug 2026 15:30:54 -0400
Subject: [PATCH 14/16] Resolve a transfer-in reg's money/records to its source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A transferred-in registration carries no money of its own — the balance,
payments, invoice, receipt, and scholarship all live on the source it came
from (the old event, where they actually paid). Route them there so the new
ticket never re-bills a paid transfer, the invoice/receipt document the old
cost, and the roster's unpaid filter buckets a transfer by the source's
balance. Hide participation callouts on a transferred-out ticket (they
withdrew) while keeping their financial/credit records, and block scholarship
creation on a transfer-in at the URL, not just the UI.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/controllers/scholarships_controller.rb | 14 ++++
app/models/event_registration.rb | 64 +++++++++++++------
app/models/registration_ticket_callout.rb | 14 ++++
app/presenters/event_invoice.rb | 4 ++
app/presenters/event_receipt.rb | 4 ++
app/services/builtin_callout_cards.rb | 5 +-
.../event_registrations/_ticket.html.erb | 6 +-
spec/models/event_registration_spec.rb | 30 +++++++++
.../registration_ticket_callout_spec.rb | 9 +++
spec/presenters/event_invoice_spec.rb | 18 ++++++
spec/presenters/event_receipt_spec.rb | 18 ++++++
spec/requests/event_registrations_spec.rb | 20 ++++++
spec/requests/scholarships_spec.rb | 23 +++++++
spec/services/builtin_callout_cards_spec.rb | 10 +++
14 files changed, 216 insertions(+), 23 deletions(-)
diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb
index 0db5478d21..4ee4b83987 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/event_registration.rb b/app/models/event_registration.rb
index e6310f1fdf..47a0e30edd 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -340,30 +340,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
@@ -709,6 +709,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
@@ -732,6 +733,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
diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb
index c936cfdd31..c3e70f0b2a 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/presenters/event_invoice.rb b/app/presenters/event_invoice.rb
index f7f8a1fabd..d989ce884f 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 0ce473e916..14557149f9 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 c427a1c7c5..a69e1edc3c 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/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index a9ae00658e..e2cec56cfe 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -72,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? %>
@@ -127,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/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index d6c23ef80a..fc311f62b6 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -565,6 +565,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
@@ -1483,6 +1501,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 a86fec1862..3d2e231140 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 3b57d3e98b..154a971f9a 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 0d9f0dede9..583fcc16bb 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/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index ed952b9895..2497296e53 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -713,6 +713,26 @@ def toggle_certificate(value)
expect(response).to redirect_to(edit_event_registration_path(final))
end
+ it "logs an Ahoy destroy lifecycle capturing the dropped middle registration's data" do
+ allow(Analytics::LifecycleBuffer).to receive(:push)
+ original = create(:event_registration, status: "transferred_out")
+ middle = create(:event_registration, registrant: original.registrant,
+ status: "transferred_out", transferred_from_registration: original)
+
+ post process_transfer_event_registration_path(middle),
+ params: { destination_event_id: destination_event.id }
+
+ expect(Analytics::LifecycleBuffer).to have_received(:push).with(
+ hash_including(
+ name: "destroy.event_registration",
+ properties: hash_including(
+ resource_id: middle.id,
+ attributes: hash_including("transferred_from_registration_id" => original.id)
+ )
+ )
+ )
+ end
+
it "restores the origin to its pre-transfer status and drops the middle when transferred back" do
origin_event = create(:event, published: true)
person = create(:person)
diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb
index 5ff95b4701..dd40e0907d 100644
--- a/spec/requests/scholarships_spec.rb
+++ b/spec/requests/scholarships_spec.rb
@@ -204,6 +204,29 @@
end
end
+ describe "blocking scholarship creation on a transferred-in registration" do
+ let(:source) { create(:event_registration, event: event) }
+ let(:transferred_in) do
+ create(:event_registration, event: create(:event, cost_cents: 5000),
+ registrant: source.registrant, transferred_from_registration: source)
+ end
+
+ it "redirects the new form to the source registration, where the scholarship belongs" do
+ get new_scholarship_path(allocatable_sgid: transferred_in.to_sgid.to_s, return_to: "registration")
+
+ expect(response).to redirect_to(edit_event_registration_path(source))
+ end
+
+ it "creates nothing and redirects the POST to the source registration" do
+ expect {
+ post scholarships_path(allocatable_sgid: transferred_in.to_sgid.to_s, return_to: "registration"),
+ params: { scholarship: { amount_dollars: "40" } }
+ }.not_to change(Scholarship, :count)
+
+ expect(response).to redirect_to(edit_event_registration_path(source))
+ end
+ end
+
describe "back link follows the page the user came from" do
it "links the new page back to the registrants roster (anchored to the row) when return_to=registrants" do
get new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants")
diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb
index edb1268877..046e43f574 100644
--- a/spec/services/builtin_callout_cards_spec.rb
+++ b/spec/services/builtin_callout_cards_spec.rb
@@ -31,6 +31,16 @@ def add_scholarship_form(event)
expect(card_titles(registration)).not_to include("Payment")
end
+ it "hides participation cards but keeps financial ones once transferred out" do
+ event.update!(videoconference_url: "https://example.zoom.us/j/1")
+ create(:event_staff, event:)
+ registration.update!(status: "transferred_out")
+
+ titles = card_titles(registration)
+ expect(titles).to include("Make your payment")
+ expect(titles).not_to include("Videoconference", "Meet the staff")
+ end
+
it "never surfaces the row-driven Handouts or FAQ cards in the code fallback" do
# These are admin-published now — there's no code fallback for them, even on
# a facilitator training.
From 85a7ace449b0266e9dd7ee8994ce0cf0f64598be Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 16 Aug 2026 20:44:46 -0400
Subject: [PATCH 15/16] Enforce same-format transfer at the endpoint, not just
the picker
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/controllers/event_registrations_controller.rb | 9 +++++++++
spec/requests/event_registrations_spec.rb | 14 ++++++++++++++
2 files changed, 23 insertions(+)
diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb
index 605cc0a6a7..65dc491082 100644
--- a/app/controllers/event_registrations_controller.rb
+++ b/app/controllers/event_registrations_controller.rb
@@ -218,6 +218,15 @@ 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.
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb
index 2497296e53..7370adb22b 100644
--- a/spec/requests/event_registrations_spec.rb
+++ b/spec/requests/event_registrations_spec.rb
@@ -760,6 +760,20 @@ def toggle_certificate(value)
expect(response).to redirect_to(transfer_event_registration_path(source))
end
+ it "rejects a destination of the wrong format, even when posted directly" do
+ # source's event is scheduled (on_demand: false); an on-demand destination
+ # is off-format and blocked at the endpoint, not just hidden in the picker.
+ off_format = create(:event, published: true, on_demand: true)
+
+ expect {
+ post process_transfer_event_registration_path(source),
+ params: { destination_event_id: off_format.id }
+ }.not_to change(EventRegistration, :count)
+
+ expect(response).to redirect_to(transfer_event_registration_path(source))
+ expect(flash[:alert]).to include("scheduled event")
+ end
+
it "certifies the source reg's CE hours at the destination reg" do
ce = create(:continuing_education_registration, event_registration: source,
professional_license: create(:professional_license, person: source.registrant))
From 394f05149eeaa3043615c2154ae8cfa59c955830 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 16 Aug 2026 21:17:04 -0400
Subject: [PATCH 16/16] Split CE into two records on transfer so each event
holds its own
A transfer now keeps two CE records instead of deriving certification across the
link: the source keeps a paid, zero-hours stub (its payments count at the
original event and it still surfaces in that event's CE searches), and the
destination gets a live record carrying the hours and the outstanding balance,
where new payments are received and the certificate is earned. Collapsing a
double transfer relocates the middle record forward instead of destroying it;
transferring back to the origin merges it into the stub. The destination record's
cost is snapshotted from the source's balance and admin-locked, and manual CE
creation on a transfer-in is blocked at the URL. CE reporting follows the record,
so the stub counts at the original event and the carried record at the new one.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
AGENTS.md | 3 +-
...uing_education_registrations_controller.rb | 24 ++++-
.../event_registrations_controller.rb | 5 +
.../continuing_education_registration.rb | 47 ++++++----
app/models/event_registration.rb | 39 +++-----
app/services/event_dashboard.rb | 31 +++++--
app/services/event_registration_readiness.rb | 9 +-
.../transfer_continuing_education.rb | 71 ++++++++++++++
.../_payment_history.html.erb | 11 ++-
.../_continuing_education.html.erb | 37 ++++++--
app/views/event_registrations/_form.html.erb | 5 +-
.../_transferred_in_financials.html.erb | 45 ++-------
.../events/_registrants_results.html.erb | 40 +++-----
.../continuing_education_registration_spec.rb | 57 +++++++-----
spec/models/event_registration_spec.rb | 40 ++++----
...continuing_education_registrations_spec.rb | 37 ++++++++
spec/requests/event_registrations_spec.rb | 61 ++++++++++--
spec/services/event_dashboard_spec.rb | 39 ++++++++
.../event_registration_readiness_spec.rb | 18 ++--
.../transfer_continuing_education_spec.rb | 93 +++++++++++++++++++
20 files changed, 521 insertions(+), 191 deletions(-)
create mode 100644 app/services/event_registration_services/transfer_continuing_education.rb
create mode 100644 spec/services/event_registration_services/transfer_continuing_education_spec.rb
diff --git a/AGENTS.md b/AGENTS.md
index cf151739df..c2c3ad5705 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 |
@@ -234,6 +234,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `EventRegistrationServices::ProcessConfirmation` — Registration confirmation flow
- `EventRegistrationServices::PublicRegistration` — Public registration handling
+- `EventRegistrationServices::TransferContinuingEducation` — Splits/relocates a registrant's CE when they transfer events (issue #1944): a simple forward transfer leaves a paid, zero-hours **stub** on the source (its payments count at the original event) and creates a **live** record on the destination carrying the hours and the outstanding balance; when the reg being transferred out is itself a transfer-in (a collapsing double transfer, or a transfer back to the origin) its live record is relocated forward — merging back into the origin's stub — instead of split again, so no third record appears. Runs inside the transfer transaction, after the destination is saved and before a collapsing middle reg is destroyed
- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
- `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb
index c4cd17be02..1e1e14acd6 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 65dc491082..5d4f0fb8d2 100644
--- a/app/controllers/event_registrations_controller.rb
+++ b/app/controllers/event_registrations_controller.rb
@@ -251,6 +251,11 @@ def process_transfer
saved = ActiveRecord::Base.transaction do
next false unless destination.save
+ # Split/relocate CE before dropping a collapsing middle reg, so its record
+ # moves forward instead of being cascade-destroyed with the reg. (#1944)
+ EventRegistrationServices::TransferContinuingEducation.new(
+ transferred_out: @event_registration, destination: destination
+ ).call
@event_registration.destroy! if @event_registration.transferred_in?
true
end
diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb
index 8d56022467..da41267757 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 }
@@ -60,30 +65,34 @@ def self.search_by_params(params)
# sign-ins/early sign-outs. You can't certify hours the sign-in sheet doesn't support.
ATTENDANCE_COVERAGE_THRESHOLD = 0.9
- # The registration whose event the hours are completed/certified at — the home
- # reg, or the destination it transferred to. Record + payment stay on the home
- # reg; only certification follows the person. Derived from the transfer link, so
- # there's nothing to keep in sync. (issue #1944)
- def certified_at_registration
- event_registration.transferred_to_registration || event_registration
+ # 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
- # True when this record's hours are certified at a *different* event than the
- # one it's billed to (i.e. the home reg transferred out).
- def certified_elsewhere?
- event_registration.transferred_to_registration.present?
+ # 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.
- # Attendance + the training-ended check run against the certified-at reg (the
- # destination event after a transfer); payment stays this record's own balance.
+ # 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?
- reg = certified_at_registration
- event = reg&.event
+ event = event_registration&.event
return false unless event&.ce_eligible?
- return false unless event.end_date&.past? && reg.attended? && paid_in_full?
+ return false unless event.end_date&.past? && event_registration.attended? && paid_in_full?
attendance_time_sufficient?
end
@@ -92,9 +101,8 @@ def certificate_available?
# cover the awarded hours before the certificate unlocks. With nothing logged (the
# portal sign-in wasn't used for this event), day-level attendance alone governs,
# so this doesn't block — it never retroactively gates events that never tracked time.
- # Reads the certified-at reg's log (the destination event after a transfer).
def attendance_time_sufficient?
- logged = certified_at_registration.attendance_minutes_total
+ logged = event_registration.attendance_minutes_total
return true if logged.zero?
logged >= required_attendance_minutes
@@ -158,8 +166,11 @@ def payment_status_label
private
# Snapshot the hours offered and total cost from the event when they aren't set
- # explicitly.
+ # explicitly. Skipped for a transfer-created record, whose hours/cost are
+ # deliberately carried over from the source (a $0 cost is intentional there).
def default_from_event
+ return if skip_event_defaults
+
event = event_registration&.event
self.hours = event.ce_hours_offered if event&.ce_hours_offered && (hours.blank? || hours.zero?)
self.cost_cents = event.ce_hours_cost_cents if event&.ce_hours_cost_cents && (cost_cents.blank? || cost_cents.zero?)
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index 47a0e30edd..43b11edba5 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -32,12 +32,6 @@ class EventRegistration < ApplicationRecord
belongs_to :transferred_from_registration, class_name: "EventRegistration", optional: true
has_one :transferred_to_registration, class_name: "EventRegistration",
foreign_key: :transferred_from_registration_id, inverse_of: :transferred_from_registration, dependent: :nullify
- # CE registrations completed/certified at THIS event because their home
- # registration transferred in here — the source reg's CE. Record + payment stay
- # on the source; only certification follows the person. (issue #1944)
- has_many :certified_ce_registrations, through: :transferred_from_registration,
- source: :continuing_education_registrations
-
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }
accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? }
# Staff correct/add attendance times on the CE edit form; a row with no sign-in
@@ -863,38 +857,29 @@ def ce_license_provided?
continuing_education_registrations.all? { |c| c.professional_license&.number_known? }
end
- # The CE records this registration is responsible for certifying — its own,
- # plus any that transferred in to be certified here. A transferred-out reg
- # certifies none (its hours moved to the destination event, which certifies
- # them). Distinct from continuing_education_registrations, the home set that
- # owns the payment. (issue #1944)
- def certifiable_ce_registrations
- return [] if transferred_out?
- (continuing_education_registrations.to_a + certified_ce_registrations.to_a).uniq
- end
-
# True when CE is registered and every CE registration's certificate has been
- # issued (sent) — the terminal state of the CE lifecycle. Based on the CE this
- # reg certifies (earned here), not the home set.
+ # 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 certifiable_ce_registrations.any?
+ return false unless continuing_education_registrations.any?
- certifiable_ce_registrations.all? { |c| c.certificate_sent_at.present? }
+ 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 registration that earns CE here that's the CE certificate
- # (certificate_sent_at on its earned CE registrations, so it stays in sync with
- # the CE edit page); otherwise the registration's own certificate_sent_at.
+ # 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.
def certificate_issued?
- certifiable_ce_registrations.any? ? 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
- certifiable = certifiable_ce_registrations
- if certifiable.any?
- certifiable.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
diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb
index 0288aea557..a8d3e31554 100644
--- a/app/services/event_dashboard.rb
+++ b/app/services/event_dashboard.rb
@@ -668,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
@@ -1228,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: billable_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
@@ -1268,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
@@ -1282,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?
diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb
index 3089b568b7..4a0c0c40e6 100644
--- a/app/services/event_registration_readiness.rb
+++ b/app/services/event_registration_readiness.rb
@@ -186,12 +186,11 @@ def ce_registrations
registration.continuing_education_registrations
end
- # The CE this registration is responsible for CERTIFYING — its own plus any
- # transferred in to be certified here (a transferred-out reg certifies none).
- # Certificate checks use this so certification follows the person to the
- # intended event. (issue #1944)
+ # 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.certifiable_ce_registrations
+ registration.continuing_education_registrations
end
# CE is paid once every CE registration is paid in full. A requested-but-not-yet
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 0000000000..f9fddaf6f3
--- /dev/null
+++ b/app/services/event_registration_services/transfer_continuing_education.rb
@@ -0,0 +1,71 @@
+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
+
+ 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/views/continuing_education_registrations/_payment_history.html.erb b/app/views/continuing_education_registrations/_payment_history.html.erb
index b96408c4b0..f147341812 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? %>
+ <%# 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) %>
+
- <% 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,14 +55,28 @@
<% end %>
- <%# After a transfer, the hours are certified at the destination event
- while this record + its payment stay here (issue #1944). %>
- <% if ce_registration.certified_elsewhere? %>
- <% certified_at = ce_registration.certified_at_registration %>
+ <%# 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 %>
+
- Hours certified at
- <%= link_to certified_at.event.title, edit_event_registration_path(certified_at, return_to: "registrants"),
+ 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 %>
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb
index 790f7fea90..218baeb13f 100644
--- a/app/views/event_registrations/_form.html.erb
+++ b/app/views/event_registrations/_form.html.erb
@@ -187,7 +187,10 @@
<% 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 = !transferred_in && (f.object.event&.scholarship_eligible? || f.object.scholarships.any?) %>
- <% show_ce = !transferred_in && f.object.event&.ce_eligible? %>
+ <%# 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) %>
<% active_orgs = f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.sort_by(&:name) %>
diff --git a/app/views/event_registrations/_transferred_in_financials.html.erb b/app/views/event_registrations/_transferred_in_financials.html.erb
index 65fb7e6bc0..3bb720144d 100644
--- a/app/views/event_registrations/_transferred_in_financials.html.erb
+++ b/app/views/event_registrations/_transferred_in_financials.html.erb
@@ -1,8 +1,9 @@
<%# ---- Transferred-in financials (issue #1944) — a read-only summary of the
- scholarship / CE / payment records that live on the SOURCE registration this
- person transferred in from. Styled distinctly (teal) so it never reads like a
- normal registration's editable cards, and every figure links back to the
- matching section on the source reg. ---- %>
+ 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 %>
@@ -16,7 +17,7 @@
Financials on the original registration
- This registrant transferred in — scholarship, CE, and payments stay on their
+ This registrant transferred in — their scholarship and payments stay on their
registration for <%= source.event.title %> and aren't billed to this event.
@@ -28,7 +29,7 @@
<% end %>
-
+
<%# ---- Payment ---- %>
@@ -83,37 +84,5 @@
None.
<% end %>
-
- <%# ---- Continuing education ---- %>
-
-
-
- Continuing education
- <% if source.ce_registered? %>
- <%= link_to source_link.call("ce-card"),
- class: "ml-auto text-xs font-medium text-teal-700 hover:underline",
- target: "_blank", rel: "noopener" do %>
- View
- <% end %>
- <% end %>
-
- <% if source.ce_registered? %>
- <%# Hours are earned/certified HERE (this event); the CE record + payment
- stay on the original registration. %>
-
- Hours earned at this event
-
-
<%= source.ce_status_label %>
-
- <%= plain_number(source.ce_hours_total) || "0" %> hrs
- <% if source.ce_amount_due_cents.positive? %>· <%= dollars_from_cents(source.ce_amount_due_cents) %> due<% end %>
-