From 47666548e2bd63c2a39fa1dce83221d47e68f734 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 22 Jul 2026 08:43:24 -0400 Subject: [PATCH] Add pay-later button to the bulk payment ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payer who submitted the bulk payment form without paying (chose "Credit card (later)" or "Check") had no self-serve way to complete a card payment — the ticket only showed "Payment pending". This adds a "Pay with credit card" button on the pending ticket, mirroring Registrations#pay: it opens a Stripe Checkout session for the submission's expected total. The resulting charge already flows through the existing webhook (PayChargeExtensions#create_bulk_payment) to record the payment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/bulk_payments_controller.rb | 65 ++++++++++++++---- app/policies/events/bulk_payment_policy.rb | 4 ++ .../events/bulk_payments/ticket.html.erb | 9 +++ config/routes.rb | 1 + spec/requests/events/bulk_payments_spec.rb | 68 +++++++++++++++++++ 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/app/controllers/events/bulk_payments_controller.rb b/app/controllers/events/bulk_payments_controller.rb index 7222f2fa5b..aad472c430 100644 --- a/app/controllers/events/bulk_payments_controller.rb +++ b/app/controllers/events/bulk_payments_controller.rb @@ -1,6 +1,6 @@ module Events class BulkPaymentsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ] + skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation, :pay ] before_action :set_event, only: [ :new, :create, :show ] before_action :set_form, only: [ :new, :create ] @@ -109,6 +109,35 @@ def resend_confirmation end end + # Public pay-later action: a payer who chose "Credit card (later)" (or landed + # on the ticket with payment pending) returns here to pay by card. Mirrors + # Registrations#pay. The resulting Stripe charge carries the submission id and + # flows through the existing webhook (PayChargeExtensions#create_bulk_payment) + # to record the payment. + def pay + authorize! :bulk_payment, to: :pay? + + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + @event = @submission.event + + if @submission.payment.present? + redirect_to bulk_payment_ticket_path(@submission.slug), notice: "This payment has already been paid." + return + end + + if @submission.bulk_payment_amount_cents(@event) <= 0 + redirect_to bulk_payment_ticket_path(@submission.slug), alert: "No payment is due." + return + end + + checkout_session = build_checkout_session( + @submission, + quantity: @submission.bulk_payment_attendee_count, + attendees_json: @submission.bulk_payment_attendees.to_json + ) + redirect_to checkout_session.url, allow_other_host: true, status: :see_other + end + private def visible_form_fields @@ -146,22 +175,28 @@ def credit_card_payment?(form_params) form_params[payment_method_field.id.to_s]&.downcase == FormBuilderService::PAYMENT_METHOD_PAY_NOW.downcase end + # Submit-path checkout: quantity and attendees come from the just-submitted + # form params (no persisted answers to read yet). def create_stripe_checkout_session(submission) - person = submission.person - unit_amount = @event.cost_cents + count_field = @form.form_fields.find_by(field_identifier: "number_of_attendees") + qty = count_field ? @form_params[count_field.id.to_s].to_i : 1 - 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 + attendees_field = @form.form_fields.find_by(field_identifier: "bulk_payment_attendees") + attendees_json = attendees_field ? @form_params[attendees_field.id.to_s] : nil - metadata = { form_submission_id: submission.id, event_id: @event.id } + build_checkout_session(submission, quantity: qty, attendees_json: attendees_json) + 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 + # Builds the Stripe Checkout session for a bulk payment. Quantity and the + # attendees metadata are passed in so both the submit path (reads form params) + # and the pay-later path (reads the saved submission) can share this. + def build_checkout_session(submission, quantity:, attendees_json:) + quantity = 1 if quantity.to_i < 1 + metadata = { form_submission_id: submission.id, event_id: @event.id } + metadata[:attendees] = attendees_json if attendees_json.present? + + person = submission.person person.set_payment_processor :stripe person.payment_processor.checkout( @@ -171,10 +206,10 @@ def create_stripe_checkout_session(submission) line_items: [ { price_data: { currency: "usd", - product_data: { name: "#{Form::BULK_PAYMENT_PUBLIC_NAME} (#{qty} attendees): #{@event.title}" }, - unit_amount: unit_amount + product_data: { name: "#{Form::BULK_PAYMENT_PUBLIC_NAME} (#{quantity} attendees): #{@event.title}" }, + unit_amount: @event.cost_cents }, - quantity: qty + quantity: quantity } ], success_url: bulk_payment_ticket_url(submission.slug, checkout: "success"), cancel_url: bulk_payment_ticket_url(submission.slug, checkout: "cancelled") diff --git a/app/policies/events/bulk_payment_policy.rb b/app/policies/events/bulk_payment_policy.rb index af94e1d6aa..ba0323f2ce 100644 --- a/app/policies/events/bulk_payment_policy.rb +++ b/app/policies/events/bulk_payment_policy.rb @@ -14,4 +14,8 @@ def show? def ticket? true end + + def pay? + true + end end diff --git a/app/views/events/bulk_payments/ticket.html.erb b/app/views/events/bulk_payments/ticket.html.erb index d3df32527f..1b94d49386 100644 --- a/app/views/events/bulk_payments/ticket.html.erb +++ b/app/views/events/bulk_payments/ticket.html.erb @@ -118,6 +118,15 @@ <% end %> + + <% if @payment.nil? && total_cents.to_i > 0 %> +
+ <%= button_to bulk_payment_pay_path(@submission.slug), + class: "inline-flex items-center justify-center gap-2 rounded-lg bg-purple-700 hover:bg-purple-800 text-white font-semibold px-5 py-2.5 transition-colors" do %> + Pay with credit card + <% end %> +
+ <% end %> diff --git a/config/routes.rb b/config/routes.rb index f1641c215a..b8cf9a4678 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,7 @@ 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 + post "bulk_payment/:slug/pay", to: "events/bulk_payments#pay", as: :bulk_payment_pay 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 diff --git a/spec/requests/events/bulk_payments_spec.rb b/spec/requests/events/bulk_payments_spec.rb index 711a6c32a2..3b17f433b6 100644 --- a/spec/requests/events/bulk_payments_spec.rb +++ b/spec/requests/events/bulk_payments_spec.rb @@ -295,6 +295,22 @@ def get_ticket expect(response.body).not_to include("Cancel registration") end + it "shows a pay-with-credit-card button while payment is pending" do + get_ticket + + expect(response.body).to include(bulk_payment_pay_path(submission.slug)) + expect(response.body).to include("Pay with credit card") + end + + it "hides the pay button once a payment has been recorded" do + submission.create_payment!(type: "CashPayment", amount_cents: 1000, amount_cents_remaining: 1000, + person: payer, payer_type: "Person") + + get_ticket + + expect(response.body).not_to include(bulk_payment_pay_path(submission.slug)) + end + it "links the invoice back to the ticket" do get_ticket @@ -348,4 +364,56 @@ def get_ticket expect(flash[:notice]).to eq("Confirmation email sent.") end end + + describe "POST pay" do + let(:event) { create(:event, :publicly_visible, cost_cents: 15_00, title: "Spring Workshop") } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + let!(:number_field) do + create(:form_field, form: form, answer_type: :free_form_input_one_line, + field_identifier: "number_of_attendees", name: "Number of attendees", required: false) + end + let(:fake_session) { double(url: "https://checkout.stripe.com/test") } + + before do + submission.form_answers.create!(form_field: number_field, submitted_answer: "3", + question_name_when_answered: "Number of attendees") + 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) + sign_out admin + end + + it "redirects the public payer to Stripe Checkout for a pending submission" do + post bulk_payment_pay_path(submission.slug) + + expect(response).to redirect_to("https://checkout.stripe.com/test") + expect(response.status).to eq(303) + end + + it "returns to the ticket when a payment already exists" do + submission.create_payment!(type: "CashPayment", amount_cents: 4500, amount_cents_remaining: 4500, + person: payer, payer_type: "Person") + + post bulk_payment_pay_path(submission.slug) + + expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug)) + expect(flash[:notice]).to eq("This payment has already been paid.") + end + + it "returns to the ticket when no payment is due" do + event.update!(cost_cents: 0) + + post bulk_payment_pay_path(submission.slug) + + expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug)) + expect(flash[:alert]).to eq("No payment is due.") + end + + it "returns 404 for an unknown slug" do + post bulk_payment_pay_path("nope") + + expect(response).to have_http_status(:not_found) + end + end end