-
Notifications
You must be signed in to change notification settings - Fork 24
Add dynamically generated event invoice with PDF download #1709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
200986b
e5e2b27
160eb51
4fff500
baff49e
5274d50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ] | ||
| before_action :set_event | ||
|
|
||
| def show | ||
| if params[:submission_id].present? | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 From Claude: One admin route serves both cases: no |
||
| # 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 From Claude: Public invoice authorized via |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,10 @@ def bulk_payments? | |
| manage? | ||
| end | ||
|
|
||
| def invoice? | ||
| manage? | ||
| end | ||
|
|
||
| def preview_reminder? | ||
| manage? | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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> |
There was a problem hiding this comment.
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: publicshow_invoice?for a bulk submission, admininvoice?for the blank template.