diff --git a/Gemfile b/Gemfile index 01bd5af1..e9ad0901 100644 --- a/Gemfile +++ b/Gemfile @@ -183,6 +183,9 @@ gem 'faker' # for seeds gem 'factory_girl_rails' +# for online payments +gem 'activemerchant' + # Use guard and spring for testing in development group :development do # to launch specs when files are modified diff --git a/Gemfile.lock b/Gemfile.lock index 4647f23c..43245d31 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -26,6 +26,11 @@ GEM activejob (4.2.5.2) activesupport (= 4.2.5.2) globalid (>= 0.3.0) + activemerchant (1.59.0) + activesupport (>= 3.2.14, < 5.1) + builder (>= 2.1.2, < 4.0.0) + i18n (>= 0.6.9) + nokogiri (~> 1.4) activemodel (4.2.5.2) activesupport (= 4.2.5.2) builder (~> 3.1) @@ -529,6 +534,7 @@ PLATFORMS DEPENDENCIES active_model_serializers + activemerchant activeuuid acts_as_commentable_with_threading acts_as_list diff --git a/app/assets/images/credit_card.png b/app/assets/images/credit_card.png new file mode 100644 index 00000000..a5e0b1e3 Binary files /dev/null and b/app/assets/images/credit_card.png differ 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..077969f1 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,38 @@ +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) + 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), flash: { success: 'Thanks! You have purchased your tickets successfully.' } + else + @total_amount_to_pay = Ticket.total_price(@conference, current_user, paid: false) + render 'new' + end + end + + private + + def update_purchased_ticket_purchases + current_user.ticket_purchases.by_conference(@conference).unpaid.update_all(paid: true, payment_id: @payment.id) + end + + def payment_params + params.require(:payment) + .permit(:full_name, :credit_card_number, :expiration_month, :expiration_year, :card_verification_value, :amount) + .merge(user: current_user, conference: @conference) + end +end diff --git a/app/controllers/ticket_purchases_controller.rb b/app/controllers/ticket_purchases_controller.rb index 30d24d82..ff52021a 100644 --- a/app/controllers/ticket_purchases_controller.rb +++ b/app/controllers/ticket_purchases_controller.rb @@ -4,11 +4,11 @@ class TicketPurchasesController < ApplicationController authorize_resource :conference_registrations, class: Registration def create + TicketPurchase.by_conference(@conference).unpaid.by_user(current_user).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 purchase tickets.' else redirect_to conference_conference_registration_path(@conference.short_title) end diff --git a/app/helpers/payments_helper.rb b/app/helpers/payments_helper.rb new file mode 100644 index 00000000..472debd2 --- /dev/null +++ b/app/helpers/payments_helper.rb @@ -0,0 +1,9 @@ +module PaymentsHelper + def months + (1..12).collect{|n| ["#{n} - #{Date::MONTHNAMES[n]}", n]} + end + + def years + (Date.current.year..Date.current.year + 15) + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 76e1fc4c..a497fe95 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], 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 9079bd71..ad95e902 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..d8c1dfd6 --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,59 @@ +class Payment < ActiveRecord::Base + has_many :ticket_purchases + belongs_to :user + belongs_to :conference + + attr_accessor :credit_card_number + attr_accessor :credit_card_type + attr_accessor :card_verification_value + attr_accessor :expiration_month + attr_accessor :expiration_year + + validates :full_name, presence: true + validates :credit_card_number, presence: true + validates :card_verification_value, presence: true, length: { minimum: 3, maximum: 4 } + validates :expiration_month, presence: true, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 12 } + validates :expiration_year, presence: true + validates :amount, presence: true, numericality: { greater_than: 0 } + validates :user_id, presence: true + validates :conference_id, presence: true + + enum status: { + unpaid: 0, + success: 1, + failure: 2 + } + + def credit_card + @credit_card ||= ActiveMerchant::Billing::CreditCard.new( + name: full_name, + number: credit_card_number, + month: expiration_month, + year: expiration_year, + verification_value: card_verification_value + ) + end + + def amount_to_pay + Ticket.total_price(conference, user, paid: false).cents + end + + def purchase + gateway_response = begin + GATEWAY.purchase(amount_to_pay, credit_card, currency: conference.tickets.first.price_currency) + rescue + ActiveMerchant::Billing::Response.new(false, 'Unable to receive any response from the payment gateway.') + end + + if gateway_response.success? + self.last4 = credit_card.display_number + self.authorization_code = gateway_response.authorization + self.status = 'success' + else + errors.add(:base, gateway_response.message) + self.status = 'failure' + end + + success? + end +end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 54f5c3d7..d2454b74 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -18,24 +18,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 d3c847a2..4878ed9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,6 +38,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/conference_registrations/show.html.haml b/app/views/conference_registrations/show.html.haml index 30cb281b..24923d07 100644 --- a/app/views/conference_registrations/show.html.haml +++ b/app/views/conference_registrations/show.html.haml @@ -89,29 +89,27 @@ %span.fa-stack %i.fa.fa-square-o.fa-stack-2x %i.fa.fa-ticket.fa-stack-1x - Tickets + Ticket Purchases -if @tickets.any? - = "(#{@total_price} #{@tickets.first.price.symbol})" + = "(#{@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 'Buy more tickets', conference_tickets_path(@conference.short_title), class: "btn btn-default" + - else + You haven't bought any tickets. + = link_to 'Please buy some tickets to support us!', conference_tickets_path(@conference.short_title) + %p + (Your registration won't be complete without buying 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..db508369 --- /dev/null +++ b/app/views/payments/_payment.html.haml @@ -0,0 +1,25 @@ += semantic_form_for(@payment, url: conference_payments_path) do |f| + .form-group + = f.label :full_name, 'Full name (as on card)' + = f.text_field :full_name, class: "form-control", placeholder: "John Doe", id: "full_name" + %span.pull-right + %img.img-responsive{:src => image_url('credit_card.png')} + .form-group + = f.label :credit_card_number, "Credit Card number (without spaces)" + .input-group + = f.text_field :credit_card_number, class: "form-control", placeholder: "XXXX XXXX XXXX XXXX", id: "credit_card_number" + %span.input-group-addon + %i.fa.fa-credit-card + .form-group.col-md-6 + = f.label :expiration_month + = f.select :expiration_month, months, {}, class: "form-control", id: "expiration_month" + .form-group.col-md-6 + = f.label :expiration_year + = f.select :expiration_year, years, {}, class: "form-control", id: "expiration_year" + .form-group.col-md-10 + = f.label :card_verification_value, 'Security Code (3 on back, AmEx: 4 on front)' + = f.text_field :card_verification_value, class: "form-control", placeholder: "XXX", id: "card_verification_value" + = f.number_field :amount, value: @total_amount_to_pay, class: "form-control", type: 'hidden' + .form-group.text-center + = f.submit "Pay #{number_to_currency @total_amount_to_pay, unit: @total_amount_to_pay.symbol}", class: "btn btn-primary", id: "Charge Card" + = link_to "Cancel", conference_conference_registration_path, class: "btn btn-danger" diff --git a/app/views/payments/new.html.haml b/app/views/payments/new.html.haml new file mode 100644 index 00000000..f7e4fc36 --- /dev/null +++ b/app/views/payments/new.html.haml @@ -0,0 +1,14 @@ +.container + .row + .col-xs-6.col-xs-offset-3 + %h1 + Buy tickets for + = @total_amount_to_pay.symbol + = humanized_money @total_amount_to_pay + - if @payment.errors.any? + .alert.alert-danger + %ul + - @payment.errors.full_messages.each do |msg| + %li= msg + .col-xs-6.col-xs-offset-3.well + = render partial: 'payment' 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..8f80e1ac 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. + * Buying a ticket is mandatory. Your registration will not complete until you buy a ticket. diff --git a/config/environments/development.rb b/config/environments/development.rb index 61d9fd57..126a7ea1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -96,4 +96,10 @@ Osem::Application.configure do end end + #Initialize Payment Gateway with valid credentials + ActiveMerchant::Billing::Base.mode = :test + ::GATEWAY = ActiveMerchant::Billing::StripeGateway.new(:login => ENV['OSEM_GATEWAY_TEST_SECRET_KEY']) end + + + diff --git a/config/environments/production.rb b/config/environments/production.rb index d2b43a46..92090758 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -83,4 +83,8 @@ Osem::Application.configure do # Set the secret_key_base from the env, if not set by any other means config.secret_key_base ||= ENV["SECRET_KEY_BASE"] + + # Initialize Payment Gateway with valid credentials + ActiveMerchant::Billing::Base.mode = :test + ::GATEWAY = ActiveMerchant::Billing::StripeGateway.new(:login => ENV['OSEM_GATEWAY_LIVE_SECRET_KEY']) end diff --git a/config/environments/test.rb b/config/environments/test.rb index 68c74dab..a6935e24 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -52,4 +52,9 @@ Osem::Application.configure do ActiveSupport::Deprecation.silenced = true end + # Initialize Payment Gateway with valid credentials + ActiveMerchant::Billing::Base.mode = :test + ::GATEWAY = ActiveMerchant::Billing::BogusGateway.new end + + diff --git a/config/routes.rb b/config/routes.rb index a2bd18a6..cf3f7a5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,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] member do diff --git a/db/migrate/20160606040848_create_payments.rb b/db/migrate/20160606040848_create_payments.rb new file mode 100644 index 00000000..20350d95 --- /dev/null +++ b/db/migrate/20160606040848_create_payments.rb @@ -0,0 +1,15 @@ +class CreatePayments < ActiveRecord::Migration + def change + create_table :payments do |t| + t.string :full_name, null: false + t.string :last4 + t.integer :amount, null: false + 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 120ea7e5..ce12dc29 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -250,6 +250,18 @@ ActiveRecord::Schema.define(version: 20160624151257) do t.datetime "updated_at" end + create_table "payments", force: :cascade do |t| + t.string "full_name", null: false + t.string "last4" + t.integer "amount", null: false + 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 @@ -399,6 +411,7 @@ ActiveRecord::Schema.define(version: 20160624151257) 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/spec/controllers/conference_registration_controller_spec.rb b/spec/controllers/conference_registration_controller_spec.rb index 2d67b228..4e07dff1 100644 --- a/spec/controllers/conference_registration_controller_spec.rb +++ b/spec/controllers/conference_registration_controller_spec.rb @@ -40,9 +40,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..4f0ef7a1 --- /dev/null +++ b/spec/factories/payments.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :payment do + user + conference + full_name { Faker::Hipster.word.to_s } + credit_card_number '4242424242424111' + card_verification_value '123' + expiration_month 6 + expiration_year { Date.current.year + 2 } + amount 10 + end + + trait :invalid_credit_card do + credit_card_number '4242424242424222' + end + + trait :exception_credit_card do + credit_card_number '4242424242424333' + end +end diff --git a/spec/features/payments_spec.rb b/spec/features/payments_spec.rb new file mode 100644 index 00000000..d9ef69dd --- /dev/null +++ b/spec/features/payments_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +feature Registration do + let!(:ticket) { create(:ticket) } + let!(:conference) { create(:conference, title: 'ExampleCon', tickets: [ticket], registration_period: create(:registration_period, start_date: 3.days.ago)) } + let!(:participant) { create(:user) } + + context 'as a participant' do + before(:each) do + sign_in participant + end + + after(:each) do + sign_out + end + + context 'who is not registered' do + + scenario 'purchases and pays for a ticket, with gateway producing error', feature: true, js: true do + visit root_path + click_link 'Register' + + expect(current_path).to eq(new_conference_conference_registration_path(conference.short_title)) + click_button 'Register' + + 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 purchase tickets.') + purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first + expect(purchase.quantity).to eq(2) + + fill_in 'full_name', with: 'foo' + select Date.current.year + 2, from: 'expiration_year' + fill_in 'card_verification_value', with: '123' + fill_in 'credit_card_number', with: '4242424242423333' + + click_button 'Charge Card' + + expect(Payment.count).to eq(0) + expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) + end + + scenario 'purchases and pays for a ticket, with card producing a transaction failure', feature: true, js: true do + visit root_path + click_link 'Register' + + expect(current_path).to eq(new_conference_conference_registration_path(conference.short_title)) + click_button 'Register' + + 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 purchase tickets.') + purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first + expect(purchase.quantity).to eq(2) + + fill_in 'full_name', with: 'foo' + select Date.current.year + 2, from: 'expiration_year' + fill_in 'card_verification_value', with: '123' + fill_in 'credit_card_number', with: '4242424242422222' + + click_button 'Charge Card' + + expect(Payment.count).to eq(0) + expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) + end + + scenario 'purchases and pays for a ticket successfully', feature: true, js: true do + visit root_path + click_link 'Register' + + expect(current_path).to eq(new_conference_conference_registration_path(conference.short_title)) + click_button 'Register' + + 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 purchase tickets.') + purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first + expect(purchase.quantity).to eq(2) + + fill_in 'full_name', with: 'foo' + select Date.current.year + 2, from: 'expiration_year' + fill_in 'card_verification_value', with: '123' + fill_in 'credit_card_number', with: '4242424242421111' + + click_button 'Charge Card' + + expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) + expect(Payment.count).to eq(1) + payment = Payment.where(user_id: participant, conference_id: conference.id).first + expect(payment.amount).to eq(20) + expect(payment.status).to eq('success') + expect(payment.first_name).to eq('foo') + expect(payment.last_name).to eq('bar') + expect(payment.last4).not_to be_empty + expect(payment.authorization_code).not_to be_empty + expect(flash).to eq('Thanks! You have purchased your tickets successfully.') + end + end + end +end diff --git a/spec/features/ticket_purchases_spec.rb b/spec/features/ticket_purchases_spec.rb index ff6957f6..3d42473f 100644 --- a/spec/features/ticket_purchases_spec.rb +++ b/spec/features/ticket_purchases_spec.rb @@ -26,26 +26,12 @@ 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 purchase 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 - 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) - - visit conference_conference_registration_path(conference.short_title) - expect(page.has_content?("4 #{ticket.title} Tickets for 10")).to be true - - click_link "ticket-#{ticket.id}-delete" - expect(flash).to eq('Ticket successfully deleted.') - expect(TicketPurchase.count).to eq(0) end end end diff --git a/spec/helpers/payments_helper_spec.rb b/spec/helpers/payments_helper_spec.rb new file mode 100644 index 00000000..91feb177 --- /dev/null +++ b/spec/helpers/payments_helper_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe PaymentsHelper, type: :helper do + describe '#months' do + it 'returns the correct strings for months' do + expect(months).to match_array(Array([['1 - January', 1], ['2 - February', 2], ['3 - March', 3], + ['4 - April', 4], ['5 - May', 5], ['6 - June', 6], + ['7 - July', 7], ['8 - August', 8], ['9 - September', 9], + ['10 - October', 10], ['11 - November', 11], ['12 - December', 12]])) + end + end + + describe '#years' do + it 'returns the correct set of options' do + expect(years).to match_array(Array(Date.current.year..Date.current.year + 15)) + end + end +end diff --git a/spec/models/conference_spec.rb b/spec/models/conference_spec.rb old mode 100755 new mode 100644 diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 00000000..0cce495e --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,148 @@ +require 'spec_helper' + +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(:full_name) } + + it { is_expected.to validate_presence_of(:credit_card_number) } + + it { is_expected.to validate_presence_of(:card_verification_value) } + + it { is_expected.to validate_presence_of(:expiration_month) } + + it { is_expected.to validate_presence_of(:expiration_year) } + + it { is_expected.to validate_presence_of(:amount) } + + it { is_expected.to validate_presence_of(:user_id) } + + it { is_expected.to validate_presence_of(:conference_id) } + + it 'is not valid with a amount equals zero' do + should_not allow_value(0).for(:amount) + end + + it 'is not valid with a amount smaller than zero' do + should_not allow_value(-1).for(:amount) + end + + it 'is valid with a amount greater than zero' do + should allow_value(1).for(:amount) + end + + end + + describe '#credit_card' do + let(:payment) { create(:payment) } + + it 'assigns correct "month"' do + expect(payment.credit_card.month).to eq(6) + end + + it 'assigns correct "year"' do + expect(payment.credit_card.year).to eq(Date.current.year + 2) + end + + it 'assigns correct "verification_value"' do + expect(payment.credit_card.verification_value).to eq('123') + end + + it 'assigns correct "card_number"' do + expect(payment.credit_card.display_number).to eq('XXXX-XXXX-XXXX-4111') + end + 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!(:ticket_1) { create(:ticket) } + let!(:conference) { create(:conference, tickets: [ticket_1]) } + let!(:payment) { create(:payment, user: user, conference: conference) } + + let!(:tickets) { {ticket_1.id.to_s => '1'} } + + before { TicketPurchase.purchase(conference, user, tickets) } + + context 'when the payment is successful' do + before { payment.purchase } + + it 'returns true' do + payment_result = payment.purchase + expect(payment_result).to eq true + end + + it "assigns 'success' to payment.status" do + expect(payment.status).to eq('success') + end + + it 'assigns last4' do + expect(payment.last4).to eq('XXXX-XXXX-XXXX-4111') + end + + it 'assigns authorization_code' do + expect(payment.authorization_code).to eq('53433') + end + end + + context 'if the payment is not successful' do + before { payment.purchase } + + let(:payment) { create(:payment, :invalid_credit_card) } + + 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 there is a connection problem with the gateway' do + let(:payment) { create(:payment, :exception_credit_card) } + + 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 + 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