Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ def unlink_organization
def destroy
authorize! @event_registration
event = @event_registration.event
if @event_registration.destroy
if !@event_registration.deletable?
flash[:alert] = "This registration can't be deleted because it has payments, scholarships, or attendance on record."
elsif @event_registration.destroy
flash[:notice] = "Registration deleted."
else
flash[:alert] = @event_registration.errors.full_messages.to_sentence
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/events/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ def destroy

authorize! @event_registration

unless @event_registration.deletable?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Enforced server-side here (not just by hiding the button) because the DELETE route stays reachable for authorized users. Points the registrant to cancel, which is a soft status change that preserves the payment record.

alert = "You can't de-register because there are payments or attendance on record. Cancel your registration instead."
respond_to do |format|
format.turbo_stream { flash.now[:alert] = alert }
format.html { redirect_to @event, alert: alert }
end
return
end

if @event_registration.destroy
success = "You are no longer registered."
respond_to do |format|
Expand Down
8 changes: 8 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ def active?
status.in?(ACTIVE_STATUSES)
end

# Safe to delete only when removing the record would not orphan financial
# data or erase attendance history. Allocations (payments and scholarships)
# have no dependent: :destroy, so a registration with any allocation must be
# kept, as must one with attendance (attended or incomplete) on record.
def deletable?
!allocations.exists? && !status.in?(%w[ attended incomplete_attendance ])
end

def checked_in?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Single source of truth for both delete paths. allocations has no dependent: :destroy, so deleting a registration with payments/scholarships would orphan those rows — hence the guard rather than a cascade.

# checked_in_at.present?
end
Expand Down
2 changes: 1 addition & 1 deletion app/views/event_registrations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@

<%# ---- Footer actions ---- %>
<div class="flex items-center gap-3 border-t border-gray-200 pt-6">
<% if allowed_to?(:destroy?, f.object) %>
<% if allowed_to?(:destroy?, f.object) && f.object.deletable? %>
<%= link_to event_registration_path(@event_registration, return_to: params[:return_to].presence),
class: "btn btn-danger-outline",
data: { turbo: true, turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } do %>
Expand Down
12 changes: 7 additions & 5 deletions app/views/events/_registration_button.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
<span class="text-xs text-red-500">No event</span>
<% elsif Current.user.nil? %>
<span class="text-xs text-gray-500">Login to register</span>
<% elsif event.actively_registered?(Current.user.person) %>
<%= link_to "De-register",
event_registrant_registration_path(event_id: event),
data: { turbo_method: :delete, turbo_confirm: "Are you sure?"},
class: "btn btn-secondary-outline text-sm" %>
<% elsif (registration = event.active_registration_for(Current.user.person)) %>
<% if registration.deletable? %>
<%= link_to "De-register",
event_registrant_registration_path(event_id: event),
data: { turbo_method: :delete, turbo_confirm: "Are you sure?"},
class: "btn btn-secondary-outline text-sm" %>
<% end %>
<span class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Registered</span>
<% elsif event.ended? %>
<span class="text-sm text-gray-500 italic">Event ended</span>
Expand Down
30 changes: 30 additions & 0 deletions spec/models/event_registration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@
end
end

describe "#deletable?" do
it "returns true for a plain registration with no allocations or attendance" do
reg = create(:event_registration, status: "registered")
expect(reg).to be_deletable
end

it "returns false when the registration has a payment allocation" do
reg = create(:event_registration, status: "registered")
payment = create(:payment, person: reg.registrant, amount_cents: 1000, amount_cents_remaining: nil)
create(:allocation, source: payment, allocatable: reg, amount: 1000)
expect(reg).not_to be_deletable
end

it "returns false when the registration has a scholarship allocation" do
reg = create(:event_registration, status: "registered")
scholarship = create(:scholarship, recipient: reg.registrant, amount_cents: 1000)
create(:allocation, source: scholarship, allocatable: reg, amount: 1000)
expect(reg).not_to be_deletable
end

it "returns false when the registration has attendance on record" do
expect(create(:event_registration, status: "attended")).not_to be_deletable
expect(create(:event_registration, status: "incomplete_attendance")).not_to be_deletable
end

it "returns true for a cancelled registration with no allocations" do
expect(create(:event_registration, status: "cancelled")).to be_deletable
end
end

describe ".registrant_ids" do
it "returns registrations for the registrants in a hyphenated id list" do
person_a = create(:person)
Expand Down
11 changes: 11 additions & 0 deletions spec/requests/event_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,17 @@ def unrequest(registration)
delete event_registration_path(existing_registration)
}.to change(EventRegistration, :count).by(-1)
end

it "refuses to delete a registration with payments on record" do
payment = create(:payment, person: regular_user.person, amount_cents: 1000, amount_cents_remaining: nil)
create(:allocation, source: payment, allocatable: existing_registration, amount: 1000)

expect {
delete event_registration_path(existing_registration)
}.not_to change(EventRegistration, :count)

expect(flash[:alert]).to include("can't be deleted")
end
end

describe "organization linking" do
Expand Down
18 changes: 18 additions & 0 deletions spec/requests/events/registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,24 @@
end
end

context "when the registration has payments on record" do
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }

before do
payment = create(:payment, person: user.person, amount_cents: 1000, amount_cents_remaining: nil)
create(:allocation, source: payment, allocatable: registration, amount: 1000)
end

it "refuses to destroy and returns an alert" do
expect {
delete event_registrant_registration_path(event_id: event.id),
headers: turbo_headers
}.not_to change(EventRegistration, :count)

expect(flash.now[:alert]).to include("Cancel your registration instead")
end
end

context "when destroy fails" do
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }

Expand Down