diff --git a/.travis.yml b/.travis.yml index f380c1b9..10af790e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ notifications: on_failure: change before_script: - cp config/database.yml.example config/database.yml + - cp config/secrets.yml.example config/secrets.yml - RAILS_ENV=test bundle exec rake db:migrate --trace script: - 'bundle exec rubocop -Dc .rubocop.yml' diff --git a/Gemfile b/Gemfile index 837d5f23..5a1cae6e 100644 --- a/Gemfile +++ b/Gemfile @@ -184,6 +184,9 @@ gem 'faker' # for seeds gem 'factory_girl_rails' +# for integrating Stripe payment gateway +gem 'stripe' + # Use guard and spring for testing in development group :development do # to launch specs when files are modified @@ -222,6 +225,8 @@ group :test do gem 'timecop' # for mocking external requests gem 'webmock' + # for mocking Stripe responses in tests + gem 'stripe-ruby-mock' end group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index b9d8b87a..98196af8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,6 +141,7 @@ GEM safe_yaml (~> 1.0.0) currencies (0.4.2) daemons (1.1.9) + dante (0.2.0) database_cleaner (1.3.0) debug_inspector (0.0.2) debugger-linecache (1.2.0) @@ -481,6 +482,12 @@ GEM activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) sqlite3 (1.3.9) + stripe (1.43.0) + rest-client (~> 1.4) + stripe-ruby-mock (2.3.0) + dante (>= 0.2.0) + multi_json (>= 1.0.0) + stripe (>= 1.31.0, <= 1.43) term-ansicolor (1.3.2) tins (~> 1.0) thor (0.19.1) @@ -615,6 +622,8 @@ DEPENDENCIES shoulda-matchers spring-commands-rspec sqlite3 + stripe + stripe-ruby-mock timecop transitions turbolinks diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d0447354..ac01136f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -14,4 +14,5 @@ *= require bootstrap-datetimepicker *= require leaflet *= require bootstrap3-switch + *= require osem-payments */ diff --git a/app/assets/stylesheets/osem-payments.css.scss b/app/assets/stylesheets/osem-payments.css.scss new file mode 100644 index 00000000..cfa451b4 --- /dev/null +++ b/app/assets/stylesheets/osem-payments.css.scss @@ -0,0 +1,3 @@ +.stripe-button-el { + float: right; +} diff --git a/app/controllers/conference_registrations_controller.rb b/app/controllers/conference_registrations_controller.rb index 95e5c447..176f7937 100644 --- a/app/controllers/conference_registrations_controller.rb +++ b/app/controllers/conference_registrations_controller.rb @@ -28,8 +28,10 @@ class ConferenceRegistrationsController < ApplicationController end def show - @total_price = Ticket.total_price(@conference, current_user) - @tickets = current_user.ticket_purchases.where(conference_id: @conference.id) + @total_price = Ticket.total_price(@conference, current_user, paid: true) + @tickets = current_user.ticket_purchases.by_conference(@conference).paid + @ticket_payments = @tickets.group_by(&:ticket_id) + @total_quantity = @tickets.group(:ticket_id).sum(:quantity) end def edit; end diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb new file mode 100644 index 00000000..db42a201 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,43 @@ +class PaymentsController < ApplicationController + before_action :authenticate_user! + load_and_authorize_resource + load_resource :conference, find_by: :short_title + authorize_resource :conference_registrations, class: Registration + + def index + @payments = current_user.payments + end + + def new + @total_amount_to_pay = Ticket.total_price(@conference, current_user, paid: false) + @unpaid_ticket_purchases = current_user.ticket_purchases.unpaid.by_conference(@conference) + end + + def create + @payment = Payment.new payment_params + + if @payment.purchase && @payment.save + update_purchased_ticket_purchases + redirect_to conference_conference_registration_path(@conference.short_title), + notice: 'Thanks! Your ticket is booked successfully.' + else + @total_amount_to_pay = Ticket.total_price(@conference, current_user, paid: false) + @unpaid_ticket_purchases = current_user.ticket_purchases.unpaid.by_conference(@conference) + flash[:error] = @payment.errors.full_messages.to_sentence + ' Please try again with correct credentials.' + render :new + 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) + end + + def update_purchased_ticket_purchases + current_user.ticket_purchases.by_conference(@conference).unpaid.update_all(paid: true, payment_id: @payment.id) + end +end diff --git a/app/controllers/ticket_purchases_controller.rb b/app/controllers/ticket_purchases_controller.rb index 30d24d82..a8749eed 100644 --- a/app/controllers/ticket_purchases_controller.rb +++ b/app/controllers/ticket_purchases_controller.rb @@ -4,13 +4,15 @@ class TicketPurchasesController < ApplicationController authorize_resource :conference_registrations, class: Registration def create + current_user.ticket_purchases.by_conference(@conference).unpaid.destroy_all message = TicketPurchase.purchase(@conference, current_user, params[:tickets][0]) if message.blank? - if current_user.ticket_purchases.any? - redirect_to conference_conference_registration_path(@conference.short_title), - notice: "Thank you for supporting #{@conference.title} by purchasing a ticket." + if current_user.ticket_purchases.by_conference(@conference).unpaid.any? + redirect_to new_conference_payment_path, + notice: 'Please pay here to get tickets.' else - redirect_to conference_conference_registration_path(@conference.short_title) + redirect_to conference_tickets_path(@conference.short_title), + error: 'Please get at least one ticket to continue.' end else redirect_to conference_conference_registration_path(@conference.short_title), @@ -18,18 +20,6 @@ class TicketPurchasesController < ApplicationController end end - def destroy - @ticket_purchases = current_user.ticket_purchases.find(params[:id]) - if @ticket_purchases.destroy - redirect_to conference_conference_registration_path(@conference.short_title), - notice: 'Ticket successfully deleted.' - else - redirect_to conference_conference_registration_path(@conference.short_title), - error: 'An error prohibited deleting your purchase! '\ - "#{@ticket_purchases.errors.full_messages.join('. ')}." - end - end - private def ticket_purchase_params diff --git a/app/models/ability.rb b/app/models/ability.rb index c2cbfa8e..760f43fe 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -81,6 +81,7 @@ class Ability can :index, Ticket can :manage, TicketPurchase, user_id: user.id + can [:new, :create], Payment, user_id: user.id can [:create, :destroy], Subscription, user_id: user.id diff --git a/app/models/conference.rb b/app/models/conference.rb index 55cd8ed5..8bf16a09 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -20,6 +20,7 @@ class Conference < ActiveRecord::Base has_one :program, dependent: :destroy has_one :venue, dependent: :destroy has_many :ticket_purchases, dependent: :destroy + has_many :payments, dependent: :destroy has_many :supporters, through: :ticket_purchases, source: :user has_many :tickets, dependent: :destroy diff --git a/app/models/payment.rb b/app/models/payment.rb new file mode 100644 index 00000000..e9386a35 --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,41 @@ +class Payment < ActiveRecord::Base + has_many :ticket_purchases + belongs_to :user + belongs_to :conference + + attr_accessor :stripe_customer_email + attr_accessor :stripe_customer_token + + validates :status, presence: true + validates :user_id, presence: true + validates :conference_id, presence: true + + enum status: { + unpaid: 0, + success: 1, + failure: 2 + } + + def amount_to_pay + Ticket.total_price(conference, user, paid: false).cents + end + + def purchase + gateway_response = Stripe::Charge.create source: stripe_customer_token, + receipt_email: stripe_customer_email, + description: "ticket purchases(#{user.username})", + amount: amount_to_pay, + currency: conference.tickets.first.price_currency + + self.amount = gateway_response[:amount] + self.last4 = gateway_response[:source][:last4] + self.authorization_code = gateway_response[:id] + self.status = 'success' + true + + rescue Stripe::StripeError => error + errors.add(:base, error.message) + self.status = 'failure' + false + end +end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 8a620f79..ec9c3436 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -20,24 +20,27 @@ class Ticket < ActiveRecord::Base end def paid?(user) - ticket_purchases.find_by(user: user, paid: true).present? + ticket_purchases.paid.by_user(user).present? end - def quantity_bought_by(user) - result = ticket_purchases.where(user_id: user.id).first - result ? result.quantity : 0 + def quantity_bought_by(user, paid: false) + ticket_purchases.by_user(user).where(paid: paid).sum(:quantity) end - def total_price(user) - quantity_bought_by(user) * price + def unpaid?(user) + ticket_purchases.unpaid.by_user(user).present? end - def self.total_price(conference, user) + def total_price(user, paid: false) + quantity_bought_by(user, paid: paid) * price + end + + def self.total_price(conference, user, paid: false) tickets = Ticket.where(conference_id: conference.id) result = nil begin tickets.each do |ticket| - price = ticket.total_price(user) + price = ticket.total_price(user, paid: paid) if result result += price unless price.zero? else diff --git a/app/models/ticket_purchase.rb b/app/models/ticket_purchase.rb index 8b0cd555..b7a8951f 100644 --- a/app/models/ticket_purchase.rb +++ b/app/models/ticket_purchase.rb @@ -7,23 +7,24 @@ class TicketPurchase < ActiveRecord::Base validates_numericality_of :quantity, greater_than: 0 - validates_uniqueness_of :user_id, - scope: :ticket_id, - message: 'already bought this ticket!' - delegate :title, to: :ticket delegate :description, to: :ticket delegate :price, to: :ticket delegate :price_cents, to: :ticket delegate :price_currency, to: :ticket + scope :paid, -> { where(paid: true) } + scope :unpaid, -> { where(paid: false) } + scope :by_conference, -> (conference) { where(conference_id: conference.id) } + scope :by_user, -> (user) { where(user_id: user.id) } + def self.purchase(conference, user, purchases) errors = [] ActiveRecord::Base.transaction do conference.tickets.each do |ticket| quantity = purchases[ticket.id.to_s].to_i # if the user bought the ticket, just update the quantity - if ticket.bought?(user) + if ticket.bought?(user) && ticket.unpaid?(user) purchase = update_quantity(conference, quantity, ticket, user) else purchase = purchase_ticket(conference, quantity, ticket, user) @@ -48,7 +49,8 @@ class TicketPurchase < ActiveRecord::Base def self.update_quantity(conference, quantity, ticket, user) purchase = TicketPurchase.where(ticket_id: ticket.id, conference_id: conference.id, - user_id: user.id).first + user_id: user.id, + paid: false).first purchase.quantity = quantity if quantity > 0 purchase diff --git a/app/models/user.rb b/app/models/user.rb index 1e86473d..29640ab3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -44,6 +44,7 @@ class User < ActiveRecord::Base has_many :registrations, dependent: :destroy has_many :events_registrations, through: :registrations has_many :ticket_purchases, dependent: :destroy + has_many :payments, dependent: :destroy has_many :tickets, through: :ticket_purchases, source: :ticket has_many :votes, dependent: :destroy has_many :voted_events, through: :votes, source: :events diff --git a/app/views/admin/tickets/index.html.haml b/app/views/admin/tickets/index.html.haml index 2a2bac6d..486da0ef 100644 --- a/app/views/admin/tickets/index.html.haml +++ b/app/views/admin/tickets/index.html.haml @@ -3,7 +3,7 @@ .page-header %h1 Tickets %p.text-muted - Tickets to purchase during registration + Tickets to get during registration - if @conference.tickets.any? .row .col-md-12 diff --git a/app/views/conference/_tickets.html.haml b/app/views/conference/_tickets.html.haml index 41108b78..8a202295 100644 --- a/app/views/conference/_tickets.html.haml +++ b/app/views/conference/_tickets.html.haml @@ -5,7 +5,7 @@ Support =@conference.short_title %p.lead - To support our event you can purchase these tickets + To support our event you can get these tickets - @conference.tickets.each_slice(4) do |slice| .row.row-centered - slice.each do |ticket| @@ -20,6 +20,6 @@ = markdown(ticket.description) %p.text-center = link_to(conference_tickets_path(@conference.short_title), class: 'btn btn-success') do - Buy Ticket + Get Ticket = humanized_money_with_symbol ticket.price diff --git a/app/views/conference_registrations/show.html.haml b/app/views/conference_registrations/show.html.haml index 30cb281b..591d0777 100644 --- a/app/views/conference_registrations/show.html.haml +++ b/app/views/conference_registrations/show.html.haml @@ -85,33 +85,31 @@ - if @conference.tickets.any? .row .col-md-12 - %h4 - %span.fa-stack - %i.fa.fa-square-o.fa-stack-2x - %i.fa.fa-ticket.fa-stack-1x - Tickets - -if @tickets.any? - = "(#{@total_price} #{@tickets.first.price.symbol})" + -if @tickets.any? + %h4 + %span.fa-stack + %i.fa.fa-square-o.fa-stack-2x + %i.fa.fa-ticket.fa-stack-1x + Ticket Purchases + = "(#{@tickets.first.price.symbol}#{humanized_money @total_price})" %ul - - @tickets.each do |ticket| - %li - = ticket.quantity - = ticket.title - = word_pluralize(ticket.quantity, 'Ticket') - for - = humanized_money ticket.price - = ticket.price.symbol - = link_to conference_ticket_purchase_path(@conference.short_title, ticket.id), method: :delete, - id: "ticket-#{ticket.id}-delete", - class: 'btn btn-danger btn-xs', - data: { confirm: "Do you really want to delete the #{ticket.title} ticket for #{@conference.title}?" } do - %i.fa.fa-trash-o - %li - - if @tickets.any? - = link_to 'Buy more tickets', conference_tickets_path(@conference.short_title) - - else - You haven't bought any tickets. - = link_to 'Please buy some tickets to support us!', conference_tickets_path(@conference.short_title) + .col-md-12 + - @ticket_payments.each_pair do |ticket_id, tickets| + %li + = @total_quantity[ticket_id] + = tickets.first.title + = word_pluralize(@total_quantity[ticket_id], 'Ticket') + for + = tickets.first.price.symbol + = humanized_money tickets.first.price + %br + - if @tickets.any? + = link_to 'Get more tickets', conference_tickets_path(@conference.short_title), class: "btn btn-default" + - else + You haven't bought any tickets. + = link_to 'Please get some tickets to support us!', conference_tickets_path(@conference.short_title) + %p + (Your participation won't be valid without getting a ticket) .row .col-md-12 diff --git a/app/views/payments/_payment.html.haml b/app/views/payments/_payment.html.haml new file mode 100644 index 00000000..56af80f7 --- /dev/null +++ b/app/views/payments/_payment.html.haml @@ -0,0 +1,27 @@ +.div + .col-md-12.table-responsive + %table.table.table-hover + %thead + %tr + %th Ticket + %th Quantity + %th Price + %th Total + %tbody + - @unpaid_ticket_purchases.each do |ticket| + %tr + %td + = ticket.title + %td + = ticket.quantity + %td + = humanized_money_with_symbol ticket.price + %td + = humanized_money_with_symbol ticket.quantity * ticket.price + += form_tag conference_payments_path 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: @total_amount_to_pay.currency, name: ENV['OSEM_NAME'] || 'OSEM', + description: "book your tickets", key: Rails.application.secrets.stripe_publishable_key, locale: "auto"}} + = 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 new file mode 100644 index 00000000..0401f785 --- /dev/null +++ b/app/views/payments/new.html.haml @@ -0,0 +1,14 @@ +.container + .row + .col-xs-6.col-xs-offset-3 + %h1 + Payment Summary : + = humanized_money_with_symbol @total_amount_to_pay + .col-xs-8.col-xs-offset-2.well + = render partial: 'payment' + .row + .col-md-13 + %p.text-muted.text-center + %small + All payments are handled securely by our payment processor, + = link_to 'Stripe', 'https://stripe.com', target: '_blank' diff --git a/app/views/tickets/_ticket.html.haml b/app/views/tickets/_ticket.html.haml index 1a93f46b..e3985773 100644 --- a/app/views/tickets/_ticket.html.haml +++ b/app/views/tickets/_ticket.html.haml @@ -8,12 +8,8 @@ - unless ticket.description.blank? = markdown(ticket.description) %td.col-sm-1.col-md-1 - - if ticket.bought?(current_user) - = text_field_tag("tickets[][#{ticket.id}]", ticket.quantity_bought_by(current_user), + = text_field_tag("tickets[][#{ticket.id}]", 0, type: 'number', min: 0, class: "form-control quantity", 'data-id' => ticket.id) - - else - = text_field_tag("tickets[][#{ticket.id}]", 0, type: 'number', min: 0, - class: "form-control quantity", 'data-id' => ticket.id) %td.col-sm-1.col-md-1.text-center = ticket.price.symbol %span{id: "price_#{ticket.id}"} diff --git a/app/views/tickets/index.html.haml b/app/views/tickets/index.html.haml index fbc2df7c..894a4178 100644 --- a/app/views/tickets/index.html.haml +++ b/app/views/tickets/index.html.haml @@ -5,10 +5,10 @@ %h1 Tickets %p.lead - If you like, support + Please choose your tickets for %strong = @conference.title - by buying a ticket* + here* =form_tag(conference_ticket_purchases_path, method: :post) do |f| %table.table.table-hover %thead @@ -35,12 +35,11 @@ .pull-right .btn-group-vertical = button_tag(type: 'submit', class: 'btn btn-success btn-lg') do - Support + Continue %i.fa.fa-shopping-cart - = link_to 'Continue without a Ticket!', conference_conference_registration_path(@conference.short_title), - class: 'btn btn-danger btn-sm' + = link_to 'Cancel registration', conference_conference_registration_path(@conference.short_title), method: :delete, class: 'btn btn-danger btn-sm' .row .col-md-13 %p.text-muted.text-center %small - * Buying a ticket is not mandatory. Checkout will be at the conference registration. + * Getting a ticket is mandatory. Your participation will not be valid until you get a ticket. diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 00000000..555cc16f --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1 @@ +Stripe.api_key = Rails.application.secrets.stripe_secret_key diff --git a/config/routes.rb b/config/routes.rb index 9a653b27..fac0b4f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -121,6 +121,7 @@ Osem::Application.routes.draw do resource :conference_registration, path: 'register' resources :tickets, only: [:index] resources :ticket_purchases, only: [:create, :destroy] + resources :payments, only: [:index, :new, :create] resource :subscriptions, only: [:create, :destroy] resource :schedule, only: [:show] do member do diff --git a/config/secrets.yml.example b/config/secrets.yml.example index 308bc858..a8d67504 100644 --- a/config/secrets.yml.example +++ b/config/secrets.yml.example @@ -13,10 +13,20 @@ development: suse_key: 'sample' suse_secret: 'sample' + # Register on stripe and add TEST keys here + # https://dashboard.stripe.com/account/apikeys + stripe_publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] %> + stripe_secret_key: <%= ENV['STRIPE_SECRET_KEY'] %> + test: # Generate your own with rake secret # secret_key_base: '12345' + # Register on stripe and add TEST keys here + # https://dashboard.stripe.com/account/apikeys + stripe_publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] %> + stripe_secret_key: <%= ENV['STRIPE_SECRET_KEY'] %> + production: # Generate your own with rake secret or use the environment # secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> @@ -56,3 +66,8 @@ production: # https://github.com/settings/applications github_key: '' github_secret: '' + + # Register on stripe and add LIVE keys here + # https://dashboard.stripe.com/account/apikeys + stripe_publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] %> + stripe_secret_key: <%= ENV['STRIPE_SECRET_KEY'] %> diff --git a/db/migrate/20160606040848_create_payments.rb b/db/migrate/20160606040848_create_payments.rb new file mode 100644 index 00000000..6792ad02 --- /dev/null +++ b/db/migrate/20160606040848_create_payments.rb @@ -0,0 +1,14 @@ +class CreatePayments < ActiveRecord::Migration + def change + create_table :payments do |t| + t.string :last4 + t.integer :amount + t.string :authorization_code + t.integer :status, default: 0, null: false + t.integer :user_id, null: false + t.integer :conference_id, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160610073948_add_payment_id_to_ticket_purchases.rb b/db/migrate/20160610073948_add_payment_id_to_ticket_purchases.rb new file mode 100644 index 00000000..133a047b --- /dev/null +++ b/db/migrate/20160610073948_add_payment_id_to_ticket_purchases.rb @@ -0,0 +1,5 @@ +class AddPaymentIdToTicketPurchases < ActiveRecord::Migration + def change + add_column :ticket_purchases, :payment_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f368565..7da98fe3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -264,6 +264,17 @@ ActiveRecord::Schema.define(version: 20160815140302) do t.datetime "updated_at" end + create_table "payments", force: :cascade do |t| + t.string "last4" + t.integer "amount" + t.string "authorization_code" + t.integer "status", default: 0, null: false + t.integer "user_id", null: false + t.integer "conference_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "programs", force: :cascade do |t| t.integer "conference_id" t.integer "rating", default: 0 @@ -424,6 +435,7 @@ ActiveRecord::Schema.define(version: 20160815140302) do t.datetime "created_at" t.integer "quantity", default: 1 t.integer "user_id" + t.integer "payment_id" end create_table "tickets", force: :cascade do |t| diff --git a/dotenv.example b/dotenv.example index 921020d1..ec2715ea 100644 --- a/dotenv.example +++ b/dotenv.example @@ -37,6 +37,11 @@ OSEM_FACEBOOK_SECRET='' OSEM_GITHUB_KEY='' OSEM_GITHUB_SECRET='' +# STRIPE Publishable/Secret keys +# test keys for development mode, live for production mode +STRIPE_PUBLISHABLE_KEY='' +STRIPE_SECRET_KEY='' + # Disable linting of factories in the test suite. # Speeds up turn around times of tests OSEM_FACTORY_LINT="false" diff --git a/spec/controllers/conference_registration_controller_spec.rb b/spec/controllers/conference_registration_controller_spec.rb index f0087e80..377e884e 100644 --- a/spec/controllers/conference_registration_controller_spec.rb +++ b/spec/controllers/conference_registration_controller_spec.rb @@ -82,9 +82,8 @@ describe ConferenceRegistrationsController, type: :controller do get :show, conference_id: conference.short_title end - it 'assigns price of purchased tickets to total_price and purchased tickets to tickets' do - expect(assigns(:total_price)).to eq Money.new(10000, 'USD') - expect(assigns(:tickets)).to match_array [@purchased_ticket] + it 'does not assign price of purchased tickets to total_price and purchased tickets to tickets without payment' do + expect(assigns(:total_price)).to eq Money.new(0, 'USD') end end diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb new file mode 100644 index 00000000..4d4d3c39 --- /dev/null +++ b/spec/factories/payments.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :payment do + user + conference + status 'unpaid' + end +end diff --git a/spec/features/ticket_purchases_spec.rb b/spec/features/ticket_purchases_spec.rb index ff6957f6..d2eadfc9 100644 --- a/spec/features/ticket_purchases_spec.rb +++ b/spec/features/ticket_purchases_spec.rb @@ -16,7 +16,7 @@ feature Registration do context 'who is not registered' do - scenario 'purchases a ticket', feature: true, js: true do + scenario 'purchases and pays for a ticket succcessfully', feature: true, js: true do visit root_path click_link 'Register' @@ -26,26 +26,66 @@ feature Registration do fill_in "tickets__#{ticket.id}", with: '2' expect(current_path).to eq(conference_tickets_path(conference.short_title)) - click_button 'Support' + click_button 'Continue' + expect(current_path).to eq(new_conference_payment_path(conference.short_title)) + 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) - expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(flash). - to eq("Thank you for supporting #{conference.title} by purchasing a ticket.") - expect(page.has_content?("2 #{ticket.title} Tickets for 10")).to be true + + if Rails.application.secrets.stripe_publishable_key + 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('book your tickets') + page.execute_script(%{ $('input#card_number').val('4242424242424242'); }) + page.execute_script(%{ $('input#cc-exp').val('08/22'); }) + page.execute_script(%{ $('input#cc-csc').val('123'); }) + page.execute_script(%{ $('#submitButton').click(); }) + sleep(20) + end + + expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) + expect(page.has_content?("2 #{ticket.title} Tickets for $ 10")).to be true + end end - scenario 'deletes a purchased ticket', feature: true, js: true do - create(:registration, conference: conference, user: participant) - create(:ticket_purchase, conference: conference, user: participant, ticket: ticket, quantity: 4) + scenario 'purchases ticket but payment fails', feature: true, js: true do + visit root_path + click_link 'Register' - visit conference_conference_registration_path(conference.short_title) - expect(page.has_content?("4 #{ticket.title} Tickets for 10")).to be true + expect(current_path).to eq(new_conference_conference_registration_path(conference.short_title)) + click_button 'Register' - click_link "ticket-#{ticket.id}-delete" - expect(flash).to eq('Ticket successfully deleted.') - expect(TicketPurchase.count).to eq(0) + fill_in "tickets__#{ticket.id}", with: '2' + expect(current_path).to eq(conference_tickets_path(conference.short_title)) + + click_button 'Continue' + + expect(current_path).to eq(new_conference_payment_path(conference.short_title)) + 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 Rails.application.secrets.stripe_publishable_key + 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('book your tickets') + page.execute_script(%{ $('input#card_number').val('4000000000000341'); }) + page.execute_script(%{ $('input#cc-exp').val('08/22'); }) + page.execute_script(%{ $('input#cc-csc').val('123'); }) + page.execute_script(%{ $('#submitButton').click(); }) + sleep(20) + end + + expect(current_path).to eq(conference_payments_path(conference.short_title)) + expect(flash).to eq('Your card was declined. Please try again with correct credentials.') + end end end end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 720aec33..df12e8b9 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -105,6 +105,9 @@ describe 'User' do it{ should be_able_to(:index, Ticket) } it{ should be_able_to(:manage, TicketPurchase.new(user_id: user.id)) } + it{ should be_able_to(:new, Payment.new(user_id: user.id)) } + it{ should be_able_to(:create, Payment.new(user_id: user.id)) } + it{ should be_able_to(:create, Subscription.new(user_id: user.id)) } it{ should be_able_to(:destroy, subscription) } diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 00000000..6af28679 --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' +require 'stripe_mock' + +describe Payment do + + context 'new payment' do + let(:payment) { create(:payment) } + it 'sets status to "unpaid" by default' do + expect(payment.status).to eq('unpaid') + end + end + + describe 'validations' do + it 'has a valid factory' do + expect(build(:payment)).to be_valid + end + + it { is_expected.to validate_presence_of(:status) } + + it { is_expected.to validate_presence_of(:user_id) } + + it { is_expected.to validate_presence_of(:conference_id) } + end + + describe '#amount_to_pay' do + let!(:user) { create(:user) } + let!(:conference) { create(:conference) } + let(:ticket_1) { create(:ticket, price: 10, price_currency: 'USD', conference: conference) } + let(:payment) { create(:payment, user: user, conference: conference) } + + it ' returns correct unpaid amount' do + create(:ticket_purchase, ticket: ticket_1, user: user, quantity: 8) + expect(payment.amount_to_pay).to eq(8000) + end + end + + describe '#purchase' do + let!(:user) { create(:user) } + 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 } + + before { StripeMock.start } + after { StripeMock.stop } + + before { TicketPurchase.purchase(conference, user, tickets) } + let(:payment) { create(:payment, user: user, conference: conference, stripe_customer_token: stripe_helper.generate_card_token, stripe_customer_email: user.email) } + + context 'when the payment is successful' do + before { payment.purchase } + + it 'assigns amount' do + expect(payment.amount).to eq(2000) + end + + it 'assigns last4' do + expect(payment.last4).to eq('4242') + end + + it "assigns 'success' to payment.status" do + expect(payment.status).to eq('success') + end + + it 'assigns authorization_code' do + expect(payment.authorization_code).to eq('test_ch_3') + end + end + + context 'if the payment is not successful' do + let(:payment) { create(:payment, user: user, conference: conference, stripe_customer_token: 'bogus_card_token', stripe_customer_email: user.email) } + + before { payment.purchase } + + context 'when the card is invalid' do + it 'returns false' do + payment_result = payment.purchase + expect(payment_result).to eq false + end + + it 'assigns "failure" to payment.status' do + expect(payment.status).to eq('failure') + end + + it 'adds errors' do + expect(payment.errors[:base].count).to eq(1) + end + 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 + 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 + 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 + 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 + 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 + 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 + end + end + end +end diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index fc67ff3d..631ca59a 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -82,31 +82,72 @@ describe Ticket do end end - describe '#quantity_bought_by' do - it 'returns the correct value if the user has bought this ticket' do - create(:ticket_purchase, - user: user, - ticket: ticket, - quantity: 20) - expect(ticket.quantity_bought_by(user)).to eq(20) + describe '#unpaid?' do + let!(:ticket_purchase) { create(:ticket_purchase, user: user, ticket: ticket) } + + context 'user has not paid' do + + it 'returns true' do + expect(ticket.unpaid?(user)).to eq(true) + end end - it 'returns zero if the user has not bought this ticket' do - expect(ticket.quantity_bought_by(user)).to eq(0) + context 'user has paid' do + before { ticket_purchase.update_attributes(paid: true) } + + it 'returns false' do + expect(ticket.unpaid?(user)).to eq(false) + end + end + end + + describe '#quantity_bought_by' do + context 'user has not paid' do + it 'returns the correct value if the user has bought this ticket' do + create(:ticket_purchase, + user: user, + ticket: ticket, + quantity: 20) + expect(ticket.quantity_bought_by(user, paid: false)).to eq(20) + end + + it 'returns zero if the user has not bought this ticket' do + expect(ticket.quantity_bought_by(user, paid: false)).to eq(0) + end + end + + context 'user has paid' do + let!(:ticket_purchase) { create(:ticket_purchase, user: user, ticket: ticket, quantity: 20) } + before { ticket_purchase.update_attributes(paid: true) } + + it 'returns the correct value if the user has bought and paid for this ticket' do + expect(ticket.quantity_bought_by(user, paid: true)).to eq(20) + end end end describe '#total_price' do - it 'returns the correct value if the user has bought this ticket' do - create(:ticket_purchase, - user: user, - ticket: ticket, - quantity: 20) - expect(ticket.total_price(user)).to eq(Money.new(100000, 'USD')) + context 'user has not paid' do + it 'returns the correct value if the user has bought this ticket' do + create(:ticket_purchase, + user: user, + ticket: ticket, + quantity: 20) + expect(ticket.total_price(user, paid: false)).to eq(Money.new(100000, 'USD')) + end + + it 'returns zero if the user has not bought this ticket' do + expect(ticket.total_price(user, paid: false)).to eq(Money.new(0, 'USD')) + end end - it 'returns zero if the user has not bought this ticket' do - expect(ticket.total_price(user)).to eq(Money.new(0, 'USD')) + context 'user has paid' do + let!(:ticket_purchase) { create(:ticket_purchase, user: user, ticket: ticket, quantity: 20) } + before { ticket_purchase.update_attributes(paid: true) } + + it 'returns the correct value if the user has bought this ticket' do + expect(ticket.total_price(user, paid: true)).to eq(Money.new(100000, 'USD')) + end end end @@ -116,7 +157,7 @@ describe Ticket do describe 'user has bought' do context 'no tickets' do it 'returns zero' do - expect(Ticket.total_price(conference, user)).to eq(Money.new(0, 'USD')) + expect(Ticket.total_price(conference, user, paid: false)).to eq(Money.new(0, 'USD')) end end @@ -126,7 +167,7 @@ describe Ticket do end it 'returns the correct total price' do - expect(Ticket.total_price(conference, user)).to eq(Money.new(100000, 'USD')) + expect(Ticket.total_price(conference, user, paid: false)).to eq(Money.new(100000, 'USD')) end end @@ -138,7 +179,7 @@ describe Ticket do it 'returns the correct total price' do total_price = Money.new(200000, 'USD') - expect(Ticket.total_price(conference, user)).to eq(total_price) + expect(Ticket.total_price(conference, user, paid: false)).to eq(total_price) end end end diff --git a/spec/support/external_request.rb b/spec/support/external_request.rb index 539fe701..645067aa 100644 --- a/spec/support/external_request.rb +++ b/spec/support/external_request.rb @@ -1,6 +1,6 @@ # Mock external requests to youtube require 'webmock/rspec' -WebMock.disable_net_connect!(allow_localhost: true) +WebMock.disable_net_connect!(allow_localhost: true, allow: /stripe.com/) RSpec.configure do |config| config.before(:each) do