diff --git a/Gemfile b/Gemfile index 48f67f96f..9f888c822 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index e8e00fba4..834cb45fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -824,6 +818,7 @@ DEPENDENCIES timecop transitions turbolinks + tzinfo-data uglifier (>= 1.3.0) unobtrusive_flash (>= 3) web-console @@ -832,7 +827,7 @@ DEPENDENCIES whenever RUBY VERSION - ruby 3.3.8p144 + ruby 3.3.10p183 BUNDLED WITH 2.5.6 diff --git a/README.md b/README.md index 035fd8cdc..11fe57e41 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [Snap!Con](https://snapcon.org) Base Repository +## [Snap!Con](https://snapcon.org) Production Repository [![Specs](https://github.com/snap-cloud/snapcon/actions/workflows/spec.yml/badge.svg)](https://github.com/snap-cloud/snapcon/actions/workflows/spec.yml) [![Maintainability](https://qlty.sh/gh/snap-cloud/projects/snapcon/maintainability.svg)](https://qlty.sh/gh/snap-cloud/projects/snapcon) [![Code Coverage](https://qlty.sh/gh/snap-cloud/projects/snapcon/coverage.svg)](https://qlty.sh/gh/snap-cloud/projects/snapcon) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 31a1fdae0..e39b04261 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -24,5 +24,5 @@ *= require selectize.bootstrap3 *= require conferences - *= require fullcalendar-scheduler/main.css + *= require fullcalendar-scheduler/main.css */ diff --git a/app/controllers/admin/physical_tickets_controller.rb b/app/controllers/admin/physical_tickets_controller.rb index 6c0414c65..e98b8a4a7 100644 --- a/app/controllers/admin/physical_tickets_controller.rb +++ b/app/controllers/admin/physical_tickets_controller.rb @@ -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 diff --git a/app/models/conference.rb b/app/models/conference.rb index 20e1ce441..585420049 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -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 # diff --git a/app/views/admin/physical_tickets/index.html.haml b/app/views/admin/physical_tickets/index.html.haml index 96722dc98..96e2957f2 100644 --- a/app/views/admin/physical_tickets/index.html.haml +++ b/app/views/admin/physical_tickets/index.html.haml @@ -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 diff --git a/config/puma.rb b/config/puma.rb index c401d6fc2..69424aac8 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -24,15 +24,16 @@ # Workers do not work on JRuby or Windows (both of which do not support # processes). # -workers ENV.fetch('WEB_CONCURRENCY') { 2 } +worker_count = ENV.fetch('WEB_CONCURRENCY') { Gem.win_platform? ? 0 : 2 }.to_i +workers worker_count # Set a 10 minute timeout in development for debugging. -worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' +worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' && worker_count > 0 # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write # process behavior so workers use less memory. -preload_app! +preload_app! if worker_count > 0 lowlevel_error_handler do |ex, env| Sentry.capture_exception( diff --git a/info.yml b/info.yml new file mode 100644 index 000000000..494e6be19 --- /dev/null +++ b/info.yml @@ -0,0 +1,27 @@ +project: + name: 'snapcon' # Your project name, e.g. Cue-to-cue + owner: 'CS169L-26' # Do not change + teamId: '04' # Your team number, e.g. 02 + identities: + heroku: 'https://sp26-04-snapcon.herokuapp.com' # Your Heroku app URL + members: + member1: # Add all project members + name: 'Ethan' # Member 1 name + surname: 'Stone' # Member 1 last name + githubUsername: 'Ethan-Stone1' # Member 1 GitHub username + herokuEmail: 'ethanstone@berkeley.edu' # Member 1 Heroku username + member2: + name: 'Benjamin' + surname: 'Sikes' + githubUsername: 'sikesbc' + herokuEmail: 'bcsikes@berkeley.edu' + member3: + name: 'Yijun' + surname: 'Zhou' + githubUsername: 'zhouyijun111' + herokuEmail: 'zhouyijun@berkeley.edu' + member4: + name: 'Xinwei' + surname: 'Li' + githubUsername: 'li-xinwei' + herokuEmail: 'xinweili@berkeley.edu' \ No newline at end of file diff --git a/lib/tasks/demo_ticket_sales.rake b/lib/tasks/demo_ticket_sales.rake new file mode 100644 index 000000000..eb01a145f --- /dev/null +++ b/lib/tasks/demo_ticket_sales.rake @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +namespace :data do + desc 'Add demo paid ticket sales (USD + EUR) for testing "Gross ticket sales by currency" chart. Usage: CONF=123123 rake data:demo_ticket_sales' + task demo_ticket_sales: :environment do + short_title = ENV['CONF'] || Conference.first&.short_title + conference = Conference.find_by(short_title: short_title) + unless conference + puts "Conference not found. Set CONF=short_title (e.g. CONF=123123) or create a conference first." + next + end + + base_currency = conference.tickets.first&.price_currency || 'USD' + # Ensure we have a non-free ticket for paid sales + paid_ticket = conference.tickets.find_by(registration_ticket: false).presence || conference.tickets.first + unless paid_ticket + puts "No ticket found for conference #{short_title}." + next + end + + # If ticket is free, create a paid "Supporter" ticket + if paid_ticket.price_cents.zero? + paid_ticket = conference.tickets.create!( + title: 'Supporter', + price_cents: 2_000, + price_currency: base_currency, + description: 'Demo paid ticket', + registration_ticket: false, + visible: true + ) + puts "Created paid ticket: #{paid_ticket.title} (#{Money.new(paid_ticket.price_cents, base_currency).format})" + end + + # Add EUR conversion so "enabled currencies" includes EUR + if base_currency == 'USD' && conference.currency_conversions.find_by(from_currency: 'USD', to_currency: 'EUR').blank? + conference.currency_conversions.create!(from_currency: 'USD', to_currency: 'EUR', rate: 0.92) + puts 'Added USD -> EUR conversion (rate 0.92).' + end + + user1 = User.first || User.create!(email: 'demo1@example.com', name: 'Demo User 1', password: 'password123456', confirmed_at: Time.current) + user2 = User.second || User.create!(email: 'demo2@example.com', name: 'Demo User 2', password: 'password123456', confirmed_at: Time.current) + + # Payment + purchase in USD + payment_usd = Payment.create!(conference: conference, user: user1, currency: 'USD', status: :success, amount: 5_000) + purchase_usd = TicketPurchase.new( + conference: conference, user: user1, ticket: paid_ticket, + quantity: 2, currency: 'USD', amount_paid_cents: 2_500, amount_paid: 25.0 + ) + purchase_usd.payment = payment_usd + purchase_usd.save!(validate: false) + purchase_usd.update_columns(paid: true) + purchase_usd.quantity.times { purchase_usd.physical_tickets.create! } + puts "Created USD purchase: 2 x #{paid_ticket.title} = $50 (5000 cents)." + + # Payment + purchase in EUR (if base is USD and we have conversion) + if conference.currency_conversions.exists?(to_currency: 'EUR') + payment_eur = Payment.create!(conference: conference, user: user2, currency: 'EUR', status: :success, amount: 4_600) + purchase_eur = TicketPurchase.new( + conference: conference, user: user2, ticket: paid_ticket, + quantity: 1, currency: 'EUR', amount_paid_cents: 4_600, amount_paid: 46.0 + ) + purchase_eur.payment = payment_eur + purchase_eur.save!(validate: false) + purchase_eur.update_columns(paid: true) + purchase_eur.physical_tickets.create! + puts "Created EUR purchase: 1 x #{paid_ticket.title} = 46 EUR (4600 cents)." + end + + puts "Done. Refresh the Ticket Purchases page to see the 'Gross ticket sales by currency' chart." + end +end