From 200986be1f918e0cdaff092f4fa819223e6aa2e7 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 13:43:07 -0400 Subject: [PATCH 1/6] Add dynamically generated event invoice with PDF download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWBW staff and registrants need a printable invoice for paid events. Rather than maintaining a static template per event, this generates the invoice from live data — the brand logo from the asset pipeline, the recipient from the registration/payer, and the line item from the event. One EventInvoice presenter normalizes three sources into a single printable layout: a per-registration invoice (public, reached via the registration's secret slug), and an admin-side event invoice that renders a blank template prefilled with the event's content, autofilling the bill-to/attention from a bulk-payment submission when submission_id is present. PDF export reuses the app's existing print convention (print: Tailwind utilities + a small print Stimulus controller) so no PDF gem or binary is introduced. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 5 +- app/controllers/events/invoices_controller.rb | 27 ++++ .../events/registrations_controller.rb | 8 +- app/frontend/javascript/controllers/index.js | 3 + .../controllers/print_controller.js | 9 ++ app/policies/event_policy.rb | 4 + app/presenters/event_invoice.rb | 139 ++++++++++++++++++ .../event_registrations/_ticket.html.erb | 9 ++ app/views/events/_bulk_payment_card.html.erb | 17 ++- app/views/events/bulk_payments.html.erb | 8 +- app/views/events/invoices/_actions.html.erb | 14 ++ app/views/events/invoices/_invoice.html.erb | 92 ++++++++++++ app/views/events/invoices/show.html.erb | 14 ++ .../events/registrations/invoice.html.erb | 8 + config/routes.rb | 2 + spec/presenters/event_invoice_spec.rb | 92 ++++++++++++ spec/requests/events/invoices_spec.rb | 58 ++++++++ spec/requests/events/registrations_spec.rb | 30 ++++ 18 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 app/controllers/events/invoices_controller.rb create mode 100644 app/frontend/javascript/controllers/print_controller.js create mode 100644 app/presenters/event_invoice.rb create mode 100644 app/views/events/invoices/_actions.html.erb create mode 100644 app/views/events/invoices/_invoice.html.erb create mode 100644 app/views/events/invoices/show.html.erb create mode 100644 app/views/events/registrations/invoice.html.erb create mode 100644 spec/presenters/event_invoice_spec.rb create mode 100644 spec/requests/events/invoices_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 525c582e7..13bd00ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ This codebase (Rails 8.1) | `app/views/` | ERB templates | ~504 files | | `app/decorators/` | Draper decorators for view logic | ~37 files | | `app/policies/` | ActionPolicy authorization rules | ~49 files | -| `app/presenters/` | Presentation objects | 1 file | +| `app/presenters/` | Presentation objects | 2 files | | `app/helpers/` | View helpers | ~19 files | | `app/mailers/` | ActionMailer classes | 5 files | | `app/inputs/` | Custom SimpleForm inputs | 1 file | @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (70) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (71) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -281,6 +281,7 @@ end - `paginated_fields` — Client-side pagination of nested fields - `password_toggle` — Show/hide password fields - `prefetch_lazy` — Prefetch lazy-loaded content +- `print` — Triggers the browser print dialog (used by the invoice "Download PDF" button) - `print_options` — Print options toggle for analytics - `remote_select` — AJAX-powered select dropdown - `reveal_section` — Expand a collapsible section and scroll to it when loaded via matching URL hash diff --git a/app/controllers/events/invoices_controller.rb b/app/controllers/events/invoices_controller.rb new file mode 100644 index 000000000..465eb8590 --- /dev/null +++ b/app/controllers/events/invoices_controller.rb @@ -0,0 +1,27 @@ +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. + class InvoicesController < ApplicationController + before_action :set_event + + def show + authorize! @event, to: :invoice? + + if params[:submission_id].present? + @submission = FormSubmission.find(params[:submission_id]) + @invoice = EventInvoice.from_bulk_payment(@submission) + else + @invoice = EventInvoice.from_event(@event) + end + + @event = @event.decorate + end + + private + + def set_event + @event = Event.find(params[:event_id]) + end + end +end diff --git a/app/controllers/events/registrations_controller.rb b/app/controllers/events/registrations_controller.rb index fa5820f10..1d62daecf 100644 --- a/app/controllers/events/registrations_controller.rb +++ b/app/controllers/events/registrations_controller.rb @@ -3,7 +3,7 @@ class RegistrationsController < ApplicationController before_action :authenticate_user!, only: [ :create, :destroy ] before_action :set_event, only: [ :create, :destroy ] before_action :set_registrant, only: [ :create, :destroy ] - before_action :set_event_registration, only: [ :show, :resend_confirmation, :cancel, :reactivate, :pay ] + before_action :set_event_registration, only: [ :show, :invoice, :resend_confirmation, :cancel, :reactivate, :pay ] def show authorize! @event_registration, to: :show_public? @@ -22,6 +22,12 @@ def show end end + def invoice + authorize! @event_registration, to: :show_public? + @event = @event_registration.event + @invoice = EventInvoice.from_registration(@event_registration) + end + def resend_confirmation authorize! @event_registration, to: :show_public? send_registration_notifications(@event_registration) diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 0eaa08ba4..5c6d28d9c 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -117,6 +117,9 @@ application.register("search-type-select", SearchTypeSelectController) import PrefetchLazyController from "./prefetch_lazy_controller" application.register("prefetch-lazy", PrefetchLazyController) +import PrintController from "./print_controller" +application.register("print", PrintController) + import PrintOptionsController from "./print_options_controller" application.register("print-options", PrintOptionsController) diff --git a/app/frontend/javascript/controllers/print_controller.js b/app/frontend/javascript/controllers/print_controller.js new file mode 100644 index 000000000..3131222fe --- /dev/null +++ b/app/frontend/javascript/controllers/print_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="print" +// Opens the browser's print dialog so the page can be saved as a PDF. +export default class extends Controller { + print() { + window.print(); + } +} diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 7bed8e13e..88a6de687 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -83,6 +83,10 @@ def bulk_payments? manage? end + def invoice? + manage? + end + def preview_reminder? manage? end diff --git a/app/presenters/event_invoice.rb b/app/presenters/event_invoice.rb new file mode 100644 index 000000000..e215f58be --- /dev/null +++ b/app/presenters/event_invoice.rb @@ -0,0 +1,139 @@ +# Normalizes the data needed to render an event invoice from either source it +# can be generated for: a single person's EventRegistration, or an +# organization's bulk-payment FormSubmission. Both resolve to the same shape +# (bill-to, attention, line items, total) so one view renders both. +class EventInvoice + 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 + PAYABLE_TO_NOTE = "Please make checks payable to A Window Between Worlds".freeze + + LineItem = Struct.new(:date, :description, :quantity, :unit_price_cents, keyword_init: true) do + def amount_cents + unit_price_cents.to_i * quantity.to_i + end + end + + attr_reader :event, :number, :date, :reference, :client_id, + :bill_to_name, :bill_to_address_lines, :bill_to_email, + :attention, :line_items + + # One registration → one attendee billed at the event cost. The registrant's + # snapshotted organization (if any) is the bill-to; otherwise bill the person. + def self.from_registration(registration) + event = registration.event + registrant = registration.registrant + organization = registration.organizations.first + addressable = organization || registrant + + new( + event: event, + number: "R-#{registration.id}", + date: registration.created_at.to_date, + client_id: organization&.id || registrant.id, + bill_to_name: organization&.name.presence || registrant.full_name, + bill_to_address_lines: address_lines_for(addressable), + bill_to_email: organization&.email.presence || registrant.preferred_email, + attention: registrant.full_name, + line_items: [ + LineItem.new( + date: registration.created_at.to_date, + description: event.title, + quantity: 1, + unit_price_cents: event.cost_cents.to_i + ) + ] + ) + end + + # A blank invoice template carrying only the event's content (one attendee at + # the event cost). The bill-to and attention are left empty to be filled in. + def self.from_event(event) + new( + event: event, + number: nil, + date: Date.current, + client_id: nil, + bill_to_name: nil, + bill_to_address_lines: [], + bill_to_email: nil, + attention: nil, + line_items: [ + LineItem.new( + date: nil, + description: event.title, + quantity: 1, + unit_price_cents: event.cost_cents.to_i + ) + ] + ) + end + + # A bulk payment bills the payer's organization for every attendee submitted. + # The form captures the payer/org as free text (no Organization record), so + # there is no structured address to show. + def self.from_bulk_payment(submission) + event = submission.resolved_event + answers = submission.answers_by_identifier + payer = submission.person + payer_name = [ answers["payer_first_name"], answers["payer_last_name"] ] + .map(&:presence).compact.join(" ").presence || payer&.full_name + quantity = [ submission.bulk_payment_attendee_count, 1 ].max + + new( + event: event, + number: "B-#{submission.id}", + date: submission.created_at.to_date, + client_id: submission.id, + bill_to_name: answers["payer_organization"].presence || payer_name, + bill_to_address_lines: [], + bill_to_email: answers["payer_email"].presence || payer&.preferred_email, + attention: payer_name, + line_items: [ + LineItem.new( + date: submission.created_at.to_date, + description: event&.title, + quantity: quantity, + unit_price_cents: event&.cost_cents.to_i + ) + ] + ) + end + + def initialize(event:, number:, date:, client_id:, bill_to_name:, + bill_to_address_lines:, bill_to_email:, attention:, line_items:, + reference: nil) + @event = event + @number = number + @date = date + @client_id = client_id + @bill_to_name = bill_to_name + @bill_to_address_lines = bill_to_address_lines + @bill_to_email = bill_to_email + @attention = attention + @line_items = line_items + @reference = reference + end + + def total_cents + line_items.sum(&:amount_cents) + end + + def issuer_name = ISSUER_NAME + def issuer_address_lines = ISSUER_ADDRESS_LINES + def issuer_email = ISSUER_EMAIL + def payable_to_note = PAYABLE_TO_NOTE + + def self.address_lines_for(addressable) + return [] unless addressable.respond_to?(:addresses) + + address = addressable.addresses.active.first + return [] unless address + + city_line = [ address.city.presence, + [ address.state.presence, address.zip_code.presence ].compact.join(" ").presence ] + .compact.join(", ") + [ address.street_address.presence, city_line.presence ].compact + end + private_class_method :address_lines_for +end diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 52c03b08a..3f5ee4224 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -128,6 +128,15 @@
+ <% if event_registration.event.cost_cents.to_i > 0 %> +
+ <%= link_to registration_invoice_path(event_registration.slug), + class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-blue-600 underline" do %> + View invoice + <% end %> +
+ <% end %> +
<% if event_registration.checked_in? %> diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index 5f477ed50..88c277e99 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -34,11 +34,18 @@ <%= render "payment_badge", payment: payment %> <%= submission.created_at.strftime("%b %-d, %Y") %> - <%= link_to form_submission_path(submission, return_to: "bulk_payments", expand: submission.id), - title: "View form submission", - 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 %> - Submission - <% end %> +
+ <%= link_to form_submission_path(submission, return_to: "bulk_payments", expand: submission.id), + title: "View form submission", + 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 %> + Submission + <% end %> + <%= link_to event_invoice_path(@event, submission_id: submission.id), + 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 %> +
<% if @submissions.any? %> diff --git a/app/views/events/invoices/_actions.html.erb b/app/views/events/invoices/_actions.html.erb new file mode 100644 index 000000000..40386e547 --- /dev/null +++ b/app/views/events/invoices/_actions.html.erb @@ -0,0 +1,14 @@ +<%# Eyebrow + "Download PDF" controls above an invoice. Hidden when printing so + only the invoice document lands in the PDF. `back_path` / `back_label` set + the return link to wherever the invoice was opened from. %> +
+ <%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> + <%= back_label %> + <% end %> +
+ +
+
diff --git a/app/views/events/invoices/_invoice.html.erb b/app/views/events/invoices/_invoice.html.erb new file mode 100644 index 000000000..b5bcc7054 --- /dev/null +++ b/app/views/events/invoices/_invoice.html.erb @@ -0,0 +1,92 @@ +<%# Renders one EventInvoice as a printable document. Shared by the per- + registration and bulk-payment invoice pages. `invoice` is an EventInvoice. %> +
+ <%# Header: issuer details (left) + brand logo (right) %> +
+
+

<%= invoice.issuer_name %>

+ <% invoice.issuer_address_lines.each do |line| %> +

<%= line %>

+ <% end %> +

<%= invoice.issuer_email %>

+
+ <%= image_tag "logo-with-tagline.png", alt: invoice.issuer_name, class: "h-16 w-auto shrink-0" %> +
+ +

INVOICE

+ + <%# Bill-to %> +
+

Invoice To:

+
+

<%= invoice.bill_to_name %>

+ <% invoice.bill_to_address_lines.each do |line| %> +

<%= line %>

+ <% end %> + <% if invoice.bill_to_email.present? %> +

<%= invoice.bill_to_email %>

+ <% end %> +
+
+ + <%# Attention %> +
+

Attention:

+
+ <%= invoice.attention.presence || "—" %> +
+
+ + <%# Meta row: number / date / reference / client id %> +
+ <% [ + [ "Invoice Number", invoice.number ], + [ "Invoice Date", invoice.date&.strftime("%-d %b, %Y") ], + [ "Your Reference", invoice.reference ], + [ "Client ID", invoice.client_id ] + ].each do |label, value| %> +
+

<%= label %>

+

<%= value.presence || " " %>

+
+ <% end %> +
+ + <%# Line items %> + + + + + + + + + + + + <% invoice.line_items.each do |item| %> + + + + + + + + <% end %> + + + + + + + + +
DateJob/Item DescriptionQuantity/UnitsUnit PriceAmount
<%= item.date&.strftime("%m/%d/%y") %><%= item.description %><%= item.quantity %><%= dollars_from_cents(item.unit_price_cents) %><%= dollars_from_cents(item.amount_cents) %>
Total + <%= dollars_from_cents(invoice.total_cents) %> +
+ +
+

<%= invoice.payable_to_note %>

+

Thank you.

+
+
diff --git a/app/views/events/invoices/show.html.erb b/app/views/events/invoices/show.html.erb new file mode 100644 index 000000000..623e56825 --- /dev/null +++ b/app/views/events/invoices/show.html.erb @@ -0,0 +1,14 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:page_title, "Invoice — #{@event.title}") %> +<% if @submission %> + <%= render "events/invoices/actions", + back_path: bulk_payments_event_path(@event, expand: @submission.id, anchor: "payment-card-#{@submission.id}"), + back_label: "Back to bulk payments" %> +<% else %> + <%= render "events/invoices/actions", + back_path: dashboard_event_path(@event), + back_label: "Back to event" %> +<% end %> +
+ <%= render "events/invoices/invoice", invoice: @invoice %> +
diff --git a/app/views/events/registrations/invoice.html.erb b/app/views/events/registrations/invoice.html.erb new file mode 100644 index 000000000..9ad6b6c20 --- /dev/null +++ b/app/views/events/registrations/invoice.html.erb @@ -0,0 +1,8 @@ +<% content_for(:page_bg_class, "public") %> +<% content_for(:page_title, "Invoice — #{@event.title}") %> +<%= render "events/invoices/actions", + back_path: registration_ticket_path(@event_registration.slug), + back_label: "Back to registration" %> +
+ <%= render "events/invoices/invoice", invoice: @invoice %> +
diff --git a/config/routes.rb b/config/routes.rb index 52b3998ba..89f599137 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,7 @@ end resources :community_news get "registration/:slug", to: "events/registrations#show", as: :registration_ticket + get "registration/:slug/invoice", to: "events/registrations#invoice", as: :registration_invoice post "registration/:slug/resend_confirmation", to: "events/registrations#resend_confirmation", as: :registration_resend_confirmation post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate @@ -143,6 +144,7 @@ resource :registrations, only: %i[ create destroy ], module: :events, as: :registrant_registration resource :public_registration, only: [ :new, :create, :show ], module: :events resource :bulk_payment, only: [ :new, :create, :show ], module: :events + resource :invoice, only: [ :show ], module: :events end resources :people do collection do diff --git a/spec/presenters/event_invoice_spec.rb b/spec/presenters/event_invoice_spec.rb new file mode 100644 index 000000000..dc3529cb7 --- /dev/null +++ b/spec/presenters/event_invoice_spec.rb @@ -0,0 +1,92 @@ +require "rails_helper" + +RSpec.describe EventInvoice do + describe ".from_registration" do + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + let(:registrant) { create(:person, first_name: "Helena", last_name: "Lopez") } + let(:registration) { create(:event_registration, event: event, registrant: registrant) } + + it "bills the registrant for one attendee at the event cost" do + invoice = described_class.from_registration(registration) + + expect(invoice.event).to eq(event) + expect(invoice.attention).to eq("Helena Lopez") + expect(invoice.bill_to_name).to eq("Helena Lopez") + expect(invoice.line_items.size).to eq(1) + + item = invoice.line_items.first + expect(item.description).to eq("AWBW 2-Day Art Facilitator Training") + expect(item.quantity).to eq(1) + expect(item.unit_price_cents).to eq(150_000) + expect(item.amount_cents).to eq(150_000) + expect(invoice.total_cents).to eq(150_000) + end + + context "with a snapshotted organization" do + let(:organization) { create(:organization, name: "A Greater Hope") } + + before do + create(:event_registration_organization, event_registration: registration, organization: organization) + create(:address, addressable: organization, street_address: "PO Box 1477", city: "Victorville", state: "CA", zip_code: "92393") + end + + it "bills the organization with its address but keeps the registrant as attention" do + invoice = described_class.from_registration(registration) + + expect(invoice.bill_to_name).to eq("A Greater Hope") + expect(invoice.attention).to eq("Helena Lopez") + expect(invoice.bill_to_address_lines).to eq([ "PO Box 1477", "Victorville, CA 92393" ]) + expect(invoice.client_id).to eq(organization.id) + end + end + end + + describe ".from_event" do + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + + it "builds a blank template carrying only the event content" do + invoice = described_class.from_event(event) + + expect(invoice.bill_to_name).to be_nil + expect(invoice.attention).to be_nil + expect(invoice.bill_to_address_lines).to eq([]) + + item = invoice.line_items.first + expect(item.description).to eq("AWBW 2-Day Art Facilitator Training") + expect(item.quantity).to eq(1) + expect(item.unit_price_cents).to eq(150_000) + expect(invoice.total_cents).to eq(150_000) + end + end + + describe ".from_bulk_payment" do + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + let(:form) { create(:form) } + let(:submission) { create(:form_submission, form: form, event: event, role: "bulk_payment") } + + def add_answer(identifier, value) + field = create(:form_field, form: form, field_identifier: identifier) + 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 "bills the payer's organization for every attendee submitted" do + invoice = described_class.from_bulk_payment(submission) + + expect(invoice.bill_to_name).to eq("A Greater Hope") + expect(invoice.attention).to eq("Helena Lopez") + + item = invoice.line_items.first + expect(item.description).to eq("AWBW 2-Day Art Facilitator Training") + expect(item.quantity).to eq(8) + expect(item.unit_price_cents).to eq(150_000) + expect(invoice.total_cents).to eq(1_200_000) + end + end +end diff --git a/spec/requests/events/invoices_spec.rb b/spec/requests/events/invoices_spec.rb new file mode 100644 index 000000000..c562349a7 --- /dev/null +++ b/spec/requests/events/invoices_spec.rb @@ -0,0 +1,58 @@ +require "rails_helper" + +RSpec.describe "Events::Invoices", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + + describe "GET /events/:event_id/invoice" do + context "as an admin" do + before { sign_in admin } + + it "renders a blank invoice template carrying the event's content" do + get event_invoice_path(event) + + expect(response).to have_http_status(:success) + expect(response.body).to include("INVOICE") + 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 + before { sign_in create(:user) } + + it "is denied and redirected" do + get event_invoice_path(event) + expect(response).to redirect_to(root_path) + end + end + end +end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index e73437550..1c21e2ac3 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -69,6 +69,36 @@ end end + describe "GET /registration/:slug/invoice" do + let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) } + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "renders the invoice for the registrant" do + get registration_invoice_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("INVOICE") + expect(response.body).to include("AWBW 2-Day Art Facilitator Training") + expect(response.body).to include("$1,500") + end + + context "as a guest" do + before { sign_out user } + + it "renders the invoice (slug is authorization)" do + get registration_invoice_path(registration.slug) + expect(response).to have_http_status(:success) + end + end + + context "with an invalid slug" do + it "returns 404" do + get registration_invoice_path("nonexistent-slug") + expect(response).to have_http_status(:not_found) + end + end + end + describe "POST /registration/:slug/resend_confirmation" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } From e5e2b27f2b14344634e7bd797e278eb09133f49c Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 13:55:00 -0400 Subject: [PATCH 2/6] Reach the invoice from submission pages; use the existing print pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk-payment payers are emailed a link to their (public) submission page, so the invoice needs to be reachable from there — add a "View invoice" link on both the public bulk-payment show page and the admin form-submission page. A bulk-payment submission's invoice is now public (gated by FormSubmission show_invoice?), matching that the submission show page is already public by id; the blank template stays admin-only. Also drop the bespoke print Stimulus controller in favor of the inline onclick="window.print();" pattern already used by sibling event views (recipients, background, social share), and render the invoice on a neutral public background rather than the admin blue tint. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +-- app/controllers/events/invoices_controller.rb | 9 +++++-- app/frontend/javascript/controllers/index.js | 3 --- .../controllers/print_controller.js | 9 ------- app/policies/form_submission_policy.rb | 7 +++++ app/views/events/_bulk_payment_card.html.erb | 2 +- app/views/events/bulk_payments/show.html.erb | 7 +++++ app/views/events/invoices/_actions.html.erb | 10 +++---- app/views/events/invoices/show.html.erb | 27 ++++++++++++------- app/views/form_submissions/show.html.erb | 9 +++++++ spec/requests/events/bulk_payments_spec.rb | 7 +++++ spec/requests/events/invoices_spec.rb | 24 ++++++++++++++++- 12 files changed, 83 insertions(+), 34 deletions(-) delete mode 100644 app/frontend/javascript/controllers/print_controller.js diff --git a/AGENTS.md b/AGENTS.md index 13bd00ee4..7a269d1ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (71) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (70) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -281,7 +281,6 @@ end - `paginated_fields` — Client-side pagination of nested fields - `password_toggle` — Show/hide password fields - `prefetch_lazy` — Prefetch lazy-loaded content -- `print` — Triggers the browser print dialog (used by the invoice "Download PDF" button) - `print_options` — Print options toggle for analytics - `remote_select` — AJAX-powered select dropdown - `reveal_section` — Expand a collapsible section and scroll to it when loaded via matching URL hash diff --git a/app/controllers/events/invoices_controller.rb b/app/controllers/events/invoices_controller.rb index 465eb8590..8b94a5245 100644 --- a/app/controllers/events/invoices_controller.rb +++ b/app/controllers/events/invoices_controller.rb @@ -3,15 +3,20 @@ module Events # event's content (line item + cost); when a `submission_id` is supplied it # autofills the bill-to/attention from that bulk-payment submission. class InvoicesController < ApplicationController + # Bulk-payment payers have no account; authorization (below) gates access. + skip_before_action :authenticate_user!, only: [ :show ] before_action :set_event def show - authorize! @event, to: :invoice? - 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 diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 5c6d28d9c..0eaa08ba4 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -117,9 +117,6 @@ application.register("search-type-select", SearchTypeSelectController) import PrefetchLazyController from "./prefetch_lazy_controller" application.register("prefetch-lazy", PrefetchLazyController) -import PrintController from "./print_controller" -application.register("print", PrintController) - import PrintOptionsController from "./print_options_controller" application.register("print-options", PrintOptionsController) diff --git a/app/frontend/javascript/controllers/print_controller.js b/app/frontend/javascript/controllers/print_controller.js deleted file mode 100644 index 3131222fe..000000000 --- a/app/frontend/javascript/controllers/print_controller.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="print" -// Opens the browser's print dialog so the page can be saved as a PDF. -export default class extends Controller { - print() { - window.print(); - } -} diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index 8d1d73ae2..4add36ee4 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -3,4 +3,11 @@ class FormSubmissionPolicy < ApplicationPolicy def show? admin? 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? + end end diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb index 88c277e99..ef6d889c3 100644 --- a/app/views/events/_bulk_payment_card.html.erb +++ b/app/views/events/_bulk_payment_card.html.erb @@ -40,7 +40,7 @@ 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 %> Submission <% end %> - <%= link_to event_invoice_path(@event, submission_id: submission.id), + <%= link_to event_invoice_path(@event, submission_id: submission.id, 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 diff --git a/app/views/events/bulk_payments/show.html.erb b/app/views/events/bulk_payments/show.html.erb index 4a30d5f30..9dab72ad3 100644 --- a/app/views/events/bulk_payments/show.html.erb +++ b/app/views/events/bulk_payments/show.html.erb @@ -45,6 +45,13 @@ <% end %> <%= render "form_submissions/submission", submission: @submission %> +
+ <%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_show"), + 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 %> +
+ <% if allowed_to?(:dashboard?, @event) %>
diff --git a/app/views/events/invoices/_actions.html.erb b/app/views/events/invoices/_actions.html.erb index 40386e547..48905dfcb 100644 --- a/app/views/events/invoices/_actions.html.erb +++ b/app/views/events/invoices/_actions.html.erb @@ -5,10 +5,8 @@ <%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> <%= back_label %> <% end %> -
- -
+
diff --git a/app/views/events/invoices/show.html.erb b/app/views/events/invoices/show.html.erb index 623e56825..a284287c0 100644 --- a/app/views/events/invoices/show.html.erb +++ b/app/views/events/invoices/show.html.erb @@ -1,14 +1,21 @@ -<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Invoice — #{@event.title}") %> -<% if @submission %> - <%= render "events/invoices/actions", - back_path: bulk_payments_event_path(@event, expand: @submission.id, anchor: "payment-card-#{@submission.id}"), - back_label: "Back to bulk payments" %> -<% else %> - <%= render "events/invoices/actions", - back_path: dashboard_event_path(@event), - back_label: "Back to event" %> -<% end %> +<% + back_path, back_label = + if @submission + case params[:return_to] + when "bulk_payment_show" + [ event_bulk_payment_path(@event, submission_id: @submission.id), "Back to submission" ] + when "form_submission" + [ form_submission_path(@submission), "Back to submission" ] + else + [ bulk_payments_event_path(@event, expand: @submission.id, anchor: "payment-card-#{@submission.id}"), "Back to bulk payments" ] + end + else + [ dashboard_event_path(@event), "Back to event" ] + end +%> +<%= render "events/invoices/actions", back_path: back_path, back_label: back_label %>
<%= render "events/invoices/invoice", invoice: @invoice %>
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb index 9cea2cf19..85810166a 100644 --- a/app/views/form_submissions/show.html.erb +++ b/app/views/form_submissions/show.html.erb @@ -29,6 +29,15 @@
<%= render "form_submissions/submission", submission: @form_submission %> + + <% if event && @form_submission.role == "bulk_payment" %> +
+ <%= link_to event_invoice_path(event, submission_id: @form_submission.id, 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 %> +
+ <% end %>
diff --git a/spec/requests/events/bulk_payments_spec.rb b/spec/requests/events/bulk_payments_spec.rb index 14605d209..c5ca40703 100644 --- a/spec/requests/events/bulk_payments_spec.rb +++ b/spec/requests/events/bulk_payments_spec.rb @@ -209,6 +209,13 @@ def get_show expect(response).to have_http_status(:ok) expect(response.body).not_to include("Payment allocations") end + + it "links to the submission's invoice" do + get_show + + expect(response.body).to include("/events/#{event.id}/invoice") + expect(response.body).to include("submission_id=#{submission.id}") + end end end end diff --git a/spec/requests/events/invoices_spec.rb b/spec/requests/events/invoices_spec.rb index c562349a7..01755557e 100644 --- a/spec/requests/events/invoices_spec.rb +++ b/spec/requests/events/invoices_spec.rb @@ -49,7 +49,29 @@ def add_answer(identifier, value) context "as a non-admin" do before { sign_in create(:user) } - it "is denied and redirected" do + it "is denied the blank template and redirected" do + get event_invoice_path(event) + expect(response).to redirect_to(root_path) + end + 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 get event_invoice_path(event) expect(response).to redirect_to(root_path) end From 160eb513be591218e5c050a263f4783f9c20607f Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 13:59:49 -0400 Subject: [PATCH 3/6] Gate the ticket invoice link on the invoice_requested opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main (#1707) added an "Invoice"/"W-9" opt-in to public registration that sets invoice_requested/w9_requested, with the digital ticket surfacing those downloads — but only the W-9 download was wired up, since the invoice page didn't exist yet. Surface the invoice the same way: a card matching the W-9 one, shown when the registrant requested an invoice, completing that intent. Co-Authored-By: Claude Opus 4.8 --- .../event_registrations/_ticket.html.erb | 24 ++++++++++++------- spec/requests/events/registrations_spec.rb | 14 +++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 3f5ee4224..5e97d2e13 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -125,18 +125,24 @@ <% end %> <% end %> - -
+ <% if event_registration.invoice_requested? %> + <%= link_to registration_invoice_path(event_registration.slug), + target: "_blank", rel: "noopener", + 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 %> + - <% if event_registration.event.cost_cents.to_i > 0 %> -
- <%= link_to registration_invoice_path(event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-blue-600 underline" do %> - View invoice - <% end %> -
+
+

View invoice

+

Itemized invoice for this registration

+
+ + + <% end %> <% end %> + +
+
<% if event_registration.checked_in? %> diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 1c21e2ac3..b4530afe4 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -61,6 +61,20 @@ end end + context "invoice link" do + it "shows the invoice link when invoice_requested" do + registration.update!(invoice_requested: true) + get registration_ticket_path(registration.slug) + expect(response.body).to include(registration_invoice_path(registration.slug)) + expect(response.body).to include("View invoice") + end + + it "hides the invoice link when not requested" do + get registration_ticket_path(registration.slug) + expect(response.body).not_to include(registration_invoice_path(registration.slug)) + end + end + context "with an invalid slug" do it "returns 404" do get registration_ticket_path("nonexistent-slug") From 4fff500e3d3e87549eee65ec2e1c905a8bffdacb Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 14:02:37 -0400 Subject: [PATCH 4/6] Seed dev registrations with invoice_requested Mark two facilitator/trauma training registrations as having requested an invoice so the dev dataset exercises the ticket's "View invoice" surface, mirroring the existing scholarship_requested seed flag. Co-Authored-By: Claude Opus 4.8 --- db/seeds/dev/events_management.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 6709a8dd4..ec6693bb2 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -378,14 +378,14 @@ # --- Facilitator Training: multiple registrations from different people, extended form --- # Amy: registered, with form submission, scholarship recipient -# Maria Johnson: registered, with form submission (has user) +# Maria Johnson: registered, with form submission (has user), requested an invoice # Anna Garcia: attended, with form submission (has user) # Mario Johnson: registered, no form submission (no user) # Kim Davis: cancelled (has user) if facilitator_training [ { person: amy_person, status: "registered", scholarship_requested: true }, - { person: maria_j, status: "registered" }, + { person: maria_j, status: "registered", invoice_requested: true }, { person: anna_g, status: "attended" }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" } @@ -396,13 +396,13 @@ end # --- Trauma Training: extended form, scholarship --- -# Sarah Smith: registered with form (has user) +# Sarah Smith: registered with form (has user), requested an invoice # Jessica Brown: registered with form, scholarship (has user) # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training [ - { person: sarah_s, status: "registered" }, + { person: sarah_s, status: "registered", invoice_requested: true }, { person: jessica_b, status: "registered", scholarship_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -467,7 +467,8 @@ event: data[:event], registrant: data[:person], status: data[:status] || "registered", - scholarship_requested: data[:scholarship_requested] || false + scholarship_requested: data[:scholarship_requested] || false, + invoice_requested: data[:invoice_requested] || false ) end From baff49e4ff970f003622ace696efb85f6fb63e0e Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 14:03:56 -0400 Subject: [PATCH 5/6] Seed Amy's training registration with W-9 and invoice requested Gives the dev dataset a registration that exercises both the W-9 and invoice ticket surfaces at once, and threads w9_requested through the seed create. Maria and Sarah keep their invoice-only flag. Co-Authored-By: Claude Opus 4.8 --- db/seeds/dev/events_management.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index ec6693bb2..e5e39e7c5 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -377,14 +377,14 @@ registrations_data = [] # --- Facilitator Training: multiple registrations from different people, extended form --- -# Amy: registered, with form submission, scholarship recipient +# Amy: registered, with form submission, scholarship recipient, requested W-9 and invoice # Maria Johnson: registered, with form submission (has user), requested an invoice # Anna Garcia: attended, with form submission (has user) # Mario Johnson: registered, no form submission (no user) # Kim Davis: cancelled (has user) if facilitator_training [ - { person: amy_person, status: "registered", scholarship_requested: true }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true }, { person: maria_j, status: "registered", invoice_requested: true }, { person: anna_g, status: "attended" }, { person: mario_j, status: "registered" }, @@ -468,6 +468,7 @@ registrant: data[:person], status: data[:status] || "registered", scholarship_requested: data[:scholarship_requested] || false, + w9_requested: data[:w9_requested] || false, invoice_requested: data[:invoice_requested] || false ) end From 5274d50c313f1e44090942c1272dfb47e0ab3014 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 17 Jun 2026 14:14:44 -0400 Subject: [PATCH 6/6] Register invoice views in page_bg_class alignment spec The new event/registration invoice views set content_for(:page_bg_class), which the alignment guard requires to be accounted for. Both are publicly reachable (slug / bulk-payment submission id), so "public" is the honest value; the blank-template admin gating is enforced in the controller. Co-Authored-By: Claude Opus 4.8 --- spec/views/page_bg_class_alignment_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 53afcd839..35aad7c2d 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -190,12 +190,16 @@ "app/views/events/public_registrations/new.html.erb" => "public", "app/views/events/public_registrations/show.html.erb" => "public", "app/views/events/registrations/show.html.erb" => "public", + "app/views/events/registrations/invoice.html.erb" => "public", "app/views/events/details.html.erb" => "public", # ─── bulk payment views ─── "app/views/events/bulk_payments/new.html.erb" => "public", "app/views/events/bulk_payments/show.html.erb" => "public", + # ─── event invoice (slug/submission-reachable; blank template gated in controller) ─── + "app/views/events/invoices/show.html.erb" => "public", + # ─── admin-only confirm/interstitial ─── "app/views/event_registrations/confirm.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/link_organization.html.erb" => "admin-only bg-blue-100",