Merge pull request #1108 from rishabhs95/stripe

Online payment feature - direct stripe integration
This commit is contained in:
Hernán Schmidt 2016-08-18 16:04:11 +02:00 committed by GitHub
commit 32019a11e1
35 changed files with 523 additions and 110 deletions

View file

@ -18,6 +18,7 @@ notifications:
on_failure: change on_failure: change
before_script: before_script:
- cp config/database.yml.example config/database.yml - 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 - RAILS_ENV=test bundle exec rake db:migrate --trace
script: script:
- 'bundle exec rubocop -Dc .rubocop.yml' - 'bundle exec rubocop -Dc .rubocop.yml'

View file

@ -184,6 +184,9 @@ gem 'faker'
# for seeds # for seeds
gem 'factory_girl_rails' gem 'factory_girl_rails'
# for integrating Stripe payment gateway
gem 'stripe'
# Use guard and spring for testing in development # Use guard and spring for testing in development
group :development do group :development do
# to launch specs when files are modified # to launch specs when files are modified
@ -222,6 +225,8 @@ group :test do
gem 'timecop' gem 'timecop'
# for mocking external requests # for mocking external requests
gem 'webmock' gem 'webmock'
# for mocking Stripe responses in tests
gem 'stripe-ruby-mock'
end end
group :development, :test do group :development, :test do

View file

@ -141,6 +141,7 @@ GEM
safe_yaml (~> 1.0.0) safe_yaml (~> 1.0.0)
currencies (0.4.2) currencies (0.4.2)
daemons (1.1.9) daemons (1.1.9)
dante (0.2.0)
database_cleaner (1.3.0) database_cleaner (1.3.0)
debug_inspector (0.0.2) debug_inspector (0.0.2)
debugger-linecache (1.2.0) debugger-linecache (1.2.0)
@ -481,6 +482,12 @@ GEM
activesupport (>= 3.0) activesupport (>= 3.0)
sprockets (>= 2.8, < 4.0) sprockets (>= 2.8, < 4.0)
sqlite3 (1.3.9) 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) term-ansicolor (1.3.2)
tins (~> 1.0) tins (~> 1.0)
thor (0.19.1) thor (0.19.1)
@ -615,6 +622,8 @@ DEPENDENCIES
shoulda-matchers shoulda-matchers
spring-commands-rspec spring-commands-rspec
sqlite3 sqlite3
stripe
stripe-ruby-mock
timecop timecop
transitions transitions
turbolinks turbolinks

View file

@ -14,4 +14,5 @@
*= require bootstrap-datetimepicker *= require bootstrap-datetimepicker
*= require leaflet *= require leaflet
*= require bootstrap3-switch *= require bootstrap3-switch
*= require osem-payments
*/ */

View file

@ -0,0 +1,3 @@
.stripe-button-el {
float: right;
}

View file

@ -28,8 +28,10 @@ class ConferenceRegistrationsController < ApplicationController
end end
def show def show
@total_price = Ticket.total_price(@conference, current_user) @total_price = Ticket.total_price(@conference, current_user, paid: true)
@tickets = current_user.ticket_purchases.where(conference_id: @conference.id) @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 end
def edit; end def edit; end

View file

@ -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

View file

@ -4,13 +4,15 @@ class TicketPurchasesController < ApplicationController
authorize_resource :conference_registrations, class: Registration authorize_resource :conference_registrations, class: Registration
def create def create
current_user.ticket_purchases.by_conference(@conference).unpaid.destroy_all
message = TicketPurchase.purchase(@conference, current_user, params[:tickets][0]) message = TicketPurchase.purchase(@conference, current_user, params[:tickets][0])
if message.blank? if message.blank?
if current_user.ticket_purchases.any? if current_user.ticket_purchases.by_conference(@conference).unpaid.any?
redirect_to conference_conference_registration_path(@conference.short_title), redirect_to new_conference_payment_path,
notice: "Thank you for supporting #{@conference.title} by purchasing a ticket." notice: 'Please pay here to get tickets.'
else 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 end
else else
redirect_to conference_conference_registration_path(@conference.short_title), redirect_to conference_conference_registration_path(@conference.short_title),
@ -18,18 +20,6 @@ class TicketPurchasesController < ApplicationController
end end
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 private
def ticket_purchase_params def ticket_purchase_params

View file

@ -81,6 +81,7 @@ class Ability
can :index, Ticket can :index, Ticket
can :manage, TicketPurchase, user_id: user.id can :manage, TicketPurchase, user_id: user.id
can [:new, :create], Payment, user_id: user.id
can [:create, :destroy], Subscription, user_id: user.id can [:create, :destroy], Subscription, user_id: user.id

View file

@ -20,6 +20,7 @@ class Conference < ActiveRecord::Base
has_one :program, dependent: :destroy has_one :program, dependent: :destroy
has_one :venue, dependent: :destroy has_one :venue, dependent: :destroy
has_many :ticket_purchases, dependent: :destroy has_many :ticket_purchases, dependent: :destroy
has_many :payments, dependent: :destroy
has_many :supporters, through: :ticket_purchases, source: :user has_many :supporters, through: :ticket_purchases, source: :user
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy

41
app/models/payment.rb Normal file
View file

@ -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

View file

@ -20,24 +20,27 @@ class Ticket < ActiveRecord::Base
end end
def paid?(user) def paid?(user)
ticket_purchases.find_by(user: user, paid: true).present? ticket_purchases.paid.by_user(user).present?
end end
def quantity_bought_by(user) def quantity_bought_by(user, paid: false)
result = ticket_purchases.where(user_id: user.id).first ticket_purchases.by_user(user).where(paid: paid).sum(:quantity)
result ? result.quantity : 0
end end
def total_price(user) def unpaid?(user)
quantity_bought_by(user) * price ticket_purchases.unpaid.by_user(user).present?
end 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) tickets = Ticket.where(conference_id: conference.id)
result = nil result = nil
begin begin
tickets.each do |ticket| tickets.each do |ticket|
price = ticket.total_price(user) price = ticket.total_price(user, paid: paid)
if result if result
result += price unless price.zero? result += price unless price.zero?
else else

View file

@ -7,23 +7,24 @@ class TicketPurchase < ActiveRecord::Base
validates_numericality_of :quantity, greater_than: 0 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 :title, to: :ticket
delegate :description, to: :ticket delegate :description, to: :ticket
delegate :price, to: :ticket delegate :price, to: :ticket
delegate :price_cents, to: :ticket delegate :price_cents, to: :ticket
delegate :price_currency, 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) def self.purchase(conference, user, purchases)
errors = [] errors = []
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
conference.tickets.each do |ticket| conference.tickets.each do |ticket|
quantity = purchases[ticket.id.to_s].to_i quantity = purchases[ticket.id.to_s].to_i
# if the user bought the ticket, just update the quantity # 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) purchase = update_quantity(conference, quantity, ticket, user)
else else
purchase = purchase_ticket(conference, quantity, ticket, user) purchase = purchase_ticket(conference, quantity, ticket, user)
@ -48,7 +49,8 @@ class TicketPurchase < ActiveRecord::Base
def self.update_quantity(conference, quantity, ticket, user) def self.update_quantity(conference, quantity, ticket, user)
purchase = TicketPurchase.where(ticket_id: ticket.id, purchase = TicketPurchase.where(ticket_id: ticket.id,
conference_id: conference.id, conference_id: conference.id,
user_id: user.id).first user_id: user.id,
paid: false).first
purchase.quantity = quantity if quantity > 0 purchase.quantity = quantity if quantity > 0
purchase purchase

View file

@ -44,6 +44,7 @@ class User < ActiveRecord::Base
has_many :registrations, dependent: :destroy has_many :registrations, dependent: :destroy
has_many :events_registrations, through: :registrations has_many :events_registrations, through: :registrations
has_many :ticket_purchases, dependent: :destroy has_many :ticket_purchases, dependent: :destroy
has_many :payments, dependent: :destroy
has_many :tickets, through: :ticket_purchases, source: :ticket has_many :tickets, through: :ticket_purchases, source: :ticket
has_many :votes, dependent: :destroy has_many :votes, dependent: :destroy
has_many :voted_events, through: :votes, source: :events has_many :voted_events, through: :votes, source: :events

View file

@ -3,7 +3,7 @@
.page-header .page-header
%h1 Tickets %h1 Tickets
%p.text-muted %p.text-muted
Tickets to purchase during registration Tickets to get during registration
- if @conference.tickets.any? - if @conference.tickets.any?
.row .row
.col-md-12 .col-md-12

View file

@ -5,7 +5,7 @@
Support Support
=@conference.short_title =@conference.short_title
%p.lead %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| - @conference.tickets.each_slice(4) do |slice|
.row.row-centered .row.row-centered
- slice.each do |ticket| - slice.each do |ticket|
@ -20,6 +20,6 @@
= markdown(ticket.description) = markdown(ticket.description)
%p.text-center %p.text-center
= link_to(conference_tickets_path(@conference.short_title), class: 'btn btn-success') do = link_to(conference_tickets_path(@conference.short_title), class: 'btn btn-success') do
Buy Ticket Get Ticket
= humanized_money_with_symbol ticket.price = humanized_money_with_symbol ticket.price

View file

@ -85,33 +85,31 @@
- if @conference.tickets.any? - if @conference.tickets.any?
.row .row
.col-md-12 .col-md-12
-if @tickets.any?
%h4 %h4
%span.fa-stack %span.fa-stack
%i.fa.fa-square-o.fa-stack-2x %i.fa.fa-square-o.fa-stack-2x
%i.fa.fa-ticket.fa-stack-1x %i.fa.fa-ticket.fa-stack-1x
Tickets Ticket Purchases
-if @tickets.any? = "(#{@tickets.first.price.symbol}#{humanized_money @total_price})"
= "(#{@total_price} #{@tickets.first.price.symbol})"
%ul %ul
- @tickets.each do |ticket| .col-md-12
- @ticket_payments.each_pair do |ticket_id, tickets|
%li %li
= ticket.quantity = @total_quantity[ticket_id]
= ticket.title = tickets.first.title
= word_pluralize(ticket.quantity, 'Ticket') = word_pluralize(@total_quantity[ticket_id], 'Ticket')
for for
= humanized_money ticket.price = tickets.first.price.symbol
= ticket.price.symbol = humanized_money tickets.first.price
= link_to conference_ticket_purchase_path(@conference.short_title, ticket.id), method: :delete, %br
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? - if @tickets.any?
= link_to 'Buy more tickets', conference_tickets_path(@conference.short_title) = link_to 'Get more tickets', conference_tickets_path(@conference.short_title), class: "btn btn-default"
- else - else
You haven't bought any tickets. You haven't bought any tickets.
= link_to 'Please buy some tickets to support us!', conference_tickets_path(@conference.short_title) = 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 .row
.col-md-12 .col-md-12

View file

@ -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'

View file

@ -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'

View file

@ -8,12 +8,8 @@
- unless ticket.description.blank? - unless ticket.description.blank?
= markdown(ticket.description) = markdown(ticket.description)
%td.col-sm-1.col-md-1 %td.col-sm-1.col-md-1
- if ticket.bought?(current_user) = text_field_tag("tickets[][#{ticket.id}]", 0,
= text_field_tag("tickets[][#{ticket.id}]", ticket.quantity_bought_by(current_user),
type: 'number', min: 0, class: "form-control quantity", 'data-id' => ticket.id) 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 %td.col-sm-1.col-md-1.text-center
= ticket.price.symbol = ticket.price.symbol
%span{id: "price_#{ticket.id}"} %span{id: "price_#{ticket.id}"}

View file

@ -5,10 +5,10 @@
%h1 %h1
Tickets Tickets
%p.lead %p.lead
If you like, support Please choose your tickets for
%strong %strong
= @conference.title = @conference.title
by buying a ticket* here*
=form_tag(conference_ticket_purchases_path, method: :post) do |f| =form_tag(conference_ticket_purchases_path, method: :post) do |f|
%table.table.table-hover %table.table.table-hover
%thead %thead
@ -35,12 +35,11 @@
.pull-right .pull-right
.btn-group-vertical .btn-group-vertical
= button_tag(type: 'submit', class: 'btn btn-success btn-lg') do = button_tag(type: 'submit', class: 'btn btn-success btn-lg') do
Support Continue
%i.fa.fa-shopping-cart %i.fa.fa-shopping-cart
= link_to 'Continue without a Ticket!', conference_conference_registration_path(@conference.short_title), = link_to 'Cancel registration', conference_conference_registration_path(@conference.short_title), method: :delete, class: 'btn btn-danger btn-sm'
class: 'btn btn-danger btn-sm'
.row .row
.col-md-13 .col-md-13
%p.text-muted.text-center %p.text-muted.text-center
%small %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.

View file

@ -0,0 +1 @@
Stripe.api_key = Rails.application.secrets.stripe_secret_key

View file

@ -121,6 +121,7 @@ Osem::Application.routes.draw do
resource :conference_registration, path: 'register' resource :conference_registration, path: 'register'
resources :tickets, only: [:index] resources :tickets, only: [:index]
resources :ticket_purchases, only: [:create, :destroy] resources :ticket_purchases, only: [:create, :destroy]
resources :payments, only: [:index, :new, :create]
resource :subscriptions, only: [:create, :destroy] resource :subscriptions, only: [:create, :destroy]
resource :schedule, only: [:show] do resource :schedule, only: [:show] do
member do member do

View file

@ -13,10 +13,20 @@ development:
suse_key: 'sample' suse_key: 'sample'
suse_secret: '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: test:
# Generate your own with rake secret # Generate your own with rake secret
# secret_key_base: '12345' # 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: production:
# Generate your own with rake secret or use the environment # Generate your own with rake secret or use the environment
# secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> # secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
@ -56,3 +66,8 @@ production:
# https://github.com/settings/applications # https://github.com/settings/applications
github_key: '' github_key: ''
github_secret: '' 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'] %>

View file

@ -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

View file

@ -0,0 +1,5 @@
class AddPaymentIdToTicketPurchases < ActiveRecord::Migration
def change
add_column :ticket_purchases, :payment_id, :integer
end
end

View file

@ -264,6 +264,17 @@ ActiveRecord::Schema.define(version: 20160815140302) do
t.datetime "updated_at" t.datetime "updated_at"
end 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| create_table "programs", force: :cascade do |t|
t.integer "conference_id" t.integer "conference_id"
t.integer "rating", default: 0 t.integer "rating", default: 0
@ -424,6 +435,7 @@ ActiveRecord::Schema.define(version: 20160815140302) do
t.datetime "created_at" t.datetime "created_at"
t.integer "quantity", default: 1 t.integer "quantity", default: 1
t.integer "user_id" t.integer "user_id"
t.integer "payment_id"
end end
create_table "tickets", force: :cascade do |t| create_table "tickets", force: :cascade do |t|

View file

@ -37,6 +37,11 @@ OSEM_FACEBOOK_SECRET=''
OSEM_GITHUB_KEY='' OSEM_GITHUB_KEY=''
OSEM_GITHUB_SECRET='' 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. # Disable linting of factories in the test suite.
# Speeds up turn around times of tests # Speeds up turn around times of tests
OSEM_FACTORY_LINT="false" OSEM_FACTORY_LINT="false"

View file

@ -82,9 +82,8 @@ describe ConferenceRegistrationsController, type: :controller do
get :show, conference_id: conference.short_title get :show, conference_id: conference.short_title
end end
it 'assigns price of purchased tickets to total_price and purchased tickets to tickets' do 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(10000, 'USD') expect(assigns(:total_price)).to eq Money.new(0, 'USD')
expect(assigns(:tickets)).to match_array [@purchased_ticket]
end end
end end

View file

@ -0,0 +1,7 @@
FactoryGirl.define do
factory :payment do
user
conference
status 'unpaid'
end
end

View file

@ -16,7 +16,7 @@ feature Registration do
context 'who is not registered' 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 visit root_path
click_link 'Register' click_link 'Register'
@ -26,26 +26,66 @@ feature Registration do
fill_in "tickets__#{ticket.id}", with: '2' fill_in "tickets__#{ticket.id}", with: '2'
expect(current_path).to eq(conference_tickets_path(conference.short_title)) 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 purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first
expect(purchase.quantity).to eq(2) 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('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(current_path).to eq(conference_conference_registration_path(conference.short_title))
expect(flash). expect(page.has_content?("2 #{ticket.title} Tickets for $ 10")).to be true
to eq("Thank you for supporting #{conference.title} by purchasing a ticket.") end
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 scenario 'purchases ticket but payment fails', feature: true, js: true do
create(:registration, conference: conference, user: participant) visit root_path
create(:ticket_purchase, conference: conference, user: participant, ticket: ticket, quantity: 4) click_link 'Register'
visit conference_conference_registration_path(conference.short_title) expect(current_path).to eq(new_conference_conference_registration_path(conference.short_title))
expect(page.has_content?("4 #{ticket.title} Tickets for 10")).to be true click_button 'Register'
click_link "ticket-#{ticket.id}-delete" fill_in "tickets__#{ticket.id}", with: '2'
expect(flash).to eq('Ticket successfully deleted.') expect(current_path).to eq(conference_tickets_path(conference.short_title))
expect(TicketPurchase.count).to eq(0)
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 end
end end

View file

@ -105,6 +105,9 @@ describe 'User' do
it{ should be_able_to(:index, Ticket) } it{ should be_able_to(:index, Ticket) }
it{ should be_able_to(:manage, TicketPurchase.new(user_id: user.id)) } 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(:create, Subscription.new(user_id: user.id)) }
it{ should be_able_to(:destroy, subscription) } it{ should be_able_to(:destroy, subscription) }

133
spec/models/payment_spec.rb Normal file
View file

@ -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

View file

@ -82,31 +82,72 @@ describe Ticket do
end end
end end
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
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 describe '#quantity_bought_by' do
context 'user has not paid' do
it 'returns the correct value if the user has bought this ticket' do it 'returns the correct value if the user has bought this ticket' do
create(:ticket_purchase, create(:ticket_purchase,
user: user, user: user,
ticket: ticket, ticket: ticket,
quantity: 20) quantity: 20)
expect(ticket.quantity_bought_by(user)).to eq(20) expect(ticket.quantity_bought_by(user, paid: false)).to eq(20)
end end
it 'returns zero if the user has not bought this ticket' do it 'returns zero if the user has not bought this ticket' do
expect(ticket.quantity_bought_by(user)).to eq(0) 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
end end
describe '#total_price' do describe '#total_price' do
context 'user has not paid' do
it 'returns the correct value if the user has bought this ticket' do it 'returns the correct value if the user has bought this ticket' do
create(:ticket_purchase, create(:ticket_purchase,
user: user, user: user,
ticket: ticket, ticket: ticket,
quantity: 20) quantity: 20)
expect(ticket.total_price(user)).to eq(Money.new(100000, 'USD')) expect(ticket.total_price(user, paid: false)).to eq(Money.new(100000, 'USD'))
end end
it 'returns zero if the user has not bought this ticket' do it 'returns zero if the user has not bought this ticket' do
expect(ticket.total_price(user)).to eq(Money.new(0, 'USD')) expect(ticket.total_price(user, paid: false)).to eq(Money.new(0, 'USD'))
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 this ticket' do
expect(ticket.total_price(user, paid: true)).to eq(Money.new(100000, 'USD'))
end
end end
end end
@ -116,7 +157,7 @@ describe Ticket do
describe 'user has bought' do describe 'user has bought' do
context 'no tickets' do context 'no tickets' do
it 'returns zero' 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
end end
@ -126,7 +167,7 @@ describe Ticket do
end end
it 'returns the correct total price' do 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
end end
@ -138,7 +179,7 @@ describe Ticket do
it 'returns the correct total price' do it 'returns the correct total price' do
total_price = Money.new(200000, 'USD') 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 end
end end

View file

@ -1,6 +1,6 @@
# Mock external requests to youtube # Mock external requests to youtube
require 'webmock/rspec' require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true) WebMock.disable_net_connect!(allow_localhost: true, allow: /stripe.com/)
RSpec.configure do |config| RSpec.configure do |config|
config.before(:each) do config.before(:each) do