From d5ef72731c9f43472b5a46d20888b87458f9b5e8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 09:57:49 -0500 Subject: [PATCH 1/5] Add admin CRUD for payments with filtering and event registration management Payments track financial transactions (Stripe, check, scholarship, etc.) linked to payers, organizations, and event registrations. The index page supports filtering by payer, organization, event, and status via remote-select search boxes. The manage registrants view now shows payment status badges that link to the filtered payments index for that payer. Co-Authored-By: Claude Opus 4.6 --- app/controllers/events_controller.rb | 2 +- app/controllers/payments_controller.rb | 82 +++++++ app/controllers/search_controller.rb | 10 +- app/decorators/payment_decorator.rb | 30 +++ app/frontend/javascript/controllers/index.js | 3 + .../payment_registrations_controller.js | 49 ++++ app/models/event.rb | 4 +- app/models/event_registration.rb | 58 ++--- app/models/organization.rb | 1 + app/models/payment.rb | 24 +- app/policies/event_policy.rb | 4 + app/policies/payment_policy.rb | 7 + app/views/events/_manage_results.html.erb | 15 +- .../_event_registration_item.html.erb | 11 + app/views/payments/_form.html.erb | 104 +++++++++ app/views/payments/_search_boxes.html.erb | 76 +++++++ app/views/payments/edit.html.erb | 14 ++ app/views/payments/index.html.erb | 92 ++++++++ app/views/payments/new.html.erb | 11 + app/views/payments/show.html.erb | 97 ++++++++ config/routes.rb | 1 + .../20260106225822_add_payments_table.rb | 2 + ...0228000001_add_payment_type_to_payments.rb | 2 +- ...301132706_refactor_payment_associations.rb | 18 ++ ...1141924_add_organization_id_to_payments.rb | 6 + db/schema.rb | 16 +- db/seeds/dummy_dev_seeds.rb | 186 ++++++++++++++++ lib/domain_theme.rb | 1 + spec/factories/payments.rb | 8 +- spec/models/payment_spec.rb | 21 +- spec/requests/events_spec.rb | 45 ++++ spec/requests/payments_spec.rb | 209 ++++++++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 4 + 33 files changed, 1130 insertions(+), 83 deletions(-) create mode 100644 app/controllers/payments_controller.rb create mode 100644 app/decorators/payment_decorator.rb create mode 100644 app/frontend/javascript/controllers/payment_registrations_controller.js create mode 100644 app/policies/payment_policy.rb create mode 100644 app/views/payments/_event_registration_item.html.erb create mode 100644 app/views/payments/_form.html.erb create mode 100644 app/views/payments/_search_boxes.html.erb create mode 100644 app/views/payments/edit.html.erb create mode 100644 app/views/payments/index.html.erb create mode 100644 app/views/payments/new.html.erb create mode 100644 app/views/payments/show.html.erb create mode 100644 db/migrate/20260301132706_refactor_payment_associations.rb create mode 100644 db/migrate/20260301141924_add_organization_id_to_payments.rb create mode 100644 spec/requests/payments_spec.rb 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..2bab11da9 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,17 @@ 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 + return false unless payment&.status == "succeeded" - def scholarship? - payments.scholarships.exists? - end - - 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? + active? && paid? end def attendance_status_label @@ -126,20 +124,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/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" } %> +
+
+ + +
+
Event Registrations
+
+
+ <% payment.event_registrations.includes(:registrant, :event).each do |er| %> + <%= render "event_registration_item", event_registration: er %> + <% end %> +
+ +
+ + +
+
+
+ +
+ <% 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" %> + +
+
+ + + + + + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + + <% end %> + +
PayerEventAmountStatusDateActions
+ <% 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" %> +
+
+
+ + + <% 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..8a14b6702 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/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/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", From f800c59f4b48095e6fea620e6e36ca8a3115ada8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 09:59:25 -0500 Subject: [PATCH 2/5] Fix rubocop spacing inside array brackets in seeds Co-Authored-By: Claude Opus 4.6 --- db/seeds/dummy_dev_seeds.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index 8a14b6702..3fb80a679 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -1252,7 +1252,7 @@ unpaid.each do |er| payment = Payment.create!( payer: payer, - amount_cents: er.event.cost_cents.to_i.nonzero? || [2500, 5000, 7500, 10000, 15000].sample, + amount_cents: er.event.cost_cents.to_i.nonzero? || [ 2500, 5000, 7500, 10000, 15000 ].sample, currency: "usd", status: "succeeded", payment_type: "stripe", From 9ad2b6eaf84298b648171642fb9091f3c9c4e5a5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 10:07:03 -0500 Subject: [PATCH 3/5] Fix ticket partial to use singular payment association The EventRegistration model now has `belongs_to :payment` (singular) instead of `has_many :payments`. Updated the ticket partial to match. Co-Authored-By: Claude Opus 4.6 --- app/views/event_registrations/_ticket.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 %>
From a1689b3140e3d3d6f49133f60d050f92fd7b7fc2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 10:20:21 -0500 Subject: [PATCH 4/5] Update specs to use singular payment association on event registration Tests were written for the old polymorphic has_many :payments model. Updated to use belongs_to :payment (singular) and column-based scholarship_recipient? instead of payment-type-derived scholarship?. Also adds scholarship_tasks_completed? check to joinable? so scholarship recipients must complete tasks before accessing videoconference links. Co-Authored-By: Claude Opus 4.6 --- app/models/event_registration.rb | 5 +- spec/models/event_registration_spec.rb | 116 +++++++------------------ spec/system/events_show_spec.rb | 14 ++- 3 files changed, 39 insertions(+), 96 deletions(-) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 2bab11da9..d9ce98eee 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -107,7 +107,10 @@ def paid_in_full? end def joinable? - active? && paid? + return false unless active? && paid? + return scholarship_tasks_completed? if scholarship_recipient? + + true end def attendance_status_label 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/system/events_show_spec.rb b/spec/system/events_show_spec.rb index 5c574d28d..ce00a12dc 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 From a107efc26aba836afbbe1c2729c1e7a509c9c09e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 13:01:47 -0500 Subject: [PATCH 5/5] Fix flaky Turbo registration test by waiting for link before DB query The test queried EventRegistration.last before the Turbo stream had finished processing, causing nil. Adding a Capybara wait for the link ensures the POST completes before the Ruby-side DB lookup. Co-Authored-By: Claude Opus 4.6 --- spec/system/events_show_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/system/events_show_spec.rb b/spec/system/events_show_spec.rb index ce00a12dc..36f2a5db6 100644 --- a/spec/system/events_show_spec.rb +++ b/spec/system/events_show_spec.rb @@ -420,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