Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
32 changes: 32 additions & 0 deletions app/controllers/events/invoices_controller.rb
Original file line number Diff line number Diff line change
@@ -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 ]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Auth is skipped here (like the bulk-payment controller's public actions) and access is enforced by the per-branch authorize! calls below: public show_invoice? for a bulk submission, admin invoice? for the blank template.

before_action :set_event

def show
if params[:submission_id].present?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: One admin route serves both cases: no submission_id → blank template with just the event content; with one → autofilled from the bulk-payment submission. This is why there's no dedicated bulk-payment invoice route.

# 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
8 changes: 7 additions & 1 deletion app/controllers/events/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -22,6 +22,12 @@ def show
end
end

def invoice

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Public invoice authorized via show_public? (the secret slug is the credential), consistent with the registration ticket page right below it. The admin /events/:id/invoice route is the manage-gated counterpart.

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)
Expand Down
4 changes: 4 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ def bulk_payments?
manage?
end

def invoice?
manage?
end

def preview_reminder?
manage?
end
Expand Down
7 changes: 7 additions & 0 deletions app/policies/form_submission_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Bulk-payment submissions are already publicly viewable by id (the payer has no account and is emailed the link), so their invoice follows the same rule. Other submission roles (registration/scholarship) stay admin-only.

139 changes: 139 additions & 0 deletions app/presenters/event_invoice.rb
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Bulk invoices bill the payer's org for every attendee submitted (qty × event cost), so an 8-person submission for a $1,500 event totals $12,000 — matching the source template. Falls back to qty 1 if the count is missing/zero.


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
15 changes: 15 additions & 0 deletions app/views/event_registrations/_ticket.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<i class="fa-solid fa-file-invoice-dollar text-blue-500 text-xl shrink-0"></i>

<div class="flex-1 min-w-0 text-left">
<p class="font-semibold text-blue-900">View invoice</p>
<p class="text-sm text-blue-700">Itemized invoice for this registration</p>
</div>

<i class="fa-solid fa-arrow-right text-blue-500 shrink-0"></i>
<% end %>
<% end %>

<!-- Divider -->
<div class="border-t border-gray-200"></div>

Expand Down
17 changes: 12 additions & 5 deletions app/views/events/_bulk_payment_card.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@
<%= render "payment_badge", payment: payment %>
</span>
<span class="text-sm text-gray-500 whitespace-nowrap"><%= submission.created_at.strftime("%b %-d, %Y") %></span>
<%= 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 %>
<i class="fa-solid fa-file-lines text-xs"></i> Submission
<% end %>
<div class="flex items-center gap-2">
<%= 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 %>
<i class="fa-solid fa-file-lines text-xs"></i> 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 %>
<i class="fa-solid fa-file-invoice-dollar text-xs"></i> Invoice
<% end %>
</div>

<button type="button"
data-action="dropdown#toggle"
Expand Down
8 changes: 7 additions & 1 deletion app/views/events/bulk_payments.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="text-2xl font-semibold text-gray-900">Bulk payments</h1>
<p class="text-sm text-gray-500"><%= @submissions.size %> submission<%= "s" unless @submissions.size == 1 %></p>
<div class="flex items-center gap-3">
<%= link_to event_invoice_path(@event),
class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors" do %>
<i class="fa-solid fa-file-invoice-dollar text-xs"></i> Blank invoice
<% end %>
<p class="text-sm text-gray-500"><%= @submissions.size %> submission<%= "s" unless @submissions.size == 1 %></p>
</div>
</div>

<% if @submissions.any? %>
Expand Down
7 changes: 7 additions & 0 deletions app/views/events/bulk_payments/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
<% end %>
<%= render "form_submissions/submission", submission: @submission %>

<div class="mt-6 flex justify-center">
<%= 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 %>
<i class="fa-solid fa-file-invoice-dollar"></i> View invoice
<% end %>
</div>

<% if allowed_to?(:dashboard?, @event) %>
<div class="admin-only mt-6 rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-4 space-y-3">
<div class="flex items-center justify-between gap-2">
Expand Down
12 changes: 12 additions & 0 deletions app/views/events/invoices/_actions.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<%# 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. %>
<div class="max-w-3xl mx-auto flex flex-wrap items-center gap-2 px-4 sm:px-8 pt-4 print:hidden">
<%= 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 %>
<i class="fa-solid fa-arrow-left text-xs"></i> <%= back_label %>
<% end %>
<button onclick="window.print();"
class="ml-auto inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 transition-colors">
<i class="fa-solid fa-file-arrow-down"></i> Download PDF
</button>
</div>
Loading