diff --git a/app/controllers/events/bulk_payment_form_submissions_controller.rb b/app/controllers/events/bulk_payment_form_submissions_controller.rb new file mode 100644 index 0000000000..56e052fc46 --- /dev/null +++ b/app/controllers/events/bulk_payment_form_submissions_controller.rb @@ -0,0 +1,174 @@ +module Events + class BulkPaymentFormSubmissionsController < 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! :form_submission + + @form_fields = visible_form_fields + @event = @event.decorate + + @attendees_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + end + + def create + authorize! :form_submission + + @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 + @attendees_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 + @attendees_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + flash.now[:alert] = result.errors.join(", ") + render :new, status: :unprocessable_content + end + end + + def show + slug = params[:slug] + if slug.present? + @submission = FormSubmission.bulk_payment + .find_by!(slug: slug, event_id: @event.id) + else + @submission = FormSubmission.bulk_payment.find_by!(id: params[:submission_id], event_id: @event.id) + end + authorize! @submission, context: { slug: slug } + + @event = @event.decorate + end + + def ticket + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + authorize! @submission, context: { slug: params[:slug] } + + @payment = @submission.payment + @event = @submission.event.decorate + end + + def resend_confirmation + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + authorize! @submission, to: :show?, context: { 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..0e44535457 100644 --- a/app/controllers/events/bulk_payments_controller.rb +++ b/app/controllers/events/bulk_payments_controller.rb @@ -1,184 +1,167 @@ 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_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) @event = @event.decorate - - @attendee_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") end def create - authorize! :bulk_payment, to: :create? + authorize! @event + @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 + @event = @event.decorate + + 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 + assign_bulk_payment_card_data(submission) - 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." + 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 - def credit_card_payment?(form_params) - payment_method_field = @form.form_fields.find_by(field_identifier: "payment_method") - return false unless payment_method_field + submission = @event.form_submissions.find(params[:submission_id]) + event_registration = EventRegistration.find_by(id: params[:event_registration_id]) - form_params[payment_method_field.id.to_s]&.downcase == FormBuilderService::PAYMENT_METHOD_PAY_NOW.downcase + 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 + + assign_bulk_payment_card_data(submission) + + 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 } + 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 + 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") - ) + 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/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/policies/events/bulk_payment_policy.rb b/app/policies/events/bulk_payment_policy.rb deleted file mode 100644 index af94e1d6aa..0000000000 --- a/app/policies/events/bulk_payment_policy.rb +++ /dev/null @@ -1,17 +0,0 @@ -class Events::BulkPaymentPolicy < ApplicationPolicy - def new? - true - end - - def create? - true - end - - def show? - true - end - - def ticket? - true - end -end diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index 7dccbbcba0..c0b3c0ec72 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -1,11 +1,25 @@ class FormSubmissionPolicy < ApplicationPolicy # See https://actionpolicy.evilmartians.io/#/writing_policies + authorize :slug, optional: true, allow_nil: true + def index? admin? end def show? - admin? + admin? || (slug.present? && record.slug == slug) + end + + def ticket? + admin? || (slug.present? && record.slug == slug) + end + + def new? + true + end + + def create? + true end # Bulk-payment payers have no account but are emailed a link to their public diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index d5c026266f..b6e46745fd 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -1,5 +1,4 @@ -<% - submission = submission.decorate +<% submission = submission.decorate payment = submission.payment payment_method = submission.answers_by_identifier["payment_method"] content_id = "payment-details-#{submission.id}" @@ -15,146 +14,109 @@ 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) -%> -
<% 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/config/routes.rb b/config/routes.rb index bf47eef923..189c537e1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -73,8 +73,8 @@ end end resources :community_news - get "bulk_payment/:slug", to: "events/bulk_payments#ticket", as: :bulk_payment_ticket - post "bulk_payment/:slug/resend_confirmation", to: "events/bulk_payments#resend_confirmation", as: :bulk_payment_resend_confirmation + get "bulk_payment/:slug", to: "events/bulk_payment_form_submissions#ticket", as: :bulk_payment_ticket + post "bulk_payment/:slug/resend_confirmation", to: "events/bulk_payment_form_submissions#resend_confirmation", as: :bulk_payment_resend_confirmation get "registration/:slug", to: "events/registrations#show", as: :registration_ticket get "registration/:slug/invoice", to: "events/registrations#invoice", as: :registration_invoice get "registration/:slug/receipt", to: "events/registrations#receipt", as: :registration_receipt @@ -149,19 +149,21 @@ get "staff/edit", action: :edit_staff, as: :edit_staff patch "staff", action: :update_staff get :recipients - get :bulk_payments + get :bulk_payments, to: "events/bulk_payments#index" get :preview_reminder patch :preview post :copy_registration_form post :confirm_reminder post :send_reminder - post :allocate_bulk_payment - post :create_bulk_payment + post :allocate_bulk_payment, to: "events/bulk_payments#allocate" + post :bulk_payments, to: "events/bulk_payments#create" + post :link_bulk_payment, to: "events/bulk_payments#link" + delete :unlink_bulk_payment, to: "events/bulk_payments#unlink" end resources :registration_ticket_callouts, only: [ :show, :update ] resource :registrations, only: %i[ create ], module: :events, as: :registrant_registration resource :public_registration, only: [ :new, :create, :show ], module: :events - resource :bulk_payment, only: [ :new, :create, :show ], module: :events + resource :bulk_payment, only: [ :new, :create, :show ], controller: "events/bulk_payment_form_submissions" resource :invoice, only: [ :show ], module: :events get "form_submissions/:person_id", to: "events/form_submissions#show", as: :registrant_submissions end diff --git a/db/migrate/20260727000000_add_metadata_to_form_submissions.rb b/db/migrate/20260727000000_add_metadata_to_form_submissions.rb new file mode 100644 index 0000000000..985eec1cc3 --- /dev/null +++ b/db/migrate/20260727000000_add_metadata_to_form_submissions.rb @@ -0,0 +1,5 @@ +class AddMetadataToFormSubmissions < ActiveRecord::Migration[8.1] + def change + add_column :form_submissions, :metadata, :json + end +end diff --git a/db/schema.rb b/db/schema.rb index d36e57d228..da164c312f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -654,6 +654,7 @@ t.datetime "created_at", null: false t.bigint "event_id" t.integer "form_id", null: false + t.json "metadata" t.bigint "person_id", null: false t.string "role" t.string "slug" diff --git a/spec/models/form_submission_spec.rb b/spec/models/form_submission_spec.rb index 6d416be252..5bf4101b67 100644 --- a/spec/models/form_submission_spec.rb +++ b/spec/models/form_submission_spec.rb @@ -80,4 +80,81 @@ expect(submission.bulk_payment_amount_cents(free_event)).to eq(0) end end + + describe "linked registrations" do + let(:event) { create(:event) } + let(:form) { create(:form) } + let(:submission) { create(:form_submission, form: form, event: event) } + let!(:reg1) { create(:event_registration, event: event) } + let!(:reg2) { create(:event_registration, event: event) } + + describe "#linked_registration_ids" do + it "returns an empty array when metadata is nil" do + expect(submission.linked_registration_ids).to eq([]) + end + + it "returns an empty array when metadata has no linked_registration_ids" do + submission.update!(metadata: { "other_key" => "value" }) + expect(submission.linked_registration_ids).to eq([]) + end + + it "returns the stored ids" do + submission.update!(metadata: { "linked_registration_ids" => [ reg1.id, reg2.id ] }) + expect(submission.linked_registration_ids).to contain_exactly(reg1.id, reg2.id) + end + end + + describe "#link_registration!" do + it "adds a registration id to metadata" do + submission.link_registration!(reg1.id) + + expect(submission.reload.linked_registration_ids).to eq([ reg1.id ]) + end + + it "does not duplicate an existing id" do + submission.link_registration!(reg1.id) + submission.link_registration!(reg1.id) + + expect(submission.reload.linked_registration_ids).to eq([ reg1.id ]) + end + + it "preserves other metadata" do + submission.update!(metadata: { "other_key" => "value" }) + submission.link_registration!(reg1.id) + + expect(submission.reload.metadata["other_key"]).to eq("value") + expect(submission.linked_registration_ids).to eq([ reg1.id ]) + end + end + + describe "#unlink_registration!" do + it "removes a registration id from metadata" do + submission.link_registration!(reg1.id) + submission.link_registration!(reg2.id) + submission.unlink_registration!(reg1.id) + + expect(submission.reload.linked_registration_ids).to eq([ reg2.id ]) + end + + it "is a no-op when the id is not linked" do + submission.link_registration!(reg1.id) + submission.unlink_registration!(reg2.id) + + expect(submission.reload.linked_registration_ids).to eq([ reg1.id ]) + end + end + + describe "#linked_registrations" do + it "returns event registrations matching linked ids" do + submission.link_registration!(reg1.id) + submission.link_registration!(reg2.id) + + expect(submission.linked_registrations).to contain_exactly(reg1, reg2) + end + + it "returns empty relation when nothing is linked" do + expect(submission.linked_registrations).to be_empty + end + end + end end 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 diff --git a/spec/requests/events/bulk_payment_form_submissions_spec.rb b/spec/requests/events/bulk_payment_form_submissions_spec.rb new file mode 100644 index 0000000000..abc0d38691 --- /dev/null +++ b/spec/requests/events/bulk_payment_form_submissions_spec.rb @@ -0,0 +1,351 @@ +require "rails_helper" + +RSpec.describe "Events::BulkPaymentFormSubmissions", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, cost_cents: 0) } + let(:form) { create(:form) } + # The bulk payment view only renders a known set of "payer" fields, so the + # min-word rule is exercised through payer_organization (a free-form text field). + let!(:org_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "payer_organization", name: "Organization", + required: true, min_words: 5) + end + let!(:payment_method_field) do + field = create(:form_field, form: form, answer_type: :single_select_radio, + field_identifier: "payment_method", name: "Payment method", + required: false) + FormBuilderService::PAYMENT_METHOD_OPTIONS.each do |option_name| + field.form_field_answer_options.create!(answer_option: AnswerOption.find_or_create_by!(name: option_name)) + end + field + end + let!(:payer_first_name_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "payer_first_name", name: "Payer first name", + required: false) + end + let!(:payer_last_name_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "payer_last_name", name: "Payer last name", + required: false) + end + let!(:payer_email_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "payer_email", name: "Payer email", + required: false) + end + + before do + EventForm.create!(event: event, form: form, role: "bulk_payment") + sign_in admin + end + + def post_bulk_payment(answer) + post event_bulk_payment_path(event), + params: { bulk_payment: { form_fields: { org_field.id.to_s => answer } } } + end + + describe "POST create with a minimum word count" do + it "rejects an answer with too few words" do + post_bulk_payment("not quite enough") + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("must be at least 5 words") + end + + it "does not flag an answer that meets the minimum" do + post_bulk_payment("this answer easily has plenty of words") + + expect(response.body).not_to include("must be at least 5 words") + end + end + + describe "GET new" do + it "shows the minimum word hint below the field" do + get new_event_bulk_payment_path(event) + + expect(response.body).to include("Minimum of 5 words.") + end + + it "renders the field at its configured width" do + org_field.update!(width: :half) + + get new_event_bulk_payment_path(event) + + expect(response.body).to include("md:col-span-6") + end + end + + describe "POST create with credit card payment" do + let(:admin) { create(:user, :admin, :with_person) } + let(:event) { create(:event, cost_cents: 15_00) } + let(:fake_session) { double(url: "https://checkout.stripe.com/test") } + + before do + fake_processor = double(checkout: fake_session) + allow_any_instance_of(Person).to receive(:set_payment_processor) + allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + end + + def payer_params + { + payer_first_name_field.id.to_s => "Jane", + payer_last_name_field.id.to_s => "Doe", + payer_email_field.id.to_s => "jane@example.com" + } + end + + it "redirects to Stripe Checkout when paying by credit card" do + post event_bulk_payment_path(event), + params: { bulk_payment: { form_fields: payer_params.merge( + org_field.id.to_s => "this answer has enough words for validation", + payment_method_field.id.to_s => "Credit card (now)" + ) } } + + expect(response).to redirect_to("https://checkout.stripe.com/test") + expect(response.status).to eq(303) + end + + it "does not redirect when payment method is not credit card" do + post event_bulk_payment_path(event), + params: { bulk_payment: { form_fields: payer_params.merge( + org_field.id.to_s => "this answer has enough words for validation", + payment_method_field.id.to_s => "Check" + ) } } + + expect(response).to have_http_status(:redirect) + expect(response.location).to match(%r{/bulk_payment/}) + expect(flash[:notice]).to eq("Your payment information has been submitted.") + end + + it "does not redirect to Stripe when event is free" do + event.update!(cost_cents: 0) + + post event_bulk_payment_path(event), + params: { bulk_payment: { form_fields: payer_params.merge( + org_field.id.to_s => "this answer has enough words for validation" + ) } } + + expect(response).to have_http_status(:redirect) + expect(response.location).to match(%r{/bulk_payment/}) + end + end + + describe "GET new with the seeded bulk payment form" do + let(:seeded_form) do + FormBuilderService.new(name: "Bulk Payment", sections: %i[bulk_payment], role: "bulk_payment").call + end + + before do + # Payer fields are logged_out_only, so test the public (signed-out) view. + sign_out admin + EventForm.where(event: event).destroy_all + EventForm.create!(event: event, form: seeded_form, role: "bulk_payment") + end + + it "renders the optional payer phone field" do + get new_event_bulk_payment_path(event) + + expect(response.body).to include("Phone") + end + + it "labels the attendee fields with the 'Attendee' prefix" do + get new_event_bulk_payment_path(event) + + expect(response.body).to include("Attendee first name", "Attendee last name", "Attendee email") + end + end + + describe "GET show" do + # 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) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let!(:org_answer) do + submission.form_answers.create!(form_field: org_field, submitted_answer: "Northside Shelter", + question_name_when_answered: org_field.name) + end + + def get_show + get event_bulk_payment_path(event, slug: submission.slug) + end + + context "as a signed-out viewer" do + before { sign_out admin } + + it "renders the submitted form publicly via the slug" do + get_show + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Payment submission") + expect(response.body).to include("Northside Shelter") + end + + it "backs to the ticket by default" do + get_show + + expect(response.body).to include(bulk_payment_ticket_path(submission.slug)) + expect(response.body).to include("Back to ticket") + end + + it "404s for an unknown slug" do + get event_bulk_payment_path(event, slug: "nope") + + expect(response).to have_http_status(:not_found) + end + + 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) + + expect(response).to have_http_status(:not_found) + end + + it "does not let a signed-out viewer reach a submission by id" do + get event_bulk_payment_path(event, submission_id: submission.id) + + expect(response).to redirect_to(root_path) + end + end + + context "as an admin viewing a slugless submission by id" do + before { submission.update_columns(slug: nil) } + + it "renders the same submission partial without a ticket back link" do + get event_bulk_payment_path(event, submission_id: submission.id, return_to: "bulk_payments") + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Payment submission") + expect(response.body).to include("Northside Shelter") + expect(response.body).not_to include("Back to ticket") + expect(response.body).to include("Back to bulk payments") + end + end + + 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, slug: submission.slug, return_to: "bulk_payments") + + expect(response.body).to include("Back to ticket") + expect(response.body).to include("Back to bulk payments") + end + end + end + + describe "GET ticket" do + let(:event) { create(:event, :publicly_visible, cost_cents: 1000, title: "Spring Workshop") } + let(:payer) { create(:person) } + let(:attendees_json) do + [ { "first_name" => "Jordan", "last_name" => "Rivers", "email" => "jordan@example.com" } ].to_json + end + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let!(:attendees_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "bulk_payment_attendees", name: "Attendees", required: false) + end + + before do + submission.form_answers.create!(form_field: attendees_field, submitted_answer: attendees_json, + question_name_when_answered: "Attendees") + sign_out admin + end + + def get_ticket + get bulk_payment_ticket_path(submission.slug) + end + + it "renders the ticket for the public payer using the slug" do + get_ticket + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Payment ticket") + expect(response.body).to include("Spring Workshop") + end + + it "lists the registrants" do + get_ticket + + expect(response.body).to include("Jordan Rivers") + expect(response.body).to include("jordan@example.com") + end + + it "shows the payer's name, organization, and registrants-covered count" do + submission.form_answers.create!(form_field: payer_first_name_field, submitted_answer: "Alex", + question_name_when_answered: "Payer first name") + submission.form_answers.create!(form_field: payer_last_name_field, submitted_answer: "Chen", + question_name_when_answered: "Payer last name") + submission.form_answers.create!(form_field: org_field, submitted_answer: "Bright Futures Academy", + question_name_when_answered: "Organization") + + get_ticket + + expect(response.body).to include("Payer") + expect(response.body).to include("Alex Chen") + expect(response.body).to include("Bright Futures Academy") + # One attendee is listed and no explicit count is set, so the covered count is 1. + expect(response.body).to include("Covering 1 registrant") + end + + it "does not show per-person actions like cancelling a registration" do + get_ticket + + expect(response.body).not_to include("Cancel registration") + end + + it "links the invoice back to the ticket" do + get_ticket + + expect(response.body).to include("return_to=bulk_payment_ticket") + end + + it "links 'View your form responses' to the public submission page" do + get_ticket + + expect(response.body).to include("View your form responses") + expect(response.body).to include(event_bulk_payment_path(event, slug: submission.slug)) + end + + it "returns 404 for an unknown slug" do + get bulk_payment_ticket_path("nope") + + expect(response).to have_http_status(:not_found) + end + + it "adds a Back to bulk payments eyebrow when arriving from the dashboard" do + get bulk_payment_ticket_path(submission.slug, return_to: "bulk_payments", expand: submission.id) + + expect(response.body).to include("Back to event") + expect(response.body).to include("Back to bulk payments") + end + + context "as an admin" do + before { sign_in admin } + + it "shows the admin allocations section" do + get_ticket + + expect(response.body).to include("Payment allocations") + end + end + end + + describe "POST resend_confirmation" do + let(:event) { create(:event, :publicly_visible, cost_cents: 1000) } + let(:payer) { create(:person, email: "payer@example.com") } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + + before { sign_out admin } + + it "re-sends the payer confirmation and returns to the ticket" do + expect { + post bulk_payment_resend_confirmation_path(submission.slug) + }.to change(Notification, :count).by(1) + + expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug)) + expect(flash[:notice]).to eq("Confirmation email sent.") + end + end +end diff --git a/spec/requests/events/bulk_payments_spec.rb b/spec/requests/events/bulk_payments_spec.rb index 711a6c32a2..0dcd009553 100644 --- a/spec/requests/events/bulk_payments_spec.rb +++ b/spec/requests/events/bulk_payments_spec.rb @@ -4,8 +4,6 @@ let(:admin) { create(:user, :admin) } let(:event) { create(:event, cost_cents: 0) } let(:form) { create(:form) } - # The bulk payment view only renders a known set of "payer" fields, so the - # min-word rule is exercised through payer_organization (a free-form text field). let!(:org_field) do create(:form_field, form: form, answer_type: :free_form_input_one_line, field_identifier: "payer_organization", name: "Organization", @@ -41,311 +39,271 @@ sign_in admin end - def post_bulk_payment(answer) - post event_bulk_payment_path(event), - params: { bulk_payment: { form_fields: { org_field.id.to_s => answer } } } - end - - describe "POST create with a minimum word count" do - it "rejects an answer with too few words" do - post_bulk_payment("not quite enough") - - expect(response).to have_http_status(:unprocessable_content) - expect(response.body).to include("must be at least 5 words") + describe "GET /events/:id/bulk_payments (admin dashboard)" do + let(:event) { create(:event, cost_cents: 2500) } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let!(:attendees_field) do + create(:form_field, form: form, field_identifier: "number_of_attendees", name: "Attendees") end - it "does not flag an answer that meets the minimum" do - post_bulk_payment("this answer easily has plenty of words") + it "shows the submitted amount even when no payment has landed" do + submission.form_answers.create!(form_field: attendees_field, submitted_answer: "3") - expect(response.body).not_to include("must be at least 5 words") - end - end + get bulk_payments_event_path(event) - describe "GET new" do - it "shows the minimum word hint below the field" do - get new_event_bulk_payment_path(event) - - expect(response.body).to include("Minimum of 5 words.") + expect(response).to have_http_status(:ok) + expect(response.body).to include("$75") end - it "renders the field at its configured width" do - org_field.update!(width: :half) + it "shows the recorded payment amount when a payment exists" do + submission.form_answers.create!(form_field: attendees_field, submitted_answer: "3") + create(:payment, person: payer, form_submission: submission, + amount_cents: 5000, amount_cents_remaining: 5000) - get new_event_bulk_payment_path(event) + get bulk_payments_event_path(event) - expect(response.body).to include("md:col-span-6") + expect(response.body).to include("$50") end - end - describe "POST create with credit card payment" do - let(:admin) { create(:user, :admin, :with_person) } - let(:event) { create(:event, cost_cents: 15_00) } - let(:fake_session) { double(url: "https://checkout.stripe.com/test") } + it "renders the targeted submission's row expanded when given an expand param" do + get bulk_payments_event_path(event, expand: submission.id) - before do - fake_processor = double(checkout: fake_session) - allow_any_instance_of(Person).to receive(:set_payment_processor) - allow_any_instance_of(Person).to receive(:payment_processor).and_return(fake_processor) + expect(response.body).to include("id=\"payment-card-#{submission.id}\"") + expect(response.body).to include("data-dropdown-target=\"expand\"") end - def payer_params - { - payer_first_name_field.id.to_s => "Jane", - payer_last_name_field.id.to_s => "Doe", - payer_email_field.id.to_s => "jane@example.com" - } + it "renders rows collapsed without an expand param" do + get bulk_payments_event_path(event) + + expect(response.body).to match(/id="payment-details-#{submission.id}"\s+class="hidden/) + expect(response.body).not_to match(/id="payment-arrow-#{submission.id}"[^>]*rotate-180/) end - it "redirects to Stripe Checkout when paying by credit card" do - post event_bulk_payment_path(event), - params: { bulk_payment: { form_fields: payer_params.merge( - org_field.id.to_s => "this answer has enough words for validation", - payment_method_field.id.to_s => "Credit card (now)" - ) } } - expect(response).to redirect_to("https://checkout.stripe.com/test") - expect(response.status).to eq(303) - end + it "shows a grey \"Paid\" instead of an orange balance when the registration is fully covered" do + attendee = create(:person, first_name: "Paid", last_name: "Infull", email: "paid.infull@example.com") + registration = create(:event_registration, event: event, registrant: attendee, status: "registered") + create(:form_field, form: form, field_identifier: "bulk_payment_attendees", name: "Attendees list") + submission.form_answers.create!( + form_field: form.form_fields.find_by(field_identifier: "bulk_payment_attendees"), + submitted_answer: [ { first_name: "Paid", last_name: "Infull", email: "paid.infull@example.com" } ].to_json + ) + create(:allocation, source: create(:payment, amount_cents: 2500, amount_cents_remaining: 2500), + allocatable: registration, amount: 2500) + submission.link_registration!(registration.id) - it "does not redirect when payment method is not credit card" do - post event_bulk_payment_path(event), - params: { bulk_payment: { form_fields: payer_params.merge( - org_field.id.to_s => "this answer has enough words for validation", - payment_method_field.id.to_s => "Check" - ) } } + get bulk_payments_event_path(event) - expect(response).to have_http_status(:redirect) - expect(response.location).to match(%r{/bulk_payment/}) - expect(flash[:notice]).to eq("Your payment information has been submitted.") + expect(response).to have_http_status(:ok) + expect(response.body).to include("text-gray-500 whitespace-nowrap\">Paid<") + expect(response.body).not_to include("$0.00") end - it "does not redirect to Stripe when event is free" do - event.update!(cost_cents: 0) + it "does not show the removed new-allocation dropdown" do + get bulk_payments_event_path(event) - post event_bulk_payment_path(event), - params: { bulk_payment: { form_fields: payer_params.merge( - org_field.id.to_s => "this answer has enough words for validation" - ) } } - - expect(response).to have_http_status(:redirect) - expect(response.location).to match(%r{/bulk_payment/}) + expect(response).to have_http_status(:ok) + expect(response.body).not_to include("New allocation") end end - describe "GET new with the seeded bulk payment form" do - let(:seeded_form) do - FormBuilderService.new(name: "Bulk Payment", sections: %i[bulk_payment], role: "bulk_payment").call - end + describe "POST /events/:id/allocate_bulk_payment" do + let(:event) { create(:event) } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let!(:payment) { create(:payment, person: payer, form_submission: submission, + amount_cents: 1000, amount_cents_remaining: 1000) } + let(:registrant) { create(:person) } + let!(:event_registration) { create(:event_registration, event: event, registrant: registrant) } - before do - # Payer fields are logged_out_only, so test the public (signed-out) view. - sign_out admin - EventForm.where(event: event).destroy_all - EventForm.create!(event: event, form: seeded_form, role: "bulk_payment") + let(:valid_params) do + { payment_id: payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" } end - it "renders the optional payer phone field" do - get new_event_bulk_payment_path(event) - - expect(response.body).to include("Phone") + it "rejects unauthenticated request" do + sign_out admin + post allocate_bulk_payment_event_path(event), params: valid_params + expect(response).to redirect_to(new_user_session_path) end - it "labels the attendee fields with the 'Attendee' prefix" do - get new_event_bulk_payment_path(event) - - expect(response.body).to include("Attendee first name", "Attendee last name", "Attendee email") + it "rejects non-admin request" do + sign_out admin + sign_in create(:user) + post allocate_bulk_payment_event_path(event), params: valid_params + expect(response).to redirect_to(root_path) end - end - describe "GET show" do - # Public submitted-form view, reached by slug via ?reg= (mirrors public - # registration). Backs to the ticket by default. - let(:event) { create(:event, :publicly_visible, cost_cents: 1000) } - let(:payer) { create(:person) } - let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } - let!(:org_answer) do - submission.form_answers.create!(form_field: org_field, submitted_answer: "Northside Shelter", - question_name_when_answered: org_field.name) - end + it "creates an allocation and returns turbo_stream" do + expect { + post allocate_bulk_payment_event_path(event), params: valid_params, as: :turbo_stream + }.to change(Allocation, :count).by(1) - def get_show - get event_bulk_payment_path(event, reg: submission.slug) + expect(payment.reload.amount_cents_remaining).to eq(500) + expect(response.media_type).to eq(Mime[:turbo_stream]) + expect(response.body).to include("Allocation successful") end - context "as a signed-out viewer" do - before { sign_out admin } - - it "renders the submitted form publicly via the slug" do - get_show - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Payment submission") - expect(response.body).to include("Northside Shelter") + context "when the allocation pays a matched registration in full" do + let(:event) { create(:event, cost_cents: 500) } + let(:registrant) { create(:person, email: "match@example.com") } + let!(:attendees_field) do + create(:form_field, form: form, field_identifier: "bulk_payment_attendees", name: "Attendees") end - it "backs to the ticket by default" do - get_show - - expect(response.body).to include(bulk_payment_ticket_path(submission.slug)) - expect(response.body).to include("Back to ticket") + before do + submission.form_answers.create!( + form_field: attendees_field, + submitted_answer: [ { first_name: registrant.first_name, last_name: registrant.last_name, email: "match@example.com" } ].to_json + ) + submission.link_registration!(event_registration.id) end - it "404s for an unknown slug" do - get event_bulk_payment_path(event, reg: "nope") + it "re-renders the card with refreshed totals and hides the inline allocate box for that registration" do + post allocate_bulk_payment_event_path(event), + params: { payment_id: payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" }, + as: :turbo_stream - expect(response).to have_http_status(:not_found) + expect(response.body).to include("payment-card-#{submission.id}") + expect(response.body).to include("rotate-180") + expect(response.body).to include(">Paid") + expect(response.body.scan(">Allocate").size).to eq(0) end + end - it "404s for a blank reg, even when a slugless bulk payment exists" do - submission.update_columns(slug: nil) - - get event_bulk_payment_path(event) + it "shows alert when event_registration_id is blank" do + params = valid_params.merge(event_registration_id: "") + expect { + post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream + }.not_to change(Allocation, :count) - expect(response).to have_http_status(:not_found) - end + expect(response.body).to include("Please select a registrant") + end - it "does not let a signed-out viewer reach a submission by id" do - get event_bulk_payment_path(event, submission_id: submission.id) + it "shows alert when event_registration_id is invalid" do + params = valid_params.merge(event_registration_id: 999999) + expect { + post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream + }.not_to change(Allocation, :count) - expect(response).to redirect_to(root_path) - end + expect(response.body).to include("Please select a registrant") end - context "as an admin viewing a slugless submission by id" do - before { submission.update_columns(slug: nil) } + it "shows alert when amount is zero" do + params = valid_params.merge(amount_dollars: "0.00") + expect { + post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream + }.not_to change(Allocation, :count) - it "renders the same submission partial without a ticket back link" do - get event_bulk_payment_path(event, submission_id: submission.id, return_to: "bulk_payments") + expect(response.body).to include("Amount must be greater than $0.00") + end - expect(response).to have_http_status(:ok) - expect(response.body).to include("Payment submission") - expect(response.body).to include("Northside Shelter") - expect(response.body).not_to include("Back to ticket") - expect(response.body).to include("Back to bulk payments") - end + it "shows alert when amount exceeds remaining balance" do + params = valid_params.merge(amount_dollars: "20.00") + expect { + post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream + }.not_to change(Allocation, :count) + + expect(response.body).to include("Amount exceeds remaining balance") end - 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") + it "shows alert when event registration is already fully paid" do + large_payment = create(:payment, person: payer, form_submission: submission, + amount_cents: 2000, amount_cents_remaining: 2000) + create(:allocation, source: large_payment, allocatable: event_registration, amount: 1099) - expect(response.body).to include("Back to ticket") - expect(response.body).to include("Back to bulk payments") - end + params = { payment_id: large_payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" } + expect { + post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream + }.not_to change(Allocation, :count) + + expect(response.body).to include("already fully paid") end end - describe "GET ticket" do - let(:event) { create(:event, :publicly_visible, cost_cents: 1000, title: "Spring Workshop") } + describe "POST /events/:id/link_bulk_payment" do + let(:event) { create(:event) } let(:payer) { create(:person) } - let(:attendees_json) do - [ { "first_name" => "Jordan", "last_name" => "Rivers", "email" => "jordan@example.com" } ].to_json - end let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } - let!(:attendees_field) do - create(:form_field, form: form, answer_type: :free_form_input_one_line, - field_identifier: "bulk_payment_attendees", name: "Attendees", required: false) - end - - before do - submission.form_answers.create!(form_field: attendees_field, submitted_answer: attendees_json, - question_name_when_answered: "Attendees") - sign_out admin - end - - def get_ticket - get bulk_payment_ticket_path(submission.slug) - end + let(:registrant) { create(:person) } + let!(:event_registration) { create(:event_registration, event: event, registrant: registrant) } - it "renders the ticket for the public payer using the slug" do - get_ticket + it "adds the registration id to the submission metadata" do + post link_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id }, + as: :turbo_stream + expect(submission.reload.linked_registration_ids).to eq([ event_registration.id ]) expect(response).to have_http_status(:ok) - expect(response.body).to include("Payment ticket") - expect(response.body).to include("Spring Workshop") end - it "lists the registrants" do - get_ticket - - expect(response.body).to include("Jordan Rivers") - expect(response.body).to include("jordan@example.com") - end + it "does not duplicate an existing link" do + submission.link_registration!(event_registration.id) - it "shows the payer's name, organization, and registrants-covered count" do - submission.form_answers.create!(form_field: payer_first_name_field, submitted_answer: "Alex", - question_name_when_answered: "Payer first name") - submission.form_answers.create!(form_field: payer_last_name_field, submitted_answer: "Chen", - question_name_when_answered: "Payer last name") - submission.form_answers.create!(form_field: org_field, submitted_answer: "Bright Futures Academy", - question_name_when_answered: "Organization") - - get_ticket - - expect(response.body).to include("Payer") - expect(response.body).to include("Alex Chen") - expect(response.body).to include("Bright Futures Academy") - # One attendee is listed and no explicit count is set, so the covered count is 1. - expect(response.body).to include("Covering 1 registrant") + expect { + post link_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id }, + as: :turbo_stream + }.not_to change { submission.reload.linked_registration_ids } end - it "does not show per-person actions like cancelling a registration" do - get_ticket + it "re-renders the card expanded" do + post link_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id }, + as: :turbo_stream - expect(response.body).not_to include("Cancel registration") + expect(response.body).to include("payment-card-#{submission.id}") end - it "links the invoice back to the ticket" do - get_ticket + it "redirects to bulk_payments with HTML format" do + post link_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id } - expect(response.body).to include("return_to=bulk_payment_ticket") + expect(response).to redirect_to(bulk_payments_event_path(event)) end - it "links 'View your form responses' to the public submission page" do - get_ticket + it "shows an alert for a missing registration" do + post link_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: 0 }, + as: :turbo_stream - 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("Registration not found") end + end - it "returns 404 for an unknown slug" do - get bulk_payment_ticket_path("nope") + describe "DELETE /events/:id/unlink_bulk_payment" do + let(:event) { create(:event) } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let(:registrant) { create(:person) } + let!(:event_registration) { create(:event_registration, event: event, registrant: registrant) } - expect(response).to have_http_status(:not_found) + before do + submission.link_registration!(event_registration.id) end - it "adds a Back to bulk payments eyebrow when arriving from the dashboard" do - get bulk_payment_ticket_path(submission.slug, return_to: "bulk_payments", expand: submission.id) + it "removes the registration id from the submission metadata" do + delete unlink_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id }, + as: :turbo_stream - expect(response.body).to include("Back to event") - expect(response.body).to include("Back to bulk payments") + expect(submission.reload.linked_registration_ids).to be_empty + expect(response).to have_http_status(:ok) end - context "as an admin" do - before { sign_in admin } - - it "shows the admin allocations section" do - get_ticket + it "re-renders the card expanded" do + delete unlink_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id }, + as: :turbo_stream - expect(response.body).to include("Payment allocations") - end + expect(response.body).to include("payment-card-#{submission.id}") end - end - describe "POST resend_confirmation" do - let(:event) { create(:event, :publicly_visible, cost_cents: 1000) } - let(:payer) { create(:person, email: "payer@example.com") } - let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } - - before { sign_out admin } - - it "re-sends the payer confirmation and returns to the ticket" do - expect { - post bulk_payment_resend_confirmation_path(submission.slug) - }.to change(Notification, :count).by(1) + it "redirects to bulk_payments with HTML format" do + delete unlink_bulk_payment_event_path(event), + params: { submission_id: submission.id, event_registration_id: event_registration.id } - expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug)) - expect(flash[:notice]).to eq("Confirmation email sent.") + expect(response).to redirect_to(bulk_payments_event_path(event)) end end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 3b9753d04f..9c0a579955 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -2279,235 +2279,6 @@ def ce_chip_text end end - describe "GET /events/:id/bulk_payments" do - let(:admin) { create(:user, :admin) } - let(:event) { create(:event, cost_cents: 2500) } - let(:bulk_form) { create(:form) } - let!(:event_form) { create(:event_form, event: event, form: bulk_form, role: "bulk_payment") } - let(:payer) { create(:person) } - let!(:submission) { create(:form_submission, person: payer, form: bulk_form, event: event, role: "bulk_payment") } - let!(:attendees_field) do - create(:form_field, form: bulk_form, field_identifier: "number_of_attendees", name: "Attendees") - end - - before { sign_in admin } - - it "shows the submitted amount even when no payment has landed" do - submission.form_answers.create!(form_field: attendees_field, submitted_answer: "3") - - get bulk_payments_event_path(event) - - expect(response).to have_http_status(:ok) - expect(response.body).to include("$75") - end - - it "shows the recorded payment amount when a payment exists" do - submission.form_answers.create!(form_field: attendees_field, submitted_answer: "3") - create(:payment, person: payer, form_submission: submission, - amount_cents: 5000, amount_cents_remaining: 5000) - - get bulk_payments_event_path(event) - - expect(response.body).to include("$50") - end - - it "renders the targeted submission's row expanded when given an expand param" do - get bulk_payments_event_path(event, expand: submission.id) - - expect(response.body).to include("id=\"payment-card-#{submission.id}\"") - # Expanded server-side: the toggle button gets data-dropdown-target="expand" - # so the dropdown controller clicks it on connect to open the card. - expect(response.body).to include("data-dropdown-target=\"expand\"") - end - - it "renders rows collapsed without an expand param" do - get bulk_payments_event_path(event) - - # Collapsed: the details panel keeps the `hidden` class and this card's chevron is not rotated. - expect(response.body).to match(/id="payment-details-#{submission.id}"\s+class="hidden/) - expect(response.body).not_to match(/id="payment-arrow-#{submission.id}"[^>]*rotate-180/) - end - - it "renders a Profile column with a circle-only profile button for matched attendees" do - attendee = create(:person, first_name: "Match", last_name: "Attendee", email: "match.attendee@example.com") - create(:event_registration, event: event, registrant: attendee, status: "registered") - create(:form_field, form: bulk_form, field_identifier: "bulk_payment_attendees", name: "Attendees list") - submission.form_answers.create!( - form_field: bulk_form.form_fields.find_by(field_identifier: "bulk_payment_attendees"), - submitted_answer: [ { first_name: "Match", last_name: "Attendee", email: "match.attendee@example.com" } ].to_json - ) - - get bulk_payments_event_path(event) - - expect(response).to have_http_status(:ok) - expect(response.body).to include(">Profile<") - # Circle-only profile button: a boxed (sky) link to the person holding just - # the compact h-5 avatar circle. - expect(response.body).to include(person_path(attendee)) - expect(response.body).to include("bg-sky-100") - expect(response.body).to include("h-5 w-5") - end - - it "shows a grey \"Paid\" instead of an orange balance when the registration is fully covered" do - attendee = create(:person, first_name: "Paid", last_name: "Infull", email: "paid.infull@example.com") - registration = create(:event_registration, event: event, registrant: attendee, status: "registered") - create(:form_field, form: bulk_form, field_identifier: "bulk_payment_attendees", name: "Attendees list") - submission.form_answers.create!( - form_field: bulk_form.form_fields.find_by(field_identifier: "bulk_payment_attendees"), - submitted_answer: [ { first_name: "Paid", last_name: "Infull", email: "paid.infull@example.com" } ].to_json - ) - # Fully cover the $25 registration fee so nothing is owed. - create(:allocation, source: create(:payment, amount_cents: 2500, amount_cents_remaining: 2500), - allocatable: registration, amount: 2500) - - get bulk_payments_event_path(event) - - expect(response).to have_http_status(:ok) - expect(response.body).to include("text-gray-500 whitespace-nowrap\">Paid<") - expect(response.body).not_to include("$0.00") - end - - it "lists only registrants who are not yet fully paid in the new-allocation dropdown" do - # The dropdown only renders when the payment still has an unallocated balance. - create(:payment, person: payer, form_submission: submission, - amount_cents: 5000, amount_cents_remaining: 5000) - unpaid = create(:person, first_name: "Owes", last_name: "Money", email: "owes.money@example.com") - create(:event_registration, event: event, registrant: unpaid, status: "registered") - paid = create(:person, first_name: "All", last_name: "Square", email: "all.square@example.com") - paid_registration = create(:event_registration, event: event, registrant: paid, status: "registered") - # Fully cover the $25 registration fee so this registrant drops off the list. - create(:allocation, source: create(:payment, amount_cents: 2500, amount_cents_remaining: 2500), - allocatable: paid_registration, amount: 2500) - - get bulk_payments_event_path(event) - - expect(response).to have_http_status(:ok) - expect(response.body).to include("Owes Money — owes.money@example.com") - expect(response.body).not_to include("All Square — all.square@example.com") - end - end - - describe "POST /events/:id/allocate_bulk_payment" do - let(:admin) { create(:user, :admin) } - let(:event) { create(:event) } - let(:bulk_form) { create(:form) } - let!(:event_form) { create(:event_form, event: event, form: bulk_form, role: "bulk_payment") } - let(:payer) { create(:person) } - let!(:submission) { create(:form_submission, person: payer, form: bulk_form, event: event, role: "bulk_payment") } - let!(:payment) { create(:payment, person: payer, form_submission: submission, - amount_cents: 1000, amount_cents_remaining: 1000) } - let(:registrant) { create(:person) } - let!(:event_registration) { create(:event_registration, event: event, registrant: registrant) } - - let(:valid_params) do - { payment_id: payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" } - end - - before { sign_in admin } - - it "rejects unauthenticated request" do - sign_out admin - post allocate_bulk_payment_event_path(event), params: valid_params - expect(response).to redirect_to(new_user_session_path) - end - - it "rejects non-admin request" do - sign_out admin - sign_in create(:user) - post allocate_bulk_payment_event_path(event), params: valid_params - expect(response).to redirect_to(root_path) - end - - it "creates an allocation and returns turbo_stream" do - expect { - post allocate_bulk_payment_event_path(event), params: valid_params, as: :turbo_stream - }.to change(Allocation, :count).by(1) - - expect(payment.reload.amount_cents_remaining).to eq(500) - expect(response.media_type).to eq(Mime[:turbo_stream]) - expect(response.body).to include("Allocation successful") - end - - context "when the allocation pays a matched registration in full" do - let(:event) { create(:event, cost_cents: 500) } - let(:registrant) { create(:person, email: "match@example.com") } - let!(:attendees_field) do - create(:form_field, form: bulk_form, field_identifier: "bulk_payment_attendees", name: "Attendees") - end - - before do - submission.form_answers.create!( - form_field: attendees_field, - submitted_answer: [ { first_name: registrant.first_name, last_name: registrant.last_name, email: "match@example.com" } ].to_json - ) - end - - it "re-renders the card with refreshed totals and hides the inline allocate box for that registration" do - post allocate_bulk_payment_event_path(event), - params: { payment_id: payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" }, - as: :turbo_stream - - # The whole card is re-rendered (kept expanded) with the new totals: the - # registration is now fully paid, so its due button reads "Paid". - expect(response.body).to include("payment-card-#{submission.id}") - expect(response.body).to include("rotate-180") - expect(response.body).to include(">Paid") - # Payment still has $5 left, so only the bottom "New allocation" form - # remains — the inline per-row box for the now-paid registration is gone. - expect(response.body.scan(">Allocate").size).to eq(1) - end - end - - it "shows alert when event_registration_id is blank" do - params = valid_params.merge(event_registration_id: "") - expect { - post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream - }.not_to change(Allocation, :count) - - expect(response.body).to include("Please select a registrant") - end - - it "shows alert when event_registration_id is invalid" do - params = valid_params.merge(event_registration_id: 999999) - expect { - post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream - }.not_to change(Allocation, :count) - - expect(response.body).to include("Please select a registrant") - end - - it "shows alert when amount is zero" do - params = valid_params.merge(amount_dollars: "0.00") - expect { - post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream - }.not_to change(Allocation, :count) - - expect(response.body).to include("Amount must be greater than $0.00") - end - - it "shows alert when amount exceeds remaining balance" do - params = valid_params.merge(amount_dollars: "20.00") - expect { - post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream - }.not_to change(Allocation, :count) - - expect(response.body).to include("Amount exceeds remaining balance") - end - - it "shows alert when event registration is already fully paid" do - large_payment = create(:payment, person: payer, form_submission: submission, - amount_cents: 2000, amount_cents_remaining: 2000) - create(:allocation, source: large_payment, allocatable: event_registration, amount: 1099) - - params = { payment_id: large_payment.id, event_registration_id: event_registration.id, amount_dollars: "5.00" } - expect { - post allocate_bulk_payment_event_path(event), params: params, as: :turbo_stream - }.not_to change(Allocation, :count) - - expect(response.body).to include("already fully paid") - end - end - describe "POST /send_reminder" do let!(:registration_one) { create(:event_registration, event: event) } let!(:registration_two) { create(:event_registration, event: event) } diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 06e77ddf6a..023520c78f 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -107,7 +107,7 @@ "app/views/category_types/index.html.erb" => "admin-only bg-blue-100", "app/views/events/dashboard.html.erb" => "admin-only bg-blue-100", "app/views/events/sample_ticket.html.erb" => "admin-only bg-blue-100", - "app/views/events/bulk_payments.html.erb" => "admin-only bg-blue-100", + "app/views/events/bulk_payments/index.html.erb" => "admin-only bg-blue-100", "app/views/events/background.html.erb" => "admin-only bg-blue-100", "app/views/events/edit_staff.html.erb" => "admin-only bg-white", "app/views/events/recipients.html.erb" => "admin-only bg-white", @@ -213,9 +213,9 @@ "app/views/registration_ticket_callouts/show.html.erb" => "public", # ─── bulk payment views ─── - "app/views/events/bulk_payments/new.html.erb" => "public", - "app/views/events/bulk_payments/show.html.erb" => "public", - "app/views/events/bulk_payments/ticket.html.erb" => "public", + "app/views/events/bulk_payment_form_submissions/new.html.erb" => "public", + "app/views/events/bulk_payment_form_submissions/show.html.erb" => "public", + "app/views/events/bulk_payment_form_submissions/ticket.html.erb" => "public", # ─── event invoice (slug/submission-reachable; blank template gated in controller) ─── "app/views/events/invoices/show.html.erb" => "public",