diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb
index 80d9769915..0cd6e29b2e 100644
--- a/app/controllers/events/reconcile_affiliations_controller.rb
+++ b/app/controllers/events/reconcile_affiliations_controller.rb
@@ -10,7 +10,8 @@ def index
track_view("events.reconcile_affiliations", { event_id: @event.id })
reconcile = AffiliationServices::ReconcileEvent.new(@event)
- @person_groups = reconcile.actionable_person_groups
+ @cancelled_groups, @person_groups = reconcile.actionable_person_groups
+ .partition { |group| group[:registration]&.status == "cancelled" }
@skipped_sections = reconcile.skipped_reason_sections
@has_rows = reconcile.any_rows?
# Restore the admin's per-row radio choices when they come back from confirm.
diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb
index 513ac38de5..eafaebf221 100644
--- a/app/services/affiliation_services/reconcile_event.rb
+++ b/app/services/affiliation_services/reconcile_event.rb
@@ -5,10 +5,17 @@ module AffiliationServices
# the timestamp live here. Job affiliations are never touched.
class ReconcileEvent
Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key,
- :minted_here, keyword_init: true) do
+ :minted_here, :jobs, keyword_init: true) do
def actionable?
action != :noop
end
+
+ # Whether removing or moving this facilitator row could leave the person
+ # unlinked from the org, so the page notes their remaining job affiliations.
+ def shows_jobs?
+ %i[ delete deactivate ].include?(action) ||
+ EventRegistration::INACTIVE_STATUSES.include?(registration&.status)
+ end
end
def initialize(event)
@@ -89,7 +96,7 @@ def all_rows
def row_for(person, registration, organization, decision)
Row.new(person:, registration:, organization:, affiliation: decision.affiliation,
action: decision.action, reason: decision.reason, minted_here: decision.minted_here,
- key: row_key(person, organization, decision))
+ key: row_key(person, organization, decision), jobs: jobs_by_person_org.fetch([ person.id, organization.id ], []))
end
# Stable per-row identity for the outcome map: the affiliation itself when there
@@ -123,6 +130,13 @@ def other_facilitators(person)
.to_a
end
+ def jobs_by_person_org
+ @jobs_by_person_org ||= Affiliation.active_or_pending
+ .where(person_id: registrations_by_person.keys.compact.map(&:id))
+ .reject(&:facilitator?)
+ .group_by { |affiliation| [ affiliation.person_id, affiliation.organization_id ] }
+ end
+
def linked_org_ids(person)
linked_organizations(registrations_by_person[person]).map(&:id)
end
diff --git a/app/services/affiliation_services/reconcile_person.rb b/app/services/affiliation_services/reconcile_person.rb
index 2e0cdaeafb..15099c07ad 100644
--- a/app/services/affiliation_services/reconcile_person.rb
+++ b/app/services/affiliation_services/reconcile_person.rb
@@ -26,6 +26,7 @@ def actionable?
# ended without a dedicated column (ADR-0003 D6b).
COMMENT_TOPIC = "Reconciliation".freeze
NOT_ATTENDED = "Didn't attend — no affiliation created".freeze
+ CANCELLED = "Cancelled — no affiliation created".freeze
# `registered` after the event is a gap in the record, not an outcome:
# #attendance_recorded? deliberately excludes it. Acting on it would be acting
# on missing data, so the row is surfaced for an admin to resolve instead.
@@ -204,6 +205,10 @@ def transferred_out?
registration_here&.transferred_out?
end
+ def cancelled_here?
+ registration_here&.status == "cancelled"
+ end
+
# A transfer isn't a failure — they're training somewhere else. The affiliation
# follows them: it re-dates to the destination event and re-points at that
# registration, rather than being ended or deleted (ADR-0003 D6d).
@@ -251,11 +256,11 @@ def needs_affiliation?
def classify(affiliation)
if completed_training?
- return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if affiliation.active?
+ return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if open?(affiliation)
return Decision.new(affiliation:, action: :reactivate) if ended_for_this_training?(affiliation)
Decision.new(affiliation:, action: :noop, reason: LAPSED)
- elsif !affiliation.active?
+ elsif !open?(affiliation)
reason = ended_by_reconciliation?(affiliation) ? ALREADY_DEACTIVATED : ALREADY_ENDED
Decision.new(affiliation:, action: :noop, reason:)
elsif attendance_unrecorded?
@@ -271,6 +276,12 @@ def classify(affiliation)
end
end
+ # A row minted off an upcoming training starts on its date, so it reads as
+ # upcoming until then — still open, not ended.
+ def open?(affiliation)
+ affiliation.active? || affiliation.upcoming?
+ end
+
# Only the row this training minted, and only when the person didn't turn up at
# all. A partial attendance is kept as an auditable record that they were here —
# it confers no facilitation either way (ADR-0001 D8a), but the row and its
@@ -284,12 +295,18 @@ def partially_attended?
end
# Waits for the governing training to end, so a pre-event run never deactivates.
- # A hand-entered row has no source training, so it waits on this event.
+ # A hand-entered row has no source training, so it waits on this event. A
+ # cancellation settles this event's outcome early, so its rows are ready at once.
def deactivation_ready?(affiliation)
- affiliation.event_registration_id ? affiliation.event_registration&.event&.ended? : @event.ended?
+ if affiliation.event_registration_id && !minted_here?(affiliation)
+ return affiliation.event_registration&.event&.ended?
+ end
+
+ @event.ended? || cancelled_here?
end
def creation_decision
+ return Decision.new(action: :noop, reason: CANCELLED) if cancelled_here? && !completed_training?
return Decision.new(action: :create) if !@event.ended? || completed_training?
Decision.new(action: :noop, reason: NOT_ATTENDED)
diff --git a/app/views/events/reconcile_affiliations/_job_note.html.erb b/app/views/events/reconcile_affiliations/_job_note.html.erb
new file mode 100644
index 0000000000..c62c72a664
--- /dev/null
+++ b/app/views/events/reconcile_affiliations/_job_note.html.erb
@@ -0,0 +1,4 @@
+<% labels = jobs.map { |job| "#{job.title.presence || "Untitled"} · #{job.decorate.period_label}" } %>
+
+ <% group[:rows].each do |row| %>
+ <%# A row this training created has nothing to preserve, so Delete leads and
+ Deactivate is shown but not selectable — the tooltip says why. %>
+ <% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %>
+ <% chosen = pre_outcome&.dig(row.key) || default_choice %>
+
+ <% outcome_options[row.action].each do |value, label, color, hint| %>
+ <% unavailable = value == "deactivate" && row.minted_here %>
+
+ <% end %>
+
+
+ <% end %>
+
+
diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb
index db43aa5550..3cc9b268a5 100644
--- a/app/views/events/reconcile_affiliations/index.html.erb
+++ b/app/views/events/reconcile_affiliations/index.html.erb
@@ -18,7 +18,8 @@
later marked attended, an ending this step applied is undone rather than leaving them with a
second affiliation for an engagement that never broke. A return after a real lapse is still a
new affiliation, so the lapse stays visible. Anyone still marked Registered is left alone and listed
- below — that's an unfilled roster, not an outcome. Job affiliations are never touched.
+ below — that's an unfilled roster, not an outcome. A cancellation doesn't wait for the training to end:
+ it's listed in its own section so the affiliation can be settled right away. Job affiliations are never touched.
Only the row this training created is deleted — it recorded an assumption that never came true. An
@@ -35,7 +36,7 @@
<% if @event.affiliations_reconciled_at %>
Last reconciled <%= @event.affiliations_reconciled_at.to_fs(:long) %>.
<% end %>
- <% if @person_groups.any? && @event.affiliations_reconciliation_stale? %>
+ <% if (@cancelled_groups.any? || @person_groups.any?) && @event.affiliations_reconciliation_stale? %>
Attendance has changed since the last reconciliation — apply again below to bring affiliations up to date.
<% end %>
@@ -53,24 +54,8 @@
<%# One set of day ticks per registration, wherever it appears: the turbo replace
targets them by id, and a person with two linked orgs has rows in both sections. %>
<% ticked = Set.new %>
- <% if @person_groups.any? %>
- <% actionable_count = @person_groups.sum { |g| g[:rows].size } %>
- <% color_class = {
- red: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300",
- green: "has-[:checked]:bg-green-50 has-[:checked]:text-green-700 has-[:checked]:border-green-300",
- blue: "has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700 has-[:checked]:border-blue-300",
- gray: "has-[:checked]:bg-gray-100 has-[:checked]:text-gray-800 has-[:checked]:border-gray-400"
- } %>
- <% fa_hint = "facilitator affiliation (FA)" %>
- <% outcome_options = {
- create: [ [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Skip", :gray ] ],
- deactivate: [ [ "keep", "Keep active", :green ], [ "delete", "Delete affiliation", :red ], [ "deactivate", "Deactivate affiliation", :red ] ],
- delete: [ [ "delete", "Delete", :red ], [ "keep", "Keep", :gray ] ],
- reactivate: [ [ "reactivate", "Reactivate", :green ], [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Leave inactive", :gray ] ],
- retarget: [ [ "retarget", "Move to the new event", :blue ], [ "keep", "Leave as is", :gray ] ]
- } %>
- <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %>
-
+ <% if @cancelled_groups.any? || @person_groups.any? %>
+ <% actionable_count = (@cancelled_groups + @person_groups).sum { |g| g[:rows].size } %>
To reconcile (<%= actionable_count %>)
@@ -82,58 +67,28 @@
can't nest — so the radios join it via the HTML form= attribute. %>
<%= form_with url: reconcile_affiliations_event_path(@event), method: :post, id: "reconcile_form", data: { turbo: false } do %><% end %>
-
- <% group[:rows].each do |row| %>
- <%# A row this training created has nothing to preserve, so Delete leads and
- Deactivate is shown but not selectable — the tooltip says why. %>
- <% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %>
- <% chosen = (@pre_outcome && @pre_outcome[row.key]) || default_choice %>
-
<% if reason == AffiliationServices::ReconcilePerson::ATTENDANCE_NOT_RECORDED && ticked.add?(row.registration&.id) %>
<%= render "attendance_day_ticks", registration: row.registration, event: @event %>
<% end %>
diff --git a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml
new file mode 100644
index 0000000000..3f75e1896e
--- /dev/null
+++ b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml
@@ -0,0 +1,14 @@
+name: "Reconcile cancelled registrants before the training"
+area: events
+display_status: admin_facing
+released_on: 2026-10-23
+action_path: "/events/1/reconcile_affiliations"
+pr_number: 2803
+summary: >-
+ Reconcile affiliations now lists cancelled registrants in their own section, so
+ the facilitator affiliation their registration created can be deleted right away
+ instead of after the training ends.
+pro_tips:
+ - "Someone who cancelled and never trained should have the affiliation deleted, not deactivated — there's no facilitation history to keep."
+ - "Registrants still marked Registered are listed under Not reconciled for reference until the training ends."
+ - "Rows for cancelled, no-show, and transferred registrants — and any row offering Delete — note whether the person still has a job affiliation with that organization."
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb
index 6afa653173..30d20a4253 100644
--- a/spec/requests/events/reconcile_affiliations_spec.rb
+++ b/spec/requests/events/reconcile_affiliations_spec.rb
@@ -53,6 +53,116 @@ def registrant_with_affiliation(status:)
expect(response.body).to include("Create new")
end
+ it "gives cancelled registrants their own section before the event, with the deletion preselected" do
+ upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now)
+ cancelled = create(:event_registration, event: upcoming, status: "cancelled")
+ registered = create(:event_registration, event: upcoming, status: "registered")
+ [ cancelled, registered ].each do |reg|
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ end
+ minted = create(:affiliation, person: cancelled.registrant, organization: organization, title: "Facilitator",
+ start_date: upcoming.starts_on, event_registration: cancelled)
+ create(:affiliation, person: registered.registrant, organization: organization, title: "Facilitator",
+ start_date: upcoming.starts_on, event_registration: registered)
+
+ get reconcile_affiliations_event_path(upcoming)
+
+ page = Nokogiri::HTML(response.body)
+ section = page.at_css("#reconcile_cancelled")
+ expect(section.text).to include(cancelled.registrant.name)
+ expect(section.text).not_to include(registered.registrant.name)
+ expect(section.at_css("#outcome_aff\\:#{minted.id}_delete")["checked"]).to be_present
+ expect(page.text).to include("Training hasn't ended yet")
+ end
+
+ it "notes on each cancelled row whether the person keeps a job affiliation with that organization" do
+ upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now)
+ with_job, without_job = Array.new(2) do
+ reg = create(:event_registration, event: upcoming, status: "cancelled")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ create(:affiliation, person: reg.registrant, organization: organization, title: "Facilitator",
+ start_date: upcoming.starts_on, event_registration: reg)
+ reg
+ end
+ create(:affiliation, person: with_job.registrant, organization: organization, title: "Counselor",
+ start_date: 1.year.ago.to_date)
+
+ get reconcile_affiliations_event_path(upcoming)
+
+ cards = Nokogiri::HTML(response.body).css("#reconcile_cancelled section")
+ card_for = ->(reg) { cards.find { |card| card.text.include?(reg.registrant.name) }.text.squish }
+ expect(card_for.call(with_job)).to include("Job affiliation: Counselor · Since #{1.year.ago.strftime('%b %Y')}")
+ expect(card_for.call(without_job)).to include("No job affiliation")
+ end
+
+ it "shows each job affiliation's date range in the note" do
+ reg = create(:event_registration, event: event, status: "cancelled")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ create(:affiliation, person: reg.registrant, organization: organization, title: "Counselor",
+ start_date: Date.new(2024, 1, 15), end_date: 2.years.from_now.to_date)
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response.body).to include("Counselor · Jan 2024 – #{2.years.from_now.strftime('%b %Y')}")
+ end
+
+ it "keeps the cancelled section and its job note after the event" do
+ reg = create(:event_registration, event: event, status: "cancelled")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ create(:affiliation, person: reg.registrant, organization: organization, title: "Facilitator",
+ start_date: event.starts_on, event_registration: reg)
+
+ get reconcile_affiliations_event_path(event)
+
+ section = Nokogiri::HTML(response.body).at_css("#reconcile_cancelled")
+ expect(section.text).to include(reg.registrant.name)
+ expect(section.text).to include("No job affiliation")
+ end
+
+ it "notes job affiliations on any row offering a delete, and only there" do
+ no_show, _affiliation = registrant_with_affiliation(status: "no_show")
+ create(:affiliation, person: no_show, organization: organization, title: "Counselor",
+ start_date: 1.year.ago.to_date)
+ upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now)
+ reg = create(:event_registration, event: upcoming, status: "registered")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+
+ get reconcile_affiliations_event_path(event)
+ expect(response.body).to include("Job affiliation: Counselor")
+
+ get reconcile_affiliations_event_path(upcoming)
+ expect(response.body).to include("Create new")
+ expect(response.body).not_to include("job affiliation")
+ end
+
+ it "notes job affiliations for a transferred-out registrant" do
+ person, _affiliation = registrant_with_affiliation(status: "registered")
+ source = person.event_registrations.find_by(event: event)
+ destination_event = create(:event, facilitator_training: true, start_date: 3.months.from_now,
+ end_date: 3.months.from_now + 1.day, registration_close_date: 2.months.from_now)
+ destination = create(:event_registration, registrant: person, event: destination_event, status: "registered")
+ create(:event_registration_organization, event_registration: destination, organization: organization)
+ destination.update!(transferred_from_registration: source)
+ source.update!(status: "transferred_out")
+ create(:affiliation, person: person, organization: organization, title: "Counselor", start_date: 1.year.ago.to_date)
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response.body).to include("Move to the new event")
+ expect(response.body).to include("Job affiliation: Counselor")
+ end
+
+ it "notes job affiliations on a no-show listed under Not reconciled" do
+ person = create(:person)
+ reg = create(:event_registration, event: event, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response.body).to include("Didn't attend — no affiliation created")
+ expect(response.body).to include("No job affiliation")
+ end
+
it "previews a facilitator affiliation on a non-training event as a deletion" do
non_training = create(:event, :ended, facilitator_training: false)
person = create(:person)
diff --git a/spec/services/affiliation_services/reconcile_person_spec.rb b/spec/services/affiliation_services/reconcile_person_spec.rb
index 6de512cb8a..431b12bb03 100644
--- a/spec/services/affiliation_services/reconcile_person_spec.rb
+++ b/spec/services/affiliation_services/reconcile_person_spec.rb
@@ -47,6 +47,27 @@ def reconcile(registration, **options)
expect(Affiliation.exists?(affiliation.id)).to be(false)
end
+ it "deletes the minted row as soon as the person cancels, without waiting for the training to end" do
+ reg = training_registration(status: "cancelled", ended: false)
+ affiliation = owned_facilitator(registration: reg, start_date: Date.current)
+
+ reconcile(reg)
+
+ expect(Affiliation.exists?(affiliation.id)).to be(false)
+ end
+
+ it "waits on another upcoming training's row even when this registration is cancelled" do
+ reg = training_registration(status: "cancelled", ended: false)
+ other = training_registration(status: "registered", ended: false)
+ affiliation = owned_facilitator(registration: other, start_date: Date.current)
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event,
+ registration: reg, include_unowned: true).plan
+
+ expect(plan.map(&:reason)).to eq([ described_class::TRAINING_PENDING ])
+ expect(affiliation.reload).to be_active
+ end
+
# They were here for part of it. Deleting the row would erase that; ending it
# records a facilitator term that was brief rather than one that never began.
it "ends the minted row rather than deleting it for a partial attendance" do
@@ -86,6 +107,25 @@ def reconcile(registration, **options)
expect(plan.first.reason).to eq(described_class::TRAINING_PENDING)
end
+ it "files a row starting on an upcoming training's date as pending, not as already ended" do
+ reg = training_registration(status: "registered", ended: false)
+ owned_facilitator(registration: reg)
+
+ plan = described_class.new(person: person, organization: organization,
+ event: reg.event, registration: reg).plan
+
+ expect(plan.map(&:reason)).to eq([ described_class::TRAINING_PENDING ])
+ end
+
+ it "deletes an upcoming training's row once the person cancels" do
+ reg = training_registration(status: "cancelled", ended: false)
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+
+ expect(Affiliation.exists?(affiliation.id)).to be(false)
+ end
+
it "deletes it on the day a one-day training ends, with no reliance on the inactive flag" do
event = create(:event, facilitator_training: true, start_date: 3.hours.ago,
end_date: 1.hour.ago, registration_close_date: 4.hours.ago)
@@ -459,6 +499,16 @@ def transferred_out(destination_event:, link_org: organization)
.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1)
end
+ it "proposes no affiliation for a registrant who cancelled before the training" do
+ reg = training_registration(status: "cancelled", ended: false)
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event,
+ registration: reg).plan
+
+ expect(plan.map(&:action)).to eq([ :noop ])
+ expect(plan.first.reason).to eq(described_class::CANCELLED)
+ end
+
it "proposes nothing when the caller passes no registration to own the new row" do
reg = training_registration(status: "attended")