justifi 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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