Skip to content
Closed
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: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ gem 'cloudinary'

# for internationalizing
gem 'rails-i18n'
# Windows: timezone data (required on Windows for tzinfo)
gem 'tzinfo-data', platforms: %i[ windows jruby ]

# as authentification framework
gem 'devise'
Expand Down
21 changes: 8 additions & 13 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,7 @@ GEM
faraday (~> 2.0)
fastimage (2.3.0)
feature (1.4.0)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x64-mingw-ucrt)
ffi (1.17.0-x86_64-linux-gnu)
font-awesome-sass (6.5.1)
sassc (~> 2.0)
Expand Down Expand Up @@ -384,9 +383,7 @@ GEM
next_rails (1.3.0)
colorize (>= 0.8.1)
nio4r (2.7.0)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
nokogiri (1.16.6-x64-mingw-ucrt)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
Expand Down Expand Up @@ -644,8 +641,7 @@ GEM
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sqlite3 (1.7.2-arm64-darwin)
sqlite3 (1.7.2-x86_64-darwin)
sqlite3 (1.7.2-x64-mingw-ucrt)
sqlite3 (1.7.2-x86_64-linux)
ssrf_filter (1.1.2)
stripe (5.55.0)
Expand All @@ -672,6 +668,8 @@ GEM
turbolinks-source (5.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.3)
tzinfo (>= 1.0.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unicode-display_width (2.5.0)
Expand Down Expand Up @@ -707,11 +705,7 @@ GEM
zeitwerk (2.6.13)

PLATFORMS
arm64-darwin-20
arm64-darwin-23
arm64-darwin-24
x86_64-darwin-21
x86_64-darwin-23
x64-mingw-ucrt
x86_64-linux

DEPENDENCIES
Expand Down Expand Up @@ -824,6 +818,7 @@ DEPENDENCIES
timecop
transitions
turbolinks
tzinfo-data
uglifier (>= 1.3.0)
unobtrusive_flash (>= 3)
web-console
Expand All @@ -832,7 +827,7 @@ DEPENDENCIES
whenever

RUBY VERSION
ruby 3.3.8p144
ruby 3.3.10p183

BUNDLED WITH
2.5.6
8 changes: 6 additions & 2 deletions app/assets/stylesheets/osem-payments.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.stripe-button-el {
float: right;
.payment-actions {
margin-top: 15px;

.btn-success {
float: right;
}
}
1 change: 1 addition & 0 deletions app/controllers/admin/physical_tickets_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def index
@physical_tickets = @conference.physical_tickets
@tickets_sold_distribution = @conference.tickets_sold_distribution
@tickets_turnover_distribution = @conference.tickets_turnover_distribution
@ticket_sales_by_currency_distribution = @conference.ticket_sales_by_currency_distribution
end
end
end
72 changes: 51 additions & 21 deletions app/controllers/payments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

class PaymentsController < ApplicationController
before_action :authenticate_user!
load_and_authorize_resource
load_and_authorize_resource only: %i[index new]
load_resource :conference, find_by: :short_title
authorize_resource :conference_registrations, class: Registration

Expand All @@ -11,7 +11,6 @@ def index
end

def new
# TODO: use "base currency"
session[:selected_currency] = params[:currency] if params[:currency].present?
selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency
from_currency = @conference.tickets.first.price_currency
Expand All @@ -27,15 +26,51 @@ def new
end

def create
@payment = Payment.new payment_params
session[:selected_currency] = params[:currency] if params[:currency].present?
selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency
from_currency = @conference.tickets.first.price_currency

if @payment.purchase && @payment.save
update_purchased_ticket_purchases
@payment = Payment.new(
user: current_user,
conference: @conference,
currency: selected_currency
)
authorize! :create, @payment

unless @payment.save
redirect_to new_conference_payment_path(@conference.short_title),
error: @payment.errors.full_messages.to_sentence
return
end

session[:has_registration_ticket] = params[:has_registration_ticket]

checkout_session = @payment.create_checkout_session(
success_url: success_conference_payments_url(@conference.short_title) + '?session_id={CHECKOUT_SESSION_ID}',
cancel_url: cancel_conference_payments_url(@conference.short_title)
)

if checkout_session
redirect_to checkout_session.url, allow_other_host: true
else
@payment.destroy
redirect_to new_conference_payment_path(@conference.short_title),
error: @payment.errors.full_messages.to_sentence.presence || 'Could not create checkout session. Please try again.'
end
end

has_registration_ticket = params[:has_registration_ticket]
def success
@payment = Payment.find_by(stripe_session_id: params[:session_id])

if @payment.nil?
redirect_to new_conference_payment_path(@conference.short_title),
error: 'Payment not found. Please try again.'
return
end

if @payment.complete_checkout
update_purchased_ticket_purchases(@payment)

has_registration_ticket = session.delete(:has_registration_ticket)
if has_registration_ticket == 'true'
registration = @conference.register_user(current_user)
if registration
Expand All @@ -50,26 +85,21 @@ def create
notice: 'Thanks! Your ticket is booked successfully.'
end
else
# TODO-SNAPCON: This case is not tested at all
@total_amount_to_pay = CurrencyConversion.convert_currency(@conference, Ticket.total_price(@conference, current_user, paid: false), from_currency, selected_currency)
@unpaid_ticket_purchases = current_user.ticket_purchases.unpaid.by_conference(@conference)
flash.now[:error] = @payment.errors.full_messages.to_sentence + ' Please try again with correct credentials.'
render :new
redirect_to new_conference_payment_path(@conference.short_title),
error: 'Payment could not be completed. Please try again.'
end
end

private

def payment_params
params.permit(:stripe_customer_email, :stripe_customer_token)
.merge(stripe_customer_email: params[:stripeEmail],
stripe_customer_token: params[:stripeToken],
user: current_user, conference: @conference, currency: session[:selected_currency])
def cancel
redirect_to new_conference_payment_path(@conference.short_title),
notice: 'Payment was cancelled. You can try again when ready.'
end

def update_purchased_ticket_purchases
private

def update_purchased_ticket_purchases(payment)
current_user.ticket_purchases.by_conference(@conference).unpaid.each do |ticket_purchase|
ticket_purchase.pay(@payment)
ticket_purchase.pay(payment)
end
end
end
36 changes: 36 additions & 0 deletions app/models/conference.rb
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,42 @@ def tickets_turnover_distribution
result
end

##
# Gross ticket sales per enabled currency (no refunds/fees).
# Returns a hash suitable for donut chart: { "USD" => { value:, color: }, ... }
# Only includes currencies that are enabled for the conference (base + conversions).
def ticket_sales_by_currency_distribution
result = {}
enabled_currencies = enabled_currencies_list
return result if enabled_currencies.blank?

# Gross sales: sum(amount_paid_cents * quantity) per currency, paid only
sums = ticket_purchases.paid.group(:currency).sum('amount_paid_cents * quantity')
enabled_currencies.each do |currency|
total_cents = sums[currency].to_i
next if total_cents.zero?

amount = Money.new(total_cents, currency)
label = "#{currency} (#{ApplicationController.helpers.humanized_money(amount)})"
# Use amount in major units (e.g. 50 for $50) so chart tooltip shows readable numbers, not cents
result[label] = {
'value' => (total_cents / 100.0).round(2),
'color' => "\##{Digest::MD5.hexdigest(currency)[0..5]}"
}
end
result
end

##
# List of currencies enabled for this conference (base + conversion targets).
def enabled_currencies_list
base = tickets.first&.price_currency
return [] if base.blank?

targets = currency_conversions.pluck(:to_currency).uniq
[base] | targets
end

##
# Calculates the overall program minutes
#
Expand Down
88 changes: 74 additions & 14 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# currency :string
# last4 :string
# status :integer default("unpaid"), not null
# stripe_session_id :string
# created_at :datetime not null
# updated_at :datetime not null
# conference_id :integer not null
Expand All @@ -20,8 +21,6 @@ class Payment < ApplicationRecord
belongs_to :user
belongs_to :conference

attr_accessor :stripe_customer_email, :stripe_customer_token

validates :status, presence: true
validates :user_id, presence: true
validates :conference_id, presence: true
Expand All @@ -41,21 +40,82 @@ def stripe_description
"Tickets for #{conference.title} #{user.name} #{user.email}"
end

def purchase
gateway_response = Stripe::Charge.create source: stripe_customer_token,
receipt_email: stripe_customer_email,
description: stripe_description,
amount: amount_to_pay,
currency: currency

self.amount = gateway_response[:amount]
self.last4 = gateway_response[:source][:last4]
self.authorization_code = gateway_response[:id]
self.status = 'success'
true
def unpaid_ticket_purchases
user.ticket_purchases.unpaid.by_conference(conference)
end

def create_checkout_session(success_url:, cancel_url:)
line_items = build_line_items
return nil if line_items.empty?

session = Stripe::Checkout::Session.create(
payment_method_types: ['card'],
mode: 'payment',
customer_email: user.email,
line_items: line_items,
success_url: success_url,
cancel_url: cancel_url,
metadata: {
payment_id: id,
conference_id: conference_id,
user_id: user_id
}
)

update(stripe_session_id: session.id)
session
rescue Stripe::StripeError => e
errors.add(:base, e.message)
self.status = 'failure'
save
nil
end

def complete_checkout
session = Stripe::Checkout::Session.retrieve(
id: stripe_session_id,
expand: ['payment_intent.latest_charge']
)

if session.payment_status == 'paid'
charge = session.payment_intent&.latest_charge

self.amount = session.amount_total
self.last4 = charge&.payment_method_details&.card&.last4
self.authorization_code = session.payment_intent&.id
self.status = 'success'
save
else
self.status = 'failure'
save
false
end
rescue Stripe::StripeError => e
errors.add(:base, e.message)
self.status = 'failure'
save
false
end

private

def build_line_items
unpaid_ticket_purchases.includes(:ticket).map do |tp|
unit_amount = CurrencyConversion.convert_currency(
conference, tp.ticket.price, tp.ticket.price_currency, currency
).fractional

{
price_data: {
currency: currency.downcase,
product_data: {
name: tp.title,
description: tp.description.presence || "#{conference.title} - #{tp.title}"
},
unit_amount: unit_amount
},
quantity: tp.quantity
}
end
end
end
3 changes: 3 additions & 0 deletions app/views/admin/physical_tickets/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
.col-md-4
= render 'donut_chart', title: 'Tickets turnover',
combined_data: @tickets_turnover_distribution
.col-md-4
= render 'donut_chart', title: 'Gross ticket sales by currency',
combined_data: @ticket_sales_by_currency_distribution
%br
- if @physical_tickets.any?
.row
Expand Down
13 changes: 3 additions & 10 deletions app/views/payments/_payment.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,7 @@
= humanized_money_with_symbol ticket.purchase_price
%td
= humanized_money_with_symbol ticket.purchase_price * ticket.quantity
= form_tag conference_payments_path(@conference.short_title, :has_registration_ticket => @has_registration_ticket) do
%script.stripe-button{ src: "https://checkout.stripe.com/checkout.js",
data: { amount: @total_amount_to_pay.cents,
label: "Pay #{humanized_money_with_symbol @total_amount_to_pay}",
email: current_user.email,
currency: @currency,
name: ENV.fetch('OSEM_NAME', 'OSEM'),
description: "#{@conference.title} tickets",
key: ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key,
locale: "auto"}}
= form_tag conference_payments_path(@conference.short_title), method: :post do
= hidden_field_tag :has_registration_ticket, @has_registration_ticket
= submit_tag "Pay #{humanized_money_with_symbol @total_amount_to_pay}", class: 'btn btn-success btn-lg'
= link_to 'Edit Purchase', conference_tickets_path(@conference.short_title), class: 'btn btn-default'
Loading
Loading