diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 72b8851ba..303663f2b 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -39,7 +39,7 @@ def manage
authorize! @event, to: :manage?
@event = @event.decorate
scope = @event.event_registrations
- .includes(:payments, :comments, registrant: [ :user, :contact_methods, { affiliations: :organization }, { avatar_attachment: :blob } ])
+ .includes(:payment, :comments, registrant: [ :user, :contact_methods, { affiliations: :organization }, { avatar_attachment: :blob } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present?
diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb
new file mode 100644
index 000000000..134145489
--- /dev/null
+++ b/app/controllers/payments_controller.rb
@@ -0,0 +1,82 @@
+class PaymentsController < ApplicationController
+ before_action :set_payment, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ authorize!
+ per_page = params[:number_of_items_per_page].presence || 25
+ base_scope = authorized_scope(Payment.all)
+ filtered = base_scope.search_by_params(params)
+ @payments_count = filtered.count
+ @payments = filtered
+ .includes(:payer, :organization, event_registrations: [ :registrant, :event ])
+ .order(created_at: :desc)
+ .paginate(page: params[:page], per_page: per_page)
+ end
+
+ def show
+ authorize! @payment
+ end
+
+ def new
+ @payment = Payment.new
+ authorize! @payment
+ set_form_variables
+ end
+
+ def edit
+ authorize! @payment
+ set_form_variables
+ end
+
+ def create
+ @payment = Payment.new(payment_params)
+ authorize! @payment
+
+ if @payment.save
+ redirect_to @payment, notice: "Payment was successfully created."
+ else
+ set_form_variables
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ authorize! @payment
+
+ if @payment.update(payment_params)
+ redirect_to @payment, notice: "Payment was successfully updated.", status: :see_other
+ else
+ set_form_variables
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ authorize! @payment
+ if @payment.destroy
+ flash[:notice] = "Payment was successfully deleted."
+ else
+ flash[:alert] = @payment.errors.full_messages.to_sentence
+ end
+ redirect_to payments_path
+ end
+
+ def set_form_variables
+ end
+
+ private
+
+ def set_payment
+ @payment = Payment.find(params[:id])
+ end
+
+ def payment_params
+ params.require(:payment).permit(
+ :amount_cents, :currency, :status, :payment_type,
+ :stripe_payment_intent_id, :stripe_charge_id,
+ :payer_id, :event_id, :organization_id,
+ :failure_code, :failure_message,
+ event_registration_ids: []
+ )
+ end
+end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 9d62ab8ad..fc8732ad3 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -29,10 +29,12 @@ def index
def allowed_model(model_param)
{
- "person" => Person,
- "user" => User,
- "workshop" => Workshop,
- "organization" => Organization
+ "person" => Person,
+ "user" => User,
+ "workshop" => Workshop,
+ "organization" => Organization,
+ "event" => Event,
+ "event_registration" => EventRegistration
}[model_param]
end
diff --git a/app/decorators/payment_decorator.rb b/app/decorators/payment_decorator.rb
new file mode 100644
index 000000000..94a23a1e6
--- /dev/null
+++ b/app/decorators/payment_decorator.rb
@@ -0,0 +1,30 @@
+class PaymentDecorator < ApplicationDecorator
+ def title
+ "Payment ##{id}"
+ end
+
+ def detail(length: nil)
+ "#{formatted_amount} - #{status.humanize}"
+ end
+
+ def formatted_amount
+ "$#{'%.2f' % (amount_cents / 100.0)}"
+ end
+
+ def status_badge
+ style = STATUS_BADGE_STYLES[status] || "bg-gray-50 text-gray-500 border-gray-200"
+ h.content_tag(:span, status.humanize,
+ class: "inline-flex items-center rounded-full text-xs font-medium border px-3 py-0.5 #{style}")
+ end
+
+ STATUS_BADGE_STYLES = {
+ "succeeded" => "bg-green-50 text-green-700 border-green-200",
+ "pending" => "bg-yellow-50 text-yellow-700 border-yellow-200",
+ "requires_action" => "bg-amber-50 text-amber-700 border-amber-200",
+ "processing" => "bg-blue-50 text-blue-700 border-blue-200",
+ "failed" => "bg-red-50 text-red-700 border-red-200",
+ "canceled" => "bg-gray-50 text-gray-500 border-gray-200",
+ "refunded" => "bg-purple-50 text-purple-700 border-purple-200",
+ "partially_refunded" => "bg-orange-50 text-orange-700 border-orange-200"
+ }.freeze
+end
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index b1ca21084..8a7de3124 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -48,6 +48,9 @@ application.register("optimistic-bookmark", OptimisticBookmarkController)
import PaginatedFieldsController from "./paginated_fields_controller"
application.register("paginated-fields", PaginatedFieldsController)
+import PaymentRegistrationsController from "./payment_registrations_controller"
+application.register("payment-registrations", PaymentRegistrationsController)
+
import PasswordToggleController from "./password_toggle_controller"
application.register("password-toggle", PasswordToggleController)
diff --git a/app/frontend/javascript/controllers/payment_registrations_controller.js b/app/frontend/javascript/controllers/payment_registrations_controller.js
new file mode 100644
index 000000000..85a401423
--- /dev/null
+++ b/app/frontend/javascript/controllers/payment_registrations_controller.js
@@ -0,0 +1,49 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["list", "select", "item"];
+
+ addItem() {
+ const select = this.selectTarget;
+ const tomSelect = select.tomselect;
+ const id = tomSelect ? tomSelect.getValue() : select.value;
+ const label = tomSelect
+ ? tomSelect.getItem(id)?.textContent
+ : select.options[select.selectedIndex]?.text;
+
+ if (!id) return;
+
+ // Prevent duplicates
+ const existing = this.listTarget.querySelector(
+ `input[value="${id}"]`
+ );
+ if (existing) return;
+
+ const item = document.createElement("div");
+ item.className = `inline-flex items-center gap-2
+ rounded-md border border-gray-300 hover:border-gray-400
+ bg-blue-50 hover:bg-blue-200
+ text-gray-700 px-3 py-1 text-sm font-medium transition`;
+ item.setAttribute("data-payment-registrations-target", "item");
+ item.innerHTML = `
+
+ ${label}
+
+ `;
+
+ this.listTarget.appendChild(item);
+
+ // Clear the select
+ if (tomSelect) {
+ tomSelect.clear();
+ } else {
+ select.value = "";
+ }
+ }
+
+ removeItem(event) {
+ event.currentTarget.closest("[data-payment-registrations-target='item']").remove();
+ }
+}
diff --git a/app/models/event.rb b/app/models/event.rb
index 0fd38e2c5..2a8cc9937 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,5 +1,5 @@
class Event < ApplicationRecord
- include Featureable, Publishable, TagFilterable, Trendable, WindowsTypeFilterable
+ include Featureable, Publishable, RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable
include ActionText::Attachable
has_rich_text :rhino_header
@@ -122,6 +122,8 @@ def to_partial_path
"events/registration_button"
end
+ remote_searchable_by :title
+
private
def public_registration_just_enabled?
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index dc38b6311..d9ce98eee 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -3,9 +3,7 @@ class EventRegistration < ApplicationRecord
belongs_to :event
has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy
has_many :notifications, as: :noticeable, dependent: :destroy
- has_many :payments, as: :payable
-
- before_destroy :create_refund_payments
+ belongs_to :payment, optional: true
accepts_nested_attributes_for :comments, reject_if: proc { |attrs| attrs["body"].blank? }
@@ -52,6 +50,18 @@ class EventRegistration < ApplicationRecord
.distinct
}
+ def self.remote_search(query)
+ return none if query.blank?
+
+ pattern = "%#{query}%"
+ joins(:registrant, :event)
+ .where(
+ "LOWER(CONCAT(people.first_name, ' ', people.last_name)) LIKE :pattern
+ OR LOWER(events.title) LIKE :pattern",
+ pattern: pattern.downcase
+ )
+ end
+
def self.search_by_params(params)
registrations = is_a?(ActiveRecord::Relation) ? self : all
if params[:registrant_id].present?
@@ -75,6 +85,10 @@ def active?
status.in?(ACTIVE_STATUSES)
end
+ def remote_search_label
+ { id: id, label: "#{registrant&.full_name} — #{event&.title}" }
+ end
+
def checked_in?
# checked_in_at.present?
end
@@ -83,33 +97,20 @@ def paid?
paid_in_full?
end
- # Sum of successful payment amounts, using preloaded collection when available
- def successful_payments_total_cents
- if payments.loaded?
- payments.select(&:succeeded?).sum(&:amount_cents)
- else
- payments.successful.sum(:amount_cents)
- end
- end
-
- # True if event is free, scholarship recipient, or total successful payments >= event.cost_cents
+ # True if event is free, scholarship recipient, or associated payment succeeded and covers cost
def paid_in_full?
return true if event.cost_cents.to_i <= 0
return true if scholarship_recipient?
- successful_payments_total_cents >= event.cost_cents.to_i
- end
-
- def scholarship?
- payments.scholarships.exists?
- end
+ return false unless payment&.status == "succeeded"
- def scholarship_tasks_met?
- return true unless scholarship?
- scholarship_tasks_completed?
+ payment.amount_cents >= event.cost_cents.to_i
end
def joinable?
- active? && paid? && scholarship_tasks_met?
+ return false unless active? && paid?
+ return scholarship_tasks_completed? if scholarship_recipient?
+
+ true
end
def attendance_status_label
@@ -126,20 +127,6 @@ def attendance_status_label
private
- def create_refund_payments
- paid_cents = payments.successful.sum(:amount_cents)
- return if paid_cents <= 0
-
- payments.create!(
- amount_cents: -paid_cents,
- payer: registrant,
- event: event,
- payment_type: "refund",
- status: "refunded",
- currency: "usd"
- )
- end
-
def generate_slug
loop do
self.slug = SecureRandom.urlsafe_base64(16)
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 1cbe9a8a4..139adcd54 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -12,6 +12,7 @@ class Organization < ApplicationRecord
has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy
has_many :reports
has_many :workshop_logs
+ has_many :payments, dependent: :nullify
has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable
has_many :sectorable_items, as: :sectorable, dependent: :destroy
diff --git a/app/models/payment.rb b/app/models/payment.rb
index d741fcf81..982141ace 100644
--- a/app/models/payment.rb
+++ b/app/models/payment.rb
@@ -1,13 +1,16 @@
class Payment < ApplicationRecord
# --- Associations ---
- belongs_to :payer, polymorphic: true
- belongs_to :payable, polymorphic: true
+ belongs_to :payer, class_name: "Person"
belongs_to :event, optional: true
+ belongs_to :organization, optional: true
+ has_many :event_registrations, dependent: :nullify
- # --- Callbacks ---
+ # --- Attributes ---
attribute :currency, :string, default: "usd"
attribute :status, :string, default: "pending"
+ normalizes :stripe_payment_intent_id, :stripe_charge_id, with: ->(v) { v.presence }
+
PAYMENT_TYPES = %w[ stripe scholarship check purchase_order other refund ].freeze
# --- Validations ---
@@ -33,11 +36,11 @@ class Payment < ApplicationRecord
validates :status, inclusion: { in: STRIPE_PAYMENT_STATUSES }
- scope :for_payable, ->(payable) { where(payable: payable) }
scope :successful, -> { where(status: "succeeded") }
scope :pendingish, -> { where(status: %w[pending requires_action processing]) }
scope :scholarships, -> { where(payment_type: "scholarship") }
- scope :refunds, -> { where(payment_type: "refund") }
+ scope :refunds, -> { where(payment_type: "refund") }
+ scope :by_status, ->(status) { where(status: status) }
def succeeded?
status == "succeeded"
@@ -46,4 +49,15 @@ def succeeded?
def scholarship?
payment_type == "scholarship"
end
+
+ def self.search_by_params(params)
+ scope = is_a?(ActiveRecord::Relation) ? self : all
+ scope = scope.by_status(params[:status]) if params[:status].present?
+ scope = scope.where(payer_id: params[:payer_id]) if params[:payer_id].present?
+ scope = scope.where(organization_id: params[:organization_id]) if params[:organization_id].present?
+ if params[:event_id].present?
+ scope = scope.joins(:event_registrations).where(event_registrations: { event_id: params[:event_id] }).distinct
+ end
+ scope
+ end
end
diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb
index 21e860c7e..5e1eb4454 100644
--- a/app/policies/event_policy.rb
+++ b/app/policies/event_policy.rb
@@ -27,6 +27,10 @@ def manage?
admin? || owner?
end
+ def search?
+ authenticated?
+ end
+
alias_rule :preview?, to: :edit?
private
diff --git a/app/policies/payment_policy.rb b/app/policies/payment_policy.rb
new file mode 100644
index 000000000..20b34e7cf
--- /dev/null
+++ b/app/policies/payment_policy.rb
@@ -0,0 +1,7 @@
+class PaymentPolicy < ApplicationPolicy
+ relation_scope do |relation|
+ next relation if admin?
+
+ relation.none
+ end
+end
diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index ffc289594..38c48d826 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -100,7 +100,7 @@
<% if event_registration.event.cost_cents.to_i > 0 && !event_registration.paid? %>
- <% paid_cents = event_registration.payments.successful.sum(:amount_cents) %>
+ <% paid_cents = event_registration.payment&.status == "succeeded" ? event_registration.payment.amount_cents : 0 %>
<% due_cents = event_registration.event.cost_cents - paid_cents %>
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb
index 80d2e0cc7..9c32b3ef3 100644
--- a/app/views/events/_manage_results.html.erb
+++ b/app/views/events/_manage_results.html.erb
@@ -69,15 +69,16 @@
<% if @event.cost_cents.to_i > 0 %>
|
- <% if registration.paid_in_full? %>
+ <% if registration.payment.present? %>
+ <%= link_to payments_path(payer_id: registration.registrant_id),
+ class: "underline #{registration.paid_in_full? ? 'text-green-700 hover:text-green-900' : 'text-amber-700 hover:text-amber-900'}",
+ data: { turbo_frame: "_top" } do %>
+ <%= registration.payment.decorate.status_badge %>
+ <% end %>
+ <% elsif registration.scholarship_recipient? %>
—
<% else %>
- Not paid in full
- <% paid_cents = registration.successful_payments_total_cents %>
- <% if paid_cents > 0 %>
- <% due_cents = @event.cost_cents - paid_cents %>
- $<%= "%.2f" % (paid_cents / 100.0) %> paid · $<%= "%.2f" % (due_cents / 100.0) %> due
- <% end %>
+ No payment
<% end %>
|
<% end %>
diff --git a/app/views/payments/_event_registration_item.html.erb b/app/views/payments/_event_registration_item.html.erb
new file mode 100644
index 000000000..05bd5c1c7
--- /dev/null
+++ b/app/views/payments/_event_registration_item.html.erb
@@ -0,0 +1,11 @@
+
+
+ <%= event_registration.registrant&.full_name %> — <%= event_registration.event&.title %>
+
+
diff --git a/app/views/payments/_form.html.erb b/app/views/payments/_form.html.erb
new file mode 100644
index 000000000..9b30c4169
--- /dev/null
+++ b/app/views/payments/_form.html.erb
@@ -0,0 +1,104 @@
+<%= simple_form_for(payment, data: { turbo: false }) do |f| %>
+ <%= render 'shared/errors', resource: payment if payment.errors.any? %>
+
+
+
+
+ <%= f.input :payer_id, as: :select,
+ collection: payment.payer ? [
+ [payment.payer.full_name, payment.payer.id]
+ ] : [],
+ include_blank: true,
+ label: "Payer",
+ input_html: {
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "person"
+ }
+ } %>
+
+
+ <%= f.input :organization_id, as: :select,
+ collection: payment.organization ? [
+ [payment.organization.name, payment.organization.id]
+ ] : [],
+ include_blank: true,
+ label: "Organization",
+ input_html: {
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization"
+ }
+ } %>
+
+
+
+
+
+ <%= f.input :payment_type, collection: Payment::PAYMENT_TYPES,
+ include_blank: false, input_html: { class: "form-control" } %>
+
+
+ <%= f.input :stripe_charge_id,
+ label: "Stripe Charge ID",
+ input_html: { class: "form-control" } %>
+
+
+ <%= f.input :stripe_payment_intent_id,
+ label: "Stripe Payment Intent ID",
+ input_html: { class: "form-control" } %>
+
+
+
+
+
+ <%= f.input :amount_cents, label: "Amount (cents)",
+ input_html: { class: "form-control" } %>
+
+
+ <%= f.input :status, collection: Payment::STRIPE_PAYMENT_STATUSES,
+ include_blank: false, input_html: { class: "form-control" } %>
+
+
+ <%= f.input :currency, input_html: { class: "form-control" } %>
+
+
+
+
+
+
+
+ <% if allowed_to?(:destroy?, f.object) && f.object.persisted? %>
+ <%= link_to "Delete", payment_path(payment), class: "btn btn-danger-outline",
+ data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %>
+ <% end %>
+ <%= link_to "Cancel", payments_path, class: "btn btn-secondary-outline" %>
+ <%= f.button :submit, class: "btn btn-primary" %>
+
+
+<% end %>
diff --git a/app/views/payments/_search_boxes.html.erb b/app/views/payments/_search_boxes.html.erb
new file mode 100644
index 000000000..e2ecb2eca
--- /dev/null
+++ b/app/views/payments/_search_boxes.html.erb
@@ -0,0 +1,76 @@
+<%= form_tag(payments_path, method: :get, class: "space-y-4") do %>
+
+
+ <%= label_tag :payer_id, "Payer", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <% payer = Person.find_by(id: params[:payer_id]) %>
+ <%= select_tag :payer_id,
+ options_for_select(
+ payer ? [ [payer.full_name, payer.id] ] : [],
+ params[:payer_id]
+ ),
+ include_blank: "All payers",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "person"
+ },
+ onchange: "this.form.submit();",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+
+
+ <%= label_tag :organization_id, "Organization", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <% org = Organization.find_by(id: params[:organization_id]) %>
+ <%= select_tag :organization_id,
+ options_for_select(
+ org ? [ [org.name, org.id] ] : [],
+ params[:organization_id]
+ ),
+ include_blank: "All organizations",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "organization"
+ },
+ onchange: "this.form.submit();",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+
+
+ <%= label_tag :event_id, "Event", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <% event = Event.find_by(id: params[:event_id]) %>
+ <%= select_tag :event_id,
+ options_for_select(
+ event ? [ [event.title, event.id] ] : [],
+ params[:event_id]
+ ),
+ include_blank: "All events",
+ data: {
+ controller: "remote-select",
+ remote_select_model_value: "event"
+ },
+ onchange: "this.form.submit();",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+
+
+ <%= label_tag :status, "Status", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= select_tag :status,
+ options_for_select(
+ Payment::STRIPE_PAYMENT_STATUSES.map { |s| [s.humanize, s] },
+ params[:status]
+ ),
+ include_blank: "All statuses",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none",
+ onchange: "this.form.submit();" %>
+
+
+
+
+ <%= link_to "Clear filters", payments_path,
+ class: "btn btn-utility-outline" %>
+
+
+<% end %>
diff --git a/app/views/payments/edit.html.erb b/app/views/payments/edit.html.erb
new file mode 100644
index 000000000..a3fb06aa6
--- /dev/null
+++ b/app/views/payments/edit.html.erb
@@ -0,0 +1,14 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Payments", payments_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View", payment_path(@payment), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
Edit payment
+
+
+ <%= render "form", payment: @payment %>
+ <%= render "shared/audit_info", resource: @payment %>
+
+
diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb
new file mode 100644
index 000000000..5bd3a119e
--- /dev/null
+++ b/app/views/payments/index.html.erb
@@ -0,0 +1,92 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+
+
+
+
+
+ Payments (<%= @payments_count %>)
+
+
+ <% if allowed_to?(:new?, Payment) %>
+ <%= link_to "New payment", new_payment_path,
+ class: "admin-only bg-green-100 btn btn-primary-outline" %>
+ <% end %>
+
+
+
+
+ <%= render "search_boxes" %>
+
+
+
+
+
+
+ | Payer |
+ Event |
+ Amount |
+ Status |
+ Date |
+ Actions |
+
+
+
+
+ <% @payments.each do |payment| %>
+
+ |
+ <% if payment.payer %>
+ <%= person_profile_button(payment.payer, subtitle: payment.organization&.name) %>
+ <% else %>
+ --
+ <% end %>
+ |
+
+ <% if payment.event_registrations.any? %>
+
+ <% payment.event_registrations.each do |er| %>
+
+ <%= link_to er.event.title, event_path(er.event),
+ class: "text-blue-700 hover:text-blue-900 underline" %>
+
+ <% end %>
+
+ <% else %>
+ --
+ <% end %>
+ |
+
+ <%= payment.decorate.formatted_amount %>
+ |
+
+ <%= payment.decorate.status_badge %>
+ |
+
+ <%= payment.created_at.strftime("%B %d, %Y") %>
+ |
+
+
+ <%= link_to "View", payment_path(payment), class: "btn btn-secondary-outline" %>
+ <%= link_to "Edit", edit_payment_path(payment), class: "btn btn-secondary-outline" %>
+
+ |
+
+ <% end %>
+
+
+
+
+
+ <% unless @payments.any? %>
+
No payments found.
+ <% end %>
+
+
+
+
+
+
+
+
diff --git a/app/views/payments/new.html.erb b/app/views/payments/new.html.erb
new file mode 100644
index 000000000..88d27df70
--- /dev/null
+++ b/app/views/payments/new.html.erb
@@ -0,0 +1,11 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+
+
+
New payment
+
+
+
+ <%= render "form", payment: @payment %>
+
+
+
diff --git a/app/views/payments/show.html.erb b/app/views/payments/show.html.erb
new file mode 100644
index 000000000..d051aba84
--- /dev/null
+++ b/app/views/payments/show.html.erb
@@ -0,0 +1,97 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Payments", payments_path, class: "admin-only bg-green-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
+
+
+
Payment Details
+
+ <% if allowed_to?(:edit?, @payment) %>
+ <%= link_to "Edit", edit_payment_path(@payment), class: "btn btn-primary-outline" %>
+ <% end %>
+
+
+
+
+
+
Amount:
+
<%= @payment.decorate.formatted_amount %>
+
+
+
Status:
+
<%= @payment.decorate.status_badge %>
+
+
+
Currency:
+
<%= @payment.currency.upcase %>
+
+ <% if @payment.stripe_payment_intent_id.present? %>
+
+
Stripe Payment Intent:
+
<%= @payment.stripe_payment_intent_id %>
+
+ <% end %>
+
+ <% if @payment.stripe_charge_id.present? %>
+
+
Stripe Charge ID:
+
<%= @payment.stripe_charge_id %>
+
+ <% end %>
+
+ <% if @payment.payer %>
+
+
Payer:
+
+ <%= link_to @payment.payer.full_name,
+ person_path(@payment.payer),
+ class: "text-blue-700 hover:text-blue-900 underline" %>
+
+
+ <% end %>
+
+ <% if @payment.organization %>
+
+
Organization:
+
+ <%= link_to @payment.organization.name,
+ organization_path(@payment.organization),
+ class: "text-blue-700 hover:text-blue-900 underline" %>
+
+
+ <% end %>
+
+ <% if @payment.event_registrations.any? %>
+
+
Event Registrations:
+
+ <% @payment.event_registrations.includes(:registrant, :event).each do |er| %>
+ -
+ <%= link_to er.registrant&.full_name, person_path(er.registrant), class: "text-blue-700 hover:text-blue-900 underline" %>
+ —
+ <%= link_to er.event&.title, event_path(er.event), class: "text-blue-700 hover:text-blue-900 underline" %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% if @payment.failure_code.present? %>
+
+
Failure:
+
<%= @payment.failure_code %>: <%= @payment.failure_message %>
+
+ <% end %>
+
+
+
Created:
+
<%= @payment.created_at.strftime("%B %d, %Y %l:%M %p") %>
+
+
+
Updated:
+
<%= @payment.updated_at.strftime("%B %d, %Y %l:%M %p") %>
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index bbd10c7d9..588092b80 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -101,6 +101,7 @@
end
resources :comments, only: [ :index, :create ]
end
+ resources :payments
resources :events do
member do
get :manage
diff --git a/db/migrate/20260106225822_add_payments_table.rb b/db/migrate/20260106225822_add_payments_table.rb
index 88ae97ece..43c5e19ff 100644
--- a/db/migrate/20260106225822_add_payments_table.rb
+++ b/db/migrate/20260106225822_add_payments_table.rb
@@ -25,5 +25,7 @@ def change
add_index :payments, [ :payer_type, :payer_id ]
add_index :payments, [ :payable_type, :payable_id ]
add_index :payments, [ :payable_type, :payable_id, :status ]
+
+ add_reference :event_registrations, :payment, null: true, foreign_key: true
end
end
diff --git a/db/migrate/20260228000001_add_payment_type_to_payments.rb b/db/migrate/20260228000001_add_payment_type_to_payments.rb
index 329fe042c..ca840b6e6 100644
--- a/db/migrate/20260228000001_add_payment_type_to_payments.rb
+++ b/db/migrate/20260228000001_add_payment_type_to_payments.rb
@@ -1,6 +1,6 @@
class AddPaymentTypeToPayments < ActiveRecord::Migration[8.1]
def change
- add_column :payments, :payment_type, :string, default: "stripe", null: false unless column_exists?(:payments, :payment_type)
+ add_column :payments, :payment_type, :string, default: "other", null: false unless column_exists?(:payments, :payment_type)
change_column_null :payments, :stripe_payment_intent_id, true
end
end
diff --git a/db/migrate/20260301132706_refactor_payment_associations.rb b/db/migrate/20260301132706_refactor_payment_associations.rb
new file mode 100644
index 000000000..089217d30
--- /dev/null
+++ b/db/migrate/20260301132706_refactor_payment_associations.rb
@@ -0,0 +1,18 @@
+class RefactorPaymentAssociations < ActiveRecord::Migration[8.1]
+ def change
+ # Remove polymorphic payable from payments
+ remove_index :payments, name: "index_payments_on_payable_type_and_payable_id_and_status"
+ remove_index :payments, name: "index_payments_on_payable"
+ remove_column :payments, :payable_type, :string, null: false
+ remove_column :payments, :payable_id, :bigint, null: false
+
+ # Payer is now a direct FK to people (instead of polymorphic)
+ remove_index :payments, name: "index_payments_on_payer"
+ remove_column :payments, :payer_type, :string, null: false
+ add_index :payments, :payer_id
+ add_foreign_key :payments, :people, column: :payer_id
+
+ # stripe_payment_intent_id is no longer required
+ change_column_null :payments, :stripe_payment_intent_id, true
+ end
+end
diff --git a/db/migrate/20260301141924_add_organization_id_to_payments.rb b/db/migrate/20260301141924_add_organization_id_to_payments.rb
new file mode 100644
index 000000000..6b9b65279
--- /dev/null
+++ b/db/migrate/20260301141924_add_organization_id_to_payments.rb
@@ -0,0 +1,6 @@
+class AddOrganizationIdToPayments < ActiveRecord::Migration[8.1]
+ def change
+ remove_column :payments, :organization_id, if_exists: true
+ add_reference :payments, :organization, type: :integer, null: true, foreign_key: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 87bc576f9..fb70d01be 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_02_28_230836) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_01_141924) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -400,6 +400,7 @@
create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "event_id"
+ t.bigint "payment_id"
t.bigint "registrant_id", null: false
t.boolean "scholarship_recipient", default: false, null: false
t.boolean "scholarship_tasks_completed", default: false, null: false
@@ -407,6 +408,7 @@
t.string "status", default: "registered", null: false
t.datetime "updated_at", null: false
t.index ["event_id"], name: "index_event_registrations_on_event_id"
+ t.index ["payment_id"], name: "index_event_registrations_on_payment_id"
t.index ["registrant_id", "event_id"], name: "index_event_registrations_on_registrant_id_and_event_id", unique: true
t.index ["registrant_id"], name: "index_event_registrations_on_registrant_id"
t.index ["slug"], name: "index_event_registrations_on_slug", unique: true
@@ -655,10 +657,8 @@
t.bigint "event_id"
t.string "failure_code"
t.string "failure_message"
- t.bigint "payable_id", null: false
- t.string "payable_type", null: false
+ t.integer "organization_id"
t.bigint "payer_id", null: false
- t.string "payer_type", null: false
t.string "payment_type", default: "stripe", null: false
t.string "status", null: false
t.string "stripe_charge_id"
@@ -666,9 +666,8 @@
t.string "stripe_payment_intent_id"
t.datetime "updated_at", null: false
t.index ["event_id"], name: "index_payments_on_event_id"
- t.index ["payable_type", "payable_id", "status"], name: "index_payments_on_payable_type_and_payable_id_and_status"
- t.index ["payable_type", "payable_id"], name: "index_payments_on_payable"
- t.index ["payer_type", "payer_id"], name: "index_payments_on_payer"
+ t.index ["organization_id"], name: "index_payments_on_organization_id"
+ t.index ["payer_id"], name: "index_payments_on_payer_id"
t.index ["stripe_charge_id"], name: "index_payments_on_stripe_charge_id", unique: true
t.index ["stripe_payment_intent_id"], name: "index_payments_on_stripe_payment_intent_id", unique: true
end
@@ -1329,6 +1328,7 @@
add_foreign_key "community_news", "windows_types"
add_foreign_key "contact_methods", "addresses"
add_foreign_key "event_registrations", "events"
+ add_foreign_key "event_registrations", "payments"
add_foreign_key "event_registrations", "people", column: "registrant_id"
add_foreign_key "events", "locations"
add_foreign_key "events", "users", column: "created_by_id"
@@ -1345,6 +1345,8 @@
add_foreign_key "organizations", "organization_statuses"
add_foreign_key "organizations", "windows_types"
add_foreign_key "payments", "events"
+ add_foreign_key "payments", "organizations"
+ add_foreign_key "payments", "people", column: "payer_id"
add_foreign_key "people", "users", column: "created_by_id"
add_foreign_key "people", "users", column: "updated_by_id"
add_foreign_key "person_form_form_fields", "form_fields"
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index 53a3ad7b7..3fb80a679 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -1205,3 +1205,189 @@
# Enable public registration after form is built (avoids triggering the default form builder)
wellness_event.update!(public_registration_enabled: true) unless wellness_event.public_registration_enabled?
+
+puts "Creating Event Registrations and Payments…"
+events = Event.all.to_a
+people = Person.all.to_a
+
+if events.any? && people.size >= 3
+ # Register people for some events — leave some events with no registrations
+ events_with_registrations = events.first(7)
+
+ events_with_registrations.each do |event|
+ registrants = people.sample(rand(2..5))
+ registrants.each do |person|
+ EventRegistration.where(event: event, registrant: person).first_or_create!(
+ status: EventRegistration::ATTENDANCE_STATUSES.sample
+ )
+ end
+ end
+
+ # Create payments — most succeeded, a few other statuses
+ registrations = EventRegistration.includes(:registrant, :event).to_a
+
+ # Group registrations by payer, then create payments — some covering multiple registrations
+ registrations_by_payer = registrations.group_by(&:registrant)
+ registrations_by_payer.each do |payer, payer_regs|
+ unpaid = payer_regs.select { |er| er.payment.blank? }
+ next if unpaid.empty?
+
+ if unpaid.size >= 2 && rand < 0.5
+ # Bundle multiple registrations under one payment
+ batch = unpaid.first(rand(2..3))
+ total = batch.sum { |er| er.event.cost_cents.to_i.nonzero? || 5000 }
+ payment = Payment.create!(
+ payer: payer,
+ amount_cents: total,
+ currency: "usd",
+ status: "succeeded",
+ payment_type: "stripe",
+ stripe_payment_intent_id: "pi_seed_#{SecureRandom.hex(12)}"
+ )
+ batch.each { |er| er.update!(payment: payment) }
+ unpaid -= batch
+ end
+
+ # Remaining registrations get individual payments
+ unpaid.each do |er|
+ payment = Payment.create!(
+ payer: payer,
+ amount_cents: er.event.cost_cents.to_i.nonzero? || [ 2500, 5000, 7500, 10000, 15000 ].sample,
+ currency: "usd",
+ status: "succeeded",
+ payment_type: "stripe",
+ stripe_payment_intent_id: "pi_seed_#{SecureRandom.hex(12)}"
+ )
+ er.update!(payment: payment)
+ end
+ end
+
+ # A scholarship payment
+ Payment.create!(
+ payer: people.sample,
+ amount_cents: 5000,
+ currency: "usd",
+ status: "succeeded",
+ payment_type: "scholarship"
+ )
+
+ # A check payment
+ Payment.create!(
+ payer: people.sample,
+ amount_cents: 7500,
+ currency: "usd",
+ status: "succeeded",
+ payment_type: "check"
+ )
+
+ # A pending payment
+ if registrations.size >= 3
+ er = registrations.find { |r| r.payment.blank? } || registrations.last
+ pending_payment = Payment.create!(
+ payer: er.registrant,
+ amount_cents: 7500,
+ currency: "usd",
+ status: "pending",
+ payment_type: "stripe",
+ stripe_payment_intent_id: "pi_seed_#{SecureRandom.hex(12)}"
+ )
+ er.update!(payment: pending_payment) if er.payment.blank?
+ end
+
+ # A failed payment (standalone)
+ Payment.create!(
+ payer: people.sample,
+ amount_cents: 10000,
+ currency: "usd",
+ status: "failed",
+ payment_type: "stripe",
+ failure_code: "card_declined",
+ failure_message: "Your card was declined.",
+ stripe_payment_intent_id: "pi_seed_#{SecureRandom.hex(12)}"
+ )
+
+ # A refunded payment (standalone)
+ Payment.create!(
+ payer: people.sample,
+ amount_cents: 5000,
+ currency: "usd",
+ status: "refunded",
+ payment_type: "stripe",
+ stripe_payment_intent_id: "pi_seed_#{SecureRandom.hex(12)}"
+ )
+
+ puts " #{EventRegistration.count} event registrations"
+ puts " #{Payment.count} payments"
+ puts " #{Event.where.not(id: EventRegistration.select(:event_id)).count} events with no registrations"
+
+ # Enable public registration on some events to create forms, then add form submissions
+ puts "Creating Event Registration Form Submissions…"
+ events_with_forms = events_with_registrations.first(4)
+ events_with_forms.each do |event|
+ event.update!(public_registration_enabled: true) unless event.public_registration_enabled?
+ end
+
+ form_submission_responses = {
+ "first_name" => ->(p) { p.first_name },
+ "last_name" => ->(p) { p.last_name },
+ "nickname" => ->(p) { [ nil, p.first_name[0..2] ].sample },
+ "pronouns" => ->(_) { %w[she/her he/him they/them].sample },
+ "primary_email" => ->(p) { p.email || Faker::Internet.email },
+ "primary_email_type" => ->(_) { %w[Personal Work].sample },
+ "secondary_email" => ->(_) { [ nil, nil, Faker::Internet.email ].sample },
+ "secondary_email_type" => ->(_) { [ nil, "Personal", "Work" ].sample },
+ "mailing_street" => ->(_) { Faker::Address.street_address },
+ "mailing_city" => ->(_) { Faker::Address.city },
+ "mailing_state" => ->(_) { Faker::Address.state_abbr },
+ "mailing_zip" => ->(_) { Faker::Address.zip_code },
+ "phone" => ->(_) { Faker::PhoneNumber.phone_number },
+ "phone_type" => ->(_) { %w[Mobile Home Work].sample },
+ "agency_name" => ->(_) { [ nil, Faker::Company.name ].sample },
+ "agency_position" => ->(_) { [ nil, "Program Director", "Case Manager", "Counselor" ].sample },
+ "agency_street" => ->(_) { [ nil, Faker::Address.street_address ].sample },
+ "agency_city" => ->(_) { [ nil, Faker::Address.city ].sample },
+ "agency_state" => ->(_) { [ nil, Faker::Address.state_abbr ].sample },
+ "agency_zip" => ->(_) { [ nil, Faker::Address.zip_code ].sample },
+ "agency_type" => ->(_) { [ nil, "Domestic Violence", "Homeless Shelter", "School", "Community Center" ].sample },
+ "agency_website" => ->(_) { [ nil, Faker::Internet.url ].sample },
+ "racial_ethnic_identity" => ->(_) { [ nil, "Latino/a", "Black/African American", "White", "Asian", "Multiracial" ].sample },
+ "referral_source" => ->(_) { [ "AWBW website", "Colleague recommendation", "Social media", "Conference" ].sample },
+ "training_motivation" => ->(_) { [ "Want to bring art workshops to my shelter", "Looking for new facilitation techniques", "Passionate about creative healing" ].sample },
+ "number_of_attendees" => ->(_) { rand(1..3).to_s },
+ "payment_method" => ->(_) { [ "Credit Card", "Check", "Purchase Order" ].sample }
+ }
+
+ submission_count = 0
+ events_with_forms.each do |event|
+ form = event.forms.find_by(name: EventRegistrationFormBuilder::FORM_NAME)
+ next unless form
+
+ # Add form submissions for roughly half the event's registrations
+ event_regs = event.event_registrations.includes(:registrant).to_a
+ regs_with_submissions = event_regs.sample([ (event_regs.size / 2.0).ceil, 1 ].max)
+
+ regs_with_submissions.each do |er|
+ person = er.registrant
+ next unless person
+ next if PersonForm.exists?(person: person, form: form)
+
+ person_form = PersonForm.create!(person: person, form: form)
+
+ form.form_fields.where.not(answer_type: :group_header).find_each do |field|
+ response_fn = form_submission_responses[field.field_key]
+ text = response_fn ? response_fn.call(person) : Faker::Lorem.sentence
+ next if text.nil?
+
+ PersonFormFormField.create!(
+ person_form: person_form,
+ form_field: field,
+ text: text.to_s
+ )
+ end
+
+ submission_count += 1
+ end
+ end
+
+ puts " #{submission_count} event registration form submissions"
+end
diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb
index d188127fb..1e8afe8df 100644
--- a/lib/domain_theme.rb
+++ b/lib/domain_theme.rb
@@ -25,6 +25,7 @@ module DomainTheme
workshop_variation_ideas: :purple,
story_ideas: :rose,
event_registrations: :blue,
+ payments: :green,
banners: :yellow,
users: :fuchsia,
diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb
index 4f5dd63ec..df59ccd40 100644
--- a/spec/factories/payments.rb
+++ b/spec/factories/payments.rb
@@ -1,8 +1,6 @@
FactoryBot.define do
factory :payment do
- # Polymorphic associations
- association :payer, factory: :user
- association :payable, factory: :event
+ association :payer, factory: :person
# Required attributes
amount_cents { 1000 } # $10.00
@@ -50,6 +48,10 @@
stripe_charge_id { "ch_#{SecureRandom.hex(24)}" }
end
+ trait :with_organization do
+ association :organization
+ end
+
trait :scholarship do
payment_type { "scholarship" }
stripe_payment_intent_id { nil }
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index 159d644f1..0a1612524 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -48,41 +48,15 @@
end
end
- describe "#scholarship?" do
- it "returns true when registration has a scholarship payment" do
- reg = create(:event_registration)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: reg.registrant.user, amount_cents: 1099)
- expect(reg).to be_scholarship
+ describe "#scholarship_recipient?" do
+ it "returns true when scholarship_recipient is true" do
+ reg = create(:event_registration, :scholarship)
+ expect(reg).to be_scholarship_recipient
end
- it "returns false when registration has only stripe payments" do
+ it "returns false when scholarship_recipient is false" do
reg = create(:event_registration)
- create(:payment, :succeeded, payable: reg, payer: reg.registrant.user, amount_cents: 1099)
- expect(reg).not_to be_scholarship
- end
-
- it "returns false when registration has no payments" do
- reg = create(:event_registration)
- expect(reg).not_to be_scholarship
- end
- end
-
- describe "#scholarship_tasks_met?" do
- it "returns true when no scholarship payment exists" do
- reg = create(:event_registration)
- expect(reg.scholarship_tasks_met?).to be true
- end
-
- it "returns false when scholarship exists but tasks not completed" do
- reg = create(:event_registration, scholarship_tasks_completed: false)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: reg.registrant.user, amount_cents: 1099)
- expect(reg.scholarship_tasks_met?).to be false
- end
-
- it "returns true when scholarship exists and tasks completed" do
- reg = create(:event_registration, scholarship_tasks_completed: true)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: reg.registrant.user, amount_cents: 1099)
- expect(reg.scholarship_tasks_met?).to be true
+ expect(reg).not_to be_scholarship_recipient
end
end
@@ -90,15 +64,15 @@
let(:event) { create(:event, cost_cents: 1099) }
let(:user) { create(:user, :with_person) }
- it "returns true for active, paid, non-scholarship registration" do
- reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 1099)
+ it "returns true for active, paid registration" do
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: 1099)
+ reg = create(:event_registration, event: event, registrant: user.person, payment: payment)
expect(reg).to be_joinable
end
it "returns false when not active" do
- reg = create(:event_registration, event: event, registrant: user.person, status: "cancelled")
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 1099)
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: 1099)
+ reg = create(:event_registration, event: event, registrant: user.person, status: "cancelled", payment: payment)
expect(reg).not_to be_joinable
end
@@ -107,16 +81,14 @@
expect(reg).not_to be_joinable
end
- it "returns false for scholarship with tasks not completed" do
- reg = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: false)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: user, amount_cents: 1099)
- expect(reg).not_to be_joinable
+ it "returns true for scholarship recipient with tasks completed" do
+ reg = create(:event_registration, :scholarship, event: event, registrant: user.person)
+ expect(reg).to be_joinable
end
- it "returns true for scholarship with tasks completed" do
- reg = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: true)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: user, amount_cents: 1099)
- expect(reg).to be_joinable
+ it "returns false for scholarship recipient with tasks not completed" do
+ reg = create(:event_registration, event: event, registrant: user.person, scholarship_recipient: true, scholarship_tasks_completed: false)
+ expect(reg).not_to be_joinable
end
it "returns true for free event with active registration" do
@@ -124,20 +96,6 @@
reg = create(:event_registration, event: free_event, registrant: user.person)
expect(reg).to be_joinable
end
-
- it "returns true for partial scholarship + partial payment covering full cost" do
- reg = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: true)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: user, amount_cents: 500)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 599)
- expect(reg).to be_joinable
- end
-
- it "returns false for partial scholarship + partial payment not covering full cost" do
- reg = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: true)
- create(:payment, :scholarship, :succeeded, payable: reg, payer: user, amount_cents: 500)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 100)
- expect(reg).not_to be_joinable
- end
end
describe "#paid_in_full?" do
@@ -155,43 +113,27 @@
expect(reg).to be_paid_in_full
end
- it "returns true when payments cover cost" do
- reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 1000)
+ it "returns true when payment covers cost" do
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: 1000)
+ reg = create(:event_registration, event: event, registrant: user.person, payment: payment)
expect(reg).to be_paid_in_full
end
- it "returns false when payments are insufficient" do
- reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 500)
+ it "returns false when payment is insufficient" do
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: 500)
+ reg = create(:event_registration, event: event, registrant: user.person, payment: payment)
expect(reg).not_to be_paid_in_full
end
- it "returns correct result when payments are preloaded" do
- reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 1000)
-
- preloaded = EventRegistration.includes(:payments).find(reg.id)
- expect(preloaded.payments).to be_loaded
- expect(preloaded).to be_paid_in_full
- end
-
- it "returns correct result when preloaded payments are insufficient" do
- reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: reg, payer: user, amount_cents: 500)
-
- preloaded = EventRegistration.includes(:payments).find(reg.id)
- expect(preloaded.payments).to be_loaded
- expect(preloaded).not_to be_paid_in_full
+ it "returns false when payment is not succeeded" do
+ payment = create(:payment, :pending, payer: user.person, amount_cents: 1000)
+ reg = create(:event_registration, event: event, registrant: user.person, payment: payment)
+ expect(reg).not_to be_paid_in_full
end
- it "ignores non-succeeded payments when preloaded" do
+ it "returns false when no payment exists" do
reg = create(:event_registration, event: event, registrant: user.person)
- create(:payment, payable: reg, payer: user, amount_cents: 1000, status: "pending")
-
- preloaded = EventRegistration.includes(:payments).find(reg.id)
- expect(preloaded.payments).to be_loaded
- expect(preloaded).not_to be_paid_in_full
+ expect(reg).not_to be_paid_in_full
end
end
diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb
index 7b5bd170b..e7679b09c 100644
--- a/spec/models/payment_spec.rb
+++ b/spec/models/payment_spec.rb
@@ -4,9 +4,10 @@
describe "associations" do
subject { create(:payment) }
- it { should belong_to(:payer).required }
- it { should belong_to(:payable).required }
+ it { should belong_to(:payer).class_name("Person").required }
it { should belong_to(:event).optional }
+ it { should belong_to(:organization).optional }
+ it { should have_many(:event_registrations).dependent(:nullify) }
end
describe "validations" do
@@ -36,7 +37,7 @@
describe "with create subject for uniqueness" do
subject { create(:payment) }
- it { should validate_uniqueness_of(:stripe_payment_intent_id).case_insensitive }
+ it { should validate_uniqueness_of(:stripe_payment_intent_id).case_insensitive.allow_nil }
it { should validate_uniqueness_of(:stripe_charge_id).case_insensitive.allow_nil }
end
@@ -73,20 +74,6 @@
end
describe "scopes" do
- let(:user) { create(:user) }
- let(:event1) { create(:event) }
- let(:event2) { create(:event) }
-
- describe ".for_payable" do
- let!(:payment1) { create(:payment, payable: event1) }
- let!(:payment2) { create(:payment, payable: event2) }
-
- it "returns payments for the specified payable" do
- expect(Payment.for_payable(event1)).to include(payment1)
- expect(Payment.for_payable(event1)).not_to include(payment2)
- end
- end
-
describe ".successful" do
let!(:succeeded_payment) { create(:payment, status: "succeeded") }
let!(:pending_payment) { create(:payment, status: "pending") }
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb
index cd30ac332..a8f4bf120 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -219,6 +219,51 @@
end
end
+ describe "GET /manage" do
+ context "as admin" do
+ before { sign_in admin }
+
+ it "renders manage page" do
+ get manage_event_path(event)
+ expect(response).to have_http_status(:ok)
+ end
+
+ context "with a paid event" do
+ let(:paid_event) { create(:event, cost_cents: 5000) }
+ let(:registrant) { create(:person) }
+ let!(:payment) { create(:payment, payer: registrant, status: "succeeded") }
+ let!(:registration) do
+ create(:event_registration, event: paid_event, registrant: registrant, payment: payment)
+ end
+
+ it "shows payment status linking to filtered payments" do
+ get manage_event_path(paid_event)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Payment status")
+ expect(response.body).to include(payments_path(payer_id: registrant.id))
+ expect(response.body).to include("Succeeded")
+ end
+
+ it "shows 'No payment' for registrations without payment" do
+ unpaid_registration = create(:event_registration, event: paid_event)
+ get manage_event_path(paid_event)
+ expect(response.body).to include("No payment")
+ end
+ end
+
+ context "with a free event" do
+ let(:free_event) { create(:event, cost_cents: 0) }
+ let!(:registration) { create(:event_registration, event: free_event) }
+
+ it "does not show payment status column" do
+ get manage_event_path(free_event)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).not_to include("Payment status")
+ end
+ end
+ end
+ end
+
describe "DELETE /destroy" do
context "as admin" do
before { sign_in admin }
diff --git a/spec/requests/payments_spec.rb b/spec/requests/payments_spec.rb
new file mode 100644
index 000000000..558f3f4ad
--- /dev/null
+++ b/spec/requests/payments_spec.rb
@@ -0,0 +1,209 @@
+require "rails_helper"
+
+RSpec.describe "Payments", type: :request do
+ let(:admin) { create(:user, :with_person, super_user: true) }
+ let(:regular_user) { create(:user, :with_person) }
+
+ let(:event) { create(:event, title: "Test Event") }
+ let(:event_registration) { create(:event_registration, event: event, registrant: admin.person) }
+ let!(:payment) do
+ create(:payment, payer: admin.person).tap do |p|
+ event_registration.update!(payment: p)
+ end
+ end
+
+ # ============================================================
+ # ADMIN
+ # ============================================================
+ context "as an admin" do
+ before { sign_in admin }
+
+ describe "GET /payments" do
+ it "can access index" do
+ get payments_path
+ expect(response).to have_http_status(:success)
+ end
+
+ it "paginates results" do
+ create_list(:payment, 3)
+ get payments_path, params: { number_of_items_per_page: 1 }
+ expect(response).to have_http_status(:success)
+ end
+
+ it "filters by status" do
+ get payments_path, params: { status: "succeeded" }
+ expect(response).to have_http_status(:success)
+ end
+
+ it "filters by payer_id" do
+ other_person = create(:person)
+ other_payment = create(:payment, payer: other_person, amount_cents: 9999)
+ get payments_path, params: { payer_id: other_person.id }
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include(other_payment.decorate.formatted_amount)
+ expect(response.body).not_to include(payment.decorate.formatted_amount)
+ end
+
+ it "filters by organization_id" do
+ org = create(:organization)
+ org_payment = create(:payment, organization: org, amount_cents: 7777)
+ get payments_path, params: { organization_id: org.id }
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include(org_payment.decorate.formatted_amount)
+ expect(response.body).not_to include(payment.decorate.formatted_amount)
+ end
+
+ it "filters by event_id" do
+ get payments_path, params: { event_id: event.id }
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include(payment.decorate.formatted_amount)
+ end
+ end
+
+ describe "GET /payments/:id" do
+ it "can view payment" do
+ get payment_path(payment)
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe "GET /payments/new" do
+ it "can access new form" do
+ get new_payment_path
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe "GET /payments/:id/edit" do
+ it "can access edit form" do
+ get edit_payment_path(payment)
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe "POST /payments" do
+ it "can create a payment" do
+ expect {
+ post payments_path, params: {
+ payment: {
+ amount_cents: 2000,
+ currency: "usd",
+ status: "succeeded",
+ payment_type: "stripe",
+ stripe_payment_intent_id: "pi_test_#{SecureRandom.hex(8)}",
+ payer_id: admin.person.id
+ }
+ }
+ }.to change(Payment, :count).by(1)
+ end
+ end
+
+ describe "PATCH /payments/:id" do
+ it "can update a payment" do
+ patch payment_path(payment), params: { payment: { status: "refunded" } }
+ expect(payment.reload.status).to eq("refunded")
+ end
+ end
+
+ describe "DELETE /payments/:id" do
+ it "can delete a payment" do
+ expect {
+ delete payment_path(payment)
+ }.to change(Payment, :count).by(-1)
+ end
+ end
+ end
+
+ # ============================================================
+ # REGULAR USER
+ # ============================================================
+ context "as a regular user" do
+ before { sign_in regular_user }
+
+ describe "GET /payments" do
+ it "redirects to root" do
+ get payments_path
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "GET /payments/:id" do
+ it "redirects to root" do
+ get payment_path(payment)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "POST /payments" do
+ it "does not create a payment" do
+ expect {
+ post payments_path, params: {
+ payment: {
+ amount_cents: 2000,
+ payer_id: regular_user.person.id
+ }
+ }
+ }.not_to change(Payment, :count)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "PATCH /payments/:id" do
+ it "redirects to root" do
+ patch payment_path(payment), params: { payment: { status: "refunded" } }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "DELETE /payments/:id" do
+ it "does not delete the payment" do
+ expect {
+ delete payment_path(payment)
+ }.not_to change(Payment, :count)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ # ============================================================
+ # GUEST
+ # ============================================================
+ context "as a guest" do
+ describe "GET /payments" do
+ it "redirects to root" do
+ get payments_path
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "POST /payments" do
+ it "does not create a payment" do
+ expect {
+ post payments_path, params: {
+ payment: {
+ amount_cents: 2000,
+ payer_id: admin.person.id
+ }
+ }
+ }.not_to change(Payment, :count)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "PATCH /payments/:id" do
+ it "redirects to root" do
+ patch payment_path(payment), params: { payment: { status: "refunded" } }
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "DELETE /payments/:id" do
+ it "does not delete the payment" do
+ expect {
+ delete payment_path(payment)
+ }.not_to change(Payment, :count)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+end
diff --git a/spec/system/events_show_spec.rb b/spec/system/events_show_spec.rb
index 5c574d28d..36f2a5db6 100644
--- a/spec/system/events_show_spec.rb
+++ b/spec/system/events_show_spec.rb
@@ -332,8 +332,8 @@
context "user registered and paid for a paid event" do
before do
- registration = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: registration, payer: user, amount_cents: event.cost_cents)
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: event.cost_cents)
+ create(:event_registration, event: event, registrant: user.person, payment: payment)
end
it "shows linked 'Join on' domain" do
@@ -359,8 +359,7 @@
context "user has full scholarship with tasks completed" do
before do
- registration = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: true)
- create(:payment, :scholarship, :succeeded, payable: registration, payer: user, amount_cents: event.cost_cents)
+ create(:event_registration, :scholarship, event: event, registrant: user.person)
end
it "shows linked 'Join on' domain" do
@@ -373,8 +372,7 @@
context "user has scholarship but tasks not completed" do
before do
- registration = create(:event_registration, event: event, registrant: user.person, scholarship_tasks_completed: false)
- create(:payment, :scholarship, :succeeded, payable: registration, payer: user, amount_cents: event.cost_cents)
+ create(:event_registration, event: event, registrant: user.person, scholarship_recipient: true, scholarship_tasks_completed: false)
end
it "does not show the join link" do
@@ -388,8 +386,8 @@
context "autoshow_videoconference_link is false" do
before do
event.update!(autoshow_videoconference_link: false)
- registration = create(:event_registration, event: event, registrant: user.person)
- create(:payment, :succeeded, payable: registration, payer: user, amount_cents: event.cost_cents)
+ payment = create(:payment, :succeeded, payer: user.person, amount_cents: event.cost_cents)
+ create(:event_registration, event: event, registrant: user.person, payment: payment)
end
it "hides the join link even when joinable" do
@@ -422,8 +420,10 @@
expect(page).to have_current_path(event_path(event))
expect(page).not_to have_button("Register")
- # "View your registration" is a clickable link to the registration show page
+ # Wait for the Turbo stream to render the link before querying the DB
+ expect(page).to have_link("View your registration")
registration = EventRegistration.last
+ expect(registration).to be_present
expect(page).to have_link("View your registration", href: registration_ticket_path(registration.slug))
end
end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index da368f997..885b3d1d3 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -102,6 +102,7 @@
"app/views/category_types/index.html.erb" => "admin-only bg-blue-100",
"app/views/events/manage.html.erb" => "admin-only bg-blue-100",
"app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100",
+ "app/views/payments/index.html.erb" => "admin-only bg-blue-100",
"app/views/notifications/index.html.erb" => "admin-only bg-blue-100",
"app/views/organization_statuses/index.html.erb" => "admin-only bg-blue-100",
"app/views/quotes/index.html.erb" => "admin-only bg-blue-100",
@@ -117,6 +118,7 @@
"app/views/category_types/show.html.erb" => "admin-only bg-blue-100",
"app/views/organization_statuses/show.html.erb" => "admin-only bg-blue-100",
"app/views/sectors/show.html.erb" => "admin-only bg-blue-100",
+ "app/views/payments/show.html.erb" => "admin-only bg-blue-100",
"app/views/users/show.html.erb" => "admin-only bg-blue-100",
"app/views/windows_types/show.html.erb" => "admin-only bg-blue-100",
"app/views/bookmarks/show.html.erb" => "admin-only bg-blue-100",
@@ -129,6 +131,7 @@
"app/views/events/new.html.erb" => "admin-only bg-blue-100",
"app/views/faqs/new.html.erb" => "admin-only bg-blue-100",
"app/views/organizations/new.html.erb" => "admin-only bg-blue-100",
+ "app/views/payments/new.html.erb" => "admin-only bg-blue-100",
"app/views/organization_statuses/new.html.erb" => "admin-only bg-blue-100",
"app/views/people/new.html.erb" => "admin-only bg-blue-100",
"app/views/quotes/new.html.erb" => "admin-only bg-blue-100",
@@ -149,6 +152,7 @@
"app/views/events/edit.html.erb" => "admin-only bg-blue-100",
"app/views/faqs/edit.html.erb" => "admin-only bg-blue-100",
"app/views/organization_statuses/edit.html.erb" => "admin-only bg-blue-100",
+ "app/views/payments/edit.html.erb" => "admin-only bg-blue-100",
"app/views/quotes/edit.html.erb" => "admin-only bg-blue-100",
"app/views/resources/edit.html.erb" => "admin-only bg-blue-100",
"app/views/sectors/edit.html.erb" => "admin-only bg-blue-100",