diff --git a/app/controllers/events/bulk_payment_form_submissions_controller.rb b/app/controllers/events/bulk_payment_form_submissions_controller.rb
index 56e052fc46..789f029c19 100644
--- a/app/controllers/events/bulk_payment_form_submissions_controller.rb
+++ b/app/controllers/events/bulk_payment_form_submissions_controller.rb
@@ -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 ]
@@ -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] }
diff --git a/app/controllers/events/invoices_controller.rb b/app/controllers/events/invoices_controller.rb
index 8b94a5245c..304612de72 100644
--- a/app/controllers/events/invoices_controller.rb
+++ b/app/controllers/events/invoices_controller.rb
@@ -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
diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb
index b174a7731a..cbd5df8ec1 100644
--- a/app/models/form_submission.rb
+++ b/app/models/form_submission.rb
@@ -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
diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb
index c0b3c0ec72..f00314d355 100644
--- a/app/policies/form_submission_policy.rb
+++ b/app/policies/form_submission_policy.rb
@@ -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
@@ -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
diff --git a/app/presenters/allocation_ledger_label.rb b/app/presenters/allocation_ledger_label.rb
index 20f4032900..230a821993 100644
--- a/app/presenters/allocation_ledger_label.rb
+++ b/app/presenters/allocation_ledger_label.rb
@@ -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
@@ -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
diff --git a/app/presenters/event_receipt.rb b/app/presenters/event_receipt.rb
index 0ce473e916..d6cd35d7fa 100644
--- a/app/presenters/event_receipt.rb
+++ b/app/presenters/event_receipt.rb
@@ -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
@@ -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
@@ -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
@@ -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)
diff --git a/app/views/events/_bulk_payment_card.html.erb b/app/views/events/_bulk_payment_card.html.erb
index b6e46745fd..8ff1f55822 100644
--- a/app/views/events/_bulk_payment_card.html.erb
+++ b/app/views/events/_bulk_payment_card.html.erb
@@ -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 %>
-
- 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 %>
+
+ Invoice
+ <% end %>
<% end %>
diff --git a/app/views/events/bulk_payment_form_submissions/receipt.html.erb b/app/views/events/bulk_payment_form_submissions/receipt.html.erb
new file mode 100644
index 0000000000..1ab9b45586
--- /dev/null
+++ b/app/views/events/bulk_payment_form_submissions/receipt.html.erb
@@ -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" %>
+
+ <%= render "events/receipts/receipt", receipt: @receipt %>
+
diff --git a/app/views/events/bulk_payment_form_submissions/ticket.html.erb b/app/views/events/bulk_payment_form_submissions/ticket.html.erb
index 1baa9e1e50..42181b0ca9 100644
--- a/app/views/events/bulk_payment_form_submissions/ticket.html.erb
+++ b/app/views/events/bulk_payment_form_submissions/ticket.html.erb
@@ -152,18 +152,50 @@
-
- <%= 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 %>
-
-
-
-
View invoice
-
Itemized invoice for this payment
-
+
+
+
Documents
+
+
+ <%= 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 %>
+
+
+
+
View invoice
+
Itemized invoice for this payment
+
-
- <% end %>
+
+ <% end %>
+
+
+ <% 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 %>
+
+
+
+
View receipt
+
Receipt for your records
+
+
+
+ <% end %>
+ <% else %>
+
+
+
+
+
Receipt
+
Available once your payment is received
+
+
+
+
+ <% end %>
+
diff --git a/app/views/events/receipts/_receipt.html.erb b/app/views/events/receipts/_receipt.html.erb
index 444592cd95..343ef18b43 100644
--- a/app/views/events/receipts/_receipt.html.erb
+++ b/app/views/events/receipts/_receipt.html.erb
@@ -17,7 +17,7 @@
RECEIPT
- Paid in full
+ <%= receipt.settles_balance? ? "Paid in full" : "Payment received" %>
@@ -115,23 +115,33 @@
- <%# Summary: total charged, amount paid, balance due (always $0) %>
+ <%# Summary. A registration receipt reconciles total charged → $0 balance due;
+ a bulk payment has no balance concept, so it just states the amount paid. %>
-
-
- Total charged
- - <%= dollars_from_cents(receipt.total_cents) %>
-
-
-
- Amount paid
- - <%= dollars_from_cents(receipt.amount_paid_cents) %>
-
-
-
- Balance due
- -
- <%= dollars_from_cents(receipt.balance_cents) %>
-
-
+ <% if receipt.settles_balance? %>
+
+
- Total charged
+ - <%= dollars_from_cents(receipt.total_cents) %>
+
+
+
- Amount paid
+ - <%= dollars_from_cents(receipt.amount_paid_cents) %>
+
+
+
- Balance due
+ -
+ <%= dollars_from_cents(receipt.balance_cents) %>
+
+
+ <% else %>
+
+
- Amount paid
+ -
+ <%= dollars_from_cents(receipt.amount_paid_cents) %>
+
+
+ <% end %>
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb
index 2d02be79ed..152adda10e 100644
--- a/app/views/form_submissions/show.html.erb
+++ b/app/views/form_submissions/show.html.erb
@@ -34,9 +34,9 @@
<%= render "form_submissions/submission", submission: @form_submission %>
- <% if event && @form_submission.role == "bulk_payment" %>
+ <% if event && @form_submission.role == "bulk_payment" && @form_submission.slug.present? %>
- <%= link_to event_invoice_path(event, submission_id: @form_submission.id, return_to: "form_submission"),
+ <%= link_to bulk_payment_invoice_path(@form_submission.slug, return_to: "form_submission"),
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 %>
View invoice
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 189c537e1f..d228a4631a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,6 +74,8 @@
end
resources :community_news
get "bulk_payment/:slug", to: "events/bulk_payment_form_submissions#ticket", as: :bulk_payment_ticket
+ get "bulk_payment/:slug/invoice", to: "events/bulk_payment_form_submissions#invoice", as: :bulk_payment_invoice
+ get "bulk_payment/:slug/receipt", to: "events/bulk_payment_form_submissions#receipt", as: :bulk_payment_receipt
post "bulk_payment/:slug/resend_confirmation", to: "events/bulk_payment_form_submissions#resend_confirmation", as: :bulk_payment_resend_confirmation
get "registration/:slug", to: "events/registrations#show", as: :registration_ticket
get "registration/:slug/invoice", to: "events/registrations#invoice", as: :registration_invoice
diff --git a/spec/models/form_submission_spec.rb b/spec/models/form_submission_spec.rb
index 5bf4101b67..06637a9969 100644
--- a/spec/models/form_submission_spec.rb
+++ b/spec/models/form_submission_spec.rb
@@ -81,6 +81,27 @@
end
end
+ describe "#bulk_payment_receipt_available?" do
+ let(:submission) { create(:form_submission, role: "bulk_payment") }
+
+ it "is true once a payment is recorded" do
+ create(:payment, form_submission: submission)
+
+ expect(submission.reload.bulk_payment_receipt_available?).to be(true)
+ end
+
+ it "is false while no payment is on file" do
+ expect(submission.bulk_payment_receipt_available?).to be(false)
+ end
+
+ it "is false for a non-bulk-payment submission even with a payment" do
+ other = create(:form_submission, role: "registration")
+ create(:payment, form_submission: other)
+
+ expect(other.reload.bulk_payment_receipt_available?).to be(false)
+ end
+ end
+
describe "linked registrations" do
let(:event) { create(:event) }
let(:form) { create(:form) }
diff --git a/spec/policies/form_submission_policy_spec.rb b/spec/policies/form_submission_policy_spec.rb
index 161b02f7f8..6a64f854e8 100644
--- a/spec/policies/form_submission_policy_spec.rb
+++ b/spec/policies/form_submission_policy_spec.rb
@@ -96,4 +96,74 @@ def policy_for(user:, record: submission)
it { is_expected.not_to be_allowed_to(:ticket?) }
end
end
+
+ describe "#invoice?" do
+ context "with admin user" do
+ subject { policy_for(user: admin_user) }
+
+ it { is_expected.to be_allowed_to(:invoice?) }
+ end
+
+ context "with regular user and matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: regular_user, slug: "abc123") }
+
+ it { is_expected.to be_allowed_to(:invoice?) }
+ end
+
+ context "with regular user and non-matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: regular_user, slug: "wrong") }
+
+ it { is_expected.not_to be_allowed_to(:invoice?) }
+ end
+
+ context "with no user and matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: nil, slug: "abc123") }
+
+ it { is_expected.to be_allowed_to(:invoice?) }
+ end
+
+ context "with no user" do
+ subject { policy_for(user: nil) }
+
+ it { is_expected.not_to be_allowed_to(:invoice?) }
+ end
+ end
+
+ describe "#receipt?" do
+ context "with admin user" do
+ subject { policy_for(user: admin_user) }
+
+ it { is_expected.to be_allowed_to(:receipt?) }
+ end
+
+ context "with regular user and matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: regular_user, slug: "abc123") }
+
+ it { is_expected.to be_allowed_to(:receipt?) }
+ end
+
+ context "with regular user and non-matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: regular_user, slug: "wrong") }
+
+ it { is_expected.not_to be_allowed_to(:receipt?) }
+ end
+
+ context "with no user and matching slug" do
+ let(:submission) { build_stubbed(:form_submission).tap { |s| s.slug = "abc123" } }
+ subject { described_class.new(submission, user: nil, slug: "abc123") }
+
+ it { is_expected.to be_allowed_to(:receipt?) }
+ end
+
+ context "with no user" do
+ subject { policy_for(user: nil) }
+
+ it { is_expected.not_to be_allowed_to(:receipt?) }
+ end
+ end
end
diff --git a/spec/presenters/event_receipt_spec.rb b/spec/presenters/event_receipt_spec.rb
index 0d9f0dede9..659537469b 100644
--- a/spec/presenters/event_receipt_spec.rb
+++ b/spec/presenters/event_receipt_spec.rb
@@ -91,4 +91,88 @@
end
end
end
+
+ describe ".from_bulk_payment" do
+ let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) }
+ let(:submitter) { create(:person, first_name: "Sam", last_name: "Submitter") }
+ let(:form) { create(:form) }
+ let(:submission) { create(:form_submission, person: submitter, form: form, event: event, role: "bulk_payment") }
+
+ def answer(identifier, value)
+ field = create(:form_field, form: form, answer_type: :free_form_input_one_line, field_identifier: identifier, name: identifier)
+ submission.form_answers.create!(form_field: field, submitted_answer: value, question_name_when_answered: identifier)
+ end
+
+ before do
+ answer("number_of_attendees", "3")
+ # Form-captured payer fields that must NOT drive the receipt: the connected
+ # payment's payer is authoritative (an admin may record it against someone
+ # other than the form submitter).
+ answer("payer_first_name", "Sam")
+ answer("payer_last_name", "Submitter")
+ answer("payer_organization", "Someone Else Inc")
+ end
+
+ context "when an organization paid" do
+ let(:organization) { create(:organization, name: "Northside Shelter", email: "billing@northside.org") }
+ let(:contact) { create(:person, first_name: "Dana", last_name: "Okafor") }
+
+ before do
+ create(:address, addressable: organization, street_address: "12 Main St", city: "Denver", state: "CO", zip_code: "80202")
+ create(:payment, form_submission: submission, organization: organization, person: contact,
+ payer_type: "Organization", type: "CashPayment", amount_cents: 450_000)
+ end
+
+ it "bills the payment's organization payer, not the form submitter" do
+ receipt = described_class.from_bulk_payment(submission.reload)
+
+ expect(receipt.number).to eq("RCPT-B-#{submission.id}")
+ expect(receipt.bill_to_name).to eq("Northside Shelter")
+ expect(receipt.bill_to_email).to eq("billing@northside.org")
+ expect(receipt.attention).to eq("Dana Okafor")
+ expect(receipt.client_id).to eq(organization.id)
+ expect(receipt.bill_to_address_lines).to eq([ "12 Main St", "Denver, CO 80202" ])
+ end
+
+ it "itemizes the event charge per attendee and records the amount paid" do
+ receipt = described_class.from_bulk_payment(submission.reload)
+
+ item = receipt.line_items.first
+ expect(item.description).to eq("AWBW 2-Day Art Facilitator Training")
+ expect(item.quantity).to eq(3)
+ expect(item.unit_price_cents).to eq(150_000)
+ expect(receipt.amount_paid_cents).to eq(450_000)
+ # A bulk payment records what was paid; it has no owed-balance concept.
+ expect(receipt.settles_balance?).to be(false)
+ end
+ end
+
+ context "when a person paid" do
+ let(:payer) { create(:person, first_name: "Alex", last_name: "Payer") }
+
+ before do
+ create(:payment, form_submission: submission, person: payer, organization: nil,
+ payer_type: "Person", type: "CheckPayment", amount_cents: 450_000, check_number: "7788")
+ end
+
+ it "bills the paying person directly" do
+ receipt = described_class.from_bulk_payment(submission.reload)
+
+ expect(receipt.bill_to_name).to eq("Alex Payer")
+ expect(receipt.attention).to eq("Alex Payer")
+ expect(receipt.bill_to_email).to eq(payer.preferred_email)
+ expect(receipt.client_id).to eq(payer.id)
+ end
+
+ it "records the payment as the one ledger entry, labelled by method" do
+ receipt = described_class.from_bulk_payment(submission.reload)
+
+ expect(receipt.entries.size).to eq(1)
+ entry = receipt.entries.first
+ expect(entry.method).to eq("Check")
+ expect(entry.reference).to eq("Check #7788")
+ expect(entry.amount_cents).to eq(450_000)
+ end
+ end
+ end
end
diff --git a/spec/requests/events/bulk_payment_form_submissions_spec.rb b/spec/requests/events/bulk_payment_form_submissions_spec.rb
index abc0d38691..7a66902f84 100644
--- a/spec/requests/events/bulk_payment_form_submissions_spec.rb
+++ b/spec/requests/events/bulk_payment_form_submissions_spec.rb
@@ -308,6 +308,22 @@ def get_ticket
expect(response.body).to include(event_bulk_payment_path(event, slug: submission.slug))
end
+ it "shows a locked receipt card until a payment is recorded" do
+ get_ticket
+
+ expect(response.body).to include("Available once your payment is received")
+ expect(response.body).not_to include(bulk_payment_receipt_path(submission.slug))
+ end
+
+ it "links the receipt once a payment is on file" do
+ create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000)
+
+ get_ticket
+
+ expect(response.body).to include("View receipt")
+ expect(response.body).to include(bulk_payment_receipt_path(submission.slug))
+ end
+
it "returns 404 for an unknown slug" do
get bulk_payment_ticket_path("nope")
@@ -332,6 +348,88 @@ def get_ticket
end
end
+ describe "GET invoice" do
+ let(:event) { create(:event, :publicly_visible, cost_cents: 150_000, title: "Spring Workshop") }
+ let(:payer) { create(:person) }
+ let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") }
+
+ before do
+ # org_field (payer_organization) is defined at the top; only the attendee
+ # count field is new here.
+ count_field = create(:form_field, form: form, field_identifier: "number_of_attendees", name: "Number of attendees")
+ submission.form_answers.create!(form_field: org_field, submitted_answer: "A Greater Hope",
+ question_name_when_answered: "Organization")
+ submission.form_answers.create!(form_field: count_field, submitted_answer: "8",
+ question_name_when_answered: "Number of attendees")
+ sign_out admin
+ end
+
+ it "renders the bulk-payment invoice publicly by slug" do
+ get bulk_payment_invoice_path(submission.slug)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("INVOICE")
+ expect(response.body).to include("A Greater Hope")
+ # 8 attendees × $1,500 = $12,000
+ expect(response.body).to include("$12,000")
+ end
+
+ it "links back to the ticket when opened from there" do
+ get bulk_payment_invoice_path(submission.slug, return_to: "bulk_payment_ticket")
+
+ expect(response.body).to include(bulk_payment_ticket_path(submission.slug))
+ expect(response.body).to include("Back to ticket")
+ end
+
+ it "returns 404 for an unknown slug (no enumerable id path)" do
+ get bulk_payment_invoice_path("nope")
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "GET receipt" do
+ let(:event) { create(:event, :publicly_visible, cost_cents: 1000, title: "Spring Workshop") }
+ let(:payer) { create(:person) }
+ let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") }
+
+ before { sign_out admin }
+
+ it "renders the receipt for the amount paid once a payment is recorded" do
+ create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000)
+
+ get bulk_payment_receipt_path(submission.slug)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("RECEIPT")
+ expect(response.body).to include("Amount paid")
+ expect(response.body).to include("RCPT-B-#{submission.id}")
+ end
+
+ it "omits the balance/paid-in-full framing (a bulk payment has no owed balance)" do
+ create(:payment, form_submission: submission, type: "CashPayment", amount_cents: 1000)
+
+ get bulk_payment_receipt_path(submission.slug)
+
+ expect(response.body).to include("Payment received")
+ expect(response.body).not_to include("Paid in full")
+ expect(response.body).not_to include("Balance due")
+ end
+
+ it "redirects back to the ticket while payment is still pending" do
+ get bulk_payment_receipt_path(submission.slug)
+
+ expect(response).to redirect_to(bulk_payment_ticket_path(submission.slug))
+ expect(flash[:alert]).to eq("A receipt is available once your payment is received.")
+ end
+
+ it "returns 404 for an unknown slug" do
+ get bulk_payment_receipt_path("nope")
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
describe "POST resend_confirmation" do
let(:event) { create(:event, :publicly_visible, cost_cents: 1000) }
let(:payer) { create(:person, email: "payer@example.com") }
diff --git a/spec/requests/events/invoices_spec.rb b/spec/requests/events/invoices_spec.rb
index 01755557ea..b6690806eb 100644
--- a/spec/requests/events/invoices_spec.rb
+++ b/spec/requests/events/invoices_spec.rb
@@ -4,6 +4,8 @@
let(:admin) { create(:user, :admin) }
let(:event) { create(:event, title: "AWBW 2-Day Art Facilitator Training", cost_cents: 150_000) }
+ # Only the admin blank template lives here now; a bulk-payment submission's
+ # invoice is served by Events::BulkPaymentFormSubmissions#invoice (slug-based).
describe "GET /events/:event_id/invoice" do
context "as an admin" do
before { sign_in admin }
@@ -16,34 +18,6 @@
expect(response.body).to include("AWBW 2-Day Art Facilitator Training")
expect(response.body).to include("$1,500")
end
-
- context "with a submission_id" do
- let(:form) { create(:form) }
- let(:payer) { create(:person) }
- let!(:submission) { create(:form_submission, person: payer, form: form, event: event, role: "bulk_payment") }
-
- def add_answer(identifier, value)
- field = create(:form_field, form: form, field_identifier: identifier, name: identifier.humanize)
- create(:form_answer, form_submission: submission, form_field: field, submitted_answer: value)
- end
-
- before do
- add_answer("payer_first_name", "Helena")
- add_answer("payer_last_name", "Lopez")
- add_answer("payer_organization", "A Greater Hope")
- add_answer("number_of_attendees", "8")
- end
-
- it "autofills the invoice from the bulk-payment submission" do
- get event_invoice_path(event, submission_id: submission.id)
-
- expect(response).to have_http_status(:success)
- expect(response.body).to include("A Greater Hope")
- expect(response.body).to include("Helena Lopez")
- # 8 attendees × $1,500 = $12,000
- expect(response.body).to include("$12,000")
- end
- end
end
context "as a non-admin" do
@@ -56,22 +30,7 @@ def add_answer(identifier, value)
end
context "as a guest (no account)" do
- let(:form) { create(:form) }
- let!(:submission) { create(:form_submission, form: form, event: event, role: "bulk_payment") }
-
- it "can view a bulk-payment submission's invoice (matches the public show page)" do
- get event_invoice_path(event, submission_id: submission.id)
- expect(response).to have_http_status(:success)
- expect(response.body).to include("INVOICE")
- end
-
- it "is denied an invoice for a non-bulk submission" do
- other = create(:form_submission, form: form, event: event, role: "registration")
- get event_invoice_path(event, submission_id: other.id)
- expect(response).to redirect_to(root_path)
- end
-
- it "is still denied the blank template" do
+ it "is denied the blank template and redirected" do
get event_invoice_path(event)
expect(response).to redirect_to(root_path)
end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index 023520c78f..e1984a67af 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -216,6 +216,7 @@
"app/views/events/bulk_payment_form_submissions/new.html.erb" => "public",
"app/views/events/bulk_payment_form_submissions/show.html.erb" => "public",
"app/views/events/bulk_payment_form_submissions/ticket.html.erb" => "public",
+ "app/views/events/bulk_payment_form_submissions/receipt.html.erb" => "public",
# ─── event invoice (slug/submission-reachable; blank template gated in controller) ───
"app/views/events/invoices/show.html.erb" => "public",