From 5a9717f5bcbaa32a7a2ad637ce244511a0481412 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 29 Jul 2026 21:06:28 -0400 Subject: [PATCH 1/3] Add downloadable receipt for paid bulk payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk payers had only an invoice. Once a payment is on file they now get a receipt (proof money changed hands) from the ticket's Documents section, and a locked card until then. The receipt bills whoever the connected Payment records as the payer (respecting payer_type) — not the form submitter, who can differ once an admin records the payment against a different person/organization. A bulk payment has no owed-balance/paid-in-full concept, so the shared receipt document (EventReceipt#settles_balance?) shows just "Amount paid" + "Payment received" for bulk payments, keeping the "Total charged / Balance due / Paid in full" reconciliation for registration receipts. Rebased onto main's split of the bulk payment controller: the payer-facing receipt action lives on BulkPaymentFormSubmissionsController and authorizes via FormSubmissionPolicy#receipt? (slug context), matching #ticket. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ulk_payment_form_submissions_controller.rb | 19 ++++- app/models/form_submission.rb | 7 ++ app/policies/form_submission_policy.rb | 4 + app/presenters/allocation_ledger_label.rb | 18 +++- app/presenters/event_receipt.rb | 77 +++++++++++++++-- .../receipt.html.erb | 8 ++ .../ticket.html.erb | 54 +++++++++--- app/views/events/receipts/_receipt.html.erb | 42 ++++++---- config/routes.rb | 1 + spec/models/form_submission_spec.rb | 21 +++++ spec/policies/form_submission_policy_spec.rb | 35 ++++++++ spec/presenters/event_receipt_spec.rb | 84 +++++++++++++++++++ .../bulk_payment_form_submissions_spec.rb | 58 +++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 14 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 app/views/events/bulk_payment_form_submissions/receipt.html.erb diff --git a/app/controllers/events/bulk_payment_form_submissions_controller.rb b/app/controllers/events/bulk_payment_form_submissions_controller.rb index 56e052fc46..9d5794e918 100644 --- a/app/controllers/events/bulk_payment_form_submissions_controller.rb +++ b/app/controllers/events/bulk_payment_form_submissions_controller.rb @@ -1,6 +1,6 @@ module Events class BulkPaymentFormSubmissionsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ] + skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :receipt, :resend_confirmation ] before_action :set_event, only: [ :new, :create, :show ] before_action :set_form, only: [ :new, :create ] @@ -77,6 +77,23 @@ def ticket @event = @submission.event.decorate end + # Public, slug-based receipt for the payer, available once a payment is on + # file. Mirrors #ticket's slug authorization; redirects back to the ticket + # while payment is still pending. + def receipt + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + authorize! @submission, to: :receipt?, context: { slug: params[:slug] } + + unless @submission.bulk_payment_receipt_available? + redirect_to bulk_payment_ticket_path(@submission.slug), + alert: "A receipt is available once your payment is received." + return + end + + @event = @submission.event.decorate + @receipt = EventReceipt.from_bulk_payment(@submission) + end + def resend_confirmation @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) authorize! @submission, to: :show?, context: { slug: params[:slug] } diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index b174a7731a..cbd5df8ec1 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -67,6 +67,13 @@ def bulk_payment_amount_cents(event) event.cost_cents.to_i * bulk_payment_attendee_count end + # A bulk payment has no owed-balance/paid-in-full concept, so its receipt simply + # records whatever was paid — it unlocks as soon as a payment is on file, the + # same signal the ticket uses to show "Payment received". + def bulk_payment_receipt_available? + bulk_payment? && payment.present? + end + # --- Linked registrations (bulk payment designations) --- def linked_registration_ids diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index c0b3c0ec72..19a0802dcd 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -14,6 +14,10 @@ def ticket? admin? || (slug.present? && record.slug == slug) end + def receipt? + admin? || (slug.present? && record.slug == slug) + end + def new? true end diff --git a/app/presenters/allocation_ledger_label.rb b/app/presenters/allocation_ledger_label.rb index 20f4032900..230a821993 100644 --- a/app/presenters/allocation_ledger_label.rb +++ b/app/presenters/allocation_ledger_label.rb @@ -17,7 +17,7 @@ module AllocationLedgerLabel def method_label(allocation) if allocation.source_type == Payment.polymorphic_name - PAYMENT_METHOD_LABELS[allocation.source&.class&.name] || "Payment" + payment_method_label(allocation.source) else allocation.source_type.underscore.humanize end @@ -26,8 +26,18 @@ def method_label(allocation) # Check number, when the payment was a check — so the payer can reconcile it # against their own records. def reference_for(allocation) - source = allocation.source - return unless source.is_a?(CheckPayment) && source.check_number.present? - "Check ##{source.check_number}" + payment_reference(allocation.source) + end + + # Method/reference for a Payment directly (not via an allocation) — used by the + # bulk payment receipt, whose ledger is the single payment the payer made + # rather than the per-registration allocations it settled. + def payment_method_label(payment) + PAYMENT_METHOD_LABELS[payment&.class&.name] || "Payment" + end + + def payment_reference(payment) + return unless payment.is_a?(CheckPayment) && payment.check_number.present? + "Check ##{payment.check_number}" end end diff --git a/app/presenters/event_receipt.rb b/app/presenters/event_receipt.rb index 0ce473e916..d6cd35d7fa 100644 --- a/app/presenters/event_receipt.rb +++ b/app/presenters/event_receipt.rb @@ -1,13 +1,18 @@ -# Normalizes the data needed to render a paid-in-full receipt for a single -# EventRegistration. Mirrors EventInvoice (same bill-to/attention/line-item -# shape) but adds the allocation ledger that settled the balance and a summary -# that always reconciles to $0 due — a receipt is only generated once the -# registration is paid in full (see EventRegistration#receipt_available?). +# Normalizes the data needed to render a receipt for either source: a single +# EventRegistration or a bulk-payment FormSubmission. Mirrors EventInvoice (same +# bill-to/attention/line-item shape) and adds a payment ledger. +# +# The two differ in how the summary reads. A registration receipt settles a +# balance — it lists every allocation and reconciles to $0 due (only generated +# once paid in full, see EventRegistration#receipt_available?). A bulk payment +# has no owed-balance concept; its receipt simply records the amount paid, so it +# skips the total-charged/balance-due framing (balance_settlement: false). class EventReceipt ISSUER_NAME = "A Window Between Worlds".freeze ISSUER_ADDRESS_LINES = [ "1029 1/2 W 24th St", "Los Angeles, CA 90007" ].freeze ISSUER_EMAIL = "info@awbw.org".freeze THANK_YOU_NOTE = "Payment received in full — thank you. Please retain this receipt for your records.".freeze + PAYMENT_RECEIVED_NOTE = "Payment received — thank you. Please retain this receipt for your records.".freeze LineItem = Struct.new(:date, :description, :quantity, :unit_price_cents, keyword_init: true) do def amount_cents @@ -65,9 +70,59 @@ def self.entry_for(allocation) end private_class_method :entry_for + # One bulk-payment FormSubmission → the event charge for every attendee as a + # single line item, and the payer's one payment as the ledger entry. Billed to + # whoever the connected Payment records as the payer (respecting payer_type) — + # NOT the form submitter, who may differ once an admin records the payment + # against a different person/organization. Only generated once a payment is on + # file (see FormSubmission#bulk_payment_receipt_available?). + def self.from_bulk_payment(submission) + event = submission.resolved_event + payment = submission.payment + payer = payment&.payer + # The org, if the org paid; the individual is the payer when a person paid, + # otherwise the org's contact person recorded on the payment. + organization = payer.is_a?(Organization) ? payer : nil + individual = payer.is_a?(Person) ? payer : payment&.person + quantity = [ submission.bulk_payment_attendee_count, 1 ].max + + new( + event: event, + number: "RCPT-B-#{submission.id}", + date: (payment&.created_at || submission.created_at).to_date, + client_id: payer&.id, + bill_to_name: organization&.name.presence || individual&.full_name, + bill_to_address_lines: address_lines_for(payer), + bill_to_email: organization&.email.presence || individual&.preferred_email, + attention: individual&.full_name, + line_items: [ + LineItem.new( + date: submission.created_at.to_date, + description: event&.title, + quantity: quantity, + unit_price_cents: event&.cost_cents.to_i + ) + ], + entries: payment ? [ entry_for_payment(payment) ] : [], + amount_paid_cents: payment&.amount_cents.to_i, + balance_cents: 0, + balance_settlement: false + ) + end + + def self.entry_for_payment(payment) + Entry.new( + date: payment.created_at.to_date, + method: AllocationLedgerLabel.payment_method_label(payment), + reference: AllocationLedgerLabel.payment_reference(payment), + amount_cents: payment.amount_cents + ) + end + private_class_method :entry_for_payment + def initialize(event:, number:, date:, client_id:, bill_to_name:, bill_to_address_lines:, bill_to_email:, attention:, line_items:, - entries:, amount_paid_cents:, balance_cents:) + entries:, amount_paid_cents:, balance_cents:, balance_settlement: true) @event = event @number = number @date = date @@ -80,6 +135,7 @@ def initialize(event:, number:, date:, client_id:, bill_to_name:, @entries = entries @amount_paid_cents = amount_paid_cents @balance_cents = balance_cents + @balance_settlement = balance_settlement end def total_cents @@ -90,10 +146,17 @@ def paid_in_full? balance_cents.to_i.zero? end + # A registration receipt settles a balance (total charged → $0 due); a bulk + # payment receipt has no such concept and just records the amount paid. The + # view branches on this to drop the total-charged/balance-due framing. + def settles_balance? + @balance_settlement + end + def issuer_name = ISSUER_NAME def issuer_address_lines = ISSUER_ADDRESS_LINES def issuer_email = ISSUER_EMAIL - def thank_you_note = THANK_YOU_NOTE + def thank_you_note = settles_balance? ? THANK_YOU_NOTE : PAYMENT_RECEIVED_NOTE def self.address_lines_for(addressable) return [] unless addressable.respond_to?(:addresses) diff --git a/app/views/events/bulk_payment_form_submissions/receipt.html.erb b/app/views/events/bulk_payment_form_submissions/receipt.html.erb new file mode 100644 index 0000000000..1ab9b45586 --- /dev/null +++ b/app/views/events/bulk_payment_form_submissions/receipt.html.erb @@ -0,0 +1,8 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "Receipt — #{@event.title}") %> +<%= render "events/invoices/actions", + back_path: bulk_payment_ticket_path(@submission.slug), + back_label: "Back to ticket" %> +
+ <%= render "events/receipts/receipt", receipt: @receipt %> +
diff --git a/app/views/events/bulk_payment_form_submissions/ticket.html.erb b/app/views/events/bulk_payment_form_submissions/ticket.html.erb index 1baa9e1e50..5c80774b96 100644 --- a/app/views/events/bulk_payment_form_submissions/ticket.html.erb +++ b/app/views/events/bulk_payment_form_submissions/ticket.html.erb @@ -152,18 +152,50 @@
- - <%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_ticket"), - class: "flex items-center gap-3 rounded-xl border-2 border-blue-200 bg-blue-50 px-4 py-3 hover:bg-blue-100 transition-colors" do %> - - -
-

View invoice

-

Itemized invoice for this payment

-
+ +
+

Documents

+ + + <%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_ticket"), + class: "flex items-center gap-3 rounded-xl border-2 border-blue-200 bg-blue-50 px-4 py-3 hover:bg-blue-100 transition-colors" do %> + + +
+

View invoice

+

Itemized invoice for this payment

+
- - <% end %> + + <% end %> + + + <% if @payment %> + <%= link_to bulk_payment_receipt_path(@submission.slug), + class: "flex items-center gap-3 rounded-xl border-2 border-green-200 bg-green-50 px-4 py-3 hover:bg-green-100 transition-colors" do %> + + +
+

View receipt

+

Receipt for your records

+
+ + + <% end %> + <% else %> +
+ + +
+

Receipt

+

Available once your payment is received

+
+ + +
+ <% end %> +
diff --git a/app/views/events/receipts/_receipt.html.erb b/app/views/events/receipts/_receipt.html.erb index 444592cd95..343ef18b43 100644 --- a/app/views/events/receipts/_receipt.html.erb +++ b/app/views/events/receipts/_receipt.html.erb @@ -17,7 +17,7 @@

RECEIPT

- Paid in full + <%= receipt.settles_balance? ? "Paid in full" : "Payment received" %>
@@ -115,23 +115,33 @@
- <%# Summary: total charged, amount paid, balance due (always $0) %> + <%# Summary. A registration receipt reconciles total charged → $0 balance due; + a bulk payment has no balance concept, so it just states the amount paid. %>
-
-
Total charged
-
<%= dollars_from_cents(receipt.total_cents) %>
-
-
-
Amount paid
-
<%= dollars_from_cents(receipt.amount_paid_cents) %>
-
-
-
Balance due
-
- <%= dollars_from_cents(receipt.balance_cents) %> -
-
+ <% if receipt.settles_balance? %> +
+
Total charged
+
<%= dollars_from_cents(receipt.total_cents) %>
+
+
+
Amount paid
+
<%= dollars_from_cents(receipt.amount_paid_cents) %>
+
+
+
Balance due
+
+ <%= dollars_from_cents(receipt.balance_cents) %> +
+
+ <% else %> +
+
Amount paid
+
+ <%= dollars_from_cents(receipt.amount_paid_cents) %> +
+
+ <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 189c537e1f..074dbb56bf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,7 @@ end resources :community_news get "bulk_payment/:slug", to: "events/bulk_payment_form_submissions#ticket", as: :bulk_payment_ticket + get "bulk_payment/:slug/receipt", to: "events/bulk_payment_form_submissions#receipt", as: :bulk_payment_receipt 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 diff --git a/spec/models/form_submission_spec.rb b/spec/models/form_submission_spec.rb index 5bf4101b67..06637a9969 100644 --- a/spec/models/form_submission_spec.rb +++ b/spec/models/form_submission_spec.rb @@ -81,6 +81,27 @@ end end + describe "#bulk_payment_receipt_available?" do + let(:submission) { create(:form_submission, role: "bulk_payment") } + + it "is true once a payment is recorded" do + create(:payment, form_submission: submission) + + expect(submission.reload.bulk_payment_receipt_available?).to be(true) + end + + it "is false while no payment is on file" do + expect(submission.bulk_payment_receipt_available?).to be(false) + end + + it "is false for a non-bulk-payment submission even with a payment" do + other = create(:form_submission, role: "registration") + create(:payment, form_submission: other) + + expect(other.reload.bulk_payment_receipt_available?).to be(false) + end + end + describe "linked registrations" do let(:event) { create(:event) } let(:form) { create(:form) } diff --git a/spec/policies/form_submission_policy_spec.rb b/spec/policies/form_submission_policy_spec.rb index 161b02f7f8..32b828bc06 100644 --- a/spec/policies/form_submission_policy_spec.rb +++ b/spec/policies/form_submission_policy_spec.rb @@ -96,4 +96,39 @@ def policy_for(user:, record: submission) it { is_expected.not_to be_allowed_to(:ticket?) } end end + + describe "#receipt?" do + context "with admin user" do + subject { policy_for(user: admin_user) } + + it { is_expected.to be_allowed_to(:receipt?) } + 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(:receipt?) } + 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(:receipt?) } + 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(:receipt?) } + end + + context "with no user" do + subject { policy_for(user: nil) } + + it { is_expected.not_to be_allowed_to(:receipt?) } + end + end end diff --git a/spec/presenters/event_receipt_spec.rb b/spec/presenters/event_receipt_spec.rb index 0d9f0dede9..659537469b 100644 --- a/spec/presenters/event_receipt_spec.rb +++ b/spec/presenters/event_receipt_spec.rb @@ -91,4 +91,88 @@ end end end + + describe ".from_bulk_payment" do + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + let(:submitter) { create(:person, first_name: "Sam", last_name: "Submitter") } + let(:form) { create(:form) } + let(:submission) { create(:form_submission, person: submitter, form: form, event: event, role: "bulk_payment") } + + def answer(identifier, value) + field = create(:form_field, form: form, answer_type: :free_form_input_one_line, field_identifier: identifier, name: identifier) + submission.form_answers.create!(form_field: field, submitted_answer: value, question_name_when_answered: identifier) + end + + before do + answer("number_of_attendees", "3") + # Form-captured payer fields that must NOT drive the receipt: the connected + # payment's payer is authoritative (an admin may record it against someone + # other than the form submitter). + answer("payer_first_name", "Sam") + answer("payer_last_name", "Submitter") + answer("payer_organization", "Someone Else Inc") + end + + context "when an organization paid" do + let(:organization) { create(:organization, name: "Northside Shelter", email: "billing@northside.org") } + let(:contact) { create(:person, first_name: "Dana", last_name: "Okafor") } + + before do + create(:address, addressable: organization, street_address: "12 Main St", city: "Denver", state: "CO", zip_code: "80202") + create(:payment, form_submission: submission, organization: organization, person: contact, + payer_type: "Organization", type: "CashPayment", amount_cents: 450_000) + end + + it "bills the payment's organization payer, not the form submitter" do + receipt = described_class.from_bulk_payment(submission.reload) + + expect(receipt.number).to eq("RCPT-B-#{submission.id}") + expect(receipt.bill_to_name).to eq("Northside Shelter") + expect(receipt.bill_to_email).to eq("billing@northside.org") + expect(receipt.attention).to eq("Dana Okafor") + expect(receipt.client_id).to eq(organization.id) + expect(receipt.bill_to_address_lines).to eq([ "12 Main St", "Denver, CO 80202" ]) + end + + it "itemizes the event charge per attendee and records the amount paid" do + receipt = described_class.from_bulk_payment(submission.reload) + + item = receipt.line_items.first + expect(item.description).to eq("AWBW 2-Day Art Facilitator Training") + expect(item.quantity).to eq(3) + expect(item.unit_price_cents).to eq(150_000) + expect(receipt.amount_paid_cents).to eq(450_000) + # A bulk payment records what was paid; it has no owed-balance concept. + expect(receipt.settles_balance?).to be(false) + end + end + + context "when a person paid" do + let(:payer) { create(:person, first_name: "Alex", last_name: "Payer") } + + before do + create(:payment, form_submission: submission, person: payer, organization: nil, + payer_type: "Person", type: "CheckPayment", amount_cents: 450_000, check_number: "7788") + end + + it "bills the paying person directly" do + receipt = described_class.from_bulk_payment(submission.reload) + + expect(receipt.bill_to_name).to eq("Alex Payer") + expect(receipt.attention).to eq("Alex Payer") + expect(receipt.bill_to_email).to eq(payer.preferred_email) + expect(receipt.client_id).to eq(payer.id) + end + + it "records the payment as the one ledger entry, labelled by method" do + receipt = described_class.from_bulk_payment(submission.reload) + + expect(receipt.entries.size).to eq(1) + entry = receipt.entries.first + expect(entry.method).to eq("Check") + expect(entry.reference).to eq("Check #7788") + expect(entry.amount_cents).to eq(450_000) + end + 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 index abc0d38691..8a7797148d 100644 --- a/spec/requests/events/bulk_payment_form_submissions_spec.rb +++ b/spec/requests/events/bulk_payment_form_submissions_spec.rb @@ -308,6 +308,22 @@ def get_ticket expect(response.body).to include(event_bulk_payment_path(event, slug: submission.slug)) end + it "shows a locked receipt card until a payment is recorded" do + get_ticket + + expect(response.body).to include("Available once your payment is received") + expect(response.body).not_to include(bulk_payment_receipt_path(submission.slug)) + end + + it "links the receipt once a payment is on file" do + create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000) + + get_ticket + + expect(response.body).to include("View receipt") + expect(response.body).to include(bulk_payment_receipt_path(submission.slug)) + end + it "returns 404 for an unknown slug" do get bulk_payment_ticket_path("nope") @@ -332,6 +348,48 @@ def get_ticket end end + describe "GET receipt" do + let(:event) { create(:event, :publicly_visible, cost_cents: 1000, title: "Spring Workshop") } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + + before { sign_out admin } + + it "renders the receipt for the amount paid once a payment is recorded" do + create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000) + + get bulk_payment_receipt_path(submission.slug) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("RECEIPT") + expect(response.body).to include("Amount paid") + expect(response.body).to include("RCPT-B-#{submission.id}") + end + + it "omits the balance/paid-in-full framing (a bulk payment has no owed balance)" do + create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000) + + get bulk_payment_receipt_path(submission.slug) + + expect(response.body).to include("Payment received") + expect(response.body).not_to include("Paid in full") + expect(response.body).not_to include("Balance due") + end + + it "redirects back to the ticket while payment is still pending" do + get bulk_payment_receipt_path(submission.slug) + + expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug)) + expect(flash[:alert]).to eq("A receipt is available once your payment is received.") + end + + it "returns 404 for an unknown slug" do + get bulk_payment_receipt_path("nope") + + expect(response).to have_http_status(:not_found) + end + end + describe "POST resend_confirmation" do let(:event) { create(:event, :publicly_visible, cost_cents: 1000) } let(:payer) { create(:person, email: "payer@example.com") } diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 023520c78f..e1984a67af 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -216,6 +216,7 @@ "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", + "app/views/events/bulk_payment_form_submissions/receipt.html.erb" => "public", # ─── event invoice (slug/submission-reachable; blank template gated in controller) ─── "app/views/events/invoices/show.html.erb" => "public", From afdebc70e8fde29d453983d25e87944a2cd34f2e Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 29 Jul 2026 22:22:17 -0400 Subject: [PATCH 2/3] Serve the bulk-payment invoice by slug, not enumerable submission_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk invoice was served by InvoicesController#show via submission_id, with FormSubmissionPolicy#show_invoice? returning true for any bulk payment — so any payer's invoice (names, emails, org, attendees) was reachable by guessing a sequential id, weaker than the slug-gated ticket/receipt for the same record. Move it to a slug-based BulkPaymentFormSubmissions#invoice, authorized like #ticket/#receipt (admin? || slug matches), colocating all three payer documents on the payer-facing controller and mirroring RegistrationsController. Update the ticket, dashboard card, and submission-page links to the slug route. InvoicesController now serves only the admin blank template. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ulk_payment_form_submissions_controller.rb | 15 +++++- app/controllers/events/invoices_controller.rb | 23 +++------ app/policies/form_submission_policy.rb | 10 ++-- app/views/events/_bulk_payment_card.html.erb | 2 +- .../ticket.html.erb | 2 +- app/views/form_submissions/show.html.erb | 2 +- config/routes.rb | 1 + spec/policies/form_submission_policy_spec.rb | 35 ++++++++++++++ .../bulk_payment_form_submissions_spec.rb | 40 ++++++++++++++++ spec/requests/events/invoices_spec.rb | 47 ++----------------- 10 files changed, 108 insertions(+), 69 deletions(-) diff --git a/app/controllers/events/bulk_payment_form_submissions_controller.rb b/app/controllers/events/bulk_payment_form_submissions_controller.rb index 9d5794e918..789f029c19 100644 --- a/app/controllers/events/bulk_payment_form_submissions_controller.rb +++ b/app/controllers/events/bulk_payment_form_submissions_controller.rb @@ -1,6 +1,6 @@ module Events class BulkPaymentFormSubmissionsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :receipt, :resend_confirmation ] + skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :invoice, :receipt, :resend_confirmation ] before_action :set_event, only: [ :new, :create, :show ] before_action :set_form, only: [ :new, :create ] @@ -77,6 +77,19 @@ def ticket @event = @submission.event.decorate end + # Public, slug-based invoice for the payer. Served here (rather than + # InvoicesController, which keeps only the admin blank template) so it's gated + # by the unguessable slug like #ticket, not an enumerable submission_id. The + # shared invoice view branches its back link on return_to. + def invoice + @submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug]) + authorize! @submission, to: :invoice?, context: { slug: params[:slug] } + + @event = @submission.event.decorate + @invoice = EventInvoice.from_bulk_payment(@submission) + render "events/invoices/show" + end + # Public, slug-based receipt for the payer, available once a payment is on # file. Mirrors #ticket's slug authorization; redirects back to the ticket # while payment is still pending. diff --git a/app/controllers/events/invoices_controller.rb b/app/controllers/events/invoices_controller.rb index 8b94a5245c..304612de72 100644 --- a/app/controllers/events/invoices_controller.rb +++ b/app/controllers/events/invoices_controller.rb @@ -1,25 +1,16 @@ module Events - # Admin-side invoice for an event. Renders a blank template prefilled with the - # event's content (line item + cost); when a `submission_id` is supplied it - # autofills the bill-to/attention from that bulk-payment submission. + # Admin-only blank invoice template for an event, prefilled with the event's + # content (line item + cost). A bulk-payment submission's invoice is served by + # BulkPaymentFormSubmissionsController#invoice (slug-based, payer-facing). class InvoicesController < ApplicationController - # Bulk-payment payers have no account; authorization (below) gates access. + # Kept skipped so an unauthorized viewer is denied by the policy (redirect to + # root) rather than bounced to a sign-in page. skip_before_action :authenticate_user!, only: [ :show ] before_action :set_event def show - if params[:submission_id].present? - # A bulk-payment submission's invoice is reachable by the payer (who has - # no account), matching the public bulk-payment show page they're sent. - @submission = FormSubmission.find(params[:submission_id]) - authorize! @submission, to: :show_invoice? - @invoice = EventInvoice.from_bulk_payment(@submission) - else - # The blank template is an admin tool. - authorize! @event, to: :invoice? - @invoice = EventInvoice.from_event(@event) - end - + authorize! @event, to: :invoice? + @invoice = EventInvoice.from_event(@event) @event = @event.decorate end diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index 19a0802dcd..f00314d355 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -26,10 +26,10 @@ def create? true end - # Bulk-payment payers have no account but are emailed a link to their public - # submission, so they can reach its invoice too. Other submission types stay - # admin-only. - def show_invoice? - record.role == "bulk_payment" || admin? + # Bulk-payment payers have no account but are emailed the unguessable slug, so + # slug possession authorizes the invoice — same gate as #ticket / #receipt. + # (Not keyed on the record id, which is enumerable.) + def invoice? + admin? || (slug.present? && record.slug == slug) end end diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index b6e46745fd..3299c4c4c3 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -46,7 +46,7 @@ Submission - ID: <%= submission.id %> <% end %> - <%= link_to event_invoice_path(@event, submission_id: submission.id, return_to: "bulk_payments"), + <%= link_to bulk_payment_invoice_path(submission.slug, return_to: "bulk_payments"), title: "View invoice", class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50 hover:text-gray-800 transition-colors whitespace-nowrap" do %> diff --git a/app/views/events/bulk_payment_form_submissions/ticket.html.erb b/app/views/events/bulk_payment_form_submissions/ticket.html.erb index 5c80774b96..42181b0ca9 100644 --- a/app/views/events/bulk_payment_form_submissions/ticket.html.erb +++ b/app/views/events/bulk_payment_form_submissions/ticket.html.erb @@ -158,7 +158,7 @@

Documents

- <%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_ticket"), + <%= link_to bulk_payment_invoice_path(@submission.slug, return_to: "bulk_payment_ticket"), class: "flex items-center gap-3 rounded-xl border-2 border-blue-200 bg-blue-50 px-4 py-3 hover:bg-blue-100 transition-colors" do %> diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb index 2d02be79ed..579702a6fe 100644 --- a/app/views/form_submissions/show.html.erb +++ b/app/views/form_submissions/show.html.erb @@ -36,7 +36,7 @@ <% if event && @form_submission.role == "bulk_payment" %>
- <%= link_to event_invoice_path(event, submission_id: @form_submission.id, return_to: "form_submission"), + <%= link_to bulk_payment_invoice_path(@form_submission.slug, return_to: "form_submission"), class: "inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors" do %> View invoice <% end %> diff --git a/config/routes.rb b/config/routes.rb index 074dbb56bf..d228a4631a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,7 @@ end resources :community_news get "bulk_payment/:slug", to: "events/bulk_payment_form_submissions#ticket", as: :bulk_payment_ticket + get "bulk_payment/:slug/invoice", to: "events/bulk_payment_form_submissions#invoice", as: :bulk_payment_invoice get "bulk_payment/:slug/receipt", to: "events/bulk_payment_form_submissions#receipt", as: :bulk_payment_receipt 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 diff --git a/spec/policies/form_submission_policy_spec.rb b/spec/policies/form_submission_policy_spec.rb index 32b828bc06..6a64f854e8 100644 --- a/spec/policies/form_submission_policy_spec.rb +++ b/spec/policies/form_submission_policy_spec.rb @@ -97,6 +97,41 @@ def policy_for(user:, record: submission) end end + describe "#invoice?" do + context "with admin user" do + subject { policy_for(user: admin_user) } + + it { is_expected.to be_allowed_to(:invoice?) } + 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(:invoice?) } + 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(:invoice?) } + 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(:invoice?) } + end + + context "with no user" do + subject { policy_for(user: nil) } + + it { is_expected.not_to be_allowed_to(:invoice?) } + end + end + describe "#receipt?" do context "with admin user" do subject { policy_for(user: admin_user) } diff --git a/spec/requests/events/bulk_payment_form_submissions_spec.rb b/spec/requests/events/bulk_payment_form_submissions_spec.rb index 8a7797148d..7a66902f84 100644 --- a/spec/requests/events/bulk_payment_form_submissions_spec.rb +++ b/spec/requests/events/bulk_payment_form_submissions_spec.rb @@ -348,6 +348,46 @@ def get_ticket end end + describe "GET invoice" do + let(:event) { create(:event, :publicly_visible, cost_cents: 150_000, title: "Spring Workshop") } + let(:payer) { create(:person) } + let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } + + before do + # org_field (payer_organization) is defined at the top; only the attendee + # count field is new here. + count_field = create(:form_field, form: form, field_identifier: "number_of_attendees", name: "Number of attendees") + submission.form_answers.create!(form_field: org_field, submitted_answer: "A Greater Hope", + question_name_when_answered: "Organization") + submission.form_answers.create!(form_field: count_field, submitted_answer: "8", + question_name_when_answered: "Number of attendees") + sign_out admin + end + + it "renders the bulk-payment invoice publicly by slug" do + get bulk_payment_invoice_path(submission.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("INVOICE") + expect(response.body).to include("A Greater Hope") + # 8 attendees × $1,500 = $12,000 + expect(response.body).to include("$12,000") + end + + it "links back to the ticket when opened from there" do + get bulk_payment_invoice_path(submission.slug, return_to: "bulk_payment_ticket") + + expect(response.body).to include(bulk_payment_ticket_path(submission.slug)) + expect(response.body).to include("Back to ticket") + end + + it "returns 404 for an unknown slug (no enumerable id path)" do + get bulk_payment_invoice_path("nope") + + expect(response).to have_http_status(:not_found) + end + end + describe "GET receipt" do let(:event) { create(:event, :publicly_visible, cost_cents: 1000, title: "Spring Workshop") } let(:payer) { create(:person) } diff --git a/spec/requests/events/invoices_spec.rb b/spec/requests/events/invoices_spec.rb index 01755557ea..b6690806eb 100644 --- a/spec/requests/events/invoices_spec.rb +++ b/spec/requests/events/invoices_spec.rb @@ -4,6 +4,8 @@ let(:admin) { create(:user, :admin) } let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + # Only the admin blank template lives here now; a bulk-payment submission's + # invoice is served by Events::BulkPaymentFormSubmissions#invoice (slug-based). describe "GET /events/:event_id/invoice" do context "as an admin" do before { sign_in admin } @@ -16,34 +18,6 @@ expect(response.body).to include("AWBW 2-Day Art Facilitator Training") expect(response.body).to include("$1,500") end - - context "with a submission_id" do - let(:form) { create(:form) } - let(:payer) { create(:person) } - let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") } - - def add_answer(identifier, value) - field = create(:form_field, form: form, field_identifier: identifier, name: identifier.humanize) - create(:form_answer, form_submission: submission, form_field: field, submitted_answer: value) - end - - before do - add_answer("payer_first_name", "Helena") - add_answer("payer_last_name", "Lopez") - add_answer("payer_organization", "A Greater Hope") - add_answer("number_of_attendees", "8") - end - - it "autofills the invoice from the bulk-payment submission" do - get event_invoice_path(event, submission_id: submission.id) - - expect(response).to have_http_status(:success) - expect(response.body).to include("A Greater Hope") - expect(response.body).to include("Helena Lopez") - # 8 attendees × $1,500 = $12,000 - expect(response.body).to include("$12,000") - end - end end context "as a non-admin" do @@ -56,22 +30,7 @@ def add_answer(identifier, value) end context "as a guest (no account)" do - let(:form) { create(:form) } - let!(:submission) { create(:form_submission, form: form, event: event, role: "bulk_payment") } - - it "can view a bulk-payment submission's invoice (matches the public show page)" do - get event_invoice_path(event, submission_id: submission.id) - expect(response).to have_http_status(:success) - expect(response.body).to include("INVOICE") - end - - it "is denied an invoice for a non-bulk submission" do - other = create(:form_submission, form: form, event: event, role: "registration") - get event_invoice_path(event, submission_id: other.id) - expect(response).to redirect_to(root_path) - end - - it "is still denied the blank template" do + it "is denied the blank template and redirected" do get event_invoice_path(event) expect(response).to redirect_to(root_path) end From 994e3e928377edc49715f9fe6c9cf3f8ee806659 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 29 Jul 2026 22:31:47 -0400 Subject: [PATCH 3/3] Guard admin invoice links against slugless bulk payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payer invoice route is slug-only, but a submission whose role becomes bulk_payment after creation (or legacy records) can have a nil slug, so the admin form-submission page and dashboard card raised UrlGenerationError. Only render the invoice link when a slug is present — slugless legacy records have no payer-facing pages anyway. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/_bulk_payment_card.html.erb | 14 +++++++++----- app/views/form_submissions/show.html.erb | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index 3299c4c4c3..8ff1f55822 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -46,11 +46,15 @@ Submission - ID: <%= submission.id %> <% end %> - <%= link_to bulk_payment_invoice_path(submission.slug, return_to: "bulk_payments"), - title: "View invoice", - class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50 hover:text-gray-800 transition-colors whitespace-nowrap" do %> - - Invoice + <%# Slug-based (payer-facing) invoice; hidden for legacy slugless records, + which have no payer-facing pages. %> + <% if submission.slug.present? %> + <%= link_to bulk_payment_invoice_path(submission.slug, return_to: "bulk_payments"), + title: "View invoice", + class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50 hover:text-gray-800 transition-colors whitespace-nowrap" do %> + + Invoice + <% end %> <% end %>
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb index 579702a6fe..152adda10e 100644 --- a/app/views/form_submissions/show.html.erb +++ b/app/views/form_submissions/show.html.erb @@ -34,7 +34,7 @@
<%= render "form_submissions/submission", submission: @form_submission %> - <% if event && @form_submission.role == "bulk_payment" %> + <% if event && @form_submission.role == "bulk_payment" && @form_submission.slug.present? %>
<%= link_to bulk_payment_invoice_path(@form_submission.slug, return_to: "form_submission"), class: "inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors" do %>