From 9f45a210d3eed9820ad8bdd0f087487aa15936c7 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 09:18:15 -0400 Subject: [PATCH 01/11] 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 | 92 +++++++++++++++---- 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, 266 insertions(+), 36 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 03838b5df0..f8d4691fff 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 ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :transfer, :process_transfer ] def index authorize! @@ -98,26 +98,35 @@ 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 + # 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 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 } @@ -173,6 +182,43 @@ def toggle_certificate_issued end end + # Follow-up screen shown after a registration is marked "transferred out": + # pick the destination event so the incoming registration is created/linked + # and the transfer trail is preserved (issue #1944). + def transfer + authorize! @event_registration, to: :transfer? + @return_to = params[:return_to] + @events = transfer_destination_events + end + + def process_transfer + authorize! @event_registration, to: :transfer? + destination_event = Event.find(params[:destination_event_id]) + + # The registrant may already be registered for the destination event, which + # would collide with the (registrant, event) uniqueness rule — link that + # record as the transfer target instead of creating a duplicate. + destination = EventRegistration.find_or_initialize_by( + registrant_id: @event_registration.registrant_id, + event_id: destination_event.id + ) + destination.transferred_from_registration = @event_registration + + if destination.save + redirect_to edit_event_registration_path(destination, return_to: params[:return_to].presence), + notice: "Transfer recorded — #{@event_registration.registrant.full_name} is now registered for #{destination_event.title}.", + status: :see_other + else + @return_to = params[:return_to] + @events = transfer_destination_events + flash.now[:alert] = destination.errors.full_messages.to_sentence + render :transfer, status: :unprocessable_content + end + rescue ActiveRecord::RecordNotFound + redirect_to transfer_event_registration_path(@event_registration, return_to: params[:return_to].presence), + alert: "Select a destination event to transfer to.", status: :see_other + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? @@ -340,6 +386,14 @@ def set_event_registration @event_registration = EventRegistration.includes({ registrant: [ :user, { affiliations: :organization } ] }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end + # Events a registrant can be transferred into: 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) + end + # Creates the audited completion row for a checklist step (recording who/when), # or removes it — so an unchecked step leaves no trace. def toggle_checklist_step(step, completed) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index cdf1071664..7ee5f4be9e 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -20,6 +20,15 @@ class EventRegistration < ApplicationRecord through: :allocations, source: :source, source_type: "Scholarship" has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy + # Event-transfer trail (issue #1944). The FK lives on the incoming record: an + # "in" points back at the "out" it came from, so an in is identifiable directly + # (transferred_from_registration_id present) without scanning other rows. The + # in keeps its own real attendance status; only the out is marked + # "transferred_out". Chained transfers form a linked list back to the original. + belongs_to :transferred_from_registration, class_name: "EventRegistration", optional: true + has_one :transferred_to_registration, class_name: "EventRegistration", + foreign_key: :transferred_from_registration_id, inverse_of: :transferred_from_registration, dependent: :nullify + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } # Lets the registration edit form edit the registrant's shout-out text (which @@ -30,7 +39,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 @@ -77,7 +86,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" @@ -490,14 +498,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 dc82897d61..ca886869eb 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 %> +

+ + Transferred to + <%= link_to destination.event.title, edit_event_registration_path(destination, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +

+ <% else %> +

+ + <%= link_to "Record where they transferred to", transfer_event_registration_path(f.object, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +

+ <% end %> + <% elsif f.object.transferred_in? %> +

+ + Transferred in from + <%= link_to f.object.transferred_from_registration.event.title, edit_event_registration_path(f.object.transferred_from_registration, return_to: params[:return_to].presence), class: "font-medium underline", data: { turbo_frame: "_top" } %> +

+ <% end %> diff --git a/app/views/event_registrations/transfer.html.erb b/app/views/event_registrations/transfer.html.erb new file mode 100644 index 0000000000..371c15df5b --- /dev/null +++ b/app/views/event_registrations/transfer.html.erb @@ -0,0 +1,55 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% + source = @event_registration + roster_path = registrants_event_path(source.event) +%> +
+ <%= link_to roster_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700" do %> + + <%= source.event.title %> registrants + <% end %> +
+ +
+
+
+
+ +
+

Where did they transfer to?

+
+ +
+
+
Registrant
+
<%= source.registrant.full_name %>
+
Transferred out of
+
<%= source.event.title %>
+
+
+ +

+ 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. +

+ + <%= form_with url: process_transfer_event_registration_path(source), method: :post, data: { turbo: false }, class: "space-y-4" do %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ + <%= select_tag :destination_event_id, + options_from_collection_for_select(@events, :id, :time_title), + include_blank: "Select an event…", + required: true, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ +
+ <%= link_to "Skip for now", roster_path, class: "btn btn-secondary-outline" %> + <%= submit_tag "Record transfer", class: "btn btn-primary ml-auto" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 7d0534d362..7a8fc084ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -100,6 +100,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 b96f6d27d3..49571b7e24 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -501,6 +501,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" @@ -509,6 +510,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| @@ -1801,6 +1803,7 @@ add_foreign_key "event_registration_checklist_completions", "users", column: "completed_by_id" add_foreign_key "event_registration_organizations", "event_registrations" 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 ffbc78926b..1e48f2bf21 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 7924fcd22a..7bd12e3264 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -248,6 +248,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 16e43ef65773a25ebed97841bb18b242ab413cf4 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 09:23:00 -0400 Subject: [PATCH 02/11] 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 d8bd3f93ed..a678e38a2b 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 @@ -533,6 +550,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("