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/app/assets/stylesheets/osem-payments.scss b/app/assets/stylesheets/osem-payments.scss index cfa451b40..9b3d4b568 100644 --- a/app/assets/stylesheets/osem-payments.scss +++ b/app/assets/stylesheets/osem-payments.scss @@ -1,3 +1,7 @@ -.stripe-button-el { - float: right; +.payment-actions { + margin-top: 15px; + + .btn-success { + float: right; + } } 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/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 17a4b94b2..1d28b81a4 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -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 @@ -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 @@ -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 @@ -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 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/models/payment.rb b/app/models/payment.rb index 5fa61c2f9..8a893421e 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -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 @@ -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 @@ -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 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/app/views/payments/_payment.html.haml b/app/views/payments/_payment.html.haml index 76544211e..ad222d863 100644 --- a/app/views/payments/_payment.html.haml +++ b/app/views/payments/_payment.html.haml @@ -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' diff --git a/app/views/payments/new.html.haml b/app/views/payments/new.html.haml index 91a779f08..2e9f7b6e2 100644 --- a/app/views/payments/new.html.haml +++ b/app/views/payments/new.html.haml @@ -3,7 +3,7 @@ .col-xs-6.col-xs-offset-3 %h1 Payment Summary : - = humanized_money_with_symbol Money.new(@total_amount_to_pay) + = humanized_money_with_symbol @total_amount_to_pay .col-xs-8.col-xs-offset-2.well = render partial: 'payment' .row @@ -18,3 +18,4 @@ %small All payments are handled securely by our payment processor, = link_to 'Stripe', 'https://stripe.com', target: '_blank' + \. You will be redirected to Stripe's secure checkout page to complete your payment. 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/config/routes.rb b/config/routes.rb index 62ac3dfdd..c49d55934 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -208,7 +208,12 @@ resource :conference_registration, path: 'register' resources :tickets, only: [:index] resources :ticket_purchases, only: %i[create destroy index] - resources :payments, only: %i[index new create] + resources :payments, only: %i[index new create] do + collection do + get :success + get :cancel + end + end resources :physical_tickets, only: %i[index show] resource :subscriptions, only: %i[create destroy] resource :schedule, only: [:show] do diff --git a/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb new file mode 100644 index 000000000..4b63911bf --- /dev/null +++ b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb @@ -0,0 +1,6 @@ +class AddStripeSessionIdToPayments < ActiveRecord::Migration[7.0] + def change + add_column :payments, :stripe_session_id, :string + add_index :payments, :stripe_session_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 18d82ff98..15c0f7e17 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[7.0].define(version: 2024_08_01_042356) do +ActiveRecord::Schema[7.0].define(version: 2026_03_05_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -332,6 +332,8 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "currency" + t.string "stripe_session_id" + t.index ["stripe_session_id"], name: "index_payments_on_stripe_session_id", unique: true end create_table "physical_tickets", force: :cascade do |t| 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 diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index a3df512e9..132aa3dab 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -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 diff --git a/spec/features/ticket_purchases_spec.rb b/spec/features/ticket_purchases_spec.rb index 32bb9fd1f..8771fac04 100644 --- a/spec/features/ticket_purchases_spec.rb +++ b/spec/features/ticket_purchases_spec.rb @@ -14,25 +14,6 @@ end let!(:participant) { create(:user) } - def make_stripe_purchase(card_number = '4242424242424242') - find('.stripe-button-el').click - - stripe_iframe = all('iframe[name=stripe_checkout_app]').last - sleep(5) - Capybara.within_frame stripe_iframe do - expect(page).to have_content(:all, "#{ENV.fetch('OSEM_NAME', nil)} tickets") - fill_in 'Card number', with: card_number - fill_in 'Expiry', with: '08/22' - fill_in 'CVC', with: '123' - click_button '$20.00' - sleep(20) - end - end - - def make_failed_stripe_purchase - make_stripe_purchase('4000000000000341') - end - context 'as a participant' do before do sign_in participant @@ -43,34 +24,12 @@ def make_failed_stripe_purchase end context 'who is not registered' do - it 'purchases and pays for a ticket succcessfully' do - visit root_path - click_link 'Register' - - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - click_button 'Register' - - fill_in "tickets__#{ticket.id}", with: '2' - expect(page).to have_current_path(conference_tickets_path(conference.short_title), ignore_query: true) + it 'purchases and is redirected to Stripe Checkout' do + mock_session = double('Stripe::Checkout::Session', + id: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123') + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) - click_button 'Continue' - page.find('#flash') - expect(page).to have_current_path(new_conference_payment_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Please pay here to get tickets.') - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end - end - - it 'purchases ticket but payment fails', feature: true, js: true do visit root_path click_link 'Register' @@ -87,13 +46,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_failed_stripe_purchase - page.find('#flash') - expect(page).to have_current_path(conference_payments_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Your card was declined. Please try again with correct credentials.') - end end it 'purchases free tickets' do @@ -152,13 +104,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: third_registration_ticket.id).first expect(purchase.quantity).to eq(1) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end it 'purchases more than one registration tickets of a single type' do @@ -238,13 +183,6 @@ def make_failed_stripe_purchase expect(purchase.quantity).to eq(1) expect(purchase.currency).to eq('EUR') expect(purchase.amount_paid).to eq(17.80) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end end @@ -267,19 +205,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - - click_button 'Unregister' - end - - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) end end end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index 34b0132d2..5515693bb 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -10,13 +10,13 @@ # 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 # user_id :integer not null # require 'spec_helper' -require 'stripe_mock' describe Payment do context 'new payment' do @@ -46,117 +46,212 @@ end end - describe '#purchase' do + describe '#create_checkout_session' do let!(:user) { create(:user) } - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: stripe_helper.generate_card_token, - stripe_customer_email: user.email) - end let!(:conference) { create(:conference) } let!(:ticket_1) { create(:ticket, price: 10, price_currency: 'USD', conference: conference) } let!(:tickets) { { ticket_1.id.to_s => '2' } } - let(:stripe_helper) { StripeMock.create_test_helper } + let(:payment) { create(:payment, user: user, conference: conference) } - before { StripeMock.start } + before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } - after { StripeMock.stop } + context 'when the session is created successfully' do + let(:mock_session) do + double('Stripe::Checkout::Session', + id: 'cs_test_session_123', + url: 'https://checkout.stripe.com/pay/cs_test_session_123') + end - before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } + before do + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) + allow(payment).to receive(:update).and_return(true) + end + + it 'creates a Stripe Checkout Session with line items' do + result = payment.create_checkout_session( + success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to eq(mock_session) + expect(Stripe::Checkout::Session).to have_received(:create).with( + hash_including( + payment_method_types: ['card'], + mode: 'payment', + customer_email: user.email, + line_items: a_collection_containing_exactly( + hash_including( + price_data: hash_including( + currency: 'usd', + product_data: hash_including(name: ticket_1.title), + unit_amount: 1000 + ), + quantity: 2 + ) + ) + ) + ) + end + + it 'stores the session id on the payment' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment).to have_received(:update).with(stripe_session_id: 'cs_test_session_123') + end + end + + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:create) + .and_raise(Stripe::StripeError.new('Test error')) + end + + it 'returns nil' do + result = payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to be_nil + end - context 'when the payment is successful' do - before { payment.purchase } + it 'sets status to failure' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.status).to eq('failure') + end + + it 'adds error message' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.errors[:base]).to include('Test error') + end + end + end + + describe '#complete_checkout' do + let!(:user) { create(:user) } + let!(:conference) { create(:conference) } + let(:payment) { create(:payment, user: user, conference: conference, stripe_session_id: 'cs_test_123') } + + context 'when the payment was successful' do + let(:mock_charge) do + double('Stripe::Charge', + payment_method_details: double(card: double(last4: '4242'))) + end + + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'paid', + amount_total: 2000, + payment_intent: double(id: 'pi_test_123', latest_charge: mock_charge)) + end + + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) + end + + it 'sets status to success' do + payment.complete_checkout + expect(payment.status).to eq('success') + end it 'assigns amount' do + payment.complete_checkout expect(payment.amount).to eq(2000) end it 'assigns last4' do + payment.complete_checkout expect(payment.last4).to eq('4242') end - it "assigns 'success' to payment.status" do - expect(payment.status).to eq('success') + it 'assigns authorization_code from payment intent' do + payment.complete_checkout + expect(payment.authorization_code).to eq('pi_test_123') end + end - it 'assigns authorization_code' do - expect(payment.authorization_code).to eq('test_ch_3') + context 'when the payment was not successful' do + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'unpaid', + payment_intent: nil) end - it 'assigns currency' do - expect(payment.currency).to eq('USD') + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) end - end - context 'if the payment is not successful' do - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: 'bogus_card_token', - stripe_customer_email: user.email) + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - before { payment.purchase } + it 'returns false' do + expect(payment.complete_checkout).to be false + end + end - context 'when the card is invalid' do - it 'returns false' do - payment_result = payment.purchase - expect(payment_result).to be false - end + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::APIConnectionError.new('Connection failed')) + end - it 'assigns "failure" to payment.status' do - expect(payment.status).to eq('failure') - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error + end - it 'adds errors' do - expect(payment.errors[:base].count).to eq(1) - end + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - context 'when the connection to Stripe drops' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIConnectionError.new) - expect { payment.purchase }.not_to raise_error - end + it 'returns false' do + expect(payment.complete_checkout).to be false end + end - context 'when there is a Stripe API Error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an authentication error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::AuthenticationError.new('Invalid API key')) end - context 'when there is authentication error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::AuthenticationError.new) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when there is a card error' do - it 'raises exception' do - StripeMock.prepare_card_error(:card_declined) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an invalid request' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::InvalidRequestError.new('Invalid session', {})) end - context 'when the request to Stripe is invalid' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::InvalidRequestError.new('Your request is invalid.', {}, code: 402)) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when Stripe rate limit exceeds' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::RateLimitError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when Stripe rate limit exceeds' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::RateLimitError.new('Rate limit exceeded')) end - context 'when the currency is invalid' do - it 'returns false' do - payment.currency = 'ABC' - expect(payment.purchase).to be false - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end end end