Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
82 changes: 82 additions & 0 deletions app/controllers/payments_controller.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions app/decorators/payment_decorator.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = `
<input type="hidden" name="payment[event_registration_ids][]" value="${id}">
<span>${label}</span>
<button type="button"
class="ml-1.5 text-gray-400 hover:text-gray-600 font-bold text-xs transition"
data-action="click->payment-registrations#removeItem">✖</button>
`;

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();
}
}
4 changes: 3 additions & 1 deletion app/models/event.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -122,6 +122,8 @@ def to_partial_path
"events/registration_button"
end

remote_searchable_by :title

private

def public_registration_just_enabled?
Expand Down
61 changes: 24 additions & 37 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }

Expand Down Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
@@ -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 ---
Expand All @@ -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"
Expand All @@ -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
4 changes: 4 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def manage?
admin? || owner?
end

def search?
authenticated?
end

alias_rule :preview?, to: :edit?

private
Expand Down
7 changes: 7 additions & 0 deletions app/policies/payment_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class PaymentPolicy < ApplicationPolicy
relation_scope do |relation|
next relation if admin?

relation.none
end
end
Loading