diff --git a/app/controllers/spree/admin/payments_controller.rb b/app/controllers/spree/admin/payments_controller.rb index 215f69972a..408e6e5c81 100644 --- a/app/controllers/spree/admin/payments_controller.rb +++ b/app/controllers/spree/admin/payments_controller.rb @@ -61,7 +61,7 @@ module Spree else flash[:error] = t(:cannot_perform_operation) end - rescue Spree::Core::GatewayError => e + rescue StandardError => e flash[:error] = e.message ensure redirect_to request.referer diff --git a/app/models/spree/payment.rb b/app/models/spree/payment.rb new file mode 100644 index 0000000000..754119034a --- /dev/null +++ b/app/models/spree/payment.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +module Spree + class Payment < ActiveRecord::Base + include Spree::Payment::Processing + extend Spree::LocalizedNumber + + localize_number :amount + + IDENTIFIER_CHARS = (('A'..'Z').to_a + ('0'..'9').to_a - %w(0 1 I O)).freeze + + delegate :line_items, to: :order + delegate :currency, to: :order + + belongs_to :order, class_name: 'Spree::Order' + belongs_to :source, polymorphic: true + belongs_to :payment_method, class_name: 'Spree::PaymentMethod' + + has_many :offsets, -> { where("source_type = 'Spree::Payment' AND amount < 0").completed }, + class_name: "Spree::Payment", foreign_key: :source_id + has_many :log_entries, as: :source, dependent: :destroy + + has_one :adjustment, as: :source, dependent: :destroy + + validate :validate_source + before_save :set_unique_identifier + + after_save :create_payment_profile, if: :profiles_supported? + + # update the order totals, etc. + after_save :ensure_correct_adjustment, :update_order + # invalidate previously entered payments + after_create :invalidate_old_payments + + # Skips the validation of the source (for example, credit card) of the payment. + # + # This is used in refunds as the validation of the card can fail but the refund can go through, + # we trust the payment gateway in these cases. For example, Stripe is accepting refunds with + # source cards that were valid when the payment was placed but are now expired, and we + # consider them invalid. + attr_accessor :skip_source_validation + attr_accessor :source_attributes + + after_initialize :build_source + + scope :from_credit_card, -> { where(source_type: 'Spree::CreditCard') } + scope :with_state, ->(s) { where(state: s.to_s) } + scope :completed, -> { with_state('completed') } + scope :pending, -> { with_state('pending') } + scope :failed, -> { with_state('failed') } + scope :valid, -> { where('state NOT IN (?)', %w(failed invalid)) } + + # order state machine (see http://github.com/pluginaweek/state_machine/tree/master for details) + state_machine initial: :checkout do + # With card payments, happens before purchase or authorization happens + event :started_processing do + transition from: [:checkout, :pending, :completed, :processing], to: :processing + end + # When processing during checkout fails + event :failure do + transition from: [:pending, :processing], to: :failed + end + # With card payments this represents authorizing the payment + event :pend do + transition from: [:checkout, :processing], to: :pending + end + # With card payments this represents completing a purchase or capture transaction + event :complete do + transition from: [:processing, :pending, :checkout], to: :completed + end + event :void do + transition from: [:pending, :completed, :checkout], to: :void + end + # when the card brand isnt supported + event :invalidate do + transition from: [:checkout], to: :invalid + end + end + + def money + Spree::Money.new(amount, currency: currency) + end + alias display_amount money + + def offsets_total + offsets.pluck(:amount).sum + end + + def credit_allowed + amount - offsets_total + end + + def can_credit? + credit_allowed.positive? + end + + def build_source + return if source_attributes.nil? + return unless payment_method.andand.payment_source_class + + self.source = payment_method.payment_source_class.new(source_attributes) + source.payment_method_id = payment_method.id + source.user_id = order.user_id if order + end + + # Pin payments lacks void and credit methods, but it does have refund + # Here we swap credit out for refund and remove void as a possible action + def actions + return [] unless payment_source&.respond_to?(:actions) + + actions = payment_source.actions.select do |action| + !payment_source.respond_to?("can_#{action}?") || + payment_source.__send__("can_#{action}?", self) + end + + if payment_method.is_a? Gateway::Pin + actions << 'refund' if actions.include? 'credit' + actions.reject! { |a| ['credit', 'void'].include? a } + end + + actions + end + + def payment_source + res = source.is_a?(Payment) ? source.source : source + res || payment_method + end + + def ensure_correct_adjustment + revoke_adjustment_eligibility if ['failed', 'invalid'].include?(state) + return if adjustment.try(:finalized?) + + if adjustment + adjustment.originator = payment_method + adjustment.label = adjustment_label + adjustment.save + else + payment_method.create_adjustment(adjustment_label, order, self, true) + association(:adjustment).reload + end + end + + def adjustment_label + I18n.t('payment_method_fee') + end + + private + + # Don't charge fees for invalid or failed payments. + # This is called twice for failed payments, because the persistence of the 'failed' + # state is acheived through some trickery using an after_rollback callback on the + # payment model. See Spree::Payment#persist_invalid + def revoke_adjustment_eligibility + return unless adjustment.try(:reload) + return if adjustment.finalized? + + adjustment.update_attribute(:eligible, false) + adjustment.finalize! + end + + def validate_source + if source && !skip_source_validation && !source.valid? + source.errors.each do |field, error| + field_name = I18n.t("activerecord.attributes.#{source.class.to_s.underscore}.#{field}") + errors.add(Spree.t(source.class.to_s.demodulize.underscore), "#{field_name} #{error}") + end + end + errors.blank? + end + + def profiles_supported? + payment_method.respond_to?(:payment_profiles_supported?) && + payment_method.payment_profiles_supported? + end + + def create_payment_profile + return unless source.is_a?(CreditCard) + return unless source.try(:save_requested_by_customer?) + return unless source.number || source.gateway_payment_profile_id + return unless source.gateway_customer_profile_id.nil? + + payment_method.create_profile(self) + rescue ActiveMerchant::ConnectionError => e + gateway_error e + end + + # Makes newly entered payments invalidate previously entered payments so the most recent payment + # is used by the gateway. + def invalidate_old_payments + order.payments.with_state('checkout').where.not(id: id).each do |payment| + # Using update_column skips validations and so it skips validate_source. As we are just + # invalidating past payments here, we don't want to validate all of them at this stage. + payment.update_column(:state, 'invalid') + payment.ensure_correct_adjustment + end + end + + def update_order + order.payments.reload + order.update! + end + + # Necessary because some payment gateways will refuse payments with + # duplicate IDs. We *were* using the Order number, but that's set once and + # is unchanging. What we need is a unique identifier on a per-payment basis, + # and this is it. Related to #1998. + # See https://github.com/spree/spree/issues/1998#issuecomment-12869105 + def set_unique_identifier + self.identifier = generate_identifier while self.class.exists?(identifier: identifier) + end + + def generate_identifier + Array.new(8){ IDENTIFIER_CHARS.sample }.join + end + end +end diff --git a/app/models/spree/payment/processing.rb b/app/models/spree/payment/processing.rb new file mode 100644 index 0000000000..2579f76de7 --- /dev/null +++ b/app/models/spree/payment/processing.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +module Spree + class Payment < ActiveRecord::Base + module Processing + def process! + return unless payment_method&.source_required? + + raise Core::GatewayError, Spree.t(:payment_processing_failed) unless source + + return if processing? + + unless payment_method.supports?(source) + invalidate! + raise Core::GatewayError, Spree.t(:payment_method_not_supported) + end + + if payment_method.auto_capture? + purchase! + else + authorize! + end + end + + def authorize! + started_processing! + gateway_action(source, :authorize, :pend) + end + + def purchase! + started_processing! + gateway_action(source, :purchase, :complete) + end + + def capture! + return true if completed? + + started_processing! + protect_from_connection_error do + check_environment + + response = if payment_method.payment_profiles_supported? + # Gateways supporting payment profiles will need access to credit + # card object because this stores the payment profile information + # so supply the authorization itself as well as the credit card, + # rather than just the authorization code + payment_method.capture(self, source, gateway_options) + else + # Standard ActiveMerchant capture usage + payment_method.capture(money.money.cents, + response_code, + gateway_options) + end + + handle_response(response, :complete, :failure) + end + end + + def void_transaction! + return true if void? + + protect_from_connection_error do + check_environment + + response = if payment_method.payment_profiles_supported? + # Gateways supporting payment profiles will need access to credit + # card object because this stores the payment profile information + # so supply the authorization itself as well as the credit card, + # rather than just the authorization code + payment_method.void(response_code, source, gateway_options) + else + # Standard ActiveMerchant void usage + payment_method.void(response_code, gateway_options) + end + + record_response(response) + + if response.success? + self.response_code = response.authorization + void + else + gateway_error(response) + end + end + end + + def credit!(credit_amount = nil) + protect_from_connection_error do + check_environment + + credit_amount = calculate_refund_amount(credit_amount) + + response = if payment_method.payment_profiles_supported? + payment_method.credit( + (credit_amount * 100).round, + source, + response_code, + gateway_options + ) + else + payment_method.credit( + (credit_amount * 100).round, + response_code, + gateway_options + ) + end + + record_response(response) + + if response.success? + self.class.create!( + order: order, + source: self, + payment_method: payment_method, + amount: credit_amount.abs * -1, + response_code: response.authorization, + state: 'completed', + skip_source_validation: true + ) + else + gateway_error(response) + end + end + end + + def refund!(refund_amount = nil) + protect_from_connection_error do + check_environment + + refund_amount = calculate_refund_amount(refund_amount) + + response = if payment_method.payment_profiles_supported? + payment_method.refund( + (refund_amount * 100).round, + source, + response_code, + gateway_options + ) + else + payment_method.refund( + (refund_amount * 100).round, + response_code, + gateway_options + ) + end + + record_response(response) + + if response.success? + self.class.create!( + order: order, + source: self, + payment_method: payment_method, + amount: refund_amount.abs * -1, + response_code: response.authorization, + state: 'completed', + skip_source_validation: true + ) + else + gateway_error(response) + end + end + end + + def partial_credit(amount) + return if amount > credit_allowed + + started_processing! + credit!(amount) + end + + def gateway_options + options = { email: order.email, + customer: order.email, + ip: order.last_ip_address, + # Need to pass in a unique identifier here to make some + # payment gateways happy. + # + # For more information, please see Spree::Payment#set_unique_identifier + order_id: gateway_order_id } + + options.merge!({ shipping: order.ship_total * 100, + tax: order.tax_total * 100, + subtotal: order.item_total * 100, + discount: order.promo_total * 100, + currency: currency }) + + options.merge!({ billing_address: order.bill_address.try(:active_merchant_hash), + shipping_address: order.ship_address.try(:active_merchant_hash) }) + + options + end + + private + + def calculate_refund_amount(refund_amount = nil) + refund_amount ||= if credit_allowed >= order.outstanding_balance.abs + order.outstanding_balance.abs + else + credit_allowed.abs + end + refund_amount.to_f + end + + def gateway_action(source, action, success_state) + protect_from_connection_error do + check_environment + + response = payment_method.public_send( + action, + (amount * 100).round, + source, + gateway_options + ) + handle_response(response, success_state, :failure) + end + end + + def handle_response(response, success_state, failure_state) + record_response(response) + + if response.success? + unless response.authorization.nil? + self.response_code = response.authorization + self.avs_response = response.avs_result['code'] + + if response.cvv_result + self.cvv_response_code = response.cvv_result['code'] + self.cvv_response_message = response.cvv_result['message'] + end + end + __send__("#{success_state}!") + else + __send__(failure_state) + gateway_error(response) + end + end + + def record_response(response) + log_entries.create(details: response.to_yaml) + end + + def protect_from_connection_error + yield + rescue ActiveMerchant::ConnectionError => e + gateway_error(e) + end + + def gateway_error(error) + text = if error.is_a? ActiveMerchant::Billing::Response + error.params['message'] || error.params['response_reason_text'] || error.message + elsif error.is_a? ActiveMerchant::ConnectionError + Spree.t(:unable_to_connect_to_gateway) + else + error.to_s + end + logger.error(Spree.t(:gateway_error)) + logger.error(" #{error.to_yaml}") + raise Core::GatewayError, text + end + + # Saftey check to make sure we're not accidentally performing operations on a live gateway. + # Ex. When testing in staging environment with a copy of production data. + def check_environment + return if payment_method.environment == Rails.env + + message = Spree.t(:gateway_config_unavailable) + " - #{Rails.env}" + raise Core::GatewayError, message + end + + # The unique identifier to be passed in to the payment gateway + def gateway_order_id + "#{order.number}-#{identifier}" + end + end + end +end diff --git a/app/models/spree/payment_decorator.rb b/app/models/spree/payment_decorator.rb deleted file mode 100644 index ec84a83a83..0000000000 --- a/app/models/spree/payment_decorator.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spree/localized_number' - -module Spree - Payment.class_eval do - extend Spree::LocalizedNumber - - delegate :line_items, to: :order - - has_one :adjustment, as: :source, dependent: :destroy - - after_save :ensure_correct_adjustment, :update_order - - localize_number :amount - - # We bypass this after_rollback callback that is setup in Spree::Payment - # The issues the callback fixes are not experienced in OFN: - # if a payment fails on checkout the state "failed" is persisted correctly - def persist_invalid; end - - def ensure_correct_adjustment - revoke_adjustment_eligibility if ['failed', 'invalid'].include?(state) - return if adjustment.try(:finalized?) - - if adjustment - adjustment.originator = payment_method - adjustment.label = adjustment_label - adjustment.save - else - payment_method.create_adjustment(adjustment_label, order, self, true) - association(:adjustment).reload - end - end - - def adjustment_label - I18n.t('payment_method_fee') - end - - # Pin payments lacks void and credit methods, but it does have refund - # Here we swap credit out for refund and remove void as a possible action - def actions_with_pin_payment_adaptations - actions = actions_without_pin_payment_adaptations - if payment_method.is_a? Gateway::Pin - actions << 'refund' if actions.include? 'credit' - actions.reject! { |a| ['credit', 'void'].include? a } - end - actions - end - alias_method_chain :actions, :pin_payment_adaptations - - def refund!(refund_amount = nil) - protect_from_connection_error do - check_environment - - refund_amount = calculate_refund_amount(refund_amount) - - if payment_method.payment_profiles_supported? - response = payment_method.refund((refund_amount * 100).round, source, response_code, gateway_options) - else - response = payment_method.refund((refund_amount * 100).round, response_code, gateway_options) - end - - record_response(response) - - if response.success? - self.class.create(order: order, - source: self, - payment_method: payment_method, - amount: refund_amount.abs * -1, - response_code: response.authorization, - state: 'completed') - else - gateway_error(response) - end - end - end - - # Import from future Spree v.2.3.0 d470b31798f37 - def build_source - return if source_attributes.nil? - return unless payment_method.andand.payment_source_class - - self.source = payment_method.payment_source_class.new(source_attributes) - source.payment_method_id = payment_method.id - source.user_id = order.user_id if order - end - - private - - def calculate_refund_amount(refund_amount = nil) - refund_amount ||= credit_allowed >= order.outstanding_balance.abs ? order.outstanding_balance.abs : credit_allowed.abs - refund_amount.to_f - end - - def create_payment_profile - return unless source.is_a?(CreditCard) - return unless source.try(:save_requested_by_customer?) - return unless source.number || source.gateway_payment_profile_id - return unless source.gateway_customer_profile_id.nil? - - payment_method.create_profile(self) - rescue ActiveMerchant::ConnectionError => e - gateway_error e - end - - # Don't charge fees for invalid or failed payments. - # This is called twice for failed payments, because the persistence of the 'failed' - # state is acheived through some trickery using an after_rollback callback on the - # payment model. See Spree::Payment#persist_invalid - def revoke_adjustment_eligibility - return unless adjustment.try(:reload) - return if adjustment.finalized? - - adjustment.update_attribute(:eligible, false) - adjustment.finalize! - end - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3726167337..b9612340a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -34,6 +34,8 @@ en: email: Customer E-Mail spree/payment: amount: Amount + state: State + source: Source spree/product: primary_taxon: "Product Category" supplier: "Supplier" diff --git a/spec/controllers/spree/admin/orders/payments/payments_controller_spec.rb b/spec/controllers/spree/admin/orders/payments/payments_controller_spec.rb index 6932efa236..8e78be69d8 100644 --- a/spec/controllers/spree/admin/orders/payments/payments_controller_spec.rb +++ b/spec/controllers/spree/admin/orders/payments/payments_controller_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Spree::Admin::PaymentsController, type: :controller do @@ -10,7 +12,7 @@ describe Spree::Admin::PaymentsController, type: :controller do allow(controller).to receive(:spree_current_user) { user } end - context "#create" do + describe "#create" do let!(:payment_method) { create(:payment_method, distributors: [shop]) } let(:params) { { amount: order.total, payment_method_id: payment_method.id } } @@ -138,4 +140,92 @@ describe Spree::Admin::PaymentsController, type: :controller do end end end + + describe '#fire' do + let(:payment_method) do + create( + :stripe_sca_payment_method, + distributor_ids: [create(:distributor_enterprise).id], + preferred_enterprise_id: create(:enterprise).id + ) + end + let(:order) { create(:order, state: 'complete') } + let(:payment) do + create(:payment, order: order, payment_method: payment_method, amount: order.total) + end + + let(:successful_response) { ActiveMerchant::Billing::Response.new(true, "Yay!") } + + context 'on credit event' do + let(:params) { { e: 'credit', order_id: order.number, id: payment.id } } + + before do + allow(request).to receive(:referer) { 'http://foo.com' } + allow(Spree::Payment).to receive(:find).with(payment.id.to_s) { payment } + end + + it 'handles gateway errors' do + allow(payment.payment_method) + .to receive(:credit).and_raise(Spree::Core::GatewayError, 'error message') + + spree_put :fire, params + + expect(flash[:error]).to eq('error message') + expect(response).to redirect_to('http://foo.com') + end + + it 'handles validation errors' do + allow(payment).to receive(:credit!).and_raise(StandardError, 'validation error') + + spree_put :fire, params + + expect(flash[:error]).to eq('validation error') + expect(response).to redirect_to('http://foo.com') + end + + it 'displays a success message and redirects to the referer' do + allow(payment_method).to receive(:credit) { successful_response } + + spree_put :fire, params + + expect(flash[:success]).to eq(I18n.t(:payment_updated)) + end + end + + context 'on refund event' do + let(:params) { { e: 'refund', order_id: order.number, id: payment.id } } + + before do + allow(request).to receive(:referer) { 'http://foo.com' } + allow(Spree::Payment).to receive(:find).with(payment.id.to_s) { payment } + end + + it 'handles gateway errors' do + allow(payment.payment_method) + .to receive(:refund).and_raise(Spree::Core::GatewayError, 'error message') + + spree_put :fire, params + + expect(flash[:error]).to eq('error message') + expect(response).to redirect_to('http://foo.com') + end + + it 'handles validation errors' do + allow(payment).to receive(:refund!).and_raise(StandardError, 'validation error') + + spree_put :fire, params + + expect(flash[:error]).to eq('validation error') + expect(response).to redirect_to('http://foo.com') + end + + it 'displays a success message and redirects to the referer' do + allow(payment_method).to receive(:refund) { successful_response } + + spree_put :fire, params + + expect(flash[:success]).to eq(I18n.t(:payment_updated)) + end + end + end end diff --git a/spec/factories.rb b/spec/factories.rb index ad3c804c92..455fc9b9a4 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -15,6 +15,7 @@ require 'spree/testing_support/factories' # * order_with_inventory_unit_shipped # * completed_order_with_totals # + FactoryBot.define do factory :classification, class: Spree::Classification do end diff --git a/spec/models/spree/payment_spec.rb b/spec/models/spree/payment_spec.rb index 1ca723566c..200efab868 100644 --- a/spec/models/spree/payment_spec.rb +++ b/spec/models/spree/payment_spec.rb @@ -1,7 +1,687 @@ require 'spec_helper' -module Spree - describe Payment do +describe Spree::Payment do + context 'original specs from Spree' do + let(:order) { create(:order) } + let(:gateway) do + gateway = Spree::Gateway::Bogus.new(:environment => 'test', :active => true) + gateway.stub :source_required => true + gateway + end + + let(:card) { create(:credit_card) } + + before { allow(card).to receive(:has_payment_profile?).and_return(true) } + + let(:payment) do + payment = create(:payment) + payment.source = card + payment.order = order + payment.payment_method = gateway + payment + end + + let(:amount_in_cents) { payment.amount.to_f * 100 } + + let!(:success_response) do + double('success_response', :success? => true, + :authorization => '123', + :avs_result => { 'code' => 'avs-code' }, + :cvv_result => { 'code' => 'cvv-code', 'message' => "CVV Result"}) + end + + let(:failed_response) { double('gateway_response', :success? => false) } + + before(:each) do + # So it doesn't create log entries every time a processing method is called + allow(payment).to receive(:record_response) + end + + context "extends LocalizedNumber" do + it_behaves_like "a model using the LocalizedNumber module", [:amount] + end + + context 'validations' do + it "returns useful error messages when source is invalid" do + payment.source = Spree::CreditCard.new + expect(payment).not_to be_valid + cc_errors = payment.errors['Credit Card'] + expect(cc_errors).to include("Number can't be blank") + expect(cc_errors).to include("Month is not a number") + expect(cc_errors).to include("Year is not a number") + expect(cc_errors).to include("Verification Value can't be blank") + end + end + + # Regression test for https://github.com/spree/spree/pull/2224 + context 'failure' do + it 'should transition to failed from pending state' do + payment.state = 'pending' + payment.failure + expect(payment.state).to eql('failed') + end + + it 'should transition to failed from processing state' do + payment.state = 'processing' + payment.failure + expect(payment.state).to eql('failed') + end + + end + + context 'invalidate' do + it 'should transition from checkout to invalid' do + payment.state = 'checkout' + payment.invalidate + expect(payment.state).to eq('invalid') + end + end + + context "processing" do + before do + payment.stub(:update_order) + payment.stub(:create_payment_profile) + end + + context "#process!" do + it "should purchase if with auto_capture" do + payment.payment_method.should_receive(:auto_capture?).and_return(true) + payment.should_receive(:purchase!) + payment.process! + end + + it "should authorize without auto_capture" do + payment.payment_method.should_receive(:auto_capture?).and_return(false) + payment.should_receive(:authorize!) + payment.process! + end + + it "should make the state 'processing'" do + payment.should_receive(:started_processing!) + payment.process! + end + + it "should invalidate if payment method doesnt support source" do + payment.payment_method.should_receive(:supports?).with(payment.source).and_return(false) + expect { payment.process!}.to raise_error(Spree::Core::GatewayError) + expect(payment.state).to eq('invalid') + end + + end + + context "#authorize" do + it "should call authorize on the gateway with the payment amount" do + payment.payment_method.should_receive(:authorize).with(amount_in_cents, + card, + anything).and_return(success_response) + payment.authorize! + end + + it "should call authorize on the gateway with the currency code" do + payment.stub :currency => 'GBP' + payment.payment_method.should_receive(:authorize).with(amount_in_cents, + card, + hash_including({:currency => "GBP"})).and_return(success_response) + payment.authorize! + end + + it "should log the response" do + payment.authorize! + expect(payment).to have_received(:record_response) + end + + context "when gateway does not match the environment" do + it "should raise an exception" do + gateway.stub :environment => "foo" + expect { payment.authorize! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "if successful" do + before do + payment.payment_method.should_receive(:authorize).with(amount_in_cents, + card, + anything).and_return(success_response) + end + + it "should store the response_code, avs_response and cvv_response fields" do + payment.authorize! + expect(payment.response_code).to eq('123') + expect(payment.avs_response).to eq('avs-code') + expect(payment.cvv_response_code).to eq('cvv-code') + expect(payment.cvv_response_message).to eq('CVV Result') + end + + it "should make payment pending" do + payment.should_receive(:pend!) + payment.authorize! + end + end + + context "if unsuccessful" do + it "should mark payment as failed" do + gateway.stub(:authorize).and_return(failed_response) + payment.should_receive(:failure) + payment.should_not_receive(:pend) + expect { + payment.authorize! + }.to raise_error(Spree::Core::GatewayError) + end + end + end + + context "purchase" do + it "should call purchase on the gateway with the payment amount" do + gateway.should_receive(:purchase).with(amount_in_cents, card, anything).and_return(success_response) + payment.purchase! + end + + it "should log the response" do + payment.purchase! + expect(payment).to have_received(:record_response) + end + + context "when gateway does not match the environment" do + it "should raise an exception" do + gateway.stub :environment => "foo" + expect { payment.purchase! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "if successful" do + before do + payment.payment_method.should_receive(:purchase).with(amount_in_cents, + card, + anything).and_return(success_response) + end + + it "should store the response_code and avs_response" do + payment.purchase! + expect(payment.response_code).to eq('123') + expect(payment.avs_response).to eq('avs-code') + end + + it "should make payment complete" do + payment.should_receive(:complete!) + payment.purchase! + end + end + + context "if unsuccessful" do + it "should make payment failed" do + gateway.stub(:purchase).and_return(failed_response) + payment.should_receive(:failure) + payment.should_not_receive(:pend) + expect { payment.purchase! }.to raise_error(Spree::Core::GatewayError) + end + end + end + + context "#capture" do + before do + payment.stub(:complete).and_return(true) + end + + context "when payment is pending" do + before do + payment.state = 'pending' + end + + context "if successful" do + before do + payment.payment_method.should_receive(:capture).with(payment, card, anything).and_return(success_response) + end + + it "should make payment complete" do + payment.should_receive(:complete) + payment.capture! + end + + it "should store the response_code" do + gateway.stub :capture => success_response + payment.capture! + expect(payment.response_code).to eq('123') + end + end + + context "if unsuccessful" do + it "should not make payment complete" do + gateway.stub :capture => failed_response + payment.should_receive(:failure) + payment.should_not_receive(:complete) + expect { payment.capture! }.to raise_error(Spree::Core::GatewayError) + end + end + end + + # Regression test for #2119 + context "when payment is completed" do + before do + payment.state = 'completed' + end + + it "should do nothing" do + payment.should_not_receive(:complete) + payment.payment_method.should_not_receive(:capture) + payment.log_entries.should_not_receive(:create) + payment.capture! + end + end + end + + context "#void" do + before do + payment.response_code = '123' + payment.state = 'pending' + end + + context "when profiles are supported" do + it "should call payment_gateway.void with the payment's response_code" do + gateway.stub :payment_profiles_supported? => true + gateway.should_receive(:void).with('123', card, anything).and_return(success_response) + payment.void_transaction! + end + end + + context "when profiles are not supported" do + it "should call payment_gateway.void with the payment's response_code" do + gateway.stub :payment_profiles_supported? => false + gateway.should_receive(:void).with('123', anything).and_return(success_response) + payment.void_transaction! + end + end + + it "should log the response" do + payment.void_transaction! + expect(payment).to have_received(:record_response) + end + + context "when gateway does not match the environment" do + it "should raise an exception" do + gateway.stub :environment => "foo" + expect { payment.void_transaction! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "if successful" do + it "should update the response_code with the authorization from the gateway" do + # Change it to something different + payment.response_code = 'abc' + payment.void_transaction! + expect(payment.response_code).to eq('12345') + end + end + + context "if unsuccessful" do + it "should not void the payment" do + gateway.stub :void => failed_response + payment.should_not_receive(:void) + expect { payment.void_transaction! }.to raise_error(Spree::Core::GatewayError) + end + end + + # Regression test for #2119 + context "if payment is already voided" do + before do + payment.state = 'void' + end + + it "should not void the payment" do + payment.payment_method.should_not_receive(:void) + payment.void_transaction! + end + end + end + + context "#credit" do + before do + payment.state = 'completed' + payment.response_code = '123' + end + + context "when outstanding_balance is less than payment amount" do + before do + payment.order.stub :outstanding_balance => 10 + payment.stub :credit_allowed => 1000 + end + + it "should call credit on the gateway with the credit amount and response_code" do + gateway.should_receive(:credit).with(1000, card, '123', anything).and_return(success_response) + payment.credit! + end + end + + context "when outstanding_balance is equal to payment amount" do + before do + payment.order.stub :outstanding_balance => payment.amount + end + + it "should call credit on the gateway with the credit amount and response_code" do + gateway.should_receive(:credit).with(amount_in_cents, card, '123', anything).and_return(success_response) + payment.credit! + end + end + + context "when outstanding_balance is greater than payment amount" do + before do + payment.order.stub :outstanding_balance => 101 + end + + it "should call credit on the gateway with the original payment amount and response_code" do + gateway.should_receive(:credit).with(amount_in_cents.to_f, card, '123', anything).and_return(success_response) + payment.credit! + end + end + + it "should log the response" do + payment.credit! + expect(payment).to have_received(:record_response) + end + + context "when gateway does not match the environment" do + it "should raise an exception" do + gateway.stub :environment => "foo" + expect { payment.credit! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "when response is successful" do + it "should create an offsetting payment" do + expect(Spree::Payment).to receive(:create!) + payment.credit! + end + + it "resulting payment should have correct values" do + allow(payment.order).to receive(:outstanding_balance) { 100 } + allow(payment).to receive(:credit_allowed) { 10 } + + offsetting_payment = payment.credit! + expect(offsetting_payment.amount.to_f).to eq(-10) + expect(offsetting_payment).to be_completed + expect(offsetting_payment.response_code).to eq('12345') + expect(offsetting_payment.source).to eq(payment) + end + + context 'and the source payment card is expired' do + let(:card) do + Spree::CreditCard.new(month: 12, year: 1995, number: '4111111111111111') + end + + let(:successful_response) do + ActiveMerchant::Billing::Response.new(true, "Yay!") + end + + it 'lets the new payment to be saved' do + allow(payment.order).to receive(:outstanding_balance) { 100 } + allow(payment).to receive(:credit_allowed) { 10 } + + offsetting_payment = payment.credit! + + expect(offsetting_payment).to be_valid + end + end + end + end + end + + context "when response is unsuccessful" do + it "should not create a payment" do + gateway.stub :credit => failed_response + Spree::Payment.should_not_receive(:create) + expect { payment.credit! }.to raise_error(Spree::Core::GatewayError) + end + end + + context "when already processing" do + it "should return nil without trying to process the source" do + payment.state = 'processing' + + payment.should_not_receive(:authorize!) + payment.should_not_receive(:purchase!) + expect(payment.process!).to be_nil + end + end + + context "with source required" do + context "raises an error if no source is specified" do + before do + payment.source = nil + end + + specify do + expect { payment.process! }.to raise_error(Spree::Core::GatewayError, Spree.t(:payment_processing_failed)) + end + end + end + + context "with source optional" do + context "raises no error if source is not specified" do + before do + payment.source = nil + payment.payment_method.stub(:source_required? => false) + end + + specify do + expect { payment.process! }.not_to raise_error + end + end + end + + context "#credit_allowed" do + it "is the difference between offsets total and payment amount" do + payment.amount = 100 + payment.stub(:offsets_total).and_return(0) + expect(payment.credit_allowed).to eq(100) + payment.stub(:offsets_total).and_return(80) + expect(payment.credit_allowed).to eq(20) + end + end + + context "#can_credit?" do + it "is true if credit_allowed > 0" do + payment.stub(:credit_allowed).and_return(100) + expect(payment.can_credit?).to be true + end + it "is false if credit_allowed is 0" do + payment.stub(:credit_allowed).and_return(0) + expect(payment.can_credit?).to be false + end + end + + context "#credit" do + context "when amount <= credit_allowed" do + it "makes the state processing" do + payment.payment_method.name = 'Gateway' + payment.payment_method.distributors << create(:distributor_enterprise) + payment.payment_method.save! + + payment.order = create(:order) + + payment.state = 'completed' + payment.stub(:credit_allowed).and_return(10) + payment.partial_credit(10) + expect(payment).to be_processing + end + it "calls credit on the source with the payment and amount" do + payment.state = 'completed' + payment.stub(:credit_allowed).and_return(10) + payment.should_receive(:credit!).with(10) + payment.partial_credit(10) + end + end + context "when amount > credit_allowed" do + it "should not call credit on the source" do + payment.state = 'completed' + payment.stub(:credit_allowed).and_return(10) + payment.partial_credit(20) + expect(payment).to be_completed + end + end + end + + context "#save" do + it "should call order#update!" do + gateway.name = 'Gateway' + gateway.distributors << create(:distributor_enterprise) + gateway.save! + + order = create(:order) + payment = Spree::Payment.create(:amount => 100, :order => order, :payment_method => gateway) + order.should_receive(:update!) + payment.save + end + + context "when profiles are supported" do + before do + gateway.stub :payment_profiles_supported? => true + payment.source.stub :has_payment_profile? => false + end + + + context "when there is an error connecting to the gateway" do + it "should call gateway_error " do + pending '[Spree build] Failing spec' + message = double("gateway_error") + connection_error = ActiveMerchant::ConnectionError.new(message, nil) + expect(gateway).to receive(:create_profile).and_raise(connection_error) + expect do + Spree::Payment.create( + :amount => 100, + :order => order, + :source => card, + :payment_method => gateway + ) + end.should raise_error(Spree::Core::GatewayError) + end + end + + context "when successfully connecting to the gateway" do + it "should create a payment profile" do + gateway.name = 'Gateway' + gateway.distributors << create(:distributor_enterprise) + gateway.save! + + payment.payment_method = gateway + payment.source.save_requested_by_customer = true + + expect(gateway).to receive(:create_profile) + + Spree::Payment.create( + :amount => 100, + :order => create(:order), + :source => card, + :payment_method => gateway + ) + end + end + + + end + + context "when profiles are not supported" do + before { gateway.stub :payment_profiles_supported? => false } + + it "should not create a payment profile" do + gateway.name = 'Gateway' + gateway.distributors << create(:distributor_enterprise) + gateway.save! + + gateway.should_not_receive :create_profile + payment = Spree::Payment.create( + :amount => 100, + :order => create(:order), + :source => card, + :payment_method => gateway + ) + end + end + end + + context "#build_source" do + it "should build the payment's source" do + params = { :amount => 100, :payment_method => gateway, + :source_attributes => { + :expiry =>"1 / 99", + :number => '1234567890123', + :verification_value => '123' + } + } + + payment = Spree::Payment.new(params) + expect(payment).to be_valid + expect(payment.source).not_to be_nil + end + + it "errors when payment source not valid" do + params = { :amount => 100, :payment_method => gateway, + :source_attributes => {:expiry => "1 / 12" }} + + payment = Spree::Payment.new(params) + expect(payment).not_to be_valid + expect(payment.source).not_to be_nil + expect(payment.source.errors[:number]).not_to be_empty + expect(payment.source.errors[:verification_value]).not_to be_empty + end + end + + context "#currency" do + before { order.stub(:currency) { "ABC" } } + it "returns the order currency" do + expect(payment.currency).to eq("ABC") + end + end + + context "#display_amount" do + it "returns a Spree::Money for this amount" do + expect(payment.display_amount).to eq(Spree::Money.new(payment.amount)) + end + end + + # Regression test for #2216 + context "#gateway_options" do + before { order.stub(:last_ip_address => "192.168.1.1") } + + it "contains an IP" do + expect(payment.gateway_options[:ip]).to eq(order.last_ip_address) + end + end + + context "#set_unique_identifier" do + # Regression test for #1998 + it "sets a unique identifier on create" do + payment.run_callbacks(:save) + expect(payment.identifier).not_to be_blank + expect(payment.identifier.size).to eq(8) + expect(payment.identifier).to be_a(String) + end + + context "other payment exists" do + let(:other_payment) { + gateway.name = 'Gateway' + gateway.distributors << create(:distributor_enterprise) + gateway.save! + + payment = Spree::Payment.new + payment.source = card + payment.order = create(:order) + payment.payment_method = gateway + payment + } + + before { other_payment.save! } + + it "doesn't set duplicate identifier" do + payment.should_receive(:generate_identifier).and_return(other_payment.identifier) + payment.should_receive(:generate_identifier).and_call_original + + payment.run_callbacks(:save) + + expect(payment.identifier).not_to be_blank + expect(payment.identifier).not_to eq(other_payment.identifier) + end + end + end + describe "available actions" do context "for most gateways" do let(:payment) { create(:payment, source: create(:credit_card)) } @@ -24,7 +704,7 @@ module Spree context "for Pin Payments" do let(:d) { create(:distributor_enterprise) } - let(:pin) { Gateway::Pin.create! name: 'pin', distributor_ids: [d.id] } + let(:pin) { Spree::Gateway::Pin.create! name: 'pin', distributor_ids: [d.id] } let(:payment) { create(:payment, source: create(:credit_card), payment_method: pin) } it "does not void" do @@ -45,7 +725,7 @@ module Spree end end - describe "refunding" do + describe "refund!" do let(:payment) { create(:payment) } let(:success) { double(success?: true, authorization: 'abc123') } let(:failure) { double(success?: false) } @@ -105,9 +785,9 @@ module Spree expect do payment.refund! - end.to change(Payment, :count).by(1) + end.to change(Spree::Payment, :count).by(1) - p = Payment.last + p = Spree::Payment.last expect(p.order).to eq(payment.order) expect(p.source).to eq(payment) expect(p.payment_method).to eq(payment.payment_method) @@ -147,12 +827,23 @@ module Spree it "creates adjustment" do payment = create(:payment, order: order, payment_method: payment_method, - amount: order.total) + amount: order.total) expect(payment.adjustment).to be_present expect(payment.adjustment.amount).not_to eq(0) end end end + end + end + + context 'OFN specs from previously decorated model' do + describe "applying transaction fees" do + let!(:order) { create(:order) } + let!(:line_item) { create(:line_item, order: order, quantity: 3, price: 5.00) } + + before do + order.reload.update! + end context "to Stripe payments" do let(:shop) { create(:enterprise) } @@ -229,9 +920,5 @@ module Spree end end end - - context "extends LocalizedNumber" do - it_behaves_like "a model using the LocalizedNumber module", [:amount] - end end end