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" } %> +
+
+ + +
+
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..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",