Skip to content

Commit 590cd18

Browse files
maebealeclaude
andauthored
Add dynamically generated event invoice with PDF download (#1709)
* Add dynamically generated event invoice with PDF download 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 <noreply@anthropic.com> * Reach the invoice from submission pages; use the existing print pattern 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 <noreply@anthropic.com> * Gate the ticket invoice link on the invoice_requested opt-in 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5691399 commit 590cd18

22 files changed

Lines changed: 611 additions & 15 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ This codebase (Rails 8.1)
6161
| `app/views/` | ERB templates | ~504 files |
6262
| `app/decorators/` | Draper decorators for view logic | ~37 files |
6363
| `app/policies/` | ActionPolicy authorization rules | ~49 files |
64-
| `app/presenters/` | Presentation objects | 1 file |
64+
| `app/presenters/` | Presentation objects | 2 files |
6565
| `app/helpers/` | View helpers | ~19 files |
6666
| `app/mailers/` | ActionMailer classes | 5 files |
6767
| `app/inputs/` | Custom SimpleForm inputs | 1 file |
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
module Events
2+
# Admin-side invoice for an event. Renders a blank template prefilled with the
3+
# event's content (line item + cost); when a `submission_id` is supplied it
4+
# autofills the bill-to/attention from that bulk-payment submission.
5+
class InvoicesController < ApplicationController
6+
# Bulk-payment payers have no account; authorization (below) gates access.
7+
skip_before_action :authenticate_user!, only: [ :show ]
8+
before_action :set_event
9+
10+
def show
11+
if params[:submission_id].present?
12+
# A bulk-payment submission's invoice is reachable by the payer (who has
13+
# no account), matching the public bulk-payment show page they're sent.
14+
@submission = FormSubmission.find(params[:submission_id])
15+
authorize! @submission, to: :show_invoice?
16+
@invoice = EventInvoice.from_bulk_payment(@submission)
17+
else
18+
# The blank template is an admin tool.
19+
authorize! @event, to: :invoice?
20+
@invoice = EventInvoice.from_event(@event)
21+
end
22+
23+
@event = @event.decorate
24+
end
25+
26+
private
27+
28+
def set_event
29+
@event = Event.find(params[:event_id])
30+
end
31+
end
32+
end

app/controllers/events/registrations_controller.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ class RegistrationsController < ApplicationController
33
before_action :authenticate_user!, only: [ :create, :destroy ]
44
before_action :set_event, only: [ :create, :destroy ]
55
before_action :set_registrant, only: [ :create, :destroy ]
6-
before_action :set_event_registration, only: [ :show, :resend_confirmation, :cancel, :reactivate, :pay ]
6+
before_action :set_event_registration, only: [ :show, :invoice, :resend_confirmation, :cancel, :reactivate, :pay ]
77

88
def show
99
authorize! @event_registration, to: :show_public?
@@ -22,6 +22,12 @@ def show
2222
end
2323
end
2424

25+
def invoice
26+
authorize! @event_registration, to: :show_public?
27+
@event = @event_registration.event
28+
@invoice = EventInvoice.from_registration(@event_registration)
29+
end
30+
2531
def resend_confirmation
2632
authorize! @event_registration, to: :show_public?
2733
send_registration_notifications(@event_registration)

app/policies/event_policy.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ def bulk_payments?
8383
manage?
8484
end
8585

86+
def invoice?
87+
manage?
88+
end
89+
8690
def preview_reminder?
8791
manage?
8892
end

app/policies/form_submission_policy.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,11 @@ class FormSubmissionPolicy < ApplicationPolicy
33
def show?
44
admin?
55
end
6+
7+
# Bulk-payment payers have no account but are emailed a link to their public
8+
# submission, so they can reach its invoice too. Other submission types stay
9+
# admin-only.
10+
def show_invoice?
11+
record.role == "bulk_payment" || admin?
12+
end
613
end

app/presenters/event_invoice.rb

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Normalizes the data needed to render an event invoice from either source it
2+
# can be generated for: a single person's EventRegistration, or an
3+
# organization's bulk-payment FormSubmission. Both resolve to the same shape
4+
# (bill-to, attention, line items, total) so one view renders both.
5+
class EventInvoice
6+
ISSUER_NAME = "A Window Between Worlds".freeze
7+
ISSUER_ADDRESS_LINES = [ "1029 1/2 W 24th St", "Los Angeles, CA 90007" ].freeze
8+
ISSUER_EMAIL = "info@awbw.org".freeze
9+
PAYABLE_TO_NOTE = "Please make checks payable to A Window Between Worlds".freeze
10+
11+
LineItem = Struct.new(:date, :description, :quantity, :unit_price_cents, keyword_init: true) do
12+
def amount_cents
13+
unit_price_cents.to_i * quantity.to_i
14+
end
15+
end
16+
17+
attr_reader :event, :number, :date, :reference, :client_id,
18+
:bill_to_name, :bill_to_address_lines, :bill_to_email,
19+
:attention, :line_items
20+
21+
# One registration → one attendee billed at the event cost. The registrant's
22+
# snapshotted organization (if any) is the bill-to; otherwise bill the person.
23+
def self.from_registration(registration)
24+
event = registration.event
25+
registrant = registration.registrant
26+
organization = registration.organizations.first
27+
addressable = organization || registrant
28+
29+
new(
30+
event: event,
31+
number: "R-#{registration.id}",
32+
date: registration.created_at.to_date,
33+
client_id: organization&.id || registrant.id,
34+
bill_to_name: organization&.name.presence || registrant.full_name,
35+
bill_to_address_lines: address_lines_for(addressable),
36+
bill_to_email: organization&.email.presence || registrant.preferred_email,
37+
attention: registrant.full_name,
38+
line_items: [
39+
LineItem.new(
40+
date: registration.created_at.to_date,
41+
description: event.title,
42+
quantity: 1,
43+
unit_price_cents: event.cost_cents.to_i
44+
)
45+
]
46+
)
47+
end
48+
49+
# A blank invoice template carrying only the event's content (one attendee at
50+
# the event cost). The bill-to and attention are left empty to be filled in.
51+
def self.from_event(event)
52+
new(
53+
event: event,
54+
number: nil,
55+
date: Date.current,
56+
client_id: nil,
57+
bill_to_name: nil,
58+
bill_to_address_lines: [],
59+
bill_to_email: nil,
60+
attention: nil,
61+
line_items: [
62+
LineItem.new(
63+
date: nil,
64+
description: event.title,
65+
quantity: 1,
66+
unit_price_cents: event.cost_cents.to_i
67+
)
68+
]
69+
)
70+
end
71+
72+
# A bulk payment bills the payer's organization for every attendee submitted.
73+
# The form captures the payer/org as free text (no Organization record), so
74+
# there is no structured address to show.
75+
def self.from_bulk_payment(submission)
76+
event = submission.resolved_event
77+
answers = submission.answers_by_identifier
78+
payer = submission.person
79+
payer_name = [ answers["payer_first_name"], answers["payer_last_name"] ]
80+
.map(&:presence).compact.join(" ").presence || payer&.full_name
81+
quantity = [ submission.bulk_payment_attendee_count, 1 ].max
82+
83+
new(
84+
event: event,
85+
number: "B-#{submission.id}",
86+
date: submission.created_at.to_date,
87+
client_id: submission.id,
88+
bill_to_name: answers["payer_organization"].presence || payer_name,
89+
bill_to_address_lines: [],
90+
bill_to_email: answers["payer_email"].presence || payer&.preferred_email,
91+
attention: payer_name,
92+
line_items: [
93+
LineItem.new(
94+
date: submission.created_at.to_date,
95+
description: event&.title,
96+
quantity: quantity,
97+
unit_price_cents: event&.cost_cents.to_i
98+
)
99+
]
100+
)
101+
end
102+
103+
def initialize(event:, number:, date:, client_id:, bill_to_name:,
104+
bill_to_address_lines:, bill_to_email:, attention:, line_items:,
105+
reference: nil)
106+
@event = event
107+
@number = number
108+
@date = date
109+
@client_id = client_id
110+
@bill_to_name = bill_to_name
111+
@bill_to_address_lines = bill_to_address_lines
112+
@bill_to_email = bill_to_email
113+
@attention = attention
114+
@line_items = line_items
115+
@reference = reference
116+
end
117+
118+
def total_cents
119+
line_items.sum(&:amount_cents)
120+
end
121+
122+
def issuer_name = ISSUER_NAME
123+
def issuer_address_lines = ISSUER_ADDRESS_LINES
124+
def issuer_email = ISSUER_EMAIL
125+
def payable_to_note = PAYABLE_TO_NOTE
126+
127+
def self.address_lines_for(addressable)
128+
return [] unless addressable.respond_to?(:addresses)
129+
130+
address = addressable.addresses.active.first
131+
return [] unless address
132+
133+
city_line = [ address.city.presence,
134+
[ address.state.presence, address.zip_code.presence ].compact.join(" ").presence ]
135+
.compact.join(", ")
136+
[ address.street_address.presence, city_line.presence ].compact
137+
end
138+
private_class_method :address_lines_for
139+
end

app/views/event_registrations/_ticket.html.erb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,21 @@
125125
<% end %>
126126
<% end %>
127127

128+
<% if event_registration.invoice_requested? %>
129+
<%= link_to registration_invoice_path(event_registration.slug),
130+
target: "_blank", rel: "noopener",
131+
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 %>
132+
<i class="fa-solid fa-file-invoice-dollar text-blue-500 text-xl shrink-0"></i>
133+
134+
<div class="flex-1 min-w-0 text-left">
135+
<p class="font-semibold text-blue-900">View invoice</p>
136+
<p class="text-sm text-blue-700">Itemized invoice for this registration</p>
137+
</div>
138+
139+
<i class="fa-solid fa-arrow-right text-blue-500 shrink-0"></i>
140+
<% end %>
141+
<% end %>
142+
128143
<!-- Divider -->
129144
<div class="border-t border-gray-200"></div>
130145

app/views/events/_bulk_payment_card.html.erb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,18 @@
3434
<%= render "payment_badge", payment: payment %>
3535
</span>
3636
<span class="text-sm text-gray-500 whitespace-nowrap"><%= submission.created_at.strftime("%b %-d, %Y") %></span>
37-
<%= link_to form_submission_path(submission, return_to: "bulk_payments", expand: submission.id),
38-
title: "View form submission",
39-
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 %>
40-
<i class="fa-solid fa-file-lines text-xs"></i> Submission
41-
<% end %>
37+
<div class="flex items-center gap-2">
38+
<%= link_to form_submission_path(submission, return_to: "bulk_payments", expand: submission.id),
39+
title: "View form submission",
40+
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 %>
41+
<i class="fa-solid fa-file-lines text-xs"></i> Submission
42+
<% end %>
43+
<%= link_to event_invoice_path(@event, submission_id: submission.id, return_to: "bulk_payments"),
44+
title: "View invoice",
45+
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 %>
46+
<i class="fa-solid fa-file-invoice-dollar text-xs"></i> Invoice
47+
<% end %>
48+
</div>
4249

4350
<button type="button"
4451
data-action="dropdown#toggle"

app/views/events/bulk_payments.html.erb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99

1010
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
1111
<h1 class="text-2xl font-semibold text-gray-900">Bulk payments</h1>
12-
<p class="text-sm text-gray-500"><%= @submissions.size %> submission<%= "s" unless @submissions.size == 1 %></p>
12+
<div class="flex items-center gap-3">
13+
<%= link_to event_invoice_path(@event),
14+
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 %>
15+
<i class="fa-solid fa-file-invoice-dollar text-xs"></i> Blank invoice
16+
<% end %>
17+
<p class="text-sm text-gray-500"><%= @submissions.size %> submission<%= "s" unless @submissions.size == 1 %></p>
18+
</div>
1319
</div>
1420

1521
<% if @submissions.any? %>

app/views/events/bulk_payments/show.html.erb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@
4545
<% end %>
4646
<%= render "form_submissions/submission", submission: @submission %>
4747

48+
<div class="mt-6 flex justify-center">
49+
<%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_show"),
50+
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 %>
51+
<i class="fa-solid fa-file-invoice-dollar"></i> View invoice
52+
<% end %>
53+
</div>
54+
4855
<% if allowed_to?(:dashboard?, @event) %>
4956
<div class="admin-only mt-6 rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-4 space-y-3">
5057
<div class="flex items-center justify-between gap-2">

0 commit comments

Comments
 (0)