diff --git a/.gitignore b/.gitignore index 933ff08..5813d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /tmp .DS_Store -/.idea/ \ No newline at end of file +/.idea/ +/coverage/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..c388647 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,13 @@ +AllCops: + TargetRubyVersion: 2.7 + NewCops: enable + SuggestExtensions: false +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented +Layout/DotPosition: + EnforcedStyle: trailing +Style/Documentation: + Enabled: false +Metrics/BlockLength: + Exclude: + - 'spec/**/*.rb' diff --git a/Gemfile b/Gemfile index dc92c4f..ffb69a5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -source 'https://rubygems.org' +# frozen_string_literal: true -# Specify your gem's dependencies in trustly-client-ruby.gemspec +source 'https://rubygems.org/' gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 5297065..c87e295 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,33 +2,113 @@ PATH remote: . specs: trustly-client-ruby (0.1.95) + faraday + faraday_middleware rake GEM remote: https://rubygems.org/ specs: - diff-lcs (1.2.5) - rake (11.2.2) - rspec (3.5.0) - rspec-core (~> 3.5.0) - rspec-expectations (~> 3.5.0) - rspec-mocks (~> 3.5.0) - rspec-core (3.5.2) - rspec-support (~> 3.5.0) - rspec-expectations (3.5.0) + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + ast (2.4.2) + crack (0.4.5) + rexml + debug (1.6.1) + irb (>= 1.3.6) + reline (>= 0.3.1) + diff-lcs (1.5.0) + docile (1.4.0) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + hashdiff (1.0.1) + io-console (0.5.11) + irb (1.4.1) + reline (>= 0.3.0) + json (2.6.2) + multipart-post (2.2.3) + parallel (1.22.1) + parser (3.1.2.0) + ast (~> 2.4.1) + public_suffix (4.0.7) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.5.0) + reline (0.3.1) + io-console (~> 0.5) + rexml (3.2.5) + rspec (3.11.0) + rspec-core (~> 3.11.0) + rspec-expectations (~> 3.11.0) + rspec-mocks (~> 3.11.0) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-mocks (3.5.0) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-support (3.5.0) + rspec-support (~> 3.11.0) + rspec-support (3.11.0) + rubocop (1.32.0) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.1.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.19.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.19.1) + parser (>= 3.1.1.0) + ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) + simplecov (0.21.2) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) + unicode-display_width (2.2.0) + webmock (3.15.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS ruby DEPENDENCIES - rspec (>= 2.0.0) + debug + rspec + rubocop + simplecov trustly-client-ruby! + webmock BUNDLED WITH - 1.10.6 + 2.3.7 diff --git a/README.md b/README.md index a7998ca..af1d00a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Trustly-client-ruby -This is an example implementation of communication with the Trustly API using Ruby. This a ruby gem that allows you use Trustly Api calls in ruby. It's based on [trustly-client-python] (https://github.com/trustly/trustly-client-python) and [turstly-client-php] (https://github.com/trustly/trustly-client-php) +This is an example implementation of communication with the Trustly API using Ruby. This a ruby gem that allows you use Trustly Api calls in ruby. It implements the standard Payments API as well as gives stubs for executing calls against the API used by the backoffice. @@ -14,7 +14,7 @@ This code is provided as-is, use it as inspiration, reference or drop it directl Add this line to your application's Gemfile: ```ruby -gem 'trustly-client-ruby',:require=>'trustly' +gem 'trustly-client-ruby', require: 'trustly' ``` And then execute: @@ -41,72 +41,89 @@ You will need to copy test and live private certificates using this naming conve ## Usage -Currently only **Deposit** and **Refund** api calls. However, other calls can be implemented very easily. +Currently supports **Deposit**, **Refund**, **AccountPayout**, **RegisterAccount** and **SelectAccount** api calls. Other calls can be implemented very easily. ### Api In order to use Trustly Api, we'll need to create a **Trustly::Api::Signed**. Example: ```ruby -api = Trustly::Api::Signed.new({ - :username=>"yourusername", - :password=>"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -}) +api = Trustly::Api::Signed.new( + username: 'yourusername', + password: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +) ``` -This will automatically load pem files from **certs/trustly** with default optons. If you want to specify other paths or options then you can call: +Also make sure you have ENV variables for certificates. Default variables are: MERCHANT_PRIVATE_KEY for the signing key and TRUSTLY_PUBLIC_KEY for the verifying key. ```ruby api = Trustly::Api::Signed.new({ - :username=>"yourusername", - :password=>"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - :host => 'test.trustly.com', - :port => 443, - :is_https => true, - :private_pem => "#{Rails.root}/certs/trustly/test.merchant.private.pem", - :public_pem => "#{Rails.root}/certs/trustly/test.trustly.public.pem" + username: 'yourusername', + password: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + host: 'test.trustly.com', + port: 443, + is_https: true, + private_pem: ENV.fetch('MY_PRIVATE_KEY_VAR', nil), + public_pem: ENV.fetch('TRUSTLY_PUBLIC_KEY_VAR', nil) }) ``` - +## Examples of RPC calls ### Deposit call -Deposit is straightfoward call. Only required arguments example: +Deposit is a straightfoward call. Only required arguments example: ```ruby -deposit = api.deposit({"EndUserID"=>10002,"MessageID"=>12349,"Amount"=>3}) +deposit = api.deposit( + 'EndUserID' => 10002, + 'MessageID' => 12349, + 'Amount' => '30.0', + 'ShopperStatement' => 'MyBrand.com', + 'Locale' => 'es_ES', + 'Country' => 'ES', + 'Currency' => 'EUR', + 'SuccessURL' => 'https://my-brand.com/thank_you.html', + 'FailURL' => 'https://my-brand.com/failure.html', + 'NotificationURL' => 'https://gateway.my-brand.com/notifications', + 'Firstname' => 'John', + 'Lastname' => 'Doe' +) ``` Optional arguments are: - -- Locale: default value "es_ES" -- Country: default "ES" -- Currency default "EUR" +- AccountID - SuggestedMinAmount - SuggestedMaxAmount -- Amount -- Currency -- Country - IP -- SuccessURL: default "https://www.trustly.com/success" -- FailURL : default "https://www.trustly.com/fail" - TemplateURL - URLTarget - MobilePhone -- Firstname -- Lastname +- Email - NationalIdentificationNumber -- ShopperStatement -- NotificationURL: default "https://test.trustly.com/demo/notifyd_test" +- UnchangeableNationalIdentificationNumber +- ShippingAddressCountry +- ShippingAddressPostalCode +- ShippingAddressLine1 +- ShippingAddressLine2 +- ShippingAddress +- RequestDirectDebitMandate +- ChargeAccountID +- QuickDeposit +- URLScheme +- ExternalReference +- PSPMerchant +- PSPMerchantURL +- MerchantCategoryCode +- RecipientInformation This will return a **Trustly::Data::JSONRPCResponse**: ```ruby -> deposit.get_data('url') +> deposit.data_at('url') => "https://test.trustly.com/_/orderclient.php?SessionID=755ea475-dcf1-476e-ac70-07913501b34e&OrderID=4257552724&Locale=es_ES" -> deposit.get_data() +> deposit.data => { - "orderid" => "4257552724", - "url" => "https://test.trustly.com/_/orderclient.php?SessionID=755ea475-dcf1-476e-ac70-07913501b34e&OrderID=4257552724&Locale=es_ES" + 'orderid' => '4257552724', + 'url' => 'https://test.trustly.com/_/orderclient.php?SessionID=755ea475-dcf1-476e-ac70-07913501b34e&OrderID=4257552724&Locale=es_ES' } ``` @@ -119,69 +136,57 @@ You can check if there was an error: > deposit.success? => false -> deposit.error_msg -=> "ERROR_DUPLICATE_MESSAGE_ID" +> deposit.error_message +=> 'ERROR_DUPLICATE_MESSAGE_ID' ``` -### Refund call - -Required parameters: - -- OrderID -- Amount -- Currency / default to "EUR" - -Example: - -```ruby -> api.refund({"OrderID"=>2205700591,"Amount"=>3,"Currency"=>"EUR"}) -``` - - ### Notifications After a **deposit** or **refund** call, Trustly will send a notification to **NotificationURL**. If you are using rails the execution flow will look like this: ```ruby def controller_action - api = Trustly::Api::Signed.new({..}) - notification = Trustly::JSONRPCNotificationRequest.new(params) + api = Trustly::Api::Signed.new({...}) + notification = Trustly::Data::JSONRPCNotificationRequest.new(notification_body: params) if api.verify_trustly_signed_notification(notification) - # do something with notification + # do something with the notification ... # reply to trustly - response = api.notification_response(notification,true) - render :text => response.json() + response = api.notification_response(notification, success: true) + render text: response.to_json else - render :nothing => true, :status => 200 + render nothing: true, status: 200 end end ``` -You can use **Trustly::JSONRPCNotificationRequest** object to access data provided using the following methods: +You can use **Trustly::Data::JSONRPCNotificationRequest** object to access data provided using the following methods: ```ruby - notification.get_data + notification.data => {"amount"=>"902.50", "currency"=>"EUR", "messageid"=>"98348932", "orderid"=>"87654567", "enduserid"=>"32123", "notificationid"=>"9876543456", "timestamp"=>"2010-01-20 14:42:04.675645+01", "attributes"=>{}} -> notification.get_method +> notification.method => "credit" -> notification.get_uuid +> notification.uuid => "258a2184-2842-b485-25ca-293525152425" -> notification.get_signature +> notification.signature => "R9+hjuMqbsH0Ku ... S16VbzRsw==" -> notification.get_data('amount') +> notification.data_at('amount') => "902.50" + +> notification.attribute_at('key') +=> nil ``` ## Contributing -1. Fork it ( https://github.com/jcarreti/trustly-client-ruby/fork ) +1. Fork it ( https://github.com/gapfish/trusty-client-ruby/fork ) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) diff --git a/Rakefile b/Rakefile index a1f5747..d247fe8 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,7 @@ -require "bundler/gem_tasks" +# frozen_string_literal: true + +require 'bundler/gem_tasks' task :console do - exec "irb -r trustly -r 'active_support/core_ext/object/try' -r 'active_support/core_ext/hash/keys' -r 'JSON' -I ./lib" + exec 'irb -r trustly -I ./lib' end diff --git a/bin/console b/bin/console index 9c65f23..3c0f8e2 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,8 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -require "bundler/setup" -require "trustly/client/ruby" +require 'bundler/setup' +require 'trustly' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. @@ -10,5 +11,5 @@ require "trustly/client/ruby" # require "pry" # Pry.start -require "irb" +require 'irb' IRB.start diff --git a/lib/generators/trustly/install_generator.rb b/lib/generators/trustly/install_generator.rb index 821fac0..ece1ecf 100644 --- a/lib/generators/trustly/install_generator.rb +++ b/lib/generators/trustly/install_generator.rb @@ -1,16 +1,17 @@ +# frozen_string_literal: true + require 'rails/generators' module Trustly module Generators class InstallGenerator < ::Rails::Generators::Base desc 'Create cert folder and trustly pem files' - source_root ::File.expand_path('../templates', __FILE__) + source_root ::File.expand_path('templates', __dir__) def copy_files - copy_file "test.trustly.public.pem", "certs/trustly/test.trustly.public.pem" - copy_file "live.trustly.public.pem", "certs/trustly/live.trustly.public.pem" + copy_file 'test.trustly.public.pem', 'certs/trustly/test.trustly.public.pem' + copy_file 'live.trustly.public.pem', 'certs/trustly/live.trustly.public.pem' end - end end -end \ No newline at end of file +end diff --git a/lib/trustly.rb b/lib/trustly.rb index de31606..a5293f6 100644 --- a/lib/trustly.rb +++ b/lib/trustly.rb @@ -1,22 +1,33 @@ +# frozen_string_literal: true + module Trustly end -require "trustly/exception" -require "trustly/exception/authentification_error" -require "trustly/exception/connection_error" -require "trustly/exception/data_error" -require "trustly/exception/jsonrpc_version_error" -require "trustly/exception/signature_error" +require 'base64' +require 'openssl' +require 'stringio' +require 'faraday' +require 'faraday_middleware' + +require 'trustly/exception/base' +require 'trustly/exception/authentification_error' +require 'trustly/exception/connection_error' +require 'trustly/exception/data_error' +require 'trustly/exception/configuration_error' +require 'trustly/exception/jsonrpc_version_error' +require 'trustly/exception/signature_error' -require "trustly/data" -require "trustly/data/request" -require "trustly/data/response" -require "trustly/data/jsonrpc_request" -require "trustly/data/jsonrpc_response" -require "trustly/data/jsonrpcnotification_request" -require "trustly/data/jsonrpcnotification_response" +require 'trustly/utils/data_transformer' +require 'trustly/utils/data_cleaner' -require "trustly/api" -require "trustly/api/signed" -require "trustly/version" +require 'trustly/data/base' +require 'trustly/data/request' +require 'trustly/data/response' +require 'trustly/data/jsonrpc_request' +require 'trustly/data/jsonrpc_response' +require 'trustly/data/jsonrpcnotification_request' +require 'trustly/data/jsonrpcnotification_response' +require 'trustly/api/base' +require 'trustly/api/signed' +require 'trustly/version' diff --git a/lib/trustly/api.rb b/lib/trustly/api.rb deleted file mode 100644 index 5860de8..0000000 --- a/lib/trustly/api.rb +++ /dev/null @@ -1,109 +0,0 @@ - -class Trustly::Api - - attr_accessor :api_host, :api_port, :api_is_https,:last_request,:trustly_publickey,:trustly_verifyer - - def serialize_data(object) - serialized = "" - if object.is_a?(Array) - # Its an array - object.each do |obj| - serialized.concat(obj.is_a?(Hash) ? serialize_data(obj) : obj.to_s) - end - elsif object.is_a?(Hash) - # Its a Hash - Hash[object.sort.each{}].each do |key,value| - serialized.concat(key.to_s).concat(serialize_data(value)) - end - else - # Anything else: numbers, symbols, values - serialized.concat object.to_s - end - return serialized - end - - def initialize(host,port,is_https,pem_file) - self.api_host = host - self.api_port = port - self.api_is_https = is_https - - self.load_trustly_publickey - end - - def load_trustly_publickey - self.trustly_publickey = OpenSSL::PKey::RSA.new(ENV['TRUSTLY_PUBLIC_KEY']) - end - - def url_path(request=nil) - raise NotImplementedError - end - - def handle_response(request,httpcall) - raise NotImplementedError - end - - def insert_credentials(request) - raise NotImplementedError - end - - def verify_trustly_signed_notification(response) - method = response.get_method() - uuid = response.get_uuid() - signature = response.get_signature() - data = response.get_data() - return self._verify_trustly_signed_data(method, uuid, signature, data) - end - - protected - - def _verify_trustly_signed_data(method, uuid, signature, data) - method = '' if method.nil? - uuid = '' if uuid.nil? - serial_data = "#{method}#{uuid}#{self.serialize_data(data)}" - raw_signature = Base64.decode64(signature) - return self.trustly_publickey.public_key.verify(OpenSSL::Digest::SHA1.new, raw_signature, serial_data) - end - - def verify_trustly_signed_response(response) - method = response.get_method() - uuid = response.get_uuid() - signature = response.get_signature() - data = response.get_data() - return self._verify_trustly_signed_data(method, uuid, signature, data) - end - - - def set_host(host=nil,port=nil,is_https=nil) - self.api_host = host unless host.nil? - self.load_trustly_publickey() unless host.nil? - self.api_port = port unless port.nil? - self.is_https = is_https unless is_https.nil? - end - - def base_url - if self.api_is_https - return (self.api_port == 443) ? "https://#{self.api_host}" : "https://#{self.api_host}:#{self.api_port}" - else - return (self.api_port == 80) ? "http://#{self.api_host}" : "http://#{self.api_host}:#{self.api_port}" - end - end - - def uri(request) - return URI("#{self.base_url}#{self.url_path(request)}") - end - - def call_rpc(request) - self.insert_credentials(request) - self.last_request = request - uri = self.uri(request) - http_req = Net::HTTP::Post.new(uri.path, initheader = {'Content-Type' =>'application/json'}) - http_req.body = request.json() - http_res = Net::HTTP.start(uri.hostname, uri.port,{use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_NONE}) { |http| http.request(http_req) } - return self.handle_response(request,http_res) - end - - - -end - - diff --git a/lib/trustly/api/base.rb b/lib/trustly/api/base.rb new file mode 100644 index 0000000..b01fb91 --- /dev/null +++ b/lib/trustly/api/base.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +module Trustly + module Api + class Base # rubocop:disable Metrics/ClassLength + attr_accessor :api_host, + :api_port, + :api_is_https, + :last_request, + :trustly_key + + def initialize(**config) + self.api_host = config[:host] + self.api_port = config[:port] + self.api_is_https = config[:is_https] + + load_trustly_key(config[:public_pem]) + validate! + end + + def verify_signed_response(response) + method = response.method || '' + uuid = response.uuid || '' + raw_signature = Base64.decode64(response.signature || '') + serial_data = "#{method}#{uuid}#{serialize(response.data)}" + trustly_key.public_key.verify( + OpenSSL::Digest.new('SHA1'), raw_signature, serial_data + ) + end + + private + + def url_path(_request = nil) + # :nocov: + raise NotImplementedError + # :nocov: + end + + def handle_response(_request, _http_call) + # :nocov: + raise NotImplementedError + # :nocov: + end + + def insert_credentials(_request) + # :nocov: + raise NotImplementedError + # :nocov: + end + + def serialize(object) + serialized = StringIO.new + case object + when Array then serialize_array(object, serialized) + when Hash then serialize_hash(object, serialized) + else serialized << object.to_s + end + serialized.string + end + + def serialize_array(object, serialized) + object.each { |value| serialized << serialize(value) } + end + + def serialize_hash(object, serialized) + object.sort.each do |key, value| + serialized << key.to_s << serialize(value) + end + end + + def load_trustly_key(pkey) + self.trustly_key = OpenSSL::PKey::RSA.new(pkey) unless pkey.nil? + rescue OpenSSL::PKey::RSAError + self.trustly_key = nil + end + + def validate! + return if configuration_errors.empty? + + errors_string = configuration_errors.join('; ') + raise Trustly::Exception::ConfigurationError, errors_string + end + + def configuration_errors + errors = [] + errors.push 'Api host not specified' if api_host.nil? + errors.push 'Trustly public key not specified' if trustly_key.nil? + errors + end + + def base_url + schema = api_is_https ? 'https' : 'http' + add_port = (api_is_https && api_port != 443) || api_port != 80 + port = add_port && !api_port.nil? ? ":#{api_port}" : '' + "#{schema}://#{api_host}#{port}" + end + + def url(request) + URI.parse("#{base_url}#{url_path(request)}") + end + + def call_rpc(request) + insert_credentials!(request) + self.last_request = request + request_uri = url(request) + body = request.to_json + response = connection(request_uri).post( + request_uri.path, body, { 'Content-Type' => 'application/json' } + ) + handle_response(request, response) + rescue Faraday::Error => e + handle_error(e, request, body) + end + + def handle_error(error, request, body) + message = error.message + exception = exception_for_error(error) + unless error.response.nil? + status = error.response_status + error_body = error.response_body + message += <<-MSG.gsub(/\s+/, ' ').rstrip + -> #{status}: #{error_body} - #{request.method}, #{body} + MSG + end + raise exception, message + end + + def exception_for_error(error) + case error + when Faraday::ParsingError, Faraday::ClientError + Trustly::Exception::DataError + else + Trustly::Exception::ConnectionError + end + end + + def connection(request_uri) + Faraday.new(request_uri.origin) do |conn| + # :nocov: + conn.response :json + conn.adapter :net_http + # :nocov: + end + end + end + end +end diff --git a/lib/trustly/api/signed.rb b/lib/trustly/api/signed.rb index 6af2c39..622cf29 100644 --- a/lib/trustly/api/signed.rb +++ b/lib/trustly/api/signed.rb @@ -1,179 +1,209 @@ -class Trustly::Api::Signed < Trustly::Api - - attr_accessor :url_path,:api_username, :api_password, :merchant_privatekey, :is_https - - - def initialize(_options) - options = { - :host => 'test.trustly.com', - :port => 443, - :is_https => true, - # :private_pem => "#{Rails.root}/certs/trustly/test.merchant.private.pem", - # :public_pem => "#{Rails.root}/certs/trustly/test.trustly.public.pem" - :private_pem => ENV['TRUSTLY_PRIVATE_KEY'], - :public_pem => ENV['TRUSTLY_PUBLIC_KEY'] - }.merge(_options) - - - # raise Trustly::Exception::SignatureError, "File '#{options[:private_pem]}' does not exist" unless File.file?(options[:private_pem]) - # raise Trustly::Exception::SignatureError, "File '#{options[:public_pem]}' does not exist" unless File.file?(options[:public_pem]) - - super(options[:host],options[:port],options[:is_https],options[:public_pem]) - - self.api_username = options.try(:[],:username) - self.api_password = options.try(:[],:password) - self.is_https = options.try(:[],:is_https) - self.url_path = '/api/1' - - raise Trustly::Exception::AuthentificationError, "Username not valid" if self.api_username.nil? - raise Trustly::Exception::AuthentificationError, "Password not valid" if self.api_password.nil? - - self.load_merchant_privatekey - - end - - # def load_merchant_privatekey(filename) - # self.merchant_privatekey = OpenSSL::PKey::RSA.new(File.read(filename)) - # end - - def load_merchant_privatekey - self.merchant_privatekey = OpenSSL::PKey::RSA.new(ENV['TRUSTLY_PRIVATE_KEY']) - end - - def handle_response(request,httpcall) - response = Trustly::Data::JSONRPCResponse.new(httpcall) - raise Trustly::Exception::SignatureError,'Incoming message signature is not valid' unless self.verify_trustly_signed_response(response) - raise Trustly::Exception::DataError, 'Incoming response is not related to request. UUID mismatch.' if response.get_uuid() != request.get_uuid() - return response - end - - def insert_credentials(request) - request.set_data( 'Username' , self.api_username) - request.set_data( 'Password' , self.api_password) - request.set_param('Signature', self.sign_merchant_request(request)) - end - - def sign_merchant_request(data) - raise Trustly::Exception::SignatureError, 'No private key has been loaded' if self.merchant_privatekey.nil? - method = data.get_method() - method = '' if method.nil? - uuid = data.get_uuid() - uuid = '' if uuid.nil? - data = data.get_data() - data = {} if data.nil? - - serial_data = "#{method}#{uuid}#{self.serialize_data(data)}" - sha1hash = OpenSSL::Digest::SHA1.new - signature = self.merchant_privatekey.sign(sha1hash,serial_data) - return Base64.encode64(signature).chop #removes \n - end - - def url_path(request=nil) - return '/api/1' - end - - def call_rpc(request) - request.set_uuid(SecureRandom.uuid) if request.get_uuid().nil? - return super(request) - end - - def void(orderid) - request = Trustly::Data::JSONRPCRequest.new('Void',{"OrderID"=>orderid},nil) - return self.call_rpc(request) - end - - def deposit(_options) - options = { - "Locale" => "es_ES", - "Country" => "ES", - "Currency" => "EUR", - "SuccessURL" => "https://www.trustly.com/success", - "FailURL" => "https://www.trustly.com/fail", - "NotificationURL" => "https://test.trustly.com/demo/notifyd_test", - "Amount" => 0 - }.merge(_options) - - ["Locale","Country","Currency","SuccessURL","FailURL","Amount","NotificationURL","EndUserID","MessageID"].each do |req_attr| - raise Trustly::Exception::DataError, "Option not valid '#{req_attr}'" if options.try(:[],req_attr).nil? +# frozen_string_literal: true + +module Trustly + module Api + class Signed < Base # rubocop:disable Metrics/ClassLength + DEFAULT_API_PATH = '/api/1' + SIGNATURE_ERROR = 'Incoming message signature is not valid' + UUID_MISMATCH = 'Incoming response is not related to the request. UUID mismatch.' + + attr_accessor :api_username, + :api_password, + :merchant_key + + def initialize(**config) + full_config = default_config.merge(config) + self.api_username = full_config.fetch(:username, nil) + self.api_password = full_config.fetch(:password, nil) + load_merchant_key(full_config[:private_pem]) + + super(**full_config.slice(*%i[host port is_https public_pem])) + end + + def void(**options) + required = %w[OrderId] + data = %w[OrderId] + call_rpc_for_data('Void', options, data: data, required: required) + end + + def deposit(**options) # rubocop:disable Metrics/MethodLength + required = %w[ + Locale Country Currency SuccessURL FailURL NotificationURL Amount + EndUserID MessageID Firstname Lastname ShopperStatement + ] + attributes = %w[ + Locale Country Currency SuggestedMinAmount SuggestedMaxAmount Amount + IP SuccessURL FailURL TemplateURL URLTarget MobilePhone ShopperStatement + Firstname Lastname NationalIdentificationNumber Email AccountID + UnchangeableNationalIdentificationNumber ShippingAddressCountry + ShippingAddressPostalCode ShippingAddressLine1 ShippingAddressLine2 + ShippingAddress RequestDirectDebitMandate ChargeAccountID QuickDeposit + URLScheme ExternalReference PSPMerchant PSPMerchantURL + MerchantCategoryCode RecipientInformation + ] + data = %w[NotificationURL EndUserID MessageID] + call_rpc_for_data( + 'Deposit', + options, data: data, attributes: attributes, required: required + ) + end + + def refund(**options) + required = %w[OrderId Amount Currency] + data = %w[OrderId Amount Currency] + attributes = %w[ExternalReference] + call_rpc_for_data( + 'Refund', options, + data: data, attributes: attributes, required: required + ) + end + + def select_account(**options) # rubocop:disable Metrics/MethodLength + required = %w[ + Locale Country SuccessURL FailURL NotificationURL EndUserID MessageID + Firstname Lastname + ] + attributes = %w[ + Locale Country Firstname Lastname SuccessURL FailURL Email IP + RequestDirectDebitMandate TemplateURL URLTarget MobilePhone + NationalIdentificationNumber UnchangeableNationalIdentificationNumber + ShopperStatement DateOfBirth URLScheme PSPMerchant PSPMerchantURL + MerchantCategoryCode + ] + data = %w[NotificationURL EndUserID MessageID] + call_rpc_for_data( + 'SelectAccount', options, + data: data, attributes: attributes, required: required + ) + end + + def account_payout(**options) # rubocop:disable Metrics/MethodLength + required = %w[ + NotificationURL AccountID EndUserID MessageID Amount Currency + ShopperStatement + ] + data = %w[ + NotificationURL AccountID EndUserID MessageID Amount Currency + ] + attributes = %w[ + ShopperStatement PSPMerchant PSPMerchantURL + ExternalReference MerchantCategoryCode SenderInformation + ] + call_rpc_for_data( + 'AccountPayout', options, + data: data, attributes: attributes, required: required + ) + end + + def register_account(**options) # rubocop:disable Metrics/MethodLength + required = %w[ + EndUserID ClearingHouse BankNumber AccountNumber Firstname Lastname + ] + data = %w[ + EndUserID ClearingHouse BankNumber AccountNumber Firstname Lastname + ] + attributes = %w[ + DateOfBirth MobilePhone NationalIdentificationNumber AddressCountry + AddressPostalCode AddressCity AddressLine1 AddressLine2 Address Email + ] + call_rpc_for_data( + 'RegisterAccount', options, + data: data, attributes: attributes, required: required + ) + end + + def get_withdrawals(**options) + data = %w[OrderId] + required = %w[OrderId] + call_rpc_for_data( + 'GetWithdrawals', options, + data: data, required: required + ) + end + + def notification_response(request, success: true) + response = Trustly::Data::JSONRPCNotificationResponse.new( + request: request, success: success + ) + response.signature = sign_merchant_request(response) + response + end + + private + + def load_merchant_key(pkey) + self.merchant_key = OpenSSL::PKey::RSA.new(pkey) if pkey + rescue OpenSSL::PKey::RSAError + self.merchant_key = nil + end + + def configuration_errors + errors = super + errors.push 'Username not specified' if api_username.nil? + errors.push 'Password not specified' if api_password.nil? + errors.push 'Merchant private key not specified' if merchant_key.nil? + errors + end + + def handle_response(request, response) + rpc_response = Trustly::Data::JSONRPCResponse.new(http_response: response) + check_response(rpc_response, request) + rpc_response + end + + def check_response(response, request) + raise Trustly::Exception::DataError, UUID_MISMATCH if response.uuid != request.uuid + raise Trustly::Exception::SignatureError, SIGNATURE_ERROR unless verify_signed_response(response) + end + + def insert_credentials!(request) + request.update_data_at('Username', api_username) + request.update_data_at('Password', api_password) + request.signature = sign_merchant_request(request) + end + + def sign_merchant_request(request) + method = request.method || '' + uuid = request.uuid || '' + data = request.data || {} + + serial_data = "#{method}#{uuid}#{serialize(data)}" + sha1hash = OpenSSL::Digest.new('SHA1') + signature = merchant_key.sign(sha1hash, serial_data) + Base64.encode64(signature).chop + end + + def url_path(_request = nil) + DEFAULT_API_PATH + end + + def call_rpc(request) + request.uuid = SecureRandom.uuid if request.uuid.nil? + super(request) + end + + def call_rpc_for_data(method, options, data:, required:, attributes: []) + missing_options = required.find_all { |req| options[req].nil? } + unless missing_options.empty? + msg = "Required data is missing: #{missing_options.join('; ')}" + raise Trustly::Exception::DataError, msg + end + request = Trustly::Data::JSONRPCRequest.new( + method: method, data: options.slice(*data), + attributes: attributes.empty? ? nil : options.slice(*attributes) + ) + call_rpc(request) + end + + def default_config + { + host: 'test.trustly.com', + port: 443, + is_https: true, + private_pem: ENV.fetch('MERCHANT_PRIVATE_KEY', nil), + public_pem: ENV.fetch('TRUSTLY_PUBLIC_KEY', nil) + } + end end - - raise Trustly::Exception::DataError, "Amount is 0" if options["Amount"].nil? || options["Amount"].to_f <= 0.0 - - attributes = options.slice( - "Locale","Country","Currency", - "SuggestedMinAmount","SuggestedMaxAmount","Amount", - "Currency","Country","IP", - "SuccessURL","FailURL","TemplateURL","URLTarget", - "MobilePhone","Firstname","Lastname","NationalIdentificationNumber", - "ShopperStatement" - ) - - data = options.slice("NotificationURL","EndUserID","MessageID") - - # check required fields - request = Trustly::Data::JSONRPCRequest.new('Deposit',data,attributes) - return self.call_rpc(request) - #options["HoldNotifications"] = "1" unless end - - def refund(_options) - options = { - "Currency" => "EUR" - }.merge(_options) - - # check for required options - ["OrderID","Amount","Currency"].each{|req_attr| raise Trustly::Exception::DataError, "Option not valid '#{req_attr}'" if options.try(:[],req_attr).nil? } - - request = Trustly::Data::JSONRPCRequest.new('Refund',options,nil) - return self.call_rpc(request) - end - - def select_account(_options) - options = { - "Locale" => "se_SE", - "Country" => "SE", - "SuccessURL" => "https://www.trustly.com/success", - "FailURL" => "https://www.trustly.com/fail", - "NotificationURL" => "https://test.trustly.com/demo/notifyd_test", - }.merge(_options) - - ["Locale","Country","SuccessURL","FailURL","NotificationURL","EndUserID","MessageID"].each do |req_attr| - raise Trustly::Exception::DataError, "Option not valid '#{req_attr}'" if options.try(:[],req_attr).nil? - end - - attributes = options.slice( - "Locale","Country","IP", - "SuccessURL","FailURL","TemplateURL","URLTarget", - "MobilePhone","Firstname","Lastname","NationalIdentificationNumber" - ) - - data = options.slice("NotificationURL","EndUserID","MessageID") - - # check required fields - request = Trustly::Data::JSONRPCRequest.new('SelectAccount',data,attributes) - return self.call_rpc(request) - end - - def account_payout(_options) - options = { - "Currency" => "SEK" - }.merge(_options) - - # check for required options - ["NotificationURL","AccountID","EndUserID","MessageID","Amount","Currency"].each{|req_attr| raise Trustly::Exception::DataError, "Option not valid '#{req_attr}'" if options.try(:[],req_attr).nil? } - - request = Trustly::Data::JSONRPCRequest.new('AccountPayout',options,nil) - return self.call_rpc(request) - end - - def notification_response(notification,success=true) - response = Trustly::JSONRPCNotificationResponse.new(notification,success) - response.set_signature(self.sign_merchant_request(response)) - return response - end - - def withdraw(_options) - - end - end diff --git a/lib/trustly/data.rb b/lib/trustly/data.rb deleted file mode 100644 index 9acb20c..0000000 --- a/lib/trustly/data.rb +++ /dev/null @@ -1,65 +0,0 @@ -class Trustly::Data - - attr_accessor :payload - - def initialize - self.payload = {} - end - # Vacuum out all keys being set to Nil in the data to be communicated - def vacumm(data) - if data.is_a? Array - ret = [] - data.each do |elem| - unless elem.nil? - v = self.vacumm elem - ret.append(v) unless v.nil? - end - end - return nil if ret.length == 0 - return ret - elsif data.is_a? Hash - ret = {} - data.each do |key,elem| - unless elem.nil? - v = self.vacumm elem - ret[key.to_s] = elem unless v.nil? - end - end - return nil if ret.length == 0 - return ret - else - return data - end - end - - def get(name=nil) - return name.nil? ? self.payload.dup : self.payload.try(:[],name) - end - - def get_from(sub,name) - return nil if sub.nil? || name.nil? || self.payload.try(:[],sub).nil? || self.payload[sub].try(:[],name).nil? - return self.payload[sub][name] - end - - def set(name,value) - self.payload[name] = value - return value - end - - def set_in(sub,name,value,parent=nil) - return nil if sub.nil? || name.nil? - self.payload[sub] = {} if self.payload.try(:[],sub).nil? - self.payload[sub][name] = value - end - - def pop(name) - v = self.payload.try(:[],name) - delete self.payload[name] unless v.nil? - return v - end - - def json() - self.payload.to_json - end - -end \ No newline at end of file diff --git a/lib/trustly/data/base.rb b/lib/trustly/data/base.rb new file mode 100644 index 0000000..508e4c5 --- /dev/null +++ b/lib/trustly/data/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Trustly + module Data + class Base + attr_accessor :payload + + def initialize(**_options) + self.payload = {} + end + + def to_json(*_args) + payload.to_json + end + end + end +end diff --git a/lib/trustly/data/jsonrpc_request.rb b/lib/trustly/data/jsonrpc_request.rb index 0bf2a2e..6984644 100644 --- a/lib/trustly/data/jsonrpc_request.rb +++ b/lib/trustly/data/jsonrpc_request.rb @@ -1,92 +1,95 @@ -class Trustly::Data::JSONRPCRequest < Trustly::Data::Request +# frozen_string_literal: true - def initialize(method=nil,data=nil,attributes=nil) +module Trustly + module Data + class JSONRPCRequest < Request + def initialize(**options) + super(**options.slice(:method, :payload)) - if !data.nil? || !attributes.nil? - self.payload = {"params"=>{}} - unless data.nil? - if !data.is_a?(Hash) && !attributes.nil? - raise TypeError, "Data must be a Hash if attributes is provided" - else - self.payload["params"]["Data"] = data - end - else - self.payload["params"]["Data"] = {} + data = options[:data] + attributes = options[:attributes] + payload['params'] ||= {} + payload['version'] ||= 1.1 + + initialize_data_and_attributes(data, attributes) end - self.payload["params"]["Data"]["Attributes"] = attributes unless attributes.nil? - end + def params + payload['params'] + end - self.payload['method'] = method unless method.nil? - self.payload['params'] = {} unless self.get('params') - self.payload['version'] = '1.1' + def data + params['Data'] + end - end + def attributes + params.dig('Data', 'Attributes') + end + def data_at(name) + params.dig('Data', name) + end - def get_param(name) - return self.payload['params'].try(:[],name) - end + def attribute_at(name) + params.dig('Data', 'Attributes', name) + end - def get_data(name=nil) - data = self.get_param('Data') - return data if name.nil? - raise KeyError, "Not found #{name} in data" if data.nil? - return data.dup if name.nil? - return data.try(:[],name) - end + def update_data_at(name, value) + params['Data'] ||= {} + params['Data'][name] = value + end - def get_attribute(name) - data = self.get_param('Data') - if data.nil? - attributes = nil - else - attributes = data.try(:[],'Attributes') - end - raise KeyError, "Not found 'Attributes' in data" if attributes.nil? - return attributes.dup if name.nil? - return attributes.try(:[],name) - end + def update_attribute_at(name, value) + params['Data'] ||= {} + params['Data']['Attributes'] ||= {} + params['Data']['Attributes'][name] = value + end - def set_param(name,value) - self.payload['params'][name] = value - end + def signature + params['Signature'] + end - def set_data(name,value) - unless name.nil? - self.payload['params']['Data'] = {} if self.payload['params'].try(:[],'Data').nil? - self.payload['params']['Data'][name] = value - end - return value - end + def signature=(value) + params['Signature'] = value + end - def set_attributes(name,value) - unless name.nil? - self.payload['params']['Data'] = {} if self.payload['params'].try(:[],'Data').nil? - self.payload['params']['Data']['Attributes'] = {} if self.payload['params']['Data'].try(:[],'Attributes').nil? - self.payload['params']['Data']['Attributes'][name] = value - end - return value - end + def method=(value) + super + payload['method'] = method + end - def set_uuid(uuid) - return self.set_param('UUID',uuid) - end + def uuid + params['UUID'] + end - def get_uuid - return self.get_param('UUID') - rescue KeyError => e - return nil - end + def uuid=(value) + params['UUID'] = value + end - def set_method(method) - return self.set('method',method) - end + private - def get_method() - return self.get('method') - rescue KeyError => e - return nil - end + def initialize_data_and_attributes(data, attributes) + return if data.nil? && attributes.nil? + + initialize_data(data, !attributes.nil?) + initialize_attributes(attributes) + end + + def initialize_data(data, with_attributes) + if data.nil? + payload['params']['Data'] ||= {} + else + raise TypeError, 'Data must be a Hash if attributes are provided' if !data.is_a?(Hash) && with_attributes + + payload['params']['Data'] = Utils::DataCleaner.vacuum(data) + end + end + + def initialize_attributes(attributes) + return if attributes.nil? -end \ No newline at end of file + payload['params']['Data']['Attributes'] ||= Utils::DataCleaner.vacuum(attributes) + end + end + end +end diff --git a/lib/trustly/data/jsonrpc_response.rb b/lib/trustly/data/jsonrpc_response.rb index 9d5b883..7566e34 100644 --- a/lib/trustly/data/jsonrpc_response.rb +++ b/lib/trustly/data/jsonrpc_response.rb @@ -1,15 +1,21 @@ -class Trustly::Data::JSONRPCResponse < Trustly::Data::Response +# frozen_string_literal: true - def initialize(http_response) - super(http_response) - version = self.get("version") - raise Trustly::Exception::JSONRPCVersionError, "JSON RPC Version is not supported" if version != '1.1' - end +module Trustly + module Data + class JSONRPCResponse < Response + VERSION_ERROR = 'JSON RPC Version is not supported' - def get_data(name=nil) - return self.response_result.try(:[],"data") if name.nil? - return Trustly::Exception::DataError, "Data not found or key is null" if self.response_result.try(:[],"data").nil? || name.nil? - return self.response_result["data"][name] - end + def initialize(**options) + super + version = payload['version'] + raise Trustly::Exception::JSONRPCVersionError, VERSION_ERROR if version != '1.1' + end -end \ No newline at end of file + def data_at(name) + return if data.nil? + + data[name] + end + end + end +end diff --git a/lib/trustly/data/jsonrpcnotification_request.rb b/lib/trustly/data/jsonrpcnotification_request.rb index 0af0126..1d71a22 100644 --- a/lib/trustly/data/jsonrpcnotification_request.rb +++ b/lib/trustly/data/jsonrpcnotification_request.rb @@ -1,52 +1,49 @@ -class Trustly::JSONRPCNotificationRequest < Trustly::Data +# frozen_string_literal: true - attr_accessor :notification_body, :payload +module Trustly + module Data + class JSONRPCNotificationRequest < Request + def initialize(**options) + super(payload: notification_body(options[:notification_body])) + return if version == '1.1' - def initialize(notification_body) - super() - self.notification_body = notification_body - unless self.notification_body.is_a?(Hash) - begin - self.payload = JSON.parse(self.notification_body) - rescue JSON::ParserError => e - raise Trustly::Exception::DataError, e.message + error_message = "JSON RPC Version #{version} is not supported" + raise Trustly::Exception::JSONRPCVersionError, error_message end - raise Trustly::Exception::JSONRPCVersionError, 'JSON RPC Version #{(self.get_version()} is not supported' if self.get_version() != '1.1' - else - self.payload = self.notification_body.deep_stringify_keys - end - end + def version + payload['version'] + end - def get_version() - return self.get('version') - end + def method + payload['method'] + end - def get_method() - return self.get('method') - end + def signature + payload.dig('params', 'signature') + end - def get_uuid() - return self.get_params('uuid') - end + def uuid + payload.dig('params', 'uuid') + end - def get_signature() - return self.get_params('signature') - end + def data_at(key) + payload.dig('params', 'data', key) + end - def get_params(name) - raise KeyError,"#{name} is not present in params" if name.nil? || self.payload.try(:[],"params").nil? || self.payload["params"].try(:[],name).nil? - return self.payload["params"][name] - end + def attribute_at(key) + payload.dig('params', 'data', 'attributes', key) + end + + private - def get_data(name=nil) - if name.nil? - raise KeyError,"Data not present" if self.payload.try(:[],"params").nil? || self.payload["params"].try(:[],"data").nil? - return self.payload["params"]["data"] - else - raise KeyError,"#{name} is not present in data" if name.nil? || self.payload.try(:[],"params").nil? || self.payload["params"].try(:[],"data").nil? || self.payload["params"]["data"].try(:[],name).nil? - return self.payload["params"]["data"][name] + def notification_body(body) + return Utils::DataTransformer.deep_stringify_hash(body) if body.is_a?(Hash) + + JSON.parse(body) + rescue JSON::ParserError => e + raise Trustly::Exception::DataError, e.message + end end end - end diff --git a/lib/trustly/data/jsonrpcnotification_response.rb b/lib/trustly/data/jsonrpcnotification_response.rb index 2539eef..6dfe47a 100644 --- a/lib/trustly/data/jsonrpcnotification_response.rb +++ b/lib/trustly/data/jsonrpcnotification_response.rb @@ -1,63 +1,69 @@ -class Trustly::JSONRPCNotificationResponse < Trustly::Data - - def initialize(request,success=nil) - super() - uuid = request.get_uuid() - method = request.get_method() - - self.set('version','1.1') - self.set_result('uuid', uuid) unless uuid.nil? - self.set_result('method', method) unless method.nil? - self.set_data( 'status', (!success.nil? && !success ? 'FAILED' : 'OK' )) - end +# frozen_string_literal: true - def set_signature(signature) - self.set_result('signature',signature) - end +module Trustly + module Data + class JSONRPCNotificationResponse < Base + def initialize(**options) + super + request = options[:request] + success = options[:success] - def set_result(name,value) - return nil if name.nil? || value.nil? - self.payload["result"] = {} if self.payload.try(:[],"result").nil? - self.payload["result"][name] = value - end + self.version = '1.1' + self.uuid = request.uuid if request.uuid + self.method = request.method if request.method + update_data_at('status', success ? 'OK' : 'FAILED') + end - def set_data(name,value) - return nil if name.nil? || value.nil? - self.payload["result"] = {} if self.payload.try(:[],"result").nil? - self.payload["result"]["data"] = {} if self.payload["result"].try(:[],"data").nil? - self.payload["result"]["data"][name] = value - end + def signature=(value) + update_result_at('signature', value) + end - def get_result(name) - raise KeyError,"#{name} is not present in result" if name.nil? || self.payload.try(:[],"result").nil? || self.payload["result"].try(:[],name).nil? - return self.payload["result"][name] - end + def method=(value) + update_result_at('method', value) + end - def get_data(name=nil) - raise KeyError,"#{name} is not present in data" if name.nil? || self.payload.try(:[],"result").nil? || self.payload["result"].try(:[],"data").nil? || self.payload["result"]["data"].try(:[],name).nil? - return self.payload["result"]["data"][name] - end + def uuid=(value) + update_result_at('uuid', value) + end - def get_data(name=nil) - if name.nil? - raise KeyError,"Data not present" if self.payload.try(:[],"result").nil? || self.payload["result"].try(:[],"data").nil? - return self.payload["result"]["data"] - else - raise KeyError,"#{name} is not present in data" if name.nil? || self.payload.try(:[],"result").nil? || self.payload["result"].try(:[],"data").nil? || self.payload["result"]["data"].try(:[],name).nil? - return self.payload["result"]["data"][name] - end - end + def version=(value) + payload['version'] = value + end - def get_method - return self.get_result('method') - end + def update_result_at(name, value) + payload['result'] ||= {} + payload['result'][name] = value + end - def get_uuid - return self.get_result('uuid') - end + def update_data_at(name, value) + payload['result'] ||= {} + payload['result']['data'] ||= {} + payload['result']['data'][name] = value + end - def get_signature - return self.get_result('signature') - end + def data + payload.dig('result', 'data') + end + + def result + payload['result'] + end + + def version + payload['version'] + end -end \ No newline at end of file + def method + result['method'] + end + + def uuid + result['uuid'] + end + + def signature + result['signature'] + end + end + end +end diff --git a/lib/trustly/data/request.rb b/lib/trustly/data/request.rb index f1e26ee..588b848 100644 --- a/lib/trustly/data/request.rb +++ b/lib/trustly/data/request.rb @@ -1,33 +1,19 @@ -class Trustly::Data::Request < Trustly::Data +# frozen_string_literal: true - attr_accessor :method +module Trustly + module Data + class Request < Base + attr_accessor :method - def initialize(method=nil,payload=nil) - super - self.payload = self.vacuum(payload) unless payload.nil? - unless method.nil? - self.method = method - else - self.method = self.payload.get('method') + def initialize(**options) + super + if (new_payload = options[:payload]) + vacuumed_payload = Utils::DataCleaner.vacuum(new_payload) + self.payload = Utils::DataTransformer. + deep_stringify_hash(vacuumed_payload) + end + self.method = options[:method] || payload['method'] + end end end - - def get_method - return self.method - end - - def set_method(method) - self.method = method - return method - end - - def get_uuid - return self.payload.get('uuid') - end - - def set_uuid - self.set('uuid',uuid) - return uuid - end - -end \ No newline at end of file +end diff --git a/lib/trustly/data/response.rb b/lib/trustly/data/response.rb index 8d0f8cc..72c2efa 100644 --- a/lib/trustly/data/response.rb +++ b/lib/trustly/data/response.rb @@ -1,80 +1,72 @@ -class Trustly::Data::Response < Trustly::Data - attr_accessor :response_status, :response_reason, :response_body, :response_result - - def initialize(http_response) #called from Net::HTTP.get_response("trustly.com","/api_path") -> returns Net::HTTPResponse - super() - self.response_status = http_response.code - self.response_reason = http_response.class.name - self.response_body = http_response.body - begin - self.payload = JSON.parse(self.response_body) - rescue JSON::ParserError => e - if self.response_status != 200 - raise Trustly::Exception::ConnectionError, "#{self.response_status}: #{self.response_reason} [#{self.response_body}]" - else - raise Trustly::Exception::DataError, e.message +# frozen_string_literal: true + +module Trustly + module Data + class Response < Base + attr_accessor :response_status, + :response_reason, + :response_body, + :response_result + + # called from Net::HTTP.get_response("trustly.com","/api_path") -> returns Net::HTTPResponse + def initialize(**options) + super + http_response = options[:http_response] + process_http_response(http_response) end - end - begin - self.response_result = self.get('result') - rescue IndexError::KeyError => e - self.response_result = nil - end + def error? + !payload['error'].nil? + end + + def error_code + return nil unless error? - if self.response_result.nil? - begin - self.response_result = self.payload["error"]["error"] - rescue IndexError::KeyError => e + response_result.dig('data', 'code') end - end - raise Trustly::Exception::DataError, "No result or error in response #{self.payload}" if self.response_result.nil? - end - def error? - return !self.get('error').nil? - rescue IndexError::KeyError => e - return false - end + def error_message + return nil unless error? - def error_code - return nil unless self.error? - return self.response_result["data"].try(:[],'code') - end + response_result.dig('data', 'message') + end - def error_msg - return nil unless self.error? - return self.response_result["data"].try(:[],'message') - end + def success? + !payload['result'].nil? + end - def success? - return !self.get('result').nil? - rescue IndexError::KeyError => e - return false - end + def data + response_result['data'] + end - def get_uuid - return self.response_result.try(:[],'uuid') - end + def uuid + response_result['uuid'] + end - def get_method - return self.response_result.try(:[],'method') - end + def method + response_result['method'] + end - def get_signature - return self.response_result.try(:[],"signature") - end + def signature + response_result['signature'] + end + + private + + def process_http_response(http_response) + self.response_status = http_response.status + self.response_reason = http_response.reason_phrase + init_response_result(http_response.body) + end + + def init_response_result(body) + self.payload = body + self.response_result = payload['result'] || payload.dig('error', 'error') + return unless response_result.nil? - def get_result - unless name.nil? - if self.response_result.is_a?(Hash) - return self.response_result.try(:[],name) - else - raise StandardError::TypeError, "Result is not a Hash" + message = "No result or error in response #{payload}" + raise Trustly::Exception::DataError, message end - else - return self.response_result.dup end end - -end \ No newline at end of file +end diff --git a/lib/trustly/exception.rb b/lib/trustly/exception.rb deleted file mode 100644 index 743e7fb..0000000 --- a/lib/trustly/exception.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Trustly::Exception < Exception - -end \ No newline at end of file diff --git a/lib/trustly/exception/authentification_error.rb b/lib/trustly/exception/authentification_error.rb index 75d923d..684bfa8 100644 --- a/lib/trustly/exception/authentification_error.rb +++ b/lib/trustly/exception/authentification_error.rb @@ -1,2 +1,8 @@ -class Trustly::Exception::AuthentificationError < Exception -end \ No newline at end of file +# frozen_string_literal: true + +module Trustly + module Exception + class AuthentificationError < Base + end + end +end diff --git a/lib/trustly/exception/base.rb b/lib/trustly/exception/base.rb new file mode 100644 index 0000000..b501193 --- /dev/null +++ b/lib/trustly/exception/base.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Trustly + module Exception + class Base < StandardError + end + end +end diff --git a/lib/trustly/exception/configuration_error.rb b/lib/trustly/exception/configuration_error.rb new file mode 100644 index 0000000..57e4281 --- /dev/null +++ b/lib/trustly/exception/configuration_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Trustly + module Exception + class ConfigurationError < Base + end + end +end diff --git a/lib/trustly/exception/connection_error.rb b/lib/trustly/exception/connection_error.rb index 6dbbebd..ad7fff2 100644 --- a/lib/trustly/exception/connection_error.rb +++ b/lib/trustly/exception/connection_error.rb @@ -1,2 +1,8 @@ -class Trustly::Exception::ConnectionError < Exception -end \ No newline at end of file +# frozen_string_literal: true + +module Trustly + module Exception + class ConnectionError < Base + end + end +end diff --git a/lib/trustly/exception/data_error.rb b/lib/trustly/exception/data_error.rb index 675397b..af2a74e 100644 --- a/lib/trustly/exception/data_error.rb +++ b/lib/trustly/exception/data_error.rb @@ -1,3 +1,8 @@ -class Trustly::Exception::DataError < Exception - -end \ No newline at end of file +# frozen_string_literal: true + +module Trustly + module Exception + class DataError < Base + end + end +end diff --git a/lib/trustly/exception/jsonrpc_version_error.rb b/lib/trustly/exception/jsonrpc_version_error.rb index fd504e5..bc22ee2 100644 --- a/lib/trustly/exception/jsonrpc_version_error.rb +++ b/lib/trustly/exception/jsonrpc_version_error.rb @@ -1,3 +1,8 @@ -class Trustly::Exception::JSONRPCVersionError < Exception - -end \ No newline at end of file +# frozen_string_literal: true + +module Trustly + module Exception + class JSONRPCVersionError < Base + end + end +end diff --git a/lib/trustly/exception/signature_error.rb b/lib/trustly/exception/signature_error.rb index 176485f..8651772 100644 --- a/lib/trustly/exception/signature_error.rb +++ b/lib/trustly/exception/signature_error.rb @@ -1,2 +1,8 @@ -class Trustly::Exception::SignatureError < Exception -end \ No newline at end of file +# frozen_string_literal: true + +module Trustly + module Exception + class SignatureError < Base + end + end +end diff --git a/lib/trustly/utils/data_cleaner.rb b/lib/trustly/utils/data_cleaner.rb new file mode 100644 index 0000000..c0d99a7 --- /dev/null +++ b/lib/trustly/utils/data_cleaner.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Trustly + module Utils + class DataCleaner + class << self + def vacuum(data) + case data + when Array then vacuum_array_data(data) + when Hash then vacuum_hash_data(data) + else data + end + end + + private + + def vacuum_array_data(data) + ret = data.filter_map { |element| vacuum(element) } + ret.empty? ? nil : ret + end + + def vacuum_hash_data(data) + ret = data.each_with_object({}) do |(key, element), acc| + next if (processed_element = vacuum(element)).nil? + + acc[key] = processed_element + end + ret.empty? ? nil : ret + end + end + end + end +end diff --git a/lib/trustly/utils/data_transformer.rb b/lib/trustly/utils/data_transformer.rb new file mode 100644 index 0000000..4f74361 --- /dev/null +++ b/lib/trustly/utils/data_transformer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Trustly + module Utils + class DataTransformer + def self.deep_stringify_hash(object) + case object + when Hash + object.each_with_object({}) do |(key, value), result| + result[key.to_s] = deep_stringify_hash(value) + end + when Array + object.map { |element| deep_stringify_hash(element) } + else + object + end + end + end + end +end diff --git a/lib/trustly/version.rb b/lib/trustly/version.rb index efa1b60..93b90ce 100644 --- a/lib/trustly/version.rb +++ b/lib/trustly/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Trustly - VERSION = "0.1.95" -end \ No newline at end of file + VERSION = '0.1.95' +end diff --git a/pkg/trustly-client-ruby-0.0.6.gem b/pkg/trustly-client-ruby-0.0.6.gem deleted file mode 100644 index 009e39e..0000000 Binary files a/pkg/trustly-client-ruby-0.0.6.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.0.7.gem b/pkg/trustly-client-ruby-0.0.7.gem deleted file mode 100644 index 4a17a08..0000000 Binary files a/pkg/trustly-client-ruby-0.0.7.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.0.8.gem b/pkg/trustly-client-ruby-0.0.8.gem deleted file mode 100644 index 9bacb02..0000000 Binary files a/pkg/trustly-client-ruby-0.0.8.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.0.9.gem b/pkg/trustly-client-ruby-0.0.9.gem deleted file mode 100644 index 0b8740f..0000000 Binary files a/pkg/trustly-client-ruby-0.0.9.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.0.gem b/pkg/trustly-client-ruby-0.1.0.gem deleted file mode 100644 index 6cabc0b..0000000 Binary files a/pkg/trustly-client-ruby-0.1.0.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.1.gem b/pkg/trustly-client-ruby-0.1.1.gem deleted file mode 100644 index 1c860fd..0000000 Binary files a/pkg/trustly-client-ruby-0.1.1.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.2.gem b/pkg/trustly-client-ruby-0.1.2.gem deleted file mode 100644 index 60026c8..0000000 Binary files a/pkg/trustly-client-ruby-0.1.2.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.3.gem b/pkg/trustly-client-ruby-0.1.3.gem deleted file mode 100644 index 639c25c..0000000 Binary files a/pkg/trustly-client-ruby-0.1.3.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.4.gem b/pkg/trustly-client-ruby-0.1.4.gem deleted file mode 100644 index 1ab7637..0000000 Binary files a/pkg/trustly-client-ruby-0.1.4.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.5.gem b/pkg/trustly-client-ruby-0.1.5.gem deleted file mode 100644 index 77377a5..0000000 Binary files a/pkg/trustly-client-ruby-0.1.5.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.6.gem b/pkg/trustly-client-ruby-0.1.6.gem deleted file mode 100644 index 576675c..0000000 Binary files a/pkg/trustly-client-ruby-0.1.6.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.7.gem b/pkg/trustly-client-ruby-0.1.7.gem deleted file mode 100644 index cd27e46..0000000 Binary files a/pkg/trustly-client-ruby-0.1.7.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.71.gem b/pkg/trustly-client-ruby-0.1.71.gem deleted file mode 100644 index 3072d12..0000000 Binary files a/pkg/trustly-client-ruby-0.1.71.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.72.gem b/pkg/trustly-client-ruby-0.1.72.gem deleted file mode 100644 index 52b04f3..0000000 Binary files a/pkg/trustly-client-ruby-0.1.72.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.75.gem b/pkg/trustly-client-ruby-0.1.75.gem deleted file mode 100644 index 79128e1..0000000 Binary files a/pkg/trustly-client-ruby-0.1.75.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.78.gem b/pkg/trustly-client-ruby-0.1.78.gem deleted file mode 100644 index dea5994..0000000 Binary files a/pkg/trustly-client-ruby-0.1.78.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.8.gem b/pkg/trustly-client-ruby-0.1.8.gem deleted file mode 100644 index b665649..0000000 Binary files a/pkg/trustly-client-ruby-0.1.8.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.81.gem b/pkg/trustly-client-ruby-0.1.81.gem deleted file mode 100644 index 35cbc99..0000000 Binary files a/pkg/trustly-client-ruby-0.1.81.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.85.gem b/pkg/trustly-client-ruby-0.1.85.gem deleted file mode 100644 index 3d5367a..0000000 Binary files a/pkg/trustly-client-ruby-0.1.85.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.86.gem b/pkg/trustly-client-ruby-0.1.86.gem deleted file mode 100644 index dc9abd7..0000000 Binary files a/pkg/trustly-client-ruby-0.1.86.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.87.gem b/pkg/trustly-client-ruby-0.1.87.gem deleted file mode 100644 index 59dce31..0000000 Binary files a/pkg/trustly-client-ruby-0.1.87.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.88.gem b/pkg/trustly-client-ruby-0.1.88.gem deleted file mode 100644 index cc9e438..0000000 Binary files a/pkg/trustly-client-ruby-0.1.88.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.89.gem b/pkg/trustly-client-ruby-0.1.89.gem deleted file mode 100644 index 4b62600..0000000 Binary files a/pkg/trustly-client-ruby-0.1.89.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.91.gem b/pkg/trustly-client-ruby-0.1.91.gem deleted file mode 100644 index 098dc03..0000000 Binary files a/pkg/trustly-client-ruby-0.1.91.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.92.gem b/pkg/trustly-client-ruby-0.1.92.gem deleted file mode 100644 index 36d5f0d..0000000 Binary files a/pkg/trustly-client-ruby-0.1.92.gem and /dev/null differ diff --git a/pkg/trustly-client-ruby-0.1.93.gem b/pkg/trustly-client-ruby-0.1.93.gem deleted file mode 100644 index f178450..0000000 Binary files a/pkg/trustly-client-ruby-0.1.93.gem and /dev/null differ diff --git a/spec/data/account_payout.json b/spec/data/account_payout.json new file mode 100644 index 0000000..b9f461d --- /dev/null +++ b/spec/data/account_payout.json @@ -0,0 +1,12 @@ +{ + "result": { + "signature": "JNtiLW1S+9SE2KgU/3xFiRS5sIieGAcGuoaPx4fUFTGuHe/MQpw9/1pyF4An\nVwawCM0zeqVcZGyyXq/FBBxt7f1adbVuGBw7QQsfRn6qiKR8abXo8a8VBjG8\n8tN4k4hHkcib90Xuq0ecfT45svje+OtsPRo70lzZzAycJnE0Dz8/gZC8Wy5V\n1lEAAJLXE87C6yCPBvAWroX98Rh4+7e4n8Us7yuAUi6VYQ8YOwTXmHSzI2dH\n0KwF9io30NkbwBtjc9PeoS6M7Ave1ri/yaDAM5DlbbcCLMjZUxledAPI+B4u\nizhiWXCUJcnXsSaQu/AMs7QmwQdutle92T5q6o1aSw==", + "uuid": "258a2184-2842-b485-25ca-293525152425", + "method": "AccountPayout", + "data": { + "orderid": "7653345737", + "result": "1" + } + }, + "version":"1.1" +} diff --git a/spec/data/credit_notification.json b/spec/data/credit_notification.json new file mode 100644 index 0000000..66a90ab --- /dev/null +++ b/spec/data/credit_notification.json @@ -0,0 +1,18 @@ +{ + "method": "credit", + "params": { + "signature": "1Cbl2QjwyX9YN1YerZUuGb1qxGjns/Qdx05eopHYNfsoI2pkAnMw1HfSYpeC\nBuOmwyHaeiva8/Sf3w9FIeRn/bgtW4srRynQRyW3bw4rYPYdqBjFzg2pH9Br\nSVxEbKyshr37U3/dO+WP3LAR3rtl+RqEGxO+gK9/tgYqZABL3xEdF8LAGcBj\nAl6MIWDqOj+ZtgQddzAq/sSzRV5a9LnjHBFeFtEKI4d39Dtakap9caiRubYP\nxVnRvefu9twgk6mssNrrI6m0ZddKnQFUUxTmzA/nFzn+djxMfmWCrv9/d9vI\nBAdoQWLTV2m4f5STFIK+zh1UYQmYBrPqI+ItJJzOKw==", + "uuid": "258a2184-2842-b485-25ca-293525152425", + "data": { + "amount": "902.50", + "currency": "EUR", + "messageid": "98348932", + "orderid": "87654567", + "enduserid": "32123", + "notificationid": "9876543456", + "timestamp": "2010-01-20 14:42:04.675645+01", + "attributes": {} + } + }, + "version": "1.1" +} diff --git a/spec/data/deposit.json b/spec/data/deposit.json new file mode 100644 index 0000000..d93f690 --- /dev/null +++ b/spec/data/deposit.json @@ -0,0 +1,12 @@ +{ + "result": { + "signature": "ywKipJ3zkwimgUtSTp4AEXxIaKhWciNBHeVI+g/1rggJ0mMqXWpsG9MfjqXB\ndeb6pWx7qAfykjkE2oQyBfivLV+TCP9tzqruKEReQvUuDQX1GqLhUcwOkSfk\nFDtMQf5ZqR3+GxacFwPOkKqJ3TU35O8YlaBAf4L0UWvotQQH2KoIoKmxgJtb\nHrarmFqFn6cWxiU6Q/EzNo230qaEdlYpEGACvXSU8v5DnxkUmGQOC/4yIUTX\ncBsxJCD3IWf6DHd7MrSDgeb1LI67bDkSkec2zzBoaxPuJJjCPBLCgIHEbxfr\ny96U5oeNUaVd03FN/V1FHF7PGj1GM9z3AXX5qwznng==", + "uuid": "258a2184-2842-b485-25ca-293525152425", + "method": "Deposit", + "data": { + "orderid": "2190971587", + "url": "https://trustly.com/_/2f6b14fa-446a-4364-92f8-84b738d589ff" + } + }, + "version": "1.1" +} diff --git a/spec/data/get_withdrawals.json b/spec/data/get_withdrawals.json new file mode 100644 index 0000000..a8b9fba --- /dev/null +++ b/spec/data/get_withdrawals.json @@ -0,0 +1,21 @@ +{ + "result": { + "uuid": "cecf1a0e-31f7-0bed-b07f-481447584126", + "method": "GetWithdrawals", + "data": [ + { + "reference": "5000010000", + "modificationdate": "2015-05-12 11:16:30.957975+02", + "orderid": "1436557899", + "datestamp": "2015-05-12 11:14:22.982842+02", + "transferstate": "CONFIRMED", + "amount": "1.00", + "accountid": "1234567890", + "currency": "SEK", + "eta": "2015-05-12 12:00:00.000000+02" + } + ], + "signature": "MGGFeJVvY1J/CoZQFmnk0fEmog+B1/rCzSw17Oq8eiA7PqZRWikeHoeqmE2e\nHYL7PNEMT2/crBqGAxT80n73S/qV7wmSIi8DQxast68pNfiRJ5UjAFmm+IAq\nKBC0MPLsJq8gCmvPOtEZwjgfrlaESzknPSraQNvaiKji3zEtX47e8U+R98o7\nJG8Vz5GfXOn9XVu9eMJGYHOEJ2eGgvTyYNt94MClqjsXGIGVD3HYCWHkQmuE\n9aP8tRtelNIoDF4wJdSvG9RuI1HdZ/IFlrU+6+ip45iNTwYkkox+i8sNoH7q\nVl46Bo9NbjDSDs/98T8NPDsy9NsBq7EUqyb+B9EyaQ==" + }, + "version": "1.1" +} diff --git a/spec/data/merchant_private_key.pem b/spec/data/merchant_private_key.pem new file mode 100644 index 0000000..dd130be --- /dev/null +++ b/spec/data/merchant_private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA4ZX5APbhU924Yl4qJuOoEVh187ppNDx8F7TOYemt4LuICrXM +V+pBb68ZaK0DtoGjeOu/4Xm5mJIFZPFe1TgraIhKPWZI4sfNA0PVEJv/lu7A81a1 +V95ivj3rMfe84KTAJznrpa7xW00IzOIJp5wgqmm3hki6vQooajkr3HGdtn9lf5gj +6PceJIgHOmiX3W5i6cn3Z7zGrU8erFBwrVFx8IT/IIsnGgepf86smjXB0Ur/qaZF +rC5UQHcYV6aftkjnO0Hibgl5oSWkAyCmRV0Ojy3AVPbWXRafHG57DDwJd16gLVhk +dZEkRIVmNGJqhWlmZ7RosDoxv7Sr4LceBt5n+wIDAQABAoIBAF+Z7UMJXYjrR/74 +JSkCBfID6Uj3USqAD58EUwqPu86n6wmO7iC7+Ctaq4v+9rnbyumuD02BBrSv/XVA +DY6TFWJhkAThWjYxsqKVlrBJTFIssLzvnD620mYJW6l7ciJJ790v4LwAneyxgu9B +RBIySm2uC8bu/6Spr2MFA5+SzuHN46toWEIhqRBMmwTh1yyhOMHWhAVUDYWjcr7W +NPRZgnusPODCOcf9joSmzuwkLElQmDFM8KG3YpP2dn2KVIYd/ndmcQ7gMO2u9Zw1 +CnH2sL498OySEldAXxq7+pfE935tPWDLv7fFNG4A3Q85bx70HfVrJQLhAnT/H0aj +1LJg58ECgYEA/w7J4gz5INjdkfDJw6G3MMO68/Eg+/FfzvRYBLpEH8zE9nK16ArG +SMhV3T+KvpUUcSMKOBrMlhAeUIW2czYm25iwgy6KgY6CvKVCMGBGXn4ArkHnd69C +bv+M5SkjUTZafDlhWVAery/V644C+4aKUps4arWY+2cXdP2crJJtBtECgYEA4mtP +5EzBvAT0Qf0zk5j43YGOSWMHyrfxqt/owRfhQTS0ByiBJKQVUIw2W4V7NUCz+KLE +XrEuYbV9WbXycNCO1y0O1UWkPtNaCeb13chSAOTQ+sAFl1WhsUqpmylkMokgDVVh +iPKiEmPGqPApXKsJzNSHsa2qXKcq3NY1FlAOjQsCgYAh3NfG0EwfJUu9fYd8FrNY +oRPoIUJs0K4UrvIkpoo24pvf0HkANrX+ocJsnmwQQ4C0SJ+ptT0mSzuLG0WO5Eii +bRI6SGqRKteGrjYscAvHrdjvScauaDFcxUbygdSzipDW31NiZTW9so8nN/KDbGhe +8Ua7PCL0dcpyeN1dOA+LkQKBgG/vvb+QcvcRO/CjzSvbJK3drwp4+xEtfzyLFfbg +Z2xlMduYGsCSnjcEGpuEkjTxmAgD8DEgR13m6+G+Ie3ELdoTXJHzrA+jTZA3rrXG +o0Pt26Mb66e1ngqYbuFWxUJ2qHHvFBkwWw/cZAqBMPGvXVj2eV9ODDtiKb6j5/rv ++UGhAoGBAIgE8O4PjToc0ZUqSBrpgpmpmRztNDQYcgvVXVsc3hq7sEXcebfyt9Da +Y0qt0yKP6Tha5oHhzUhVqrK3RHRR9nASM22KLh9Ak81mySiW4oY6x/XjhAvfpcBa +oACbpGv+4lKl5SRIlEGBOUkH3YmobZvB/dFOAwAdvk5zw6ucBXYU +-----END RSA PRIVATE KEY----- diff --git a/spec/data/merchant_public_key.pem b/spec/data/merchant_public_key.pem new file mode 100644 index 0000000..3a85fdd --- /dev/null +++ b/spec/data/merchant_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ZX5APbhU924Yl4qJuOo +EVh187ppNDx8F7TOYemt4LuICrXMV+pBb68ZaK0DtoGjeOu/4Xm5mJIFZPFe1Tgr +aIhKPWZI4sfNA0PVEJv/lu7A81a1V95ivj3rMfe84KTAJznrpa7xW00IzOIJp5wg +qmm3hki6vQooajkr3HGdtn9lf5gj6PceJIgHOmiX3W5i6cn3Z7zGrU8erFBwrVFx +8IT/IIsnGgepf86smjXB0Ur/qaZFrC5UQHcYV6aftkjnO0Hibgl5oSWkAyCmRV0O +jy3AVPbWXRafHG57DDwJd16gLVhkdZEkRIVmNGJqhWlmZ7RosDoxv7Sr4LceBt5n ++wIDAQAB +-----END PUBLIC KEY----- diff --git a/spec/data/refund.json b/spec/data/refund.json new file mode 100644 index 0000000..9f7bf96 --- /dev/null +++ b/spec/data/refund.json @@ -0,0 +1,12 @@ +{ + "version": "1.1", + "result": { + "signature": "b8PabwcB4ETNAx0hRQWLLamCiLMYKVFmQZ+jZ+f8mRZtJKy0PJ+M+Pmcaqjj\nkLXH6FjEtLIOebIH2lwc4qmnG9Kli7Z4TbkLkk80icI4RSFTyCJnFHE1ERSR\nZ+OAfomR+4dZ6gcgJiG08a7QjpV0kzAeLjCz/1iouCXOivg7r0TbqaxywrjY\n/nf/OROP6YGLie/zu0FjTv1pp/zO1pF1nLTd8S+/KjPP0t9YZM58Zu6yMBgm\ntMSF5jPSIP894+wMC0Bzy4Sg0HF2lrgDSTvCCaiscLl4mX3PaHvIXc5wyNW9\nrbqlXzFUtetSeWT41m0YSLvs79v9TYaU97rN0OFR/Q==", + "method": "Refund", + "data": { + "result": "1", + "orderid": "1187741486" + }, + "uuid": "8bedfbd4-8181-38e1-f0be-f360171aefc6" + } +} diff --git a/spec/data/register_account.json b/spec/data/register_account.json new file mode 100644 index 0000000..479b4bb --- /dev/null +++ b/spec/data/register_account.json @@ -0,0 +1,14 @@ +{ + "result": { + "signature": "4uDNGT+/9dXjMtRBJ/dybFfl0qVFrvncL3WxKze7wbvBzByAg4B63Ar3pRFn\nff18ewAXZOOK+GOOeUZMdv/lXMKtGbXwS+iapbYm/qjKJW8NCFLfqrHjooH3\nES/8XUvrZ8wNyjTZx6nKt4ZWO0SgP+2htUI/2K2GCuB/FLZJ2qICdiJh7ZPv\nPIbocsdbvifKUyrIB3Lgrmdl5OoW5RmfXFCQ4CuHNWlpeioTZ2D6BlL/cPK2\nGso/b+WPT9w5ADw77ucqDvhBFOQrQFuINAaP7AXSvcJRpTRwcTMEf0JsBlex\nldn2JWNsruxzWbU+DBY+0ijz0CKCar7/5tMWgueqNQ==", + "uuid": "258a2184-2842-b485-25ca-293525152425", + "method": "RegisterAccount", + "data": { + "accountid": "7653385737", + "clearinghouse": "SWEDEN", + "bank": "Handelsbanken", + "descriptor": "**706212" + } + }, + "version": "1.1" +} diff --git a/spec/data/select_account.json b/spec/data/select_account.json new file mode 100644 index 0000000..945c8d9 --- /dev/null +++ b/spec/data/select_account.json @@ -0,0 +1,12 @@ +{ + "result": { + "signature": "4FTSgwn7JqKns/vRjmdgyoUOhQQP0BM3SccezsLVThLqX5DXCsu5D0MTRheR\n1943yJ4ahm5122HLYx1a4Ug05u5uxvNv/9/IezICb3DCn/xQP0ucIp4ZENEb\nPxzvf1f1HQ8KW8rA4qqyackFxeV2NHAtYIXbA2fgB2FcQMmfWqPXsoUIW2fl\nkUhMfgIMWkUZKd/lALZIt0a9dG9cDePlFHjZ4HtLJLR2j57uHa+jUQ9FqHho\nLRS+egaCx++U8cLtnbgu6F/IP4moVRCIb7tdpkcxdINkQS+gCnrq/Y3OPNJ5\nZG1xnkPRclW8Q9bBBgl6VOo7F/bldzvcqHh+XHmTNg==", + "uuid": "258a2184-2842-b485-25ca-293525152425", + "method": "SelectAccount", + "data": { + "orderid": "2190971587", + "url": "https://trustly.com/_/bec96a48-d454-448e-a9ba-25fea8eeba3f" + } + }, + "version": "1.1" +} diff --git a/spec/data/trustly_private_key.pem b/spec/data/trustly_private_key.pem new file mode 100644 index 0000000..ca2dfcf --- /dev/null +++ b/spec/data/trustly_private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA50Rcabjgmkj/iS46bRk+dNfqQpaLLuQcFoPaQ1pxrTMpXLM4 +ZJCjejn//6t3nXSJBPjeypG9ANp6Mx26cTv2G2sc4lQHDOmdRXgJn/LmWCPpgjVj +4EMI0KZaErXisWdz8q5MJ1D3+jzTuRFeVPyMA//uPQ0u367pQq0C6G2t1GtZbvD7 +zaPBgxTcBIbUtERRhmuSC97wuoP7+6BZBPO2dF1cysfAWZX785xdR1fi53qZGQhF +qDwjrhI87lsWkNxDlbZSlozWinXL1vsFHzZ1jZtrm4NLzmjzBg1iBQRNNoTll/d8 +IdeUUhCNtTehoETkYbhWtOUGaXS22kXADPMwiQIDAQABAoIBAQCekuAu2caf49fb +ryf+sKWDpp0JRYJwB5c+1O/u6PAzS3ZcCsNrKUX+xBBFtcPR4hslnqPdECshj6zk +qciyZePtjveCNQ2UjAb7oEAxPXM2EoHFd2hhWHWN49K1K6Qh8oata1fqSXmPSu/9 +4OvmqDg1ceJgWE7Ar4Vf45Ov3ayojhkm9iOyoi3kxgLNhzKZ2A/IgJ3hqXhUYtZv +2ZUBo24GP2l5CZmHRN2EA6qQ9V3iKZ7YqwdrvICBQvJ0LreCfscbLBvohr3LxBDn +PvVGGDaoU0r0odG5voUE+rx6ytpT7t7Bv4O2+qYqRyrzrENwagrI3pPWdNsEoZzh +aOwgQ4IBAoGBAPvSl/0Ze6irGA94Gdt1prH1sXP7oLN15qZZIRM9SgcVP7hkkO0d +qiqIoky3UCRQg8/M2ZrGB81oFQoymcZLt/WnctToosYqDhKyIKbvOKzCGdBun8lA +FwVekaHpxQ9E+dje32Gcpmpe2huR0WCAoLWETogE4rA8qIcO/BdlaMxBAoGBAOsa +eXxwxlpaKsZux3kn8ycHIlKB16Cgu2AFnHD705fVfQsg1ltd+YsC0evZNPSpTKHf +puwQEgGejMiYKnTmU8dWPdhKQV9NwpcQQ+wSwgl8R79IUgV71/0BIue7FngEwe9f +/OpfjnXplrotENc1lq+hslvN616u7uUP/60gHHJJAoGBAOBaW3bvATDgXetKQR84 +zm62Sobeo+m/HOMPfVw6un1c/Qw27LeUOkryuEZI+2mfIhA8nZI65DCojjYrprz4 +MMj3imMNcBfE2AzoDhcsAf5IX99G76zJILlz66OpNhvIhCAnUDUS72DNaNwvKa8k +agnN+nlMgPoq0KqjOw1NF/UBAoGAP3XD+R0PzW+tQCbC3Sc1cQFx+EdoBsmcCk05 +bx3qfX944zoX4k25gBZgx4K30pqoPsF58xpbYeiEI9k/DJLnZlUXGHzirHD254PS +cbSWf6z2SOGikixdnsNhwp8zb24JUy3bvP/SGm3U66gidZTXeczxseohcEtT3Ky2 +3OpgA1ECgYBG4bkEe0P0ojbMWtMHCvNSxt020Fnt5IE+EdmVTZn60Vxb2yxmQw7v +fH2Oj+OShNDo6Ap8YcApQCdUQEGypv17oIcqDyVXh8CH3J/iBLtjRyRNgHbxM+w5 +w+rnbjl9ws8tmeyyXuCQLq5Oo8Zb2isEIp/E7X8vweDjT3bc/s7H+g== +-----END RSA PRIVATE KEY----- diff --git a/spec/data/trustly_public_key.pem b/spec/data/trustly_public_key.pem new file mode 100644 index 0000000..5ec88cb --- /dev/null +++ b/spec/data/trustly_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA50Rcabjgmkj/iS46bRk+ +dNfqQpaLLuQcFoPaQ1pxrTMpXLM4ZJCjejn//6t3nXSJBPjeypG9ANp6Mx26cTv2 +G2sc4lQHDOmdRXgJn/LmWCPpgjVj4EMI0KZaErXisWdz8q5MJ1D3+jzTuRFeVPyM +A//uPQ0u367pQq0C6G2t1GtZbvD7zaPBgxTcBIbUtERRhmuSC97wuoP7+6BZBPO2 +dF1cysfAWZX785xdR1fi53qZGQhFqDwjrhI87lsWkNxDlbZSlozWinXL1vsFHzZ1 +jZtrm4NLzmjzBg1iBQRNNoTll/d8IdeUUhCNtTehoETkYbhWtOUGaXS22kXADPMw +iQIDAQAB +-----END PUBLIC KEY----- diff --git a/spec/data/void.json b/spec/data/void.json new file mode 100644 index 0000000..20b3b21 --- /dev/null +++ b/spec/data/void.json @@ -0,0 +1,11 @@ +{ + "version": "1.1", + "result": { + "signature": "ICPnfczGVjEc24+0DeRhZtQTLEXN2LWXiIQhlhsdB5A8hA4xfzcOOyDGtvcF\ntLT4Psckez1f0/dn+v9VNK2/qBcN0/pVdfIkKgUeM3TwMWJTJsG+z+p30Nwn\n8lsicvXZRsYi7xs1SxOiSe3L937HCQv41YP0bk6SfvnAi4xnJ9jwT+UCr+fj\nh7vcBqlrwsDMuYT1X5qRlnpYJoI9/PfMIhu15vQ3LsLF00s0yzU2cU58NSBQ\nuDP7NSU394Pev4xE6RGHOLvYxh7CXbXdP5rQKMk+xE48SdCUAiLVB1zXHN6r\nMZAXEFJN6deWuqzSjtqF+zVAmBM/2bW4VzYqSErP4Q==", + "method": "Void", + "data": { + "result": "1" + }, + "uuid": "8bedfbd4-8181-38e1-f0be-f360171aefc6" + } +} diff --git a/spec/jsonrpc_request_spec.rb b/spec/jsonrpc_request_spec.rb new file mode 100644 index 0000000..5f16ae6 --- /dev/null +++ b/spec/jsonrpc_request_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Data::JSONRPCRequest do + describe '#new' do + subject { described_class.new(**params) } + let(:method) { 'Test' } + let(:data) { { 'A' => 1 } } + let(:attributes) { { 'B' => 2, 'Array' => [1, 2, 3] } } + let(:uuid) { SecureRandom.uuid } + let(:signature) { 'signature' } + + shared_examples_for 'successful initialization' do + it 'has method' do + expect(subject.payload).to include({ + 'method' => method + }) + end + it 'has version' do + expect(subject.payload).to include({ + 'version' => 1.1 + }) + end + it 'initializes payload' do + expect(subject.payload).to include({ + 'params' => a_hash_including({ + 'Data' => { + 'Attributes' => attributes + }.merge(data) + }) + }) + end + it 'initializes attributes' do + expect(subject.attributes).to eq({ 'B' => 2, 'Array' => [1, 2, 3] }) + end + it 'initializes data' do + expect(subject.data).to eq({ 'A' => 1, 'Attributes' => attributes }) + end + it 'reads data' do + expect(subject.data_at('A')).to eq(1) + end + it 'reads attributes' do + expect(subject.attribute_at('B')).to eq(2) + end + end + + context 'with data and attributes' do + context 'with invalid data' do + let(:params) do + { + data: [1, 2, 3], + attributes: { 'Data' => 'test' } + } + end + it 'raises TypeError' do + expect { subject }.to raise_error( + TypeError, + 'Data must be a Hash if attributes are provided' + ) + end + end + context 'with valid data' do + context 'with cleanup' do + let(:params) do + { + data: data, + attributes: attributes + } + end + let(:data) do + { + 'A' => 1, + 'EmptyArray' => [], + 'EmptyValue' => nil, + 'EmptyNestedHash' => { 'EmptyArray' => [], 'EmptyValue' => nil } + } + end + let(:attributes) do + { + 'B' => 2, + 'EmptyNestedHash' => { 'EmptyArray' => [], 'EmptyValue' => nil } + } + end + + it 'cleans up data' do + expect(subject.data).to eq('A' => 1, 'Attributes' => { 'B' => 2 }) + end + end + context 'without cleanup' do + let(:params) do + { + data: data, + attributes: attributes, + method: method + } + end + include_examples 'successful initialization' + end + end + end + context 'with attributes only' do + let(:params) do + { + attributes: attributes + } + end + + it 'still initializes data' do + expect(subject.data).to eq('Attributes' => attributes) + end + + it 'initializes attributes' do + expect(subject.attributes).to eq(attributes) + end + end + context 'with data only' do + let(:params) do + { + data: data + } + end + + it 'initializes data' do + expect(subject.data).to eq('A' => 1) + end + + it 'does not initialize attributes' do + expect(subject.attributes).to be_nil + end + end + context 'without data and attributes' do + let(:params) do + {} + end + + it 'does not initialize data' do + expect(subject.data).to be_nil + end + + it 'initializes params' do + expect(subject.params).to eq({}) + end + end + context 'with payload' do + let(:params) do + { + payload: { + 'method' => method, + 'params' => { + 'UUID' => uuid, + 'Signature' => signature, + 'Data' => { + 'Attributes' => attributes + }.merge(data) + } + } + } + end + include_examples 'successful initialization' + it 'initializes uuid' do + expect(subject.uuid).to eq(uuid) + end + it 'initializes signature' do + expect(subject.signature).to eq(signature) + end + end + end + describe '#setters' do + subject do + described_class.new(payload: payload) + end + let(:payload) do + { + 'method' => 'Test', + 'params' => { + 'Data' => { + 'A' => 1, + 'Attributes' => { + 'B' => 2 + } + } + } + } + end + + describe '#update_data_at' do + it 'updates data at a specific key' do + subject.update_data_at('A', 10) + expect(subject.data).to include({ 'A' => 10 }) + end + + it 'inserts new data at a specific key' do + subject.update_data_at('C', 30) + expect(subject.data).to include({ 'A' => 1, 'C' => 30 }) + end + end + + describe '#update_attribute_at' do + it 'updates attribute at a specific key' do + subject.update_attribute_at('B', 10) + expect(subject.attributes).to include({ 'B' => 10 }) + end + + it 'inserts a new attribute at a specific key' do + subject.update_attribute_at('C', 30) + expect(subject.attributes).to include({ 'B' => 2, 'C' => 30 }) + end + end + + describe '#signature=' do + it 'updates signature' do + subject.signature = 'signature' + expect(subject.params).to include('Signature' => 'signature') + end + end + + describe '#uuid=' do + it 'updates signature' do + uuid = SecureRandom.uuid + subject.uuid = uuid + expect(subject.params).to include('UUID' => uuid) + end + end + + describe '#method=' do + it 'updates signature' do + subject.method = 'OtherTest' + expect(subject.payload).to include('method' => 'OtherTest') + end + end + + describe '#to_json' do + it 'transforms payload to json' do + expect(subject.to_json).to eq(payload.merge('version' => 1.1).to_json) + end + end + end +end diff --git a/spec/jsonrpc_response_spec.rb b/spec/jsonrpc_response_spec.rb new file mode 100644 index 0000000..9ccf127 --- /dev/null +++ b/spec/jsonrpc_response_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Data::JSONRPCResponse do + let(:uuid) { '8bedfbd4-8181-38e1-f0be-f360171aefc6' } + let(:signature) { 'signature' } + let(:method) { 'Test' } + let(:data) do + { + 'result' => '1', + 'orderid' => '1187741486' + } + end + let(:payload) do + { + 'version' => '1.1', + 'result' => { + 'signature' => signature, + 'method' => method, + 'data' => data, + 'uuid' => uuid + } + } + end + let(:status) { 200 } + let(:phrase) { 'OK' } + let(:response) do + Faraday::Response.new( + status: status, + response_headers: { 'Content-Type': 'application/json' }, + response_body: payload, + method: :post, + reason_phrase: phrase + ) + end + subject { described_class.new(http_response: response) } + describe '#new' do + shared_examples_for 'parsed response' do |success| + it 'has method' do + expect(subject.method).to eq(method) + end + + it 'has signature' do + expect(subject.signature).to eq(signature) + end + + it 'has UUID' do + expect(subject.uuid).to eq(uuid) + end + + it 'has data' do + expect(subject.data).to eq(data) + end + + if success + it 'is marked as a success' do + expect(subject.success?).to be_truthy + end + it 'is not marked as an error' do + expect(subject.error?).to be_falsy + end + it 'does not have an error code' do + expect(subject.error_code).to be_nil + end + it 'has an error message' do + expect(subject.error_message).to be_nil + end + else + it 'is marked as en error' do + expect(subject.error?).to be_truthy + end + it 'is not marked as a success' do + expect(subject.success?).to be_falsy + end + it 'has an error code' do + expect(subject.error_code).to eq(error_code) + end + it 'has an error message' do + expect(subject.error_message).to eq(error_message) + end + end + end + + context 'with an invalid response payload' do + before do + payload.delete('result') + payload['data'] = {} + end + + it 'fails' do + expect { subject }.to raise_error( + Trustly::Exception::DataError, + "No result or error in response #{payload}" + ) + end + end + context 'with an invalid API version' do + before do + payload.merge!('version' => '1.0') + end + + it 'fails' do + expect { subject }.to raise_error( + Trustly::Exception::JSONRPCVersionError, + 'JSON RPC Version is not supported' + ) + end + end + context 'with a valid response' do + include_examples 'parsed response', true + end + context 'with an error response' do + let(:error_code) { 616 } + let(:error_message) { 'ERROR_INVALID_CREDENTIALS' } + let(:data) do + { + 'code' => error_code, + 'message' => error_message + } + end + let(:payload) do + { + 'version' => '1.1', + 'error' => { + 'name' => 'JSONRPCError', + 'code' => error_code, + 'message' => error_message, + 'error' => { + 'signature' => signature, + 'uuid' => uuid, + 'method' => method, + 'data' => data + } + } + } + end + include_examples 'parsed response', false + end + end + describe '#data_at' do + context 'with response data' do + it 'returns data for a specific key' do + expect(subject.data_at('orderid')).to eq('1187741486') + end + end + context 'without data' do + before do + payload['result'].delete('data') + end + + it 'returns nil' do + expect(subject.data_at('orderid')).to be_nil + end + end + end +end diff --git a/spec/jsonrpcnotification_request_spec.rb b/spec/jsonrpcnotification_request_spec.rb new file mode 100644 index 0000000..0cb09d2 --- /dev/null +++ b/spec/jsonrpcnotification_request_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Data::JSONRPCNotificationRequest do + let(:method) { 'Test' } + let(:uuid) { '8bedfbd4-8181-38e1-f0be-f360171aefc6' } + let(:request_payload) do + { + 'method' => method, + 'params' => { + 'signature' => 'signature', + 'uuid' => uuid, + 'data' => { + 'notificationid' => '35673567', + 'messageid' => '453455465', + 'orderid' => '3473567567', + 'accountid' => '1234567890', + 'verified' => '1', + 'attributes' => + { + 'clearinghouse' => 'SWEDEN', + 'bank' => 'SEB', + 'descriptor' => '**** *084057', + 'lastdigits' => '084057' + } + } + }, + 'version' => '1.1' + } + end + subject { described_class.new(notification_body: request_payload) } + + describe '#new' do + context 'with an invalid version' do + before do + request_payload['version'] = '1.0' + end + + it 'fails with a version error' do + expect { subject }.to raise_error( + Trustly::Exception::JSONRPCVersionError, + 'JSON RPC Version 1.0 is not supported' + ) + end + end + + shared_examples_for 'valid request' do + it 'has a uuid' do + expect(subject.uuid).to eq(uuid) + end + + it 'has a method' do + expect(subject.method).to eq(method) + end + + it 'has a signature' do + expect(subject.signature).to eq('signature') + end + + it 'has a version' do + expect(subject.version).to eq('1.1') + end + end + + context 'with a json request payload' do + let(:request_payload) do + super().to_json + end + + include_examples 'valid request' + end + + context 'with a valid request payload' do + include_examples 'valid request' + end + + context 'with an invalid request payload' do + let(:request_payload) do + 'invalid json' + end + + it 'fails with a data error' do + expect { subject }.to raise_error( + Trustly::Exception::DataError + ) + end + end + end + describe '#data_at' do + it 'fetches data field' do + expect(subject.data_at('messageid')).to eq('453455465') + end + end + describe '#attribute_at' do + it 'fetches attribute field' do + expect(subject.attribute_at('clearinghouse')).to eq('SWEDEN') + end + end +end diff --git a/spec/jsonrpcnotification_response_spec.rb b/spec/jsonrpcnotification_response_spec.rb new file mode 100644 index 0000000..42286a1 --- /dev/null +++ b/spec/jsonrpcnotification_response_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Data::JSONRPCNotificationResponse do + let(:method) { 'Test' } + let(:uuid) { '8bedfbd4-8181-38e1-f0be-f360171aefc6' } + let(:request_payload) do + { + 'method' => method, + 'params' => { + 'signature' => 'signature', + 'uuid' => uuid, + 'data' => { + 'notificationid' => '35673567', + 'messageid' => '453455465', + 'orderid' => '3473567567', + 'accountid' => '1234567890', + 'verified' => '1', + 'attributes' => + { + 'clearinghouse' => 'SWEDEN', + 'bank' => 'SEB', + 'descriptor' => '**** *084057', + 'lastdigits' => '084057' + } + } + }, + 'version' => '1.1' + } + end + let(:request) do + Trustly::Data::JSONRPCNotificationRequest.new( + notification_body: request_payload + ) + end + let(:success) { true } + + shared_examples_for 'notification response' do |status| + it 'matches the request\'s uuid' do + expect(subject.uuid).to eq(uuid) + end + it 'matches the request\'s method' do + expect(subject.method).to eq(method) + end + it 'has a correct version' do + expect(subject.version).to eq('1.1') + end + it 'has response data' do + expect(subject.data).to eq('status' => status) + end + it 'builds a correct payload' do + expect(subject.payload).to eq( + 'version' => '1.1', + 'result' => { + 'method' => method, + 'uuid' => uuid, + 'data' => { + 'status' => status + } + } + ) + end + end + describe '#new' do + subject { described_class.new(request: request, success: success) } + context 'with a successful request' do + include_examples 'notification response', 'OK' + end + context 'with an error request' do + let(:success) { false } + include_examples 'notification response', 'FAILED' + end + end + describe '#signature=' do + subject { described_class.new(request: request, success: success) } + let(:signature) { 'new signature' } + + context 'with a signature provided' do + before do + subject.signature = signature + end + + it 'retrieves the signature with a getter' do + expect(subject.signature).to eq(signature) + end + + it 'properly sets up the signature in the payload' do + expect(subject.payload['result']['signature']).to eq(signature) + end + end + end +end diff --git a/spec/signed_api_spec.rb b/spec/signed_api_spec.rb new file mode 100644 index 0000000..3d85404 --- /dev/null +++ b/spec/signed_api_spec.rb @@ -0,0 +1,622 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Api::Signed do + let(:basic_params) do + { + username: 'User', + password: 'Password', + private_pem: ENV.fetch('MERCHANT_PRIVATE_KEY', nil), + public_pem: ENV.fetch('TRUSTLY_PUBLIC_KEY', nil), + host: 'test.trustly.com' + } + end + describe '#new' do + context 'successful initialization' do + subject { described_class.new(**params) } + + context 'when private key is not specified in the params' do + let(:params) do + basic_params.except(:private_pem) + end + it 'uses default key' do + expect(subject.merchant_key.to_pem).to eq ENV.fetch('MERCHANT_PRIVATE_KEY', nil) + end + end + + context 'when public key is not specified in the params' do + let(:params) do + basic_params.except(:public_key) + end + it 'uses default key' do + expect(subject.trustly_key.to_pem).to eq ENV.fetch('TRUSTLY_PUBLIC_KEY', nil) + end + end + + context 'when host is not specified in the params' do + let(:params) do + basic_params.except(:host) + end + it 'uses default key' do + expect(subject.api_host).to eq 'test.trustly.com' + end + end + + context 'with valid params' do + let(:params) do + basic_params.merge( + host: 'prod.trustly.com', port: 80, is_https: false + ) + end + it 'initializes correctly' do + expect(subject.api_host).to eq 'prod.trustly.com' + expect(subject.api_port).to eq 80 + expect(subject.api_is_https).to eq false + expect(subject.api_username).to eq 'User' + expect(subject.api_password).to eq 'Password' + end + end + end + + context 'initialization errors' do + shared_examples_for 'incorrect configuration' do + it 'fails' do + expect do + described_class.new(**params) + end.to raise_error( + Trustly::Exception::ConfigurationError, message + ) + end + end + context 'with username not specified' do + let(:params) do + basic_params.except(:username) + end + let(:message) do + 'Username not specified' + end + include_examples 'incorrect configuration' + end + context 'with password not specified' do + let(:params) do + basic_params.except(:password) + end + let(:message) do + 'Password not specified' + end + include_examples 'incorrect configuration' + end + context 'when private key is invalid' do + let(:params) { basic_params } + before do + expect(OpenSSL::PKey::RSA).to receive(:new). + with(basic_params[:public_pem]).and_call_original + expect(OpenSSL::PKey::RSA).to receive(:new). + with(basic_params[:private_pem]).and_raise(OpenSSL::PKey::RSAError) + end + let(:message) do + 'Merchant private key not specified' + end + include_examples 'incorrect configuration' + end + context 'with private key is nil' do + let(:params) do + basic_params.merge(private_pem: nil) + end + let(:message) do + 'Merchant private key not specified' + end + include_examples 'incorrect configuration' + end + context 'with public key is nil' do + let(:params) do + basic_params.merge(public_pem: nil) + end + let(:message) do + 'Trustly public key not specified' + end + include_examples 'incorrect configuration' + end + context 'with public key having incorrect value' do + let(:params) do + basic_params.merge(public_pem: 'test') + end + let(:message) do + 'Trustly public key not specified' + end + include_examples 'incorrect configuration' + end + context 'with multiple errors' do + let(:params) do + basic_params.except(:username).merge(host: nil) + end + let(:message) do + 'Api host not specified; Username not specified' + end + include_examples 'incorrect configuration' + end + end + end + + describe '#verify_signed_response' do + subject { described_class.new(**params).verify_signed_response(response) } + let(:params) { basic_params } + let(:payload) do + path = File.expand_path('./data/account_payout.json', __dir__) + json = File.read(path) + JSON.parse(json) + end + let(:http_response) do + Faraday::Response.new( + status: 200, + response_headers: { 'Content-Type': 'application/json' }, + response_body: payload, + method: :post, + reason_phrase: 'OK' + ) + end + let(:response) do + Trustly::Data::JSONRPCResponse.new(http_response: http_response) + end + context 'with valid body and signature' do + it { is_expected.to be_truthy } + end + + context 'with the data in body not matching the signature' do + before do + payload['result']['method'] = 'AccountPayouts' + end + + it { is_expected.to be_falsy } + end + end + + describe 'rpc calls' do + subject { described_class.new(**basic_params) } + shared_examples_for 'rpc call' do |required_params| + required_params.each do |param| + context "with missing required param #{param}" do + let(:modified_params) do + params.except(param) + end + it 'raises data error' do + expect do + subject.public_send(rpc_call, **modified_params) + end.to raise_error( + Trustly::Exception::DataError, + "Required data is missing: #{param}" + ) + end + end + end + context 'with valid data' do + let(:request) do + instance_double(Trustly::Data::JSONRPCRequest) + end + let(:serial_data) do + "#{method}#{uuid}ArrayKey123KeyValue" + end + let(:signature) do + Base64.encode64('signature').chop + end + let(:connection) do + instance_double(Faraday::Connection) + end + let(:body) do + '{"Key":"Value"}' + end + let(:http_response) do + instance_double(Faraday::Response) + end + let(:response) do + instance_double(Trustly::Data::JSONRPCResponse) + end + let(:uuid) do + '8bedfbd4-8181-38e1-f0be-f360171aefc6' + end + let(:response_uuid) { uuid } + let(:response_body) { { 'key' => 'value' } } + let(:response_signature) { signature } + let(:serial_response_data) do + "#{method}#{response_uuid}keyvalue" + end + + before do + expect(Trustly::Data::JSONRPCRequest).to receive(:new). + with(method: method, data: data, attributes: attributes). + and_return(request) + expect(SecureRandom).to receive(:uuid).and_return(uuid) + expect(request).to receive(:uuid=).with(uuid) + expect(request).to receive(:signature=).with(signature) + expect(request).to receive(:uuid).and_return(nil) + expect(request).to receive(:uuid).at_least(:once).and_return(uuid) + expect(request).to receive(:method).at_least(:once).and_return(method) + expect(request).to receive(:data).and_return('Key' => 'Value', 'ArrayKey' => [1, 2, 3]) + expect(request).to receive(:update_data_at).with('Username', 'User') + expect(request).to receive(:update_data_at).with('Password', 'Password') + expect(subject.merchant_key).to receive(:sign).with( + instance_of(OpenSSL::Digest), serial_data + ).and_return('signature') + expect(request).to receive(:to_json).and_return(body) + expect(Faraday).to receive(:new).with('https://test.trustly.com'). + and_return(connection) + end + context 'with a successful response' do + before do + expect(connection).to receive(:post).with( + '/api/1', body, { 'Content-Type' => 'application/json' } + ).and_return(http_response) + expect(Trustly::Data::JSONRPCResponse).to receive(:new). + with(http_response: http_response).and_return(response) + expect(response).to receive(:uuid).at_least(:once). + and_return(response_uuid) + expect(response).to receive(:data).at_least(:once). + and_return(response_body) + expect(response).to receive(:method).at_least(:once). + and_return(method) + expect(response).to receive(:signature). + and_return(response_signature) + expect(subject.trustly_key).to receive_message_chain( + :public_key, :verify + ).with( + instance_of(OpenSSL::Digest), 'signature', serial_response_data + ).and_return(true) + end + it 'makes a JSON RPC request and verifies its response' do + expect(subject.public_send(rpc_call, **params)).to eq(response) + end + end + context 'with a failed response' do + context 'with a faraday error' do + let(:error_response) { nil } + + before do + exception = error_klass.new( + StandardError.new(error_message), error_response + ) + expect(connection).to receive(:post).with( + '/api/1', body, { 'Content-Type' => 'application/json' } + ).and_raise(exception) + end + + shared_examples_for 'faraday error' do + it 'fails with an expected trustly error' do + expect { subject.public_send(rpc_call, **params) }.to raise_error( + expected_error, expected_error_message + ) + end + end + + context 'with a client error' do + let(:error_klass) { Faraday::ClientError } + let(:expected_error) { Trustly::Exception::DataError } + let(:error_response) { { status: 400, body: '{"error":"failed"}' } } + let(:error_message) { 'Bad request' } + let(:expected_error_message) do + "Bad request -> 400: {\"error\":\"failed\"} - #{method}, {\"Key\":\"Value\"}" + end + include_examples 'faraday error' + end + + context 'with a server error' do + let(:error_klass) { Faraday::ServerError } + let(:expected_error) { Trustly::Exception::ConnectionError } + let(:error_message) { 'Server error' } + let(:expected_error_message) do + 'Server error' + end + include_examples 'faraday error' + end + + context 'with a connection error' do + let(:error_klass) { Faraday::ConnectionFailed } + let(:expected_error) { Trustly::Exception::ConnectionError } + let(:error_message) { 'Connection error' } + let(:expected_error_message) do + 'Connection error' + end + include_examples 'faraday error' + end + + context 'with an ssl error' do + let(:error_klass) { Faraday::SSLError } + let(:expected_error) { Trustly::Exception::ConnectionError } + let(:error_message) { 'SSL error' } + let(:expected_error_message) do + 'SSL error' + end + include_examples 'faraday error' + end + + context 'with a parsing error' do + let(:error_klass) { Faraday::ParsingError } + let(:expected_error) { Trustly::Exception::DataError } + let(:error_message) { 'JSON error' } + let(:error_response) { { status: 200, body: 'abcd' } } + let(:expected_error_message) do + "JSON error -> 200: abcd - #{method}, {\"Key\":\"Value\"}" + end + include_examples 'faraday error' + end + end + + context 'with an invalid response payload' do + before do + expect(connection).to receive(:post).with( + '/api/1', body, { 'Content-Type' => 'application/json' } + ).and_return(http_response) + expect(Trustly::Data::JSONRPCResponse).to receive(:new). + with(http_response: http_response).and_return(response) + expect(response).to receive(:uuid).at_least(:once). + and_return(response_uuid) + end + + context 'with an incorrect uuid' do + let(:response_uuid) do + '8bedfbd4-8181-38e1-f0be-f360171aef6c' + end + + it 'fails with a uuid mismatch error' do + expect { subject.public_send(rpc_call, **params) }.to raise_error( + Trustly::Exception::DataError, + 'Incoming response is not related to the request. UUID mismatch.' + ) + end + end + + context 'with an incorrect signature' do + before do + expect(response).to receive(:data).and_return(response_body) + expect(response).to receive(:method).and_return(method) + expect(response).to receive(:signature). + and_return(response_signature) + expect(subject.trustly_key).to receive_message_chain( + :public_key, :verify + ).with( + instance_of(OpenSSL::Digest), 'signature', serial_response_data + ).and_return(false) + end + + it 'fails with a signature error' do + expect { subject.public_send(rpc_call, **params) }.to raise_error( + Trustly::Exception::SignatureError, + 'Incoming message signature is not valid' + ) + end + end + end + end + end + end + + describe '#refund' do + let(:method) { 'Refund' } + let(:rpc_call) { :refund } + let(:data) do + { + 'OrderId' => '12345', + 'Amount' => 100, + 'Currency' => 'EUR' + } + end + let(:attributes) do + { + 'ExternalReference' => { 'Value' => 'Test' } + } + end + let(:params) do + data.merge(attributes) + end + include_examples 'rpc call', %w[OrderId Amount Currency] + end + + describe '#void' do + let(:method) { 'Void' } + let(:rpc_call) { :void } + let(:data) do + { + 'OrderId' => '12345' + } + end + let(:attributes) do + nil + end + let(:params) do + data + end + include_examples 'rpc call', %w[OrderId] + end + + describe '#get_withdrawals' do + let(:method) { 'GetWithdrawals' } + let(:rpc_call) { :get_withdrawals } + let(:data) do + { + 'OrderId' => '12345' + } + end + let(:attributes) do + nil + end + let(:params) do + data + end + include_examples 'rpc call', %w[OrderId] + end + + describe '#refund' do + let(:method) { 'Refund' } + let(:rpc_call) { :refund } + let(:data) do + { + 'OrderId' => '12345', + 'Amount' => '100.00', + 'Currency' => 'EUR' + } + end + let(:attributes) do + { + 'ExternalReference' => { 'Value' => 'Test' } + } + end + let(:params) do + data.merge(attributes) + end + include_examples 'rpc call', %w[OrderId Amount Currency] + end + + describe '#deposit' do + let(:method) { 'Deposit' } + let(:rpc_call) { :deposit } + let(:data) do + { + 'NotificationURL' => 'https://example.com/notify', + 'EndUserID' => '123', + 'MessageID' => '123' + } + end + let(:attributes) do + { + 'Locale' => 'en-gb', + 'Country' => 'NL', + 'Currency' => 'EUR', + 'SuccessURL' => 'https://example.com/success', + 'FailURL' => 'https://example.com/fail', + 'Amount' => '200.00', + 'Firstname' => 'First', + 'Lastname' => 'Last', + 'ShopperStatement' => 'Trustly.com', + 'MobilePhone' => '+49 151 88888888' + } + end + let(:params) do + data.merge(attributes) + end + include_examples 'rpc call', %w[ + Locale Country Currency SuccessURL FailURL NotificationURL Amount + EndUserID MessageID Firstname Lastname ShopperStatement + ] + end + + describe '#select_account' do + let(:method) { 'SelectAccount' } + let(:rpc_call) { :select_account } + let(:data) do + { + 'NotificationURL' => 'https://example.com/notify', + 'EndUserID' => '123', + 'MessageID' => '123' + } + end + let(:extra_attributes) do + attributes.merge('WillBeRemoved' => true) + end + let(:attributes) do + { + 'Locale' => 'en-gb', + 'Country' => 'NL', + 'SuccessURL' => 'https://example.com/success', + 'FailURL' => 'https://example.com/fail', + 'Firstname' => 'First', + 'Lastname' => 'Last', + 'Email' => 'test@mail.com' + } + end + let(:params) do + data.merge(extra_attributes) + end + include_examples 'rpc call', %w[ + Locale Country SuccessURL FailURL NotificationURL EndUserID MessageID + Firstname Lastname + ] + end + + describe '#register_account' do + let(:method) { 'RegisterAccount' } + let(:rpc_call) { :register_account } + let(:data) do + { + 'EndUserID' => '123', + 'ClearingHouse' => 'SWEDEN', + 'BankNumber' => '6612', + 'AccountNumber' => '69706212', + 'Firstname' => 'First', + 'Lastname' => 'Last' + } + end + let(:attributes) do + { + 'DateOfBirth' => '15/08/1993', + 'MobilePhone' => '+49 151 88888888' + } + end + let(:params) do + data.merge(attributes) + end + include_examples 'rpc call', %w[ + EndUserID ClearingHouse BankNumber AccountNumber Firstname Lastname + ] + end + + describe '#account_payout' do + let(:method) { 'AccountPayout' } + let(:rpc_call) { :account_payout } + let(:data) do + { + 'NotificationURL' => 'https://example.com/notify', + 'EndUserID' => '123', + 'MessageID' => '123', + 'AccountID' => '123', + 'Amount' => '500.00', + 'Currency' => 'EUR' + } + end + let(:attributes) do + { + 'ShopperStatement' => 'Trustly.com' + } + end + let(:params) do + data.merge(attributes) + end + include_examples 'rpc call', %w[ + NotificationURL AccountID EndUserID MessageID Amount Currency + ShopperStatement + ] + end + end + describe '#notification_response' do + subject do + described_class.new(**basic_params) + end + context 'with a request' do + let(:success) { true } + let(:uuid) { '123' } + let(:method) { 'Test' } + let(:data) { { 'Key' => 'Value' } } + let(:serial_data) { 'Test123KeyValue' } + let(:signature) { Base64.encode64('signature').chop } + let(:request) { instance_double(Trustly::Data::JSONRPCNotificationRequest) } + let(:response) { instance_double(Trustly::Data::JSONRPCNotificationResponse) } + + before do + expect(Trustly::Data::JSONRPCNotificationResponse).to receive(:new). + with(request: request, success: success).and_return(response) + expect(response).to receive(:uuid).and_return(uuid) + expect(response).to receive(:method).and_return(method) + expect(response).to receive(:data).and_return(data) + expect(subject.merchant_key).to receive(:sign). + with(instance_of(OpenSSL::Digest), serial_data). + and_return('signature') + expect(response).to receive(:signature=).with(signature) + end + + it 'returns a response' do + result = subject. + notification_response(request, success: success) + expect(result).to eq(response) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9a6bfc0 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'simplecov' +require 'webmock/rspec' +require 'debug' + +WebMock.disable_net_connect!(allow_localhost: false) + +SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter] +SimpleCov.start do + add_filter '/spec/' + minimum_coverage 100 +end + +ENV['MERCHANT_PRIVATE_KEY'] ||= begin + path = File.expand_path('data/merchant_private_key.pem', __dir__) + File.read(path) if File.file?(path) +end + +ENV['MERCHANT_PUBLIC_KEY'] ||= begin + path = File.expand_path('data/merchant_public_key.pem', __dir__) + File.read(path) if File.file?(path) +end + +ENV['TRUSTLY_PRIVATE_KEY'] ||= begin + path = File.expand_path('data/trustly_private_key.pem', __dir__) + File.read(path) if File.file?(path) +end + +ENV['TRUSTLY_PUBLIC_KEY'] ||= begin + path = File.expand_path('data/trustly_public_key.pem', __dir__) + File.read(path) if File.file?(path) +end + +require 'trustly' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + # config.disable_monkey_patching! + # config.default_formatter = 'doc' if config.files_to_run.one? + # config.profile_examples = 10 + config.order = :random + config.color = true + config.tty = true + Kernel.srand config.seed +end diff --git a/spec/utils/data_cleaner_spec.rb b/spec/utils/data_cleaner_spec.rb new file mode 100644 index 0000000..046de7a --- /dev/null +++ b/spec/utils/data_cleaner_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Utils::DataCleaner do + describe '#vacuum' do + subject { described_class.vacuum(params) } + + context 'with a hash containing empty values and hollow arrays' do + let(:params) do + { + a: 10, + b: [], + c: { a: nil }, + e: { a: [] }, + f: { a: { a: [] }, b: 10 } + } + end + + it { is_expected.to eq(a: 10, f: { b: 10 }) } + end + end +end diff --git a/spec/utils/data_transformer_spec.rb b/spec/utils/data_transformer_spec.rb new file mode 100644 index 0000000..f4a1700 --- /dev/null +++ b/spec/utils/data_transformer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Trustly::Utils::DataTransformer do + describe '#deep_stringify_hash' do + subject { described_class.deep_stringify_hash(params) } + + context 'with a hash containing symbolized keys' do + let(:params) do + { + a: 10, + b: [{ a: 10 }, :b, :c], + c: { a: [], b: { a: 10 } } + } + end + + it do + is_expected.to eq( + 'a' => 10, + 'b' => [{ 'a' => 10 }, :b, :c], + 'c' => { 'a' => [], 'b' => { 'a' => 10 } } + ) + end + end + end +end diff --git a/trustly-client-ruby.gemspec b/trustly-client-ruby.gemspec index c2c0b7f..3dd34a0 100644 --- a/trustly-client-ruby.gemspec +++ b/trustly-client-ruby.gemspec @@ -1,26 +1,36 @@ -# -*- encoding: utf-8 -*- -lib = File.expand_path("../lib", __FILE__) +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "trustly/version" +require 'trustly/version' Gem::Specification.new do |gem| gem.name = 'trustly-client-ruby' gem.version = Trustly::VERSION - gem.date = Date.today.to_s + gem.required_ruby_version = '>= 2.7' + gem.platform = Gem::Platform::RUBY - gem.summary = "Trustly Client Ruby Support" - gem.description = "Support for Ruby use of trustly API" + gem.summary = 'Trustly Client Ruby Support' + gem.description = 'Support for Ruby use of Trustly API' gem.authors = ['Jorge Carretie'] gem.email = 'jorge@carretie.com' gem.homepage = 'https://github.com/jcarreti/trusty-client-ruby' - gem.license = "MIT" + gem.license = 'MIT' + gem.add_runtime_dependency 'faraday' + gem.add_runtime_dependency 'faraday_middleware' + gem.add_runtime_dependency 'rake' - gem.add_dependency('rake') - gem.add_development_dependency('rspec', [">= 2.0.0"]) + gem.add_development_dependency 'debug' + gem.add_development_dependency 'rspec' + gem.add_development_dependency 'rubocop' + gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'webmock' # ensure the gem is built out of versioned files - gem.files = Dir['{lib}/**/*', 'README*', 'LICENSE*'] - gem.require_paths = ["lib"] -end \ No newline at end of file + gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) + gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } + gem.require_paths = ['lib'] + gem.metadata['rubygems_mfa_required'] = 'true' +end diff --git a/trustly-client-ruby.gemspec~ b/trustly-client-ruby.gemspec~ deleted file mode 100644 index f0f4367..0000000 --- a/trustly-client-ruby.gemspec~ +++ /dev/null @@ -1,25 +0,0 @@ -# -*- encoding: utf-8 -*- -lib = File.expand_path("../lib", __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "trustly/version" - -Gem::Specification.new do |gem| - gem.name = 'trustly-client-ruby' - gem.version = Trustly::VERSION - gem.date = Date.today.to_s - - gem.summary = "Trustly Client Ruby Support" - gem.description = "Support for Ruby use of trustly API" - - gem.authors = ['Jorge Carretie'] - gem.email = 'jorge@carretie.com' - gem.homepage = 'https://github.com/jcarreti/trusty-client-ruby' - gem.license = "MIT" - - - gem.add_dependency('rake') - gem.add_development_dependency('rspec', [">= 2.0.0"]) - - # ensure the gem is built out of versioned files - gem.files = Dir['{lib}/**/*', 'README*', 'LICENSE*'] -end \ No newline at end of file