Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Events
class BulkPaymentFormSubmissionsController < ApplicationController
skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :resend_confirmation ]
skip_before_action :authenticate_user!, only: [ :new, :create, :show, :ticket, :invoice, :receipt, :resend_confirmation ]
before_action :set_event, only: [ :new, :create, :show ]
before_action :set_form, only: [ :new, :create ]

Expand Down Expand Up @@ -77,6 +77,36 @@ def ticket
@event = @submission.event.decorate
end

# Public, slug-based invoice for the payer. Served here (rather than
# InvoicesController, which keeps only the admin blank template) so it's gated
# by the unguessable slug like #ticket, not an enumerable submission_id. The
# shared invoice view branches its back link on return_to.
def invoice
@submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug])
authorize! @submission, to: :invoice?, context: { slug: params[:slug] }

@event = @submission.event.decorate
@invoice = EventInvoice.from_bulk_payment(@submission)
render "events/invoices/show"
end

# Public, slug-based receipt for the payer, available once a payment is on
# file. Mirrors #ticket's slug authorization; redirects back to the ticket
# while payment is still pending.
def receipt
@submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug])
authorize! @submission, to: :receipt?, context: { slug: params[:slug] }

unless @submission.bulk_payment_receipt_available?
redirect_to bulk_payment_ticket_path(@submission.slug),
alert: "A receipt is available once your payment is received."
return
end

@event = @submission.event.decorate
@receipt = EventReceipt.from_bulk_payment(@submission)
end

def resend_confirmation
@submission = FormSubmission.bulk_payment.find_by!(slug: params[:slug])
authorize! @submission, to: :show?, context: { slug: params[:slug] }
Expand Down
23 changes: 7 additions & 16 deletions app/controllers/events/invoices_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
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.
# Admin-only blank invoice template for an event, prefilled with the event's
# content (line item + cost). A bulk-payment submission's invoice is served by
# BulkPaymentFormSubmissionsController#invoice (slug-based, payer-facing).
class InvoicesController < ApplicationController
# Bulk-payment payers have no account; authorization (below) gates access.
# Kept skipped so an unauthorized viewer is denied by the policy (redirect to
# root) rather than bounced to a sign-in page.
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

authorize! @event, to: :invoice?
@invoice = EventInvoice.from_event(@event)
@event = @event.decorate
end

Expand Down
7 changes: 7 additions & 0 deletions app/models/form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ def bulk_payment_amount_cents(event)
event.cost_cents.to_i * bulk_payment_attendee_count
end

# A bulk payment has no owed-balance/paid-in-full concept, so its receipt simply
# records whatever was paid β€” it unlocks as soon as a payment is on file, the
# same signal the ticket uses to show "Payment received".
def bulk_payment_receipt_available?
bulk_payment? && payment.present?
end

# --- Linked registrations (bulk payment designations) ---

def linked_registration_ids
Expand Down
14 changes: 9 additions & 5 deletions app/policies/form_submission_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def ticket?
admin? || (slug.present? && record.slug == slug)
end

def receipt?
admin? || (slug.present? && record.slug == slug)
end

def new?
true
end
Expand All @@ -22,10 +26,10 @@ def create?
true
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?
# Bulk-payment payers have no account but are emailed the unguessable slug, so
# slug possession authorizes the invoice β€” same gate as #ticket / #receipt.
# (Not keyed on the record id, which is enumerable.)
def invoice?
admin? || (slug.present? && record.slug == slug)
end
end
18 changes: 14 additions & 4 deletions app/presenters/allocation_ledger_label.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module AllocationLedgerLabel

def method_label(allocation)
if allocation.source_type == Payment.polymorphic_name
PAYMENT_METHOD_LABELS[allocation.source&.class&.name] || "Payment"
payment_method_label(allocation.source)
else
allocation.source_type.underscore.humanize
end
Expand All @@ -26,8 +26,18 @@ def method_label(allocation)
# Check number, when the payment was a check β€” so the payer can reconcile it
# against their own records.
def reference_for(allocation)
source = allocation.source
return unless source.is_a?(CheckPayment) && source.check_number.present?
"Check ##{source.check_number}"
payment_reference(allocation.source)
end

# Method/reference for a Payment directly (not via an allocation) β€” used by the
# bulk payment receipt, whose ledger is the single payment the payer made
# rather than the per-registration allocations it settled.
def payment_method_label(payment)
PAYMENT_METHOD_LABELS[payment&.class&.name] || "Payment"
end

def payment_reference(payment)
return unless payment.is_a?(CheckPayment) && payment.check_number.present?
"Check ##{payment.check_number}"
end
end
77 changes: 70 additions & 7 deletions app/presenters/event_receipt.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# Normalizes the data needed to render a paid-in-full receipt for a single
# EventRegistration. Mirrors EventInvoice (same bill-to/attention/line-item
# shape) but adds the allocation ledger that settled the balance and a summary
# that always reconciles to $0 due β€” a receipt is only generated once the
# registration is paid in full (see EventRegistration#receipt_available?).
# Normalizes the data needed to render a receipt for either source: a single
# EventRegistration or a bulk-payment FormSubmission. Mirrors EventInvoice (same
# bill-to/attention/line-item shape) and adds a payment ledger.
#
# The two differ in how the summary reads. A registration receipt settles a
# balance β€” it lists every allocation and reconciles to $0 due (only generated
# once paid in full, see EventRegistration#receipt_available?). A bulk payment
# has no owed-balance concept; its receipt simply records the amount paid, so it
# skips the total-charged/balance-due framing (balance_settlement: false).
class EventReceipt
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
THANK_YOU_NOTE = "Payment received in full β€” thank you. Please retain this receipt for your records.".freeze
PAYMENT_RECEIVED_NOTE = "Payment received β€” thank you. Please retain this receipt for your records.".freeze

LineItem = Struct.new(:date, :description, :quantity, :unit_price_cents, keyword_init: true) do
def amount_cents
Expand Down Expand Up @@ -65,9 +70,59 @@ def self.entry_for(allocation)
end
private_class_method :entry_for

# One bulk-payment FormSubmission β†’ the event charge for every attendee as a
# single line item, and the payer's one payment as the ledger entry. Billed to
# whoever the connected Payment records as the payer (respecting payer_type) β€”
# NOT the form submitter, who may differ once an admin records the payment
# against a different person/organization. Only generated once a payment is on
# file (see FormSubmission#bulk_payment_receipt_available?).
def self.from_bulk_payment(submission)
event = submission.resolved_event
payment = submission.payment
payer = payment&.payer
# The org, if the org paid; the individual is the payer when a person paid,
# otherwise the org's contact person recorded on the payment.
organization = payer.is_a?(Organization) ? payer : nil
individual = payer.is_a?(Person) ? payer : payment&.person
quantity = [ submission.bulk_payment_attendee_count, 1 ].max

new(
event: event,
number: "RCPT-B-#{submission.id}",
date: (payment&.created_at || submission.created_at).to_date,
client_id: payer&.id,
bill_to_name: organization&.name.presence || individual&.full_name,
bill_to_address_lines: address_lines_for(payer),
bill_to_email: organization&.email.presence || individual&.preferred_email,
attention: individual&.full_name,
line_items: [
LineItem.new(
date: submission.created_at.to_date,
description: event&.title,
quantity: quantity,
unit_price_cents: event&.cost_cents.to_i
)
],
entries: payment ? [ entry_for_payment(payment) ] : [],
amount_paid_cents: payment&.amount_cents.to_i,
balance_cents: 0,
balance_settlement: false
)
end

def self.entry_for_payment(payment)
Entry.new(
date: payment.created_at.to_date,
method: AllocationLedgerLabel.payment_method_label(payment),
reference: AllocationLedgerLabel.payment_reference(payment),
amount_cents: payment.amount_cents
)
end
private_class_method :entry_for_payment

def initialize(event:, number:, date:, client_id:, bill_to_name:,
bill_to_address_lines:, bill_to_email:, attention:, line_items:,
entries:, amount_paid_cents:, balance_cents:)
entries:, amount_paid_cents:, balance_cents:, balance_settlement: true)
@event = event
@number = number
@date = date
Expand All @@ -80,6 +135,7 @@ def initialize(event:, number:, date:, client_id:, bill_to_name:,
@entries = entries
@amount_paid_cents = amount_paid_cents
@balance_cents = balance_cents
@balance_settlement = balance_settlement
end

def total_cents
Expand All @@ -90,10 +146,17 @@ def paid_in_full?
balance_cents.to_i.zero?
end

# A registration receipt settles a balance (total charged β†’ $0 due); a bulk
# payment receipt has no such concept and just records the amount paid. The
# view branches on this to drop the total-charged/balance-due framing.
def settles_balance?
@balance_settlement
end

def issuer_name = ISSUER_NAME
def issuer_address_lines = ISSUER_ADDRESS_LINES
def issuer_email = ISSUER_EMAIL
def thank_you_note = THANK_YOU_NOTE
def thank_you_note = settles_balance? ? THANK_YOU_NOTE : PAYMENT_RECEIVED_NOTE

def self.address_lines_for(addressable)
return [] unless addressable.respond_to?(:addresses)
Expand Down
14 changes: 9 additions & 5 deletions app/views/events/_bulk_payment_card.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@
Submission - ID: <%= submission.id %>
<% 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
<%# Slug-based (payer-facing) invoice; hidden for legacy slugless records,
which have no payer-facing pages. %>
<% if submission.slug.present? %>
<%= link_to bulk_payment_invoice_path(submission.slug, 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 %>
<% end %>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<% content_for(:page_bg_class, "public") %>
<% content_for(:page_title, "Receipt β€” #{@event.title}") %>
<%= render "events/invoices/actions",
back_path: bulk_payment_ticket_path(@submission.slug),
back_label: "Back to ticket" %>
<div class="px-4 sm:px-8 py-6 print:p-0">
<%= render "events/receipts/receipt", receipt: @receipt %>
</div>
54 changes: 43 additions & 11 deletions app/views/events/bulk_payment_form_submissions/ticket.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,50 @@
<!-- Divider -->
<div class="border-t border-gray-200"></div>

<!-- Invoice -->
<%= link_to event_invoice_path(@event, submission_id: @submission.id, return_to: "bulk_payment_ticket"),
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 payment</p>
</div>
<!-- Documents: the invoice is always available; the receipt links once a
payment is on file, and is a locked card until then. -->
<div class="space-y-3">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wide text-center">Documents</h3>

<!-- Invoice (always available) -->
<%= link_to bulk_payment_invoice_path(@submission.slug, return_to: "bulk_payment_ticket"),
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 payment</p>
</div>

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

<!-- Receipt: available once a payment is recorded, otherwise locked -->
<% if @payment %>
<%= link_to bulk_payment_receipt_path(@submission.slug),
class: "flex items-center gap-3 rounded-xl border-2 border-green-200 bg-green-50 px-4 py-3 hover:bg-green-100 transition-colors" do %>
<i class="fa-solid fa-receipt text-green-600 text-xl shrink-0"></i>

<div class="flex-1 min-w-0 text-left">
<p class="font-semibold text-green-900">View receipt</p>
<p class="text-sm text-green-700">Receipt for your records</p>
</div>

<i class="fa-solid fa-arrow-right text-green-600 shrink-0"></i>
<% end %>
<% else %>
<div class="flex items-center gap-3 rounded-xl border-2 border-gray-200 bg-gray-100 px-4 py-3 opacity-75">
<i class="fa-solid fa-receipt text-gray-400 text-xl shrink-0"></i>

<div class="flex-1 min-w-0 text-left">
<p class="font-semibold text-gray-500">Receipt</p>
<p class="text-sm text-gray-500">Available once your payment is received</p>
</div>

<i class="fa-solid fa-lock text-gray-400 shrink-0"></i>
</div>
<% end %>
</div>

<!-- Secondary text actions: form responses + resend confirmation -->
<div class="flex flex-col items-center gap-1.5">
Expand Down
Loading