diff --git a/AGENTS.md b/AGENTS.md
index 525c582e7..7a269d1ff 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 |
diff --git a/app/controllers/events/invoices_controller.rb b/app/controllers/events/invoices_controller.rb
new file mode 100644
index 000000000..8b94a5245
--- /dev/null
+++ b/app/controllers/events/invoices_controller.rb
@@ -0,0 +1,32 @@
+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
+ # Bulk-payment payers have no account; authorization (below) gates access.
+ 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
+
+ @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/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/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/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..5e97d2e13 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -125,6 +125,21 @@
<% 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 %>
+
+
+
+
View invoice
+
Itemized invoice for this registration
+
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb
index 5f477ed50..ef6d889c3 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, 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 %>
+