From 1b27999f91d43cddc32052d11a880c19a4bd92a3 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:46:50 -0400 Subject: [PATCH 01/22] refactor bulk payments with submission controller and bulk payment controller --- .../bulk_payment_submissions_controller.rb | 184 ++++++++ .../events/bulk_payments_controller.rb | 266 ++++++----- app/controllers/events_controller.rb | 114 +---- app/decorators/form_submission_decorator.rb | 26 ++ app/models/form_submission.rb | 20 + app/views/events/_bulk_payment_card.html.erb | 191 ++++---- app/views/events/_bulk_payment_form.html.erb | 2 +- .../new.html.erb | 0 .../show.html.erb | 0 .../ticket.html.erb | 0 .../allocate.turbo_stream.erb} | 0 .../create.turbo_stream.erb} | 2 +- .../index.html.erb} | 2 +- .../bulk_payments/link.turbo_stream.erb | 10 + .../bulk_payments/unlink.turbo_stream.erb | 10 + config/routes.rb | 14 +- ...000000_add_metadata_to_form_submissions.rb | 5 + db/schema.rb | 3 +- spec/models/form_submission_spec.rb | 77 ++++ .../events/bulk_payment_submissions_spec.rb | 351 ++++++++++++++ spec/requests/events/bulk_payments_spec.rb | 429 +++++++++--------- spec/requests/events_spec.rb | 229 ---------- 22 files changed, 1138 insertions(+), 797 deletions(-) create mode 100644 app/controllers/events/bulk_payment_submissions_controller.rb rename app/views/events/{bulk_payments => bulk_payment_submissions}/new.html.erb (100%) rename app/views/events/{bulk_payments => bulk_payment_submissions}/show.html.erb (100%) rename app/views/events/{bulk_payments => bulk_payment_submissions}/ticket.html.erb (100%) rename app/views/events/{allocate_bulk_payment.turbo_stream.erb => bulk_payments/allocate.turbo_stream.erb} (100%) rename app/views/events/{create_bulk_payment.turbo_stream.erb => bulk_payments/create.turbo_stream.erb} (83%) rename app/views/events/{bulk_payments.html.erb => bulk_payments/index.html.erb} (96%) create mode 100644 app/views/events/bulk_payments/link.turbo_stream.erb create mode 100644 app/views/events/bulk_payments/unlink.turbo_stream.erb create mode 100644 db/migrate/20260727000000_add_metadata_to_form_submissions.rb create mode 100644 spec/requests/events/bulk_payment_submissions_spec.rb diff --git a/app/controllers/events/bulk_payment_submissions_controller.rb b/app/controllers/events/bulk_payment_submissions_controller.rb new file mode 100644 index 0000000000..a296917bc3 --- /dev/null +++ b/app/controllers/events/bulk_payment_submissions_controller.rb @@ -0,0 +1,184 @@ +module Events + class BulkPaymentSubmissionsController < ApplicationController + skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ] + before_action :set_event, only: [ :new, :create, :show ] + before_action :set_form, only: [ :new, :create ] + + rescue_from ActionController::InvalidAuthenticityToken do + flash[:alert] = "Your session has expired. Please try submitting the form again." + redirect_to new_event_bulk_payment_path(@event) + end + + def new + authorize! :bulk_payment, to: :new? + + @form_fields = visible_form_fields + @event = @event.decorate + + @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + end + + def create + authorize! :bulk_payment, to: :create? + + @form_params = params.dig(:bulk_payment, :form_fields)&.to_unsafe_h || {} + + @field_errors = validate_required_fields + if @field_errors.any? + @form_fields = visible_form_fields + @event = @event.decorate + @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + render :new, status: :unprocessable_content + return + end + + result = EventRegistrationServices::BulkPayment.call( + event: @event, + form: @form, + form_params: @form_params, + person: current_user&.person + ) + + if result.success? + if @event.cost_cents.to_i > 0 && credit_card_payment?(@form_params) + checkout_session = create_stripe_checkout_session(result.form_submission) + redirect_to checkout_session.url, allow_other_host: true, status: :see_other + else + redirect_to bulk_payment_ticket_path(result.form_submission.slug), + notice: "Your payment information has been submitted." + end + else + @form_fields = visible_form_fields + @event = @event.decorate + @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + flash.now[:alert] = result.errors.join(", ") + render :new, status: :unprocessable_content + end + end + + # View of the submitted bulk payment form, rendering the same partial either + # way. Mirrors PublicRegistrations#show: the payer reaches it publicly by slug + # (?reg=), while admins (e.g. from the dashboard, including legacy submissions + # with no slug) reach it by id (?submission_id=). + def show + if params[:reg].present? + authorize! :bulk_payment, to: :show? + # where.not(slug: nil) keeps a blank reg from matching a slugless record. + @submission = FormSubmission.bulk_payment.where.not(slug: nil) + .find_by!(slug: params[:reg], event_id: @event.id) + else + @submission = FormSubmission.bulk_payment.find_by!(id: params[:submission_id], event_id: @event.id) + authorize! @submission, to: :show? + end + + @event = @event.decorate + end + + # Public, slug-based ticket for the payer. Shows the event details, the + # registrants they paid for, and the submitted form — but none of the + # per-person admin actions found on the bulk payments dashboard. + def ticket + authorize! :bulk_payment, to: :ticket? + + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + @payment = @submission.payment + @event = @submission.event.decorate + end + + # Re-sends the payer their bulk payment confirmation email (the one carrying + # the ticket link). Reachable by the payer from the ticket. + def resend_confirmation + authorize! :bulk_payment, to: :show? + + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + payer_email = @submission.person.preferred_email.presence || + @submission.answers_by_identifier["payer_email"]&.strip + + if payer_email.present? + NotificationServices::CreateNotification.call( + noticeable: @submission, + kind: :bulk_payment_confirmation, + recipient_role: :person, + recipient_email: payer_email, + notification_type: 0 + ) + redirect_to bulk_payment_ticket_path(@submission.slug), notice: "Confirmation email sent." + else + redirect_to bulk_payment_ticket_path(@submission.slug), + alert: "No email address on file to send the confirmation to." + end + end + + private + + def visible_form_fields + scope = @form.form_fields.reorder(position: :asc) + + if current_user + logged_out_only_ids = scope.where(visibility: :logged_out_only).ids + scope = scope.where.not(id: logged_out_only_ids) if logged_out_only_ids.any? + end + + scope + end + + def set_event + @event = Event.find(params[:event_id]) + end + + def set_form + @form = @event.bulk_payment_form + unless @form + redirect_to event_path(@event), alert: "#{Form::BULK_PAYMENT_PUBLIC_NAME} form is not available for this event." + end + end + + def validate_required_fields + # The nested attendees field is validated separately, so exclude it here. + fields = visible_form_fields.reject { |field| field.field_identifier == "bulk_payment_attendees" } + FormAnswerValidator.call(fields, @form_params) + end + + def credit_card_payment?(form_params) + payment_method_field = @form.form_fields.find_by(field_identifier: "payment_method") + return false unless payment_method_field + + form_params[payment_method_field.id.to_s]&.downcase == FormBuilderService::PAYMENT_METHOD_PAY_NOW.downcase + end + + def create_stripe_checkout_session(submission) + person = submission.person + unit_amount = @event.cost_cents + + attendees_field = @form.form_fields.find_by(field_identifier: "number_of_attendees") + qty = attendees_field ? @form_params[attendees_field.id.to_s].to_i : 1 + qty = 1 if qty < 1 + + metadata = { form_submission_id: submission.id, event_id: @event.id } + + attendees_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + if attendees_field + attendees_json = @form_params[attendees_field.id.to_s] + metadata[:attendees] = attendees_json if attendees_json.present? + end + + person.set_payment_processor :stripe + + person.payment_processor.checkout( + mode: "payment", + metadata: metadata, + payment_intent_data: { metadata: metadata, description: "Training Fee: #{@event.title}" }, + line_items: [ { + price_data: { + currency: "usd", + product_data: { name: "#{Form::BULK_PAYMENT_PUBLIC_NAME} (#{qty} attendees): #{@event.title}" }, + unit_amount: unit_amount + }, + quantity: qty + } ], + success_url: bulk_payment_ticket_url(submission.slug, checkout: "success"), + cancel_url: bulk_payment_ticket_url(submission.slug, checkout: "cancelled") + ) + end + end +end diff --git a/app/controllers/events/bulk_payments_controller.rb b/app/controllers/events/bulk_payments_controller.rb index 7222f2fa5b..c2bcb6ab1b 100644 --- a/app/controllers/events/bulk_payments_controller.rb +++ b/app/controllers/events/bulk_payments_controller.rb @@ -1,184 +1,174 @@ module Events class BulkPaymentsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ] - before_action :set_event, only: [ :new, :create, :show ] - before_action :set_form, only: [ :new, :create ] + before_action :set_event - rescue_from ActionController::InvalidAuthenticityToken do - flash[:alert] = "Your session has expired. Please try submitting the form again." - redirect_to new_event_bulk_payment_path(@event) - end - - def new - authorize! :bulk_payment, to: :new? + def index + authorize! @event - @form_fields = visible_form_fields @event = @event.decorate - - @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + @event_registrations = @event.event_registrations.active.includes(:registrant) + @submissions = @event.form_submissions + .where(role: "bulk_payment") + .includes(:person, form_answers: :form_field, payment: :allocations) + .order(created_at: :desc) + @allocated_by_registration = allocated_cents_by_registration(@event_registrations) end def create - authorize! :bulk_payment, to: :create? + authorize! @event + @event = @event.decorate + @event_registrations = @event.event_registrations.active.includes(:registrant) + @allocated_by_registration = allocated_cents_by_registration(@event_registrations) - @form_params = params.dig(:bulk_payment, :form_fields)&.to_unsafe_h || {} + submission = @event.form_submissions.find(params[:submission_id]) + payment_type = params[:payment_type] - @field_errors = validate_required_fields - if @field_errors.any? - @form_fields = visible_form_fields - @event = @event.decorate - @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") - render :new, status: :unprocessable_content + unless %w[CashPayment CheckPayment].include?(payment_type) + flash.now[:alert] = "Invalid payment type" + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), alert: "Invalid payment type" } + end return end - result = EventRegistrationServices::BulkPayment.call( - event: @event, - form: @form, - form_params: @form_params, - person: current_user&.person + payment = submission.build_payment( + amount_cents: (params[:amount_dollars].to_d * 100).to_i, + currency: params[:currency].presence || "usd", + type: payment_type, + check_number: params[:check_number].presence, + memo: params[:memo].presence ) + payment.payer_sgid = params[:payer_sgid] + payment.additional_designation_sgid = params[:additional_designation_sgid] - if result.success? - if @event.cost_cents.to_i > 0 && credit_card_payment?(@form_params) - checkout_session = create_stripe_checkout_session(result.form_submission) - redirect_to checkout_session.url, allow_other_host: true, status: :see_other - else - redirect_to bulk_payment_ticket_path(result.form_submission.slug), - notice: "Your payment information has been submitted." - end + if payment.save + @payment = payment + @submission = submission.decorate + flash.now[:notice] = "Payment recorded" else - @form_fields = visible_form_fields - @event = @event.decorate - @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") - flash.now[:alert] = result.errors.join(", ") - render :new, status: :unprocessable_content + flash.now[:alert] = payment.errors.full_messages.to_sentence end - end - # View of the submitted bulk payment form, rendering the same partial either - # way. Mirrors PublicRegistrations#show: the payer reaches it publicly by slug - # (?reg=), while admins (e.g. from the dashboard, including legacy submissions - # with no slug) reach it by id (?submission_id=). - def show - if params[:reg].present? - authorize! :bulk_payment, to: :show? - # where.not(slug: nil) keeps a blank reg from matching a slugless record. - @submission = FormSubmission.bulk_payment.where.not(slug: nil) - .find_by!(slug: params[:reg], event_id: @event.id) - else - @submission = FormSubmission.bulk_payment.find_by!(id: params[:submission_id], event_id: @event.id) - authorize! @submission, to: :show? + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:alert] || "Payment recorded" } end + end + def allocate + authorize! @event @event = @event.decorate - end + payment = Payment.find(params[:payment_id]) + event_registration = EventRegistration.find_by(id: params[:event_registration_id]) + unless event_registration + flash.now[:alert] = "Please select a registrant" + assign_allocation_card_data(payment) + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), alert: "Please select a registrant" } + end + return + end + amount_cents = (params[:amount_dollars].to_d * 100).to_i - # Public, slug-based ticket for the payer. Shows the event details, the - # registrants they paid for, and the submitted form — but none of the - # per-person admin actions found on the bulk payments dashboard. - def ticket - authorize! :bulk_payment, to: :ticket? + if amount_cents <= 0 + flash.now[:alert] = "Amount must be greater than $0.00" + elsif amount_cents > (payment.amount_cents_remaining || 0) + flash.now[:alert] = "Amount exceeds remaining balance" + else + allocation = Allocation.new(source: payment, allocatable: event_registration, amount: amount_cents) + if allocation.save + flash.now[:notice] = "Allocation successful" + else + flash.now[:alert] = allocation.errors.full_messages.to_sentence + end + end - @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) - @payment = @submission.payment - @event = @submission.event.decorate - end + assign_allocation_card_data(payment) - # Re-sends the payer their bulk payment confirmation email (the one carrying - # the ticket link). Reachable by the payer from the ticket. - def resend_confirmation - authorize! :bulk_payment, to: :show? - - @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) - payer_email = @submission.person.preferred_email.presence || - @submission.answers_by_identifier["payer_email"]&.strip - - if payer_email.present? - NotificationServices::CreateNotification.call( - noticeable: @submission, - kind: :bulk_payment_confirmation, - recipient_role: :person, - recipient_email: payer_email, - notification_type: 0 - ) - redirect_to bulk_payment_ticket_path(@submission.slug), notice: "Confirmation email sent." - else - redirect_to bulk_payment_ticket_path(@submission.slug), - alert: "No email address on file to send the confirmation to." + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:alert] || "Allocation successful" } end end - private + def link + authorize! @event + @event = @event.decorate - def visible_form_fields - scope = @form.form_fields.reorder(position: :asc) + submission = @event.form_submissions.find(params[:submission_id]) + event_registration = EventRegistration.find_by(id: params[:event_registration_id]) - if current_user - logged_out_only_ids = scope.where(visibility: :logged_out_only).ids - scope = scope.where.not(id: logged_out_only_ids) if logged_out_only_ids.any? + if event_registration + submission.link_registration!(event_registration.id) + flash.now[:notice] = "Linked #{event_registration.registrant.name}." + else + flash.now[:alert] = "Registration not found." end - scope - end - - def set_event - @event = Event.find(params[:event_id]) - end + assign_bulk_payment_card_data(submission) - def set_form - @form = @event.bulk_payment_form - unless @form - redirect_to event_path(@event), alert: "#{Form::BULK_PAYMENT_PUBLIC_NAME} form is not available for this event." + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:notice] || flash.now[:alert] } end end - def validate_required_fields - # The nested attendees field is validated separately, so exclude it here. - fields = visible_form_fields.reject { |field| field.field_identifier == "bulk_payment_attendees" } - FormAnswerValidator.call(fields, @form_params) - end + def unlink + authorize! @event + @event = @event.decorate + + submission = @event.form_submissions.find(params[:submission_id]) + event_registration = EventRegistration.find_by(id: params[:event_registration_id]) + + if event_registration + submission.unlink_registration!(event_registration.id) + flash.now[:notice] = "Unlinked #{event_registration.registrant.name}." + else + flash.now[:alert] = "Registration not found." + end - def credit_card_payment?(form_params) - payment_method_field = @form.form_fields.find_by(field_identifier: "payment_method") - return false unless payment_method_field + assign_bulk_payment_card_data(submission) - form_params[payment_method_field.id.to_s]&.downcase == FormBuilderService::PAYMENT_METHOD_PAY_NOW.downcase + respond_to do |format| + format.turbo_stream + format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:notice] || flash.now[:alert] } + end end - def create_stripe_checkout_session(submission) - person = submission.person - unit_amount = @event.cost_cents + private - attendees_field = @form.form_fields.find_by(field_identifier: "number_of_attendees") - qty = attendees_field ? @form_params[attendees_field.id.to_s].to_i : 1 - qty = 1 if qty < 1 + def set_event + @event = Event.find(params[:id]) + end - metadata = { form_submission_id: submission.id, event_id: @event.id } + # Reloads the payment and the data its bulk payment card needs, so the + # allocate turbo stream can re-render the whole card with fresh due/allocated + # totals and re-evaluate whether each registration is now paid in full. + def assign_allocation_card_data(payment) + @payment = payment.reload + @submission = @payment.form_submission + @event_registrations = @event.event_registrations.active.includes(:registrant) + @allocated_by_registration = allocated_cents_by_registration(@event_registrations) + end - attendees_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") - if attendees_field - attendees_json = @form_params[attendees_field.id.to_s] - metadata[:attendees] = attendees_json if attendees_json.present? - end + # Reloads the submission and the data its bulk payment card needs, so the + # link/unlink turbo stream can re-render the whole card with fresh linked + # registration data. + def assign_bulk_payment_card_data(submission) + @submission = submission.reload.decorate + @event_registrations = @event.event_registrations.active.includes(:registrant) + @allocated_by_registration = allocated_cents_by_registration(@event_registrations) + end - person.set_payment_processor :stripe - - person.payment_processor.checkout( - mode: "payment", - metadata: metadata, - payment_intent_data: { metadata: metadata, description: "Training Fee: #{@event.title}" }, - line_items: [ { - price_data: { - currency: "usd", - product_data: { name: "#{Form::BULK_PAYMENT_PUBLIC_NAME} (#{qty} attendees): #{@event.title}" }, - unit_amount: unit_amount - }, - quantity: qty - } ], - success_url: bulk_payment_ticket_url(submission.slug, checkout: "success"), - cancel_url: bulk_payment_ticket_url(submission.slug, checkout: "cancelled") - ) + # Allocated cents per registration id, fetched in one grouped query so the + # bulk payment cards read totals from a hash instead of querying per row. + def allocated_cents_by_registration(registrations) + Allocation + .where(allocatable_type: "EventRegistration", allocatable_id: registrations.ids) + .group(:allocatable_id) + .sum(:amount) end end end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c6db2a6fff..daa1c6052b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -2,7 +2,7 @@ class EventsController < ApplicationController include AhoyTracking, TagAssignable skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] - before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients bulk_payments preview_reminder confirm_reminder send_reminder copy_registration_form allocate_bulk_payment create_bulk_payment ] + before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ] def index authorize! @@ -213,99 +213,6 @@ def recipients @dashboard = EventDashboard.new(@event) end - def bulk_payments - authorize! @event - - @event = @event.decorate - # Shared across every card so attendee matching and allocated totals don't - # re-query per registration per card. - @event_registrations = @event.event_registrations.active.includes(:registrant) - @submissions = @event.form_submissions - .where(role: "bulk_payment") - .includes(:person, form_answers: :form_field, payment: :allocations) - .order(created_at: :desc) - @allocated_by_registration = allocated_cents_by_registration(@event_registrations) - end - - def allocate_bulk_payment - authorize! @event - @event = @event.decorate - payment = Payment.find(params[:payment_id]) - event_registration = EventRegistration.find_by(id: params[:event_registration_id]) - unless event_registration - flash.now[:alert] = "Please select a registrant" - assign_allocation_card_data(payment) - respond_to do |format| - format.turbo_stream - format.html { redirect_to bulk_payments_event_path(@event), alert: "Please select a registrant" } - end - return - end - amount_cents = (params[:amount_dollars].to_d * 100).to_i - - if amount_cents <= 0 - flash.now[:alert] = "Amount must be greater than $0.00" - elsif amount_cents > (payment.amount_cents_remaining || 0) - flash.now[:alert] = "Amount exceeds remaining balance" - else - allocation = Allocation.new(source: payment, allocatable: event_registration, amount: amount_cents) - if allocation.save - flash.now[:notice] = "Allocation successful" - else - flash.now[:alert] = allocation.errors.full_messages.to_sentence - end - end - - assign_allocation_card_data(payment) - - respond_to do |format| - format.turbo_stream - format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:alert] || "Allocation successful" } - end - end - - def create_bulk_payment - authorize! @event - @event = @event.decorate - @event_registrations = @event.event_registrations.active.includes(:registrant) - @allocated_by_registration = allocated_cents_by_registration(@event_registrations) - - submission = @event.form_submissions.find(params[:submission_id]) - payment_type = params[:payment_type] - - unless %w[CashPayment CheckPayment].include?(payment_type) - flash.now[:alert] = "Invalid payment type" - respond_to do |format| - format.turbo_stream - format.html { redirect_to bulk_payments_event_path(@event), alert: "Invalid payment type" } - end - return - end - - payment = submission.build_payment( - amount_cents: (params[:amount_dollars].to_d * 100).to_i, - currency: params[:currency].presence || "usd", - type: payment_type, - check_number: params[:check_number].presence, - memo: params[:memo].presence - ) - payment.payer_sgid = params[:payer_sgid] - payment.additional_designation_sgid = params[:additional_designation_sgid] - - if payment.save - @payment = payment - @submission = submission.decorate - flash.now[:notice] = "Payment recorded" - else - flash.now[:alert] = payment.errors.full_messages.to_sentence - end - - respond_to do |format| - format.turbo_stream - format.html { redirect_to bulk_payments_event_path(@event), notice: flash.now[:alert] || "Payment recorded" } - end - end - def preview_reminder authorize! @event @event = @event.decorate @@ -516,25 +423,6 @@ def selected_reminder_registrations .select { |r| r.registrant.preferred_email.present? } end - # Reloads the payment and the data its bulk payment card needs, so the - # allocate turbo stream can re-render the whole card with fresh due/allocated - # totals and re-evaluate whether each registration is now paid in full. - def assign_allocation_card_data(payment) - @payment = payment.reload - @submission = @payment.form_submission - @event_registrations = @event.event_registrations.active.includes(:registrant) - @allocated_by_registration = allocated_cents_by_registration(@event_registrations) - end - - # Allocated cents per registration id, fetched in one grouped query so the - # bulk payment cards read totals from a hash instead of querying per row. - def allocated_cents_by_registration(registrations) - Allocation - .where(allocatable_type: "EventRegistration", allocatable_id: registrations.ids) - .group(:allocatable_id) - .sum(:amount) - end - # Maps registrant person_id => the organization name they typed on the # registration form (the `agency_name` answer), in one batch query. Drives both # the roster's Pending/None org chip and the readiness "Organization not linked" diff --git a/app/decorators/form_submission_decorator.rb b/app/decorators/form_submission_decorator.rb index 487ca067fb..01e50f40aa 100644 --- a/app/decorators/form_submission_decorator.rb +++ b/app/decorators/form_submission_decorator.rb @@ -28,4 +28,30 @@ def matched_attendees(event_registrations) { first_name: first, last_name: last, email: email, matches: matches } end end + + # Single best match for one attendee. Prefers first+last name match, + # falls back to email match. Returns nil when no match is found. + def best_match_for(attendee, event_registrations) + first = attendee["first_name"]&.strip + last = attendee["last_name"]&.strip + email = attendee["email"]&.strip + + first_variants = first.present? ? NicknameMap.variants_for(first).to_set : Set.new + normalized_last = last.present? ? NicknameMap.normalize(last) : nil + + event_registrations.find do |reg| + person = reg.registrant + next false unless person + + first_matches = first_variants.include?(NicknameMap.normalize(person.first_name)) + last_matches = normalized_last.present? && + NicknameMap.normalize(person.last_name) == normalized_last + email_matches = email.present? && ( + person.email&.downcase == email.downcase || + person.email_2&.downcase == email.downcase + ) + + (first_matches && last_matches) || email_matches + end + end end diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index 6714a1d2bf..b174a7731a 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -67,6 +67,26 @@ def bulk_payment_amount_cents(event) event.cost_cents.to_i * bulk_payment_attendee_count end + # --- Linked registrations (bulk payment designations) --- + + def linked_registration_ids + (metadata || {}).fetch("linked_registration_ids", []) + end + + def link_registration!(event_registration_id) + ids = linked_registration_ids | [ event_registration_id.to_i ] + update!(metadata: (metadata || {}).merge("linked_registration_ids" => ids)) + end + + def unlink_registration!(event_registration_id) + ids = linked_registration_ids - [ event_registration_id.to_i ] + update!(metadata: (metadata || {}).merge("linked_registration_ids" => ids)) + end + + def linked_registrations + EventRegistration.where(id: linked_registration_ids) + end + private def generate_slug diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index d5c026266f..b8f0b1f95c 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -16,6 +16,8 @@ allocated_by_registration ||= {} event_cost_cents = @event.cost_cents.to_i attendee_matches = submission.matched_attendees(event_registrations) + linked_ids = submission.linked_registration_ids.to_set + linked_regs = event_registrations.select { |r| linked_ids.include?(r.id) } %>
<% if @event %> - View submission diff --git a/app/views/notification_mailer/bulk_payment_confirmation_fyi.text.erb b/app/views/notification_mailer/bulk_payment_confirmation_fyi.text.erb index ab802f28f1..0932be7327 100644 --- a/app/views/notification_mailer/bulk_payment_confirmation_fyi.text.erb +++ b/app/views/notification_mailer/bulk_payment_confirmation_fyi.text.erb @@ -32,7 +32,7 @@ Cost ------------------------------------------------------------ <% if @event %> View submission: -<%= event_bulk_payment_url(@event, reg: @submission.slug) %> +<%= event_bulk_payment_url(@event, slug: @submission.slug) %> <% end %> View profile: <%= profile_url %> diff --git a/spec/requests/events/bulk_payment_submissions_spec.rb b/spec/requests/events/bulk_payment_submissions_spec.rb index 1b3a645604..ef948fb666 100644 --- a/spec/requests/events/bulk_payment_submissions_spec.rb +++ b/spec/requests/events/bulk_payment_submissions_spec.rb @@ -158,7 +158,7 @@ def payer_params end describe "GET show" do - # Public submitted-form view, reached by slug via ?reg= (mirrors public + # Public submitted-form view, reached by slug via ?slug= (mirrors public # registration). Backs to the ticket by default. let(:event) { create(:event, :publicly_visible, cost_cents: 1000) } let(:payer) { create(:person) } @@ -169,7 +169,7 @@ def payer_params end def get_show - get event_bulk_payment_path(event, reg: submission.slug) + get event_bulk_payment_path(event, slug: submission.slug) end context "as a signed-out viewer" do @@ -191,12 +191,12 @@ def get_show end it "404s for an unknown slug" do - get event_bulk_payment_path(event, reg: "nope") + get event_bulk_payment_path(event, slug: "nope") expect(response).to have_http_status(:not_found) end - it "404s for a blank reg, even when a slugless bulk payment exists" do + it "404s for a blank slug, even when a slugless bulk payment exists" do submission.update_columns(slug: nil) get event_bulk_payment_path(event) @@ -227,7 +227,7 @@ def get_show context "as an admin arriving from the dashboard" do it "shows a Back to ticket link plus a second Back to bulk payments link" do - get event_bulk_payment_path(event, reg: submission.slug, return_to: "bulk_payments") + get event_bulk_payment_path(event, slug: submission.slug, return_to: "bulk_payments") expect(response.body).to include("Back to ticket") expect(response.body).to include("Back to bulk payments") @@ -305,7 +305,7 @@ def get_ticket get_ticket expect(response.body).to include("View your form responses") - expect(response.body).to include(event_bulk_payment_path(event, reg: submission.slug)) + expect(response.body).to include(event_bulk_payment_path(event, slug: submission.slug)) end it "returns 404 for an unknown slug" do From dc73e04bf37b482cc26397baad2f8625131da152 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:02:41 -0400 Subject: [PATCH 16/22] guard nil slug --- app/policies/form_submission_policy.rb | 4 +- spec/policies/form_submission_policy_spec.rb | 68 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index a60bf7ca65..c0b3c0ec72 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -7,11 +7,11 @@ def index? end def show? - admin? || record.slug == slug + admin? || (slug.present? && record.slug == slug) end def ticket? - admin? || record.slug == slug + admin? || (slug.present? && record.slug == slug) end def new? diff --git a/spec/policies/form_submission_policy_spec.rb b/spec/policies/form_submission_policy_spec.rb index 4b1edb9bb3..161b02f7f8 100644 --- a/spec/policies/form_submission_policy_spec.rb +++ b/spec/policies/form_submission_policy_spec.rb @@ -22,10 +22,78 @@ def policy_for(user:, record: submission) it { is_expected.not_to be_allowed_to(:show?) } end + context "with regular user and matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: regular_user, slug: "abc123") } + + it { is_expected.to be_allowed_to(:show?) } + end + + context "with regular user and non-matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: regular_user, slug: "wrong") } + + it { is_expected.not_to be_allowed_to(:show?) } + end + + context "with regular user and no slug context" do + subject { policy_for(user: regular_user) } + + it { is_expected.not_to be_allowed_to(:show?) } + end + + context "with no user and matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: nil, slug: "abc123") } + + it { is_expected.to be_allowed_to(:show?) } + end + context "with no user" do subject { policy_for(user: nil) } it { is_expected.not_to be_allowed_to(:show?) } end end + + describe "#ticket?" do + context "with admin user" do + subject { policy_for(user: admin_user) } + + it { is_expected.to be_allowed_to(:ticket?) } + end + + context "with regular user and matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: regular_user, slug: "abc123") } + + it { is_expected.to be_allowed_to(:ticket?) } + end + + context "with regular user and non-matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: regular_user, slug: "wrong") } + + it { is_expected.not_to be_allowed_to(:ticket?) } + end + + context "with regular user and no slug context" do + subject { policy_for(user: regular_user) } + + it { is_expected.not_to be_allowed_to(:ticket?) } + end + + context "with no user and matching slug" do + let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } } + subject { described_class.new(submission, user: nil, slug: "abc123") } + + it { is_expected.to be_allowed_to(:ticket?) } + end + + context "with no user" do + subject { policy_for(user: nil) } + + it { is_expected.not_to be_allowed_to(:ticket?) } + end + end end From 43ae403dbf7209a383966adf7bb8fb31cfc214f9 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:27:07 -0400 Subject: [PATCH 17/22] clean up --- app/decorators/form_submission_decorator.rb | 28 -------------------- app/views/events/_bulk_payment_card.html.erb | 15 +++++------ 2 files changed, 7 insertions(+), 36 deletions(-) diff --git a/app/decorators/form_submission_decorator.rb b/app/decorators/form_submission_decorator.rb index 01e50f40aa..9528e6bcda 100644 --- a/app/decorators/form_submission_decorator.rb +++ b/app/decorators/form_submission_decorator.rb @@ -1,34 +1,6 @@ class FormSubmissionDecorator < ApplicationDecorator delegate_all - def matched_attendees(event_registrations) - object.bulk_payment_attendees.map do |attendee| - first = attendee["first_name"]&.strip - last = attendee["last_name"]&.strip - email = attendee["email"]&.strip - - first_variants = first.present? ? NicknameMap.variants_for(first).to_set : Set.new - normalized_last = last.present? ? NicknameMap.normalize(last) : nil - - matches = event_registrations.select do |reg| - person = reg.registrant - next false unless person - - first_matches = first_variants.include?(NicknameMap.normalize(person.first_name)) - last_matches = normalized_last.present? && - NicknameMap.normalize(person.last_name) == normalized_last - email_matches = email.present? && ( - person.email&.downcase == email.downcase || - person.email_2&.downcase == email.downcase - ) - - (first_matches && last_matches) || email_matches - end - - { first_name: first, last_name: last, email: email, matches: matches } - end - end - # Single best match for one attendee. Prefers first+last name match, # falls back to email match. Returns nil when no match is found. def best_match_for(attendee, event_registrations) diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index 314cf9c948..2a11e91483 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -14,7 +14,6 @@ event_registrations ||= @event_registrations || @event.event_registrations.active.includes(:registrant) allocated_by_registration ||= {} event_cost_cents = @event.cost_cents.to_i - attendee_matches = submission.matched_attendees(event_registrations) linked_ids = submission.linked_registration_ids.to_set linked_regs = event_registrations.select { |r| linked_ids.include?(r.id) } %> @@ -61,7 +60,7 @@ data-dropdown-target="expand" <% end %> data-dropdown-payload-param='[{"<%= content_id %>":"hidden"}, {"<%= arrow_id %>":"rotate-180"}]' - class="text-gray-400 hover:text-gray-600 p-1 justify-self-end" + class="cursor-pointer text-gray-400 hover:text-gray-600 p-1 justify-self-end" title="More info" > @@ -128,15 +127,15 @@