justifi 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "justifi"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/justifi.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/justifi/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "justifi"
7
+ spec.version = Justifi::VERSION
8
+ spec.authors = ["JustiFi"]
9
+ spec.email = ["support@justifi.ai"]
10
+
11
+ spec.summary = "JustiFi API wrapper gem"
12
+ spec.description = "Used to communicate with JustiFi APIs"
13
+ spec.homepage = "https://justifi.ai"
14
+ spec.required_ruby_version = ">= 2.4"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/justifi-tech/justifi-ruby"
18
+ spec.metadata["github_repo"] = "ssh://github.com/justifi-tech/justifi-ruby"
19
+ spec.metadata["changelog_uri"] = "https://github.com/justifi-tech/justifi-ruby/changelog"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|gem)/}) }
25
+ end
26
+
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_development_dependency "byebug"
32
+ spec.add_development_dependency "dotenv"
33
+ spec.add_development_dependency "rubocop"
34
+ spec.add_development_dependency "standard"
35
+ spec.add_development_dependency "webmock", ">= 3.8.0"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+ spec.add_development_dependency "rspec", "~> 3.0"
38
+ spec.add_development_dependency "simplecov"
39
+ spec.add_development_dependency "simplecov-small-badge"
40
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Justifi
6
+ module APIOperations
7
+ module ClassMethods
8
+ def execute_post_request(path, body, headers)
9
+ raise ArgumentError, "body should be a string" if body && !body.is_a?(String)
10
+ raise ArgumentError, "headers should be a hash" if headers && !headers.is_a?(Hash)
11
+
12
+ response = execute_request(:post, path, body, headers)
13
+
14
+ JustifiObject.construct_from(path, response, headers)
15
+ end
16
+
17
+ def execute_get_request(path, query, headers)
18
+ raise ArgumentError, "query should be a string" if query && !query.is_a?(String)
19
+ raise ArgumentError, "headers should be a hash" if headers && !headers.is_a?(Hash)
20
+
21
+ path = "#{path}?#{query}"
22
+ response = execute_request(:get, path, nil, headers)
23
+
24
+ JustifiObject.construct_from(path, response, headers)
25
+ end
26
+
27
+ def execute_patch_request(path, body, headers)
28
+ raise ArgumentError, "body should be a string" if body && !body.is_a?(String)
29
+ raise ArgumentError, "headers should be a hash" if headers && !headers.is_a?(Hash)
30
+
31
+ response = execute_request(:patch, path, body, headers)
32
+
33
+ JustifiObject.construct_from(path, response, headers)
34
+ end
35
+
36
+ def idempotently_request(path, method:, params:, headers:, idempotency_key: nil)
37
+ idempotency_key ||= Justifi.get_idempotency_key
38
+ headers[:idempotency_key] = idempotency_key
39
+
40
+ retryable_request do
41
+ send("execute_#{method}_request", path, params, headers)
42
+ end
43
+ end
44
+
45
+ private def execute_request(method_name, path, body, headers)
46
+ headers["Content-Type"] = "application/json"
47
+ headers["User-Agent"] = "justifi-ruby-#{Justifi::VERSION}"
48
+
49
+ connection = http_connection(path)
50
+
51
+ method_name = method_name.to_s.upcase
52
+ has_response_body = method_name != "HEAD"
53
+ request = Net::HTTPGenericRequest.new(
54
+ method_name,
55
+ (body ? true : false),
56
+ has_response_body,
57
+ path,
58
+ headers
59
+ )
60
+
61
+ response = JustifiResponse.from_net_http(connection.request(request, body))
62
+ raise InvalidHttpResponseError.new(response: response) unless response.success
63
+ response
64
+ end
65
+
66
+ private def retryable_request
67
+ attempt = 1
68
+
69
+ begin
70
+ response = yield
71
+ rescue => e
72
+ if should_retry?(e, attempt: attempt)
73
+ attempt += 1
74
+ retry
75
+ end
76
+
77
+ raise
78
+ end
79
+
80
+ response
81
+ end
82
+
83
+ private def should_retry?(error, attempt:)
84
+ return false if attempt >= Justifi.max_attempts
85
+
86
+ case error
87
+ when Net::OpenTimeout, Net::ReadTimeout
88
+ true
89
+ when Justifi::Error
90
+ # 409 Conflict
91
+ return true if error.response_code == 409
92
+ # Add more cases
93
+ else
94
+ false
95
+ end
96
+ end
97
+
98
+ def http_connection(path)
99
+ uri = URI("#{Justifi.api_url}#{path}")
100
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Justifi
6
+ class Configuration
7
+ API_BASE_URL = "https://api.justifi.ai"
8
+
9
+ attr_accessor :client_id
10
+ attr_accessor :client_secret
11
+ attr_accessor :access_token
12
+ attr_accessor :environment
13
+ attr_accessor :max_attempts
14
+ attr_accessor :cache
15
+
16
+ def self.setup
17
+ new.tap do |instance|
18
+ yield(instance) if block_given?
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @max_attempts = 3
24
+ end
25
+
26
+ def credentials
27
+ raise Justifi::BadCredentialsError, "credentials not set" if bad_credentials?
28
+
29
+ {
30
+ client_id: client_id,
31
+ client_secret: client_secret
32
+ }
33
+ end
34
+
35
+ def bad_credentials?
36
+ # TODO: improve this
37
+ return true if client_id.nil? || client_secret.nil?
38
+ end
39
+
40
+ def clear_credentials
41
+ @client_id = nil
42
+ @client_secret = nil
43
+ end
44
+
45
+ def api_url
46
+ case environment
47
+ when "staging"
48
+ ENV["API_STAGING_BASE_URL"]
49
+ else
50
+ API_BASE_URL
51
+ end
52
+ end
53
+
54
+ def use_production
55
+ @environment = "production"
56
+ end
57
+
58
+ def use_staging
59
+ @environment = "staging"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ module Dispute
5
+ class << self
6
+ def list(params: {}, headers: {}, seller_account_id: nil)
7
+ headers[:seller_account] = seller_account_id if seller_account_id
8
+ JustifiOperations.execute_get_request("/v1/disputes", params, headers)
9
+ end
10
+
11
+ def get(dispute_id:, headers: {})
12
+ JustifiOperations.execute_get_request("/v1/disputes/#{dispute_id}",
13
+ {},
14
+ headers)
15
+ end
16
+
17
+ def update(dispute_id:, params: {}, headers: {}, idempotency_key: nil)
18
+ JustifiOperations.idempotently_request("/v1/disputes/#{dispute_id}",
19
+ method: :patch,
20
+ params: params,
21
+ headers: {})
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ module Justifi
2
+ class InMemoryCache
3
+ attr_reader :data
4
+
5
+ HALF_DAY_IN_SECONDS = 43200
6
+
7
+ def initialize
8
+ @data = {}
9
+ end
10
+
11
+ # preload data into the cache
12
+ def init(data)
13
+ data.map do |key, value|
14
+ set(key, value)
15
+ end
16
+ end
17
+
18
+ def set(key, value, expiration: nil)
19
+ expiration ||= Time.now + HALF_DAY_IN_SECONDS
20
+ @data[key.to_s] = expirable_value(value, expiration)
21
+ end
22
+
23
+ def set_and_return(key, value, expiration: nil)
24
+ set(key, value, expiration: expiration)
25
+ value
26
+ end
27
+
28
+ def expirable_value(value, expiration)
29
+ {value: value, expiration: expiration}
30
+ end
31
+
32
+ def get(key)
33
+ expire_key!(key) if expired?(@data[key.to_s]&.fetch(:expiration))
34
+ @data[key.to_s]&.fetch(:value)
35
+ end
36
+
37
+ def get_expiration(key)
38
+ expire_key!(key) if expired?(@data[key.to_s]&.fetch(:expiration))
39
+ @data[key.to_s]&.fetch(:expiration)
40
+ end
41
+
42
+ def expired?(expiration)
43
+ !expiration.nil? && Time.now > expiration
44
+ end
45
+
46
+ def expire_key!(key)
47
+ @data.delete(key.to_s)
48
+ end
49
+
50
+ def clear_cache
51
+ @data = {}
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ class Error < StandardError
5
+ attr_reader :response_code
6
+
7
+ def initialize(response_code, msg)
8
+ super(msg)
9
+ @response_code = response_code
10
+ end
11
+ end
12
+
13
+ class InvalidHttpResponseError < Error
14
+ def initialize(response:)
15
+ super(response.http_status, response.error_message)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ class JustifiObject
5
+ extend Justifi::JustifiOperations
6
+
7
+ attr_reader :raw_response, :id, :headers
8
+
9
+ def initialize(id: nil, headers: {}, raw_response: nil)
10
+ @id = id
11
+ @headers = Util.normalize_headers(headers)
12
+ @raw_response = raw_response
13
+ end
14
+
15
+ def self.construct_from(path, response, headers = {})
16
+ values = response.is_a?(JustifiResponse) ? response.data : response
17
+
18
+ new(id: values[:id], raw_response: response)
19
+ .send(:initialize_from, values, headers)
20
+ end
21
+
22
+ protected def initialize_from(values, headers)
23
+ values.each do |k, v|
24
+ instance_variable_set("@#{k}", v.is_a?(Hash) ? OpenStruct.new(v) : v)
25
+ self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") })
26
+ self.class.send(:define_method, "#{k}=", proc { |v| instance_variable_set("@#{k}", v) })
27
+ end
28
+
29
+ @headers = Util.normalize_headers(headers)
30
+
31
+ self
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ module JustifiOperations
5
+ extend APIOperations::ClassMethods
6
+
7
+ class << self
8
+ def execute_post_request(path, params, headers)
9
+ params = Util.normalize_params(params)
10
+ headers[:authorization] = "Bearer #{Justifi::OAuth.get_token}"
11
+
12
+ headers = Util.normalize_headers(headers)
13
+ super(path, params, headers)
14
+ end
15
+
16
+ def execute_get_request(path, params, headers)
17
+ query = Util.encode_parameters(params)
18
+ headers[:authorization] = "Bearer #{Justifi::OAuth.get_token}"
19
+ headers = Util.normalize_headers(headers)
20
+
21
+ super(path, query, headers)
22
+ end
23
+
24
+ def execute_patch_request(path, params, headers)
25
+ params = Util.normalize_params(params)
26
+ headers[:authorization] = "Bearer #{Justifi::OAuth.get_token}"
27
+
28
+ headers = Util.normalize_headers(headers)
29
+ super(path, params, headers)
30
+ end
31
+
32
+ def list(path, params = {}, headers = {})
33
+ Justifi::ListObject.list(path, params, headers)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ # Headers provides an access wrapper to an API response's header data. It
5
+ # mainly exists so that we don't need to expose the entire
6
+ # `Net::HTTPResponse` object while still getting some of its benefits like
7
+ # case-insensitive access to header names and flattening of header values.
8
+ class JustifiResponseHeaders
9
+ # Initializes a Headers object from a Net::HTTP::HTTPResponse object.
10
+ def self.from_net_http(resp)
11
+ new(resp.to_hash)
12
+ end
13
+
14
+ # `hash` is expected to be a hash mapping header names to arrays of
15
+ # header values. This is the default format generated by calling
16
+ # `#to_hash` on a `Net::HTTPResponse` object because headers can be
17
+ # repeated multiple times. Using `#[]` will collapse values down to just
18
+ # the first.
19
+ def initialize(hash)
20
+ if !hash.is_a?(Hash)
21
+ raise ArgumentError,
22
+ "expect hash to be a map of string header names to arrays of " \
23
+ "header values"
24
+ end
25
+
26
+ @hash = {}
27
+
28
+ # This shouldn't be strictly necessary because `Net::HTTPResponse` will
29
+ # produce a hash with all headers downcased, but do it anyway just in
30
+ # case an object of this class was constructed manually.
31
+ #
32
+ # Also has the effect of duplicating the hash, which is desirable for a
33
+ # little extra object safety.
34
+ hash.each do |k, v|
35
+ @hash[k.downcase] = v
36
+ end
37
+ end
38
+ end
39
+
40
+ module JustifiResponseBase
41
+ # A Hash of the HTTP headers of the response.
42
+ attr_accessor :http_headers
43
+
44
+ # The integer HTTP status code of the response.
45
+ attr_accessor :http_status
46
+
47
+ # The JustiFi request ID of the response.
48
+ attr_accessor :request_id
49
+
50
+ def self.populate_for_net_http(resp, http_resp)
51
+ resp.http_headers = JustifiResponseHeaders.from_net_http(http_resp)
52
+ resp.http_status = http_resp.code.to_i
53
+ resp.request_id = http_resp["request-id"]
54
+ end
55
+ end
56
+
57
+ # JustifiResponse encapsulates some vitals of a response that came back from
58
+ # the Justifi API.
59
+ class JustifiResponse
60
+ include JustifiResponseBase
61
+ # The data contained by the HTTP body of the response deserialized from
62
+ # JSON.
63
+ attr_accessor :data
64
+
65
+ # The raw HTTP body of the response.
66
+ attr_accessor :http_body
67
+
68
+ # The error error_message from JustiFi API
69
+ attr_accessor :error_message
70
+
71
+ # The boolean flag based on Net::HTTPSuccess
72
+ attr_accessor :success
73
+
74
+ # Initializes a JustifiResponse object from a Net::HTTP::HTTPResponse
75
+ # object.
76
+ def self.from_net_http(http_resp)
77
+ resp = JustifiResponse.new
78
+ resp.data = JSON.parse(http_resp.body, symbolize_names: true)
79
+ resp.http_body = http_resp.body
80
+ resp.success = http_resp.is_a? Net::HTTPSuccess
81
+ resp.error_message = resp.data.dig(:error, :message)
82
+ JustifiResponseBase.populate_for_net_http(resp, http_resp)
83
+ resp
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ class ListObject
5
+ extend Forwardable
6
+
7
+ attr_reader :justifi_object
8
+ attr_accessor :request_params, :request_headers, :path
9
+
10
+ def_delegators :@justifi_object, :data, :data
11
+ def_delegators :@justifi_object, :page_info, :page_info
12
+ def_delegators :@justifi_object, :http_status, :http_status
13
+ def_delegators :@justifi_object, :raw_response, :raw_response
14
+
15
+ # An empty list object to return when has_next or has_previous
16
+ # does not exist
17
+ def self.empty_list(headers = {})
18
+ JustifiObject.construct_from({data: []}, headers)
19
+ end
20
+
21
+ def initialize(justifi_object:, path:, params: {}, headers: {})
22
+ @justifi_object = justifi_object
23
+ @path = path
24
+ @request_params = params
25
+ @request_headers = headers
26
+ end
27
+
28
+ # Iterates through each resource in the page represented by the current
29
+ # `ListObject`.
30
+ def each(&block)
31
+ data.each(&block)
32
+ end
33
+
34
+ # Returns true if the page object contains no elements.
35
+ def empty?
36
+ data.empty?
37
+ end
38
+
39
+ def has_previous
40
+ page_info.has_previous
41
+ end
42
+
43
+ def has_next
44
+ page_info.has_next
45
+ end
46
+
47
+ def start_cursor
48
+ page_info.start_cursor
49
+ end
50
+
51
+ def end_cursor
52
+ page_info.end_cursor
53
+ end
54
+
55
+ # Fetches the next page based on page_info[:end_cursor] paginaton
56
+ def next_page(params = {}, headers = {})
57
+ return self.class.empty_list(headers) unless has_next
58
+
59
+ params[:after_cursor] = end_cursor
60
+
61
+ Justifi::ListObject.list(path, @request_params.merge(params), @request_headers.merge(headers))
62
+ end
63
+
64
+ # Fetches the next page based on page_info[:start_cursor] paginaton
65
+ def previous_page(params = {}, headers = {})
66
+ return self.class.empty_list(headers) unless has_previous
67
+
68
+ params[:before_cursor] = start_cursor
69
+
70
+ Justifi::ListObject.list(path, @request_params.merge(params), @request_headers.merge(headers))
71
+ end
72
+
73
+ def self.list(path, params = {}, headers = {})
74
+ justifi_object = JustifiOperations.execute_get_request(path, params, headers)
75
+ new(justifi_object: justifi_object, path: path, params: params, headers: headers)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ module OAuth
5
+ module OAuthOperations
6
+ extend APIOperations::ClassMethods
7
+
8
+ def self.execute_post_request(path, params, headers)
9
+ params = Util.normalize_params(params.merge(Justifi.credentials))
10
+ super(path, params, headers)
11
+ end
12
+ end
13
+
14
+ class << self
15
+ def get_token(params = {}, headers = {})
16
+ token = Justifi.cache.get(:access_token)
17
+ return token unless token.nil?
18
+
19
+ response = OAuthOperations.execute_post_request(
20
+ "/oauth/token", params, headers
21
+ )
22
+
23
+ Justifi.cache.set_and_return(:access_token, response.access_token)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Justifi
4
+ module Payment
5
+ class << self
6
+ def create(params: {}, headers: {}, idempotency_key: nil, seller_account_id: nil)
7
+ headers[:seller_account] = seller_account_id if seller_account_id
8
+ JustifiOperations.idempotently_request("/v1/payments",
9
+ method: :post,
10
+ params: params,
11
+ headers: headers)
12
+ end
13
+
14
+ def create_refund(amount:, payment_id:, reason: nil, description: nil)
15
+ refund_params = {amount: amount, description: description, reason: reason}
16
+ JustifiOperations.idempotently_request("/v1/payments/#{payment_id}/refunds",
17
+ method: :post,
18
+ params: refund_params,
19
+ headers: {})
20
+ end
21
+
22
+ def list(params: {}, headers: {}, seller_account_id: nil)
23
+ headers[:seller_account] = seller_account_id if seller_account_id
24
+ JustifiOperations.execute_get_request("/v1/payments", params, headers)
25
+ end
26
+
27
+ def get(payment_id:, headers: {})
28
+ JustifiOperations.execute_get_request("/v1/payments/#{payment_id}",
29
+ {},
30
+ headers)
31
+ end
32
+
33
+ def update(payment_id:, params: {}, headers: {}, idempotency_key: nil)
34
+ JustifiOperations.idempotently_request("/v1/payments/#{payment_id}",
35
+ method: :patch,
36
+ params: params,
37
+ headers: {})
38
+ end
39
+
40
+ def capture(payment_id:, amount: nil, headers: {}, idempotency_key: nil)
41
+ params = amount.nil? ? {} : {amount: amount}
42
+ JustifiOperations.idempotently_request("/v1/payments/#{payment_id}/capture",
43
+ method: :post,
44
+ params: params,
45
+ headers: {})
46
+ end
47
+ end
48
+ end
49
+ end