diff --git a/Gemfile b/Gemfile index 7c62a122..d1466712 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,9 @@ source 'https://rubygems.org' + +group :test, :remote_test do + gem 'pry-rails' +end + gemspec gem 'jruby-openssl', :platforms => :jruby diff --git a/activemerchant.gemspec b/activemerchant.gemspec index 5c322135..e17fe676 100644 --- a/activemerchant.gemspec +++ b/activemerchant.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |s| s.homepage = 'http://activemerchant.org/' s.rubyforge_project = 'activemerchant' - s.required_ruby_version = '>= 2' + s.required_ruby_version = '>= 2.3' s.files = Dir['CHANGELOG', 'README.md', 'MIT-LICENSE', 'CONTRIBUTORS', 'lib/**/*', 'vendor/**/*'] s.require_path = 'lib' @@ -30,4 +30,5 @@ Gem::Specification.new do |s| s.add_development_dependency('test-unit', '~> 3') s.add_development_dependency('mocha', '~> 1') s.add_development_dependency('thor') + s.add_development_dependency('shopify_api', '~> 4.0') end diff --git a/lib/active_merchant/billing/gateways/shopify.rb b/lib/active_merchant/billing/gateways/shopify.rb new file mode 100644 index 00000000..4117870d --- /dev/null +++ b/lib/active_merchant/billing/gateways/shopify.rb @@ -0,0 +1,134 @@ +require 'shopify_api' + +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + class ShopifyGateway < Gateway + class TransactionNotFoundError < Error; end + class CreditedAmountBiggerThanTransaction < Error; end + + self.homepage_url = 'https://shopify.ca/' + self.display_name = 'Shopify' + + def initialize(options = {}) + requires!(options, :api_key) + requires!(options, :password) + requires!(options, :shop_name) + + @api_key = options[:api_key] + @password = options[:password] + @shop_name = options[:shop_name] + + init_shopify_api! + + super + end + + def void(transaction_id, options = {}) + order_id = options[:order_id] + voider = ShopifyVoider.new(transaction_id, order_id) + voider.perform + end + + def refund(money, transaction_id, options = {}) + refunder = ShopifyRefunder.new(money, transaction_id, options) + refunder.perform + end + + private + + attr_reader :api_key, :password, :shop_name + + def init_shopify_api! + ::ShopifyAPI::Base.site = shop_url + end + + def shop_url + "https://#{api_key}:#{password}@#{shop_name}" + end + end + end +end + +class ShopifyVoider + def initialize(transaction_id, order_id) + @order_id = order_id + @transaction = ::ShopifyAPI::Transaction.find(transaction_id, params: { order_id: order_id }) + end + + def perform + raise ActiveMerchant::Billing::ShopifyGateway::TransactionNotFoundError if transaction.nil? + + options = { order_id: order_id, reason: 'Payment voided' } + full_amount_to_cents = BigDecimal.new(transaction.amount) * 100 + refunder = ShopifyRefunder.new(full_amount_to_cents, transaction.id, options) + refunder.perform + end + + private + + attr_reader :transaction, :order_id +end + +class ShopifyRefunder + def initialize(credited_money, transaction_id, options) + @refund_reason = options[:reason] + @order_id = options[:order_id] + @credited_money = BigDecimal.new(credited_money) + @transaction = ::ShopifyAPI::Transaction.find(transaction_id, params: { order_id: order_id }) + end + + def perform + raise ActiveMerchant::Billing::ShopifyGateway::TransactionNotFoundError if transaction.nil? + + # NOTE(cab): This should be refactored when we are sure that this is the + # behavior we want + if full_refund? + perform_refund_on_shopify + elsif partial_refund? + perform_refund_on_shopify + else + raise ActiveMerchant::Billing::ShopifyGateway::CreditedAmountBiggerThanTransaction + end + end + + private + + def perform_refund_on_shopify + refund = ::ShopifyAPI::Refund.create(order_id: order_id, + shipping: { amount: 0 }, + note: refund_reason, + notify: false, + restock: false, + transactions: [{ + parent_id: transaction.id, + amount: amount_to_dollars(credited_money), + gateway: 'shopify-payments', + kind: 'refund' + }]) + + success = refund.errors == [] + if success || refund.errors.messages.empty? + ActiveMerchant::Billing::Response.new(true, nil) + else + ActiveMerchant::Billing::Response.new(success, refund.errors.messages) + end + end + + def full_refund? + credited_money == amount_to_cents(transaction.amount) + end + + def partial_refund? + credited_money < amount_to_cents(transaction.amount) + end + + def amount_to_cents(amount) + BigDecimal.new(amount) * 100 + end + + def amount_to_dollars(amount) + BigDecimal.new(amount) / 100 + end + + attr_accessor :credited_money, :refund_reason, :transaction, :order_id +end diff --git a/test/fixtures.yml b/test/fixtures.yml index 8586621d..51323f92 100644 --- a/test/fixtures.yml +++ b/test/fixtures.yml @@ -942,6 +942,12 @@ spreedly_core: password: "Y2i7AjgU03SUjwY4xnOPqzdsv4dMbPDCQzorAk8Bcoy0U8EIVE4innGjuoMQv7MN" gateway_token: "3gLeg4726V5P0HK7cq7QzHsL0a6" +# Replace with your own credentials +shopify: + api_key: "use" + password: "your" + shop_name: "own" + # Working credentials, no need to replace stripe: login: sk_test_3OD4TdKSIOhDOL2146JJcC79 diff --git a/test/test_helper.rb b/test/test_helper.rb index c80e72c3..0a89ff09 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,6 +11,8 @@ require 'active_merchant' require 'comm_stub' +require 'pry' + require 'active_support/core_ext/integer/time' require 'active_support/core_ext/numeric/time' require 'active_support/core_ext/time/acts_like' diff --git a/test/unit/gateways/remote_shopify_test.rb b/test/unit/gateways/remote_shopify_test.rb new file mode 100644 index 00000000..8db61e7b --- /dev/null +++ b/test/unit/gateways/remote_shopify_test.rb @@ -0,0 +1,87 @@ +require 'test_helper' + +class RemoteStripeTest < Test::Unit::TestCase + def setup + @gateway = ShopifyGateway.new(fixtures(:shopify)) + + @refund_amount = 50 + @refund_amount_in_cents = @refund_amount * 100 + @order = create_fulfilled_paid_shopify_order + @transaction = ::ShopifyAPI::Order.find(@order.id).transactions.first + + @refund_options = { order_id: @order.id, reason: 'Object is malfunctioning' } + @void_options = { order_id: @order.id, reason: 'Payment voided' } + end + + def teardown + @order.destroy + end + + def test_successful_void + assert response = @gateway.void(@transaction.id, @void_options) + assert_success response + end + + def test_successful_full_refund + assert response = @gateway.refund(@refund_amount_in_cents, @transaction.id, @refund_options) + assert_success response + end + + def test_successful_partial_refund + assert response = @gateway.refund(@refund_amount_in_cents / 2, @transaction.id, @refund_options) + assert_success response + end + + private + + def create_fulfilled_paid_shopify_order + order = ::ShopifyAPI::Order.new + order.email = 'cab@godynamo.com' + order.test = true + order.fulfillment_status = 'fulfilled' + order.line_items = [ + { + variant_id: '447654529', + quantity: 1, + name: 'test', + price: @refund_amount, + title: 'title' + } + ] + order.customer = { first_name: 'Paul', + last_name: 'Norman', + email: 'paul.norman@example.com' } + + order.billing_address = { + first_name: 'John', + last_name: 'Smith', + address1: '123 Fake Street', + phone: '555-555-5555', + city: 'Fakecity', + province: 'Ontario', + country: 'Canada', + zip: 'K2P 1L4' + } + order.shipping_address = { + first_name: 'John', + last_name: 'Smith', + address1: '123 Fake Street', + phone: '555-555-5555', + city: 'Fakecity', + province: 'Ontario', + country: 'Canada', + zip: 'K2P 1L4' + } + order.transactions = [ + { + kind: 'capture', + status: 'success', + amount: @refund_amount + } + ] + order.financial_status = 'paid' + order.save + + order + end +end diff --git a/test/unit/gateways/shopify_test.rb b/test/unit/gateways/shopify_test.rb new file mode 100644 index 00000000..f9bb5e34 --- /dev/null +++ b/test/unit/gateways/shopify_test.rb @@ -0,0 +1,55 @@ +require 'test_helper' + +class ShopifyTest < Test::Unit::TestCase + def setup + @gateway = ShopifyGateway.new(api_key: 'api_key', + password: 'password', + shop_name: 'shop_name') + end + + def test_void_calls_refund + transaction_id = 123 + transaction = stub(amount: 1, id: transaction_id) + refunder_instance = stub(perform: true) + ::ShopifyAPI::Transaction.expects(:find).returns(transaction) + ShopifyRefunder.expects(:new).returns(refunder_instance) + + refunder_instance.expects(:perform).once + @gateway.void(123, { order_id: '123' }) + end + + def test_void_with_not_found_transaction + ::ShopifyAPI::Transaction.expects(:find).returns(nil) + assert_raises(::ActiveMerchant::Billing::ShopifyGateway::TransactionNotFoundError) { @gateway.void(123, order_id: '123') } + end + + def test_refund_with_not_found_transaction + ::ShopifyAPI::Transaction.expects(:find).returns(nil) + assert_raises(::ActiveMerchant::Billing::ShopifyGateway::TransactionNotFoundError) { @gateway.refund(123, 123, { order_id: '123', reason: 'reason' }) } + end + + def test_refund_with_credit_to_big + transaction = stub(amount: 1) + ::ShopifyAPI::Transaction.stubs(:find).returns(transaction) + assert_raises(::ActiveMerchant::Billing::ShopifyGateway::CreditedAmountBiggerThanTransaction) { @gateway.refund(10000, 123, { order_id: '123', reason: 'reason' }) } + end + + def test_response_value_of_unsuccessful_refund + transaction_id = 123 + transaction = stub(amount: 1, id: transaction_id) + refund = stub(errors: []) + ::ShopifyAPI::Transaction.stubs(:find).returns(transaction) + ::ShopifyAPI::Refund.stubs(:create).returns(refund) + assert_success(@gateway.refund(100, transaction_id, { order_id: '123', reason: 'reason' })) + end + + def test_reponse_value_of_successful_refund + transaction_id = 123 + transaction = stub(amount: 1, id: transaction_id) + errors = stub(messages: { error: 'error1' }) + refund = stub(errors: errors) + ::ShopifyAPI::Transaction.stubs(:find).returns(transaction) + ::ShopifyAPI::Refund.stubs(:create).returns(refund) + assert_failure(@gateway.refund(100, transaction_id, { order_id: '123', reason: 'reason' })) + end +end