Bring in Payment model from Spree

This commit is contained in:
Pau Perez
2020-06-26 12:06:38 +02:00
parent c1d700196b
commit 06aa56164f
4 changed files with 1023 additions and 0 deletions

158
app/models/spree/payment.rb Normal file
View File

@@ -0,0 +1,158 @@
module Spree
class Payment < ActiveRecord::Base
include Spree::Payment::Processing
IDENTIFIER_CHARS = (('A'..'Z').to_a + ('0'..'9').to_a - %w(0 1 I O)).freeze
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 AND state = 'completed'") },
class_name: "Spree::Payment", foreign_key: :source_id
has_many :log_entries, as: :source
before_validation :validate_source
before_save :set_unique_identifier
after_save :create_payment_profile, if: :profiles_supported?
# update the order totals, etc.
after_save :update_order
# invalidate previously entered payments
after_create :invalidate_old_payments
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)) }
after_rollback :persist_invalid
def persist_invalid
return unless ['failed', 'invalid'].include?(state)
state_will_change!
save
end
# 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 currency
order.currency
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 > 0
end
# see https://github.com/spree/spree/issues/981
def build_source
return if source_attributes.nil?
if payment_method and payment_method.payment_source_class
self.source = payment_method.payment_source_class.new(source_attributes)
end
end
def actions
return [] unless payment_source and payment_source.respond_to? :actions
payment_source.actions.select { |action| !payment_source.respond_to?("can_#{action}?") or payment_source.send("can_#{action}?", self) }
end
def payment_source
res = source.is_a?(Payment) ? source.source : source
res || payment_method
end
private
def validate_source
if source && !source.valid?
source.errors.each do |field, error|
field_name = I18n.t("activerecord.attributes.#{source.class.to_s.underscore}.#{field}")
self.errors.add(Spree.t(source.class.to_s.demodulize.underscore), "#{field_name} #{error}")
end
end
return !errors.present?
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) && source.number && !source.has_payment_profile?
payment_method.create_profile(self)
rescue ActiveMerchant::ConnectionError => e
gateway_error e
end
def invalidate_old_payments
order.payments.with_state('checkout').where("id != ?", self.id).each do |payment|
payment.invalidate!
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
begin
self.identifier = generate_identifier
end while self.class.exists?(identifier: self.identifier)
end
def generate_identifier
Array.new(8){ IDENTIFIER_CHARS.sample }.join
end
end
end

View File

@@ -0,0 +1,210 @@
module Spree
class Payment < ActiveRecord::Base
module Processing
def process!
if payment_method && payment_method.source_required?
if source
if !processing?
if payment_method.supports?(source)
if payment_method.auto_capture?
purchase!
else
authorize!
end
else
invalidate!
raise Core::GatewayError.new(Spree.t(:payment_method_not_supported))
end
end
else
raise Core::GatewayError.new(Spree.t(:payment_processing_failed))
end
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
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
response = payment_method.capture(self, source, gateway_options)
else
# Standard ActiveMerchant capture usage
response = 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
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
response = payment_method.void(self.response_code, source, gateway_options)
else
# Standard ActiveMerchant void usage
response = payment_method.void(self.response_code, gateway_options)
end
record_response(response)
if response.success?
self.response_code = response.authorization
self.void
else
gateway_error(response)
end
end
end
def credit!(credit_amount=nil)
protect_from_connection_error do
check_environment
credit_amount ||= credit_allowed >= order.outstanding_balance.abs ? order.outstanding_balance.abs : credit_allowed.abs
credit_amount = credit_amount.to_f
if payment_method.payment_profiles_supported?
response = payment_method.credit((credit_amount * 100).round, source, response_code, gateway_options)
else
response = 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'
)
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 gateway_action(source, action, success_state)
protect_from_connection_error do
check_environment
response = payment_method.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
self.send("#{success_state}!")
else
self.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
begin
yield
rescue ActiveMerchant::ConnectionError => e
gateway_error(e)
end
end
def gateway_error(error)
if error.is_a? ActiveMerchant::Billing::Response
text = error.params['message'] || error.params['response_reason_text'] || error.message
elsif error.is_a? ActiveMerchant::ConnectionError
text = Spree.t(:unable_to_connect_to_gateway)
else
text = error.to_s
end
logger.error(Spree.t(:gateway_error))
logger.error(" #{error.to_yaml}")
raise Core::GatewayError.new(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.new(message)
end
# The unique identifier to be passed in to the payment gateway
def gateway_order_id
"#{order.number}-#{self.identifier}"
end
end
end
end

View File

@@ -15,6 +15,22 @@ require 'spree/testing_support/factories'
# * order_with_inventory_unit_shipped
# * completed_order_with_totals
#
# allows credit card info to be saved to the database which is needed for factories to work properly
class TestCard < Spree::CreditCard
def remove_readonly_attributes(attributes) attributes; end
end
FactoryBot.define do
factory :credit_card, class: TestCard do
verification_value 123
month 12
year { Time.now.year + 1 }
number '4111111111111111'
cc_type 'visa'
end
end
FactoryBot.define do
factory :classification, class: Spree::Classification do
end

View File

@@ -0,0 +1,639 @@
require 'spec_helper'
describe Spree::Payment do
let(:order) do
order = Spree::Order.new(:bill_address => Spree::Address.new,
:ship_address => Spree::Address.new)
end
let(:gateway) do
gateway = Spree::Gateway::Bogus.new(:environment => 'test', :active => true)
gateway.stub :source_required => true
gateway
end
let(:card) do
mock_model(Spree::CreditCard, :number => "4111111111111111",
:has_payment_profile? => true)
end
let(:payment) do
payment = Spree::Payment.new
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
payment.log_entries.stub(:create)
end
context 'validations' do
it "returns useful error messages when source is invalid" do
payment.source = Spree::CreditCard.new
payment.should_not be_valid
cc_errors = payment.errors['Credit Card']
cc_errors.should include("Number can't be blank")
cc_errors.should include("Month is not a number")
cc_errors.should include("Year is not a number")
cc_errors.should 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
payment.state.should eql('failed')
end
it 'should transition to failed from processing state' do
payment.state = 'processing'
payment.failure
payment.state.should eql('failed')
end
end
context 'invalidate' do
it 'should transition from checkout to invalid' do
payment.state = 'checkout'
payment.invalidate
payment.state.should 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)
payment.state.should 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.log_entries.should_receive(:create).with(:details => anything)
payment.authorize!
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!
payment.response_code.should == '123'
payment.avs_response.should == 'avs-code'
payment.cvv_response_code.should == 'cvv-code'
payment.cvv_response_message.should == '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)
lambda {
payment.authorize!
}.should 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.log_entries.should_receive(:create).with(:details => anything)
payment.purchase!
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!
payment.response_code.should == '123'
payment.avs_response.should == '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!
payment.response_code.should == '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.log_entries.should_receive(:create).with(:details => anything)
payment.void_transaction!
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!
payment.response_code.should == '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 = 'complete'
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.log_entries.should_receive(:create).with(:details => anything)
payment.credit!
end
context "when gateway does not match the environment" do
it "should raise an exception" do
gateway.stub :environment => "foo"
lambda { payment.credit! }.should raise_error(Spree::Core::GatewayError)
end
end
context "when response is successful" do
it "should create an offsetting payment" do
Spree::Payment.should_receive(:create)
payment.credit!
end
it "resulting payment should have correct values" do
payment.order.stub :outstanding_balance => 100
payment.stub :credit_allowed => 10
offsetting_payment = payment.credit!
offsetting_payment.amount.to_f.should == -10
offsetting_payment.should be_completed
offsetting_payment.response_code.should == '12345'
offsetting_payment.source.should == payment
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!)
payment.process!.should 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)
payment.credit_allowed.should == 100
payment.stub(:offsets_total).and_return(80)
payment.credit_allowed.should == 20
end
end
context "#can_credit?" do
it "is true if credit_allowed > 0" do
payment.stub(:credit_allowed).and_return(100)
payment.can_credit?.should be_true
end
it "is false if credit_allowed is 0" do
payment.stub(:credit_allowed).and_return(0)
payment.can_credit?.should be_false
end
end
context "#credit" do
context "when amount <= credit_allowed" do
it "makes the state processing" do
payment.state = 'completed'
payment.stub(:credit_allowed).and_return(10)
payment.partial_credit(10)
payment.should 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)
payment.should be_completed
end
end
end
context "#save" do
it "should call order#update!" do
payment = Spree::Payment.create(:amount => 100, :order => order)
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)
lambda 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
payment.payment_method.should_receive :create_profile
payment = Spree::Payment.create(
:amount => 100,
:order => 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.should_not_receive :create_profile
payment = Spree::Payment.create(
:amount => 100,
:order => 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)
payment.should be_valid
payment.source.should_not 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)
payment.should_not be_valid
payment.source.should_not be_nil
payment.source.should have(1).error_on(:number)
payment.source.should have(1).error_on(:verification_value)
end
end
context "#currency" do
before { order.stub(:currency) { "ABC" } }
it "returns the order currency" do
payment.currency.should == "ABC"
end
end
context "#display_amount" do
it "returns a Spree::Money for this amount" do
payment.display_amount.should == 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
payment.gateway_options[:ip].should == 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)
payment.identifier.should_not be_blank
payment.identifier.size.should == 8
payment.identifier.should be_a(String)
end
context "other payment exists" do
let(:other_payment) {
payment = Spree::Payment.new
payment.source = card
payment.order = 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)
payment.identifier.should_not be_blank
payment.identifier.should_not == other_payment.identifier
end
end
end
end