increase 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43bc6e7b10ff9beb7aad80057ffd20e38540f60b6d2324e119326218794e14b3
4
- data.tar.gz: 8bb3bb23e78e81801fb0fa3ab29019c2b663a9dd2cd0462d3946698c347fff5a
3
+ metadata.gz: c94e77f42a4d8c725f5518af02301564c6cbff71f90015063313e983aeac5124
4
+ data.tar.gz: 1d5ec565ec368dd713b47f18a41d5eb1363e624ef6792c05b87952f9fe368695
5
5
  SHA512:
6
- metadata.gz: af671bbe00de695ebee4d75c90e0207f87f79c20b2ad431919d6016987859821397c15ab596268ace2958cbee054832349262764413cc53ddda844a4bc7a4178
7
- data.tar.gz: 1187e2cefba42d32e0c18c13bfbac49be565def33cbc832ef4a897d88aaf87d087a781a3d3750979fb697026fce327c16b74131058e3e76e2426502126de5968
6
+ metadata.gz: 4dd0a71c6eac5742ca4f63237eb8bbccffc8eefe625ce63e345b97cb4431b3cba04744e9acd426665757ab0d1240637a4fb02311cda84cbe7dc8fa1f2657c7bf
7
+ data.tar.gz: cba66503cbbeaf4fd7bdc6541899d033126a2efbc20a7ce70cb8b14f42a78a30628b3ea40b3da1b7b176e4d8219291d800846061f3004c70c2d0e96f4b548a64
data/README.md CHANGED
@@ -1,35 +1,200 @@
1
1
  # Increase
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/increase`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ A Ruby API client for [Increase](https://increase.com/), a platform for Bare-Metal Banking APIs!
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
7
  Install the gem and add to the application's Gemfile by executing:
12
8
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
9
+ ```sh
10
+ $ bundle add increase
11
+ ```
14
12
 
15
13
  If bundler is not being used to manage dependencies, install the gem by executing:
16
14
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
15
+ ```sh
16
+ $ gem install increase
17
+ ```
18
18
 
19
19
  ## Usage
20
20
 
21
- TODO: Write usage instructions here
21
+ ```ruby
22
+ require 'increase'
23
+
24
+ # Grab your API key from https://dashboard.increase.com/developers/api_keys
25
+ Increase.api_key = 'my_api_key'
26
+ Increase.base_url = 'https://api.increase.com'
27
+
28
+ # List transactions
29
+ Increase::Transactions.list
30
+
31
+ # Retrieve a transaction
32
+ Increase::Transactions.retrieve('transaction_1234abcd')
33
+
34
+ # Create an ACH Transfer
35
+ Increase::AchTransfers.create(
36
+ account_id: 'account_1234abcd',
37
+ amount: 100_00, # 10,000 cents ($100 dollars)
38
+ routing_number: '123456789',
39
+ account_number: '9876543210',
40
+ statement_descriptor: 'broke the bank for some retail therapy'
41
+ )
42
+ ```
43
+
44
+ ### Per-request Configuration
45
+
46
+ By default, the client will use the global API key and configurations. However, you can define a custom client to be
47
+ used for per-request configuration.
48
+
49
+ For example, you may want to have access to production and sandbox data at the same.
50
+
51
+ ```ruby
52
+ sandbox = Increase::Client.new(
53
+ api_key: 'time_is_money',
54
+ base_url: 'https://sandbox.increase.com'
55
+ )
56
+
57
+ # This request will use the `sandbox` client and its configurations
58
+ Increase::Transactions.with_config(sandbox).list
59
+ # => [{some sandbox transactions here}, {transaction}, {transaction}]
60
+
61
+ # This request will still use the global configurations (where the API key is a production key)
62
+ Increase::Transactions.list
63
+ # => [{some production transactions here}, {transaction}, {transaction}]
64
+ ```
65
+
66
+ See the [Configuration](#configuration) section for more information on the available configurations.
67
+
68
+ ### Pagination
69
+
70
+ When listing resources (e.g. transactions), **Increase** limits the number of results per page to 100. Luckily, the
71
+ client will automatically paginate through all the results for you!
72
+
73
+ ```ruby
74
+ Increase::Transactions.list(limit: :all) do |transactions|
75
+ # This block will be called once for each page of results
76
+ puts "I got #{transactions.count} transactions!"
77
+ end
78
+
79
+ # Or, if you'd like a gargantuan array of all the transactions
80
+ Increase::Transactions.list(limit: :all)
81
+ Increase::Transactions.list(limit: 2_000)
82
+ ```
83
+
84
+ Watch out for the rate limit!
85
+
86
+ ### Error Handling
87
+
88
+ Whenever you make an oopsies, the client will raise an error! Errors originating from the API will be a subclass
89
+ of `Increase::ApiError`.
90
+
91
+ ```ruby
92
+
93
+ begin
94
+ Increase::Transactions.retrieve('transaction_1234abcd')
95
+ rescue Increase::ApiError => e
96
+ puts e.message # "[404: object_not_found_error] Could not find the specified object. No resource of type ..."
97
+ puts e.title # "Could not find the specified object."
98
+ puts e.detail # "No resource of type transaction was found with ID transaction_1234abcd."
99
+ puts e.status # 404
100
+
101
+ puts e.response # This contains the full response from the API, including headers! (its a Faraday::Env object)
102
+
103
+ puts e.class # Increase::ObjectNotFoundError (it's a subclass of Increase::ApiError!)
104
+ end
105
+ ```
106
+
107
+ To disable this behavior, set `Increase.raise_api_errors = false`. Errors will then be returned as a normal response.
108
+
109
+ ```ruby
110
+ Increase.raise_api_errors = false # Default: true
111
+
112
+ Increase::Transactions.retrieve('transaction_1234abcd')
113
+ # => {"status"=>404, "type"=>"object_not_found_error", ... }
114
+ ```
115
+
116
+ ### Configuration
117
+
118
+ | Name | Description | Default |
119
+ |----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------|
120
+ | **api_key** | Your Increase API Key. Grab it from https://dashboard.increase.com/developers/api_keys | `nil` |
121
+ | **base_url** | The base URL for Increase's API. You can use `:production` (https://api.increase.com), `:sandbox` (https://sandbox.increase.com), or set an actual URL | `"https://api.increase.com"` |
122
+ | **raise_api_errors** | Whether to raise an error when the API returns a non-2XX status. Learn more about Increase's errors [here](https://increase.com/documentation/api#errors). See error classes [here](https://github.com/garyhtou/increase-ruby/blob/main/lib/increase/errors.rb) | `true` |
123
+
124
+ There are multiple syntaxes for configuring the client. Choose your favorite!
125
+
126
+ ```ruby
127
+ # Set the configurations directly
128
+ Increase.api_key = 'terabytes_of_cash' # Default: nil (you'll need one tho!)
129
+ Increase.base_url = :production # Default: :production
130
+ Increase.raise_api_errors = true # Default: true
131
+
132
+ # Or, you can use a block
133
+ Increase.configure do |config|
134
+ config.api_key = 'digital_dough'
135
+ config.base_url = :sandbox
136
+ config.raise_api_errors = false
137
+ end
138
+
139
+ # Or, you can pass in a hash
140
+ Increase.configure(api_key: 'just_my_two_cents')
141
+ ```
142
+
143
+ ### Idempotency
144
+
145
+ **Increase** supports [idempotent requests](https://increase.com/documentation/api#idempotency) to allow for safely
146
+ retrying requests without accidentally performing the same operation twice.
147
+
148
+ ```ruby
149
+ card = Increase::Cards.create(
150
+ {
151
+ # Card parameters
152
+ account_id: 'account_1234abcd',
153
+ description: 'My Chipotle card'
154
+ },
155
+ {
156
+ # Request headers
157
+ 'Idempotency-Key': 'use a V4 UUID here'
158
+ }
159
+ )
160
+ # => {"id"=>"card_1234abcd", "type"=>"card", ... }
161
+
162
+ idempotent_replayed = card.response.headers['Idempotent-Replayed']
163
+ # => "false"
164
+ ```
165
+
166
+ Reusing the key in subsequent requests will return the same response code and body as the original request along with an
167
+ additional HTTP header (Idempotent-Replayed: true). This applies to both success and error responses. In situations
168
+ where your request results in a validation error, you'll need to update your request and retry with a new idempotency
169
+ key.
22
170
 
23
171
  ## Development
24
172
 
25
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
173
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can
174
+ also run `bin/console` for an interactive prompt that will allow you to experiment.
26
175
 
27
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
176
+ You can also run `INCREASE_API_KEY=my_key_here INCREASE_BASE_URL=https://sandbox.increase.com bin/console` to run the
177
+ console with your Increase sandbox API key pre-filled.
178
+
179
+ To install this gem onto your local machine, run `bundle exec rake install`.
180
+
181
+ To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will
182
+ create a git tag for the version, push git commits and the created tag, and push the `.gem` file
183
+ to [rubygems.org](https://rubygems.org).
184
+
185
+ Alternatively, use [`gem-release`](https://github.com/svenfuchs/gem-release) and
186
+ run `gem bump --version patch|minor|major`. Then release the gem by running `bundle exec rake release`.
28
187
 
29
188
  ## Contributing
30
189
 
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/increase.
190
+ Bug reports and pull requests are welcome on GitHub at https://github.com/garyhtou/increase.
32
191
 
33
192
  ## License
34
193
 
35
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
194
+ The gem is available as open source under the terms of
195
+ the [MIT License](https://github.com/garyhtou/increase-ruby/blob/main/LICENSE.txt).
196
+
197
+ ---
198
+
199
+ Please note that this is not an official library written by **Increase**. Its written and maintained
200
+ by [Gary Tou](https://garytou.com/) who just uses Increase at work!
@@ -27,7 +27,8 @@ module Increase
27
27
  Faraday.new(
28
28
  url: @configuration.base_url,
29
29
  headers: {
30
- Authorization: "Bearer #{@configuration.api_key}"
30
+ Authorization: "Bearer #{@configuration.api_key}",
31
+ "User-Agent": "Increase Ruby Gem v#{Increase::VERSION} (https://github.com/garyhtou/increase-ruby)"
31
32
  }
32
33
  ) do |f|
33
34
  f.request :json
@@ -8,16 +8,13 @@ module Increase
8
8
  # TODO: support Faraday config
9
9
 
10
10
  def initialize(config = nil)
11
- if config.nil?
12
- reset
13
- else
14
- configure(config)
15
- end
11
+ reset
12
+ configure(config) if config
16
13
  end
17
14
 
18
15
  def reset
19
16
  @base_url = ENV["INCREASE_BASE_URL"] || Increase::PRODUCTION_URL
20
- @api_key = nil || ENV["INCREASE_API_KEY"]
17
+ @api_key = ENV["INCREASE_API_KEY"]
21
18
  @raise_api_errors = true
22
19
  end
23
20
 
@@ -82,4 +82,18 @@ module Increase
82
82
  "private_feature_error" => PrivateFeatureError,
83
83
  "rate_limited_error" => RateLimitedError
84
84
  }
85
+
86
+ # WebhookSignatureVerificationError is raised when a received webhook's
87
+ # signature is invalid.
88
+ class WebhookSignatureVerificationError < Error
89
+ attr_reader :signature_header
90
+ attr_reader :payload
91
+
92
+ def initialize(message = "Increase webhook signature verification failed", signature_header: nil, payload: nil)
93
+ @signature_header = signature_header
94
+ @payload = payload
95
+
96
+ super(message)
97
+ end
98
+ end
85
99
  end
@@ -29,41 +29,170 @@ module Increase
29
29
  name.split("::").last.gsub(/[A-Z]/, ' \0').strip
30
30
  end
31
31
 
32
- def self.endpoint(method, as: nil, with: nil)
33
- if as == :action
34
- raise Error, "`with` must be a valid HTTP method" unless %i[get post put patch delete].include?(with)
35
- return endpoint_action(method, with)
36
- end
37
- raise Error, "as must be :action" if as
32
+ def self.endpoint(name, http_method, to: :same_as_name, with: nil)
33
+ to = nil if to == :root
34
+ to = name.to_s if to == :same_as_name
35
+ to = [to].flatten.compact
36
+ with = [with].flatten.compact
37
+
38
+ raise Error, "Invalid `to`. Max of 2 elements allowed" if to.size > 2
39
+ raise Error, "Only one `to` allowed when not `with` an `id`" if to.size > 1 && !with.include?(:id)
40
+
41
+ request_method = :request
42
+ request_method = :paginated_request if with.include?(:pagination)
43
+
44
+ method =
45
+ if with.include?(:id)
46
+ # Method signature with a required `id` param
47
+ ->(id, params = nil, headers = nil, &block) do
48
+ url = self.class.resource_url
49
+ url +=
50
+ if to.size == 2
51
+ "/#{to[0]}/#{id}/#{to[1]}"
52
+ elsif to.size == 1
53
+ # Default to id first
54
+ "/#{id}/#{to[0]}"
55
+ else
56
+ "/#{id}"
57
+ end
58
+
59
+ send(request_method, http_method, url, params, headers, &block)
60
+ end
61
+ else
62
+ # Method signature without a required `id` param
63
+ ->(params = nil, headers = nil, &block) do
64
+ url = self.class.resource_url
65
+ url += "/#{to[0]}" if to.size == 1
38
66
 
39
- define_singleton_method(method) do |*args, &block|
40
- new.send(method, *args, &block)
41
- end
67
+ send(request_method, http_method, url, params, headers, &block)
68
+ end
69
+ end
70
+
71
+ # Define instance method
72
+ define_method(name, &method)
42
73
 
43
- public method
74
+ # Define class method (uses default config by calling `new`)
75
+ define_singleton_method(name) do |*args, &block|
76
+ new.send(name, *args, &block)
77
+ end
44
78
  end
45
79
 
46
80
  private_class_method :endpoint
47
81
 
48
- def self.endpoint_action(method, http_method)
49
- define_singleton_method(method) do |*args, &block|
50
- new.send(:action, method, http_method, *args, &block)
82
+ class << self
83
+ private
84
+
85
+ # These methods here are shortcuts for the `endpoint` method. They define
86
+ # commonly used endpoints. For example, nearly all resources have a `list`
87
+ # endpoint which is a `GET` request to the resource's root URL.
88
+
89
+ def create
90
+ endpoint :create, :post, to: :root
51
91
  end
52
92
 
53
- define_method(method) do |*args, &block|
54
- new.send(:action, method, http_method, *args, &block)
93
+ def list
94
+ endpoint :list, :get, to: :root, with: :pagination
95
+ end
96
+
97
+ def update
98
+ endpoint :update, :patch, to: :root, with: :id
99
+ end
100
+
101
+ def retrieve
102
+ endpoint :retrieve, :get, to: :root, with: :id
55
103
  end
56
104
  end
57
105
 
58
- private_class_method :endpoint_action
106
+ # def self.endpoint_action(method, http_method)
107
+ # define_singleton_method(method) do |*args, &block|
108
+ # new.send(:action, method, http_method, *args, &block)
109
+ # end
110
+ #
111
+ # define_method(method) do |*args, &block|
112
+ # new.send(:action, method, http_method, *args, &block)
113
+ # end
114
+ # end
115
+ #
116
+ # private_class_method :endpoint_action
117
+ #
118
+ # private
119
+ #
120
+ # def create(params = nil, headers = nil)
121
+ # request(:post, self.class.resource_url, params, headers)
122
+ # end
123
+ #
124
+ # def list(params = nil, headers = nil, &block)
125
+ # results = []
126
+ # count = 0
127
+ # limit = params&.[](:limit) || params&.[]("limit")
128
+ # if limit == :all || limit&.>(100)
129
+ # params&.delete(:limit)
130
+ # params&.delete("limit")
131
+ # end
132
+ #
133
+ # loop do
134
+ # res = request(:get, self.class.resource_url, params, headers)
135
+ # data = res["data"]
136
+ # count += data.size
137
+ # if ![nil, :all].include?(limit) && count >= limit
138
+ # data = data[0..(limit - (count - data.size) - 1)]
139
+ # end
140
+ #
141
+ # if block
142
+ # block.call(data)
143
+ # else
144
+ # results += data
145
+ # end
146
+ #
147
+ # if limit.nil? || (limit != :all && count >= limit) || res["next_cursor"].nil?
148
+ # if block
149
+ # break
150
+ # else
151
+ # return results
152
+ # end
153
+ # end
154
+ #
155
+ # params = (params || {}).merge({ cursor: res["next_cursor"] })
156
+ # end
157
+ # end
158
+ #
159
+ # def update(id, params = nil, headers = nil)
160
+ # raise Error, "id must be a string" unless id.is_a?(String)
161
+ # path = "#{self.class.resource_url}/#{id}"
162
+ # request(:patch, path, params, headers)
163
+ # end
164
+ #
165
+ # def retrieve(id, params = nil, headers = nil)
166
+ # raise Error, "id must be a string" unless id.is_a?(String)
167
+ # path = "#{self.class.resource_url}/#{id}"
168
+ # request(:get, path, params, headers)
169
+ # end
170
+ #
171
+ # # Such as for "/accounts/{account_id}/close"
172
+ # # "close" is the action.
173
+ # def action(action, http_method, id, params = nil, headers = nil)
174
+ # raise Error, "id must be a string" unless id.is_a?(String)
175
+ # path = "#{self.class.resource_url}/#{id}/#{action}"
176
+ # request(http_method, path, params, headers)
177
+ # end
59
178
 
60
179
  private
61
180
 
62
- def create(params = nil, headers = nil)
63
- request(:post, self.class.resource_url, params, headers)
181
+ def request(method, path, params = nil, headers = nil, &block)
182
+ if block
183
+ # Assume the caller wants to automatically paginate
184
+ return paginated_request(method, path, params, headers, &block)
185
+ end
186
+
187
+ if method == :post
188
+ headers = {"Content-Type" => "application/json"}.merge!(headers || {})
189
+ end
190
+
191
+ response = @client.connection.send(method, path, params, headers)
192
+ ResponseHash.new(response.body, response: response)
64
193
  end
65
194
 
66
- def list(params = nil, headers = nil, &block)
195
+ def paginated_request(method, path, params = nil, headers = nil, &block)
67
196
  results = []
68
197
  count = 0
69
198
  limit = params&.[](:limit) || params&.[]("limit")
@@ -73,8 +202,17 @@ module Increase
73
202
  end
74
203
 
75
204
  loop do
76
- res = request(:get, self.class.resource_url, params, headers)
205
+ res = request(method, path, params, headers)
77
206
  data = res["data"]
207
+
208
+ # Handle case where endpoint doesn't actually support pagination.
209
+ # For example, someone passes a block to `Account.create`
210
+ if data.nil?
211
+ # In this case, we'll both yield and return the response
212
+ yield res if block
213
+ return res
214
+ end
215
+
78
216
  count += data.size
79
217
  if ![nil, :all].include?(limit) && count >= limit
80
218
  data = data[0..(limit - (count - data.size) - 1)]
@@ -97,34 +235,5 @@ module Increase
97
235
  params = (params || {}).merge({cursor: res["next_cursor"]})
98
236
  end
99
237
  end
100
-
101
- def update(id, params = nil, headers = nil)
102
- raise Error, "id must be a string" unless id.is_a?(String)
103
- path = "#{self.class.resource_url}/#{id}"
104
- request(:patch, path, params, headers)
105
- end
106
-
107
- def retrieve(id, params = nil, headers = nil)
108
- raise Error, "id must be a string" unless id.is_a?(String)
109
- path = "#{self.class.resource_url}/#{id}"
110
- request(:get, path, params, headers)
111
- end
112
-
113
- # Such as for "/accounts/{account_id}/close"
114
- # "close" is the action.
115
- def action(action, http_method, id, params = nil, headers = nil)
116
- raise Error, "id must be a string" unless id.is_a?(String)
117
- path = "#{self.class.resource_url}/#{id}/#{action}"
118
- request(http_method, path, params, headers)
119
- end
120
-
121
- def request(method, path, params = nil, headers = nil)
122
- if method == :post
123
- headers = {"Content-Type" => "application/json"}.merge!(headers || {})
124
- end
125
-
126
- response = @client.connection.send(method, path, params, headers)
127
- ResponseHash.new(response.body, response: response)
128
- end
129
238
  end
130
239
  end
@@ -4,9 +4,9 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class AccountNumbers < Resource
7
- endpoint :create
8
- endpoint :list
9
- endpoint :update
10
- endpoint :retrieve
7
+ create
8
+ list
9
+ update
10
+ retrieve
11
11
  end
12
12
  end
@@ -4,10 +4,10 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class AccountTransfers < Resource
7
- endpoint :create
8
- endpoint :list
9
- endpoint :retrieve
10
- endpoint :approve, as: :action, with: :post
11
- endpoint :cancel, as: :action, with: :post
7
+ create
8
+ list
9
+ retrieve
10
+ endpoint :approve, :post, with: :id
11
+ endpoint :cancel, :post, with: :id
12
12
  end
13
13
  end
@@ -4,10 +4,10 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class Accounts < Resource
7
- endpoint :create
8
- endpoint :list
9
- endpoint :update
10
- endpoint :retrieve
11
- endpoint :close, as: :action, with: :post
7
+ create
8
+ list
9
+ update
10
+ retrieve
11
+ endpoint :close, :post, with: :id
12
12
  end
13
13
  end
@@ -4,10 +4,10 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class AchTransfers < Resource
7
- endpoint :create
8
- endpoint :list
9
- endpoint :retrieve
10
- endpoint :approve, as: :action, with: :post
11
- endpoint :cancel, as: :action, with: :post
7
+ create
8
+ list
9
+ retrieve
10
+ endpoint :approve, :post, with: :id
11
+ endpoint :cancel, :post, with: :id
12
12
  end
13
13
  end
@@ -4,10 +4,10 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class Cards < Resource
7
- endpoint :create
8
- endpoint :list
9
- endpoint :details, as: :action, with: :get
10
- endpoint :update
11
- endpoint :retrieve
7
+ create
8
+ list
9
+ endpoint :details, :get, with: :id
10
+ update
11
+ retrieve
12
12
  end
13
13
  end
@@ -4,7 +4,7 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class Events < Resource
7
- endpoint :list
8
- endpoint :retrieve
7
+ list
8
+ retrieve
9
9
  end
10
10
  end
@@ -4,7 +4,7 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class PendingTransactions < Resource
7
- endpoint :list
8
- endpoint :retrieve
7
+ list
8
+ retrieve
9
9
  end
10
10
  end
@@ -4,7 +4,7 @@ require "increase/resource"
4
4
 
5
5
  module Increase
6
6
  class Transactions < Resource
7
- endpoint :list
8
- endpoint :retrieve
7
+ list
8
+ retrieve
9
9
  end
10
10
  end
@@ -0,0 +1,15 @@
1
+ module Increase
2
+ module Util
3
+ # Constant time string comparison to prevent timing attacks
4
+ # Code borrowed from `stripe-ruby`, which was borrowed from ActiveSupport
5
+ def self.secure_compare(a, b)
6
+ return false unless a.bytesize == b.bytesize
7
+
8
+ l = a.unpack "C#{a.bytesize}"
9
+
10
+ res = 0
11
+ b.each_byte { |byte| res |= byte ^ l.shift }
12
+ res.zero?
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Increase
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -0,0 +1,56 @@
1
+ require "increase/util"
2
+ require "increase/errors"
3
+
4
+ module Increase
5
+ # Keeping this module singular in case Increase adds a `webhooks` resource
6
+ module Webhook
7
+ module Signature
8
+ DEFAULT_TIME_TOLERANCE = 300 # 300 seconds (5 minutes)
9
+ DEFAULT_SCHEME = "v1"
10
+
11
+ def self.verify?(payload:, signature_header:, secret:, scheme: DEFAULT_SCHEME, time_tolerance: DEFAULT_TIME_TOLERANCE)
12
+ # Helper for raising errors with additional metadata
13
+ sig_error = ->(msg) do
14
+ WebhookSignatureVerificationError.new(msg, signature_header: signature_header, payload: payload)
15
+ end
16
+
17
+ # Parse header
18
+ sig_values = signature_header.split(",").map { |pair| pair.split("=") }.to_h
19
+
20
+ # Extract values
21
+ t = sig_values["t"] # Should be a string (ISO-8601 timestamp)
22
+ sig = sig_values[scheme]
23
+ raise sig_error.call("No timestamp found in signature header") if t.nil?
24
+ raise sig_error.call("No signature found with scheme #{scheme} in signature header") if sig.nil?
25
+
26
+ # Check signature
27
+ expected_sig = compute_signature(timestamp: t, payload: payload, secret: secret)
28
+ matches = Util.secure_compare(expected_sig, sig)
29
+ raise sig_error.call("Signature mismatch") unless matches
30
+
31
+ # Check timestamp tolerance to prevent timing attacks
32
+ if time_tolerance > 0
33
+ begin
34
+ timestamp = DateTime.parse(t)
35
+ now = DateTime.now
36
+ diff = (now - timestamp) * 24 * 60 * 60 # in seconds
37
+
38
+ # Don't allow timestamps in the future
39
+ if diff > time_tolerance || diff < 0
40
+ raise sig_error.call("Timestamp outside of the tolerance zone")
41
+ end
42
+ rescue Date::Error
43
+ raise sig_error.call("Invalid timestamp in signature header: #{t}")
44
+ end
45
+ end
46
+
47
+ true
48
+ end
49
+
50
+ def self.compute_signature(timestamp:, payload:, secret:)
51
+ signed_payload = timestamp.to_s + "." + payload.to_s
52
+ OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/increase.rb CHANGED
@@ -5,7 +5,7 @@ require "increase/client"
5
5
  require "increase/configuration"
6
6
  require "increase/errors"
7
7
  require "increase/resources"
8
- require "increase/webhooks"
8
+ require "increase/webhook/signature"
9
9
 
10
10
  module Increase
11
11
  PRODUCTION_URL = "https://api.increase.com"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: increase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Tou
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-19 00:00:00.000000000 Z
11
+ date: 2023-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - '='
39
39
  - !ruby/object:Gem::Version
40
40
  version: 0.3.0
41
- - !ruby/object:Gem::Dependency
42
- name: securecompare
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '1.0'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '1.0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: rake
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -151,8 +137,9 @@ files:
151
137
  - lib/increase/resources/pending_transactions.rb
152
138
  - lib/increase/resources/transactions.rb
153
139
  - lib/increase/response_hash.rb
140
+ - lib/increase/util.rb
154
141
  - lib/increase/version.rb
155
- - lib/increase/webhooks.rb
142
+ - lib/increase/webhook/signature.rb
156
143
  - sig/increase.rbs
157
144
  homepage: https://github.com/garyhtou/increase-ruby
158
145
  licenses:
@@ -169,7 +156,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
169
156
  requirements:
170
157
  - - ">="
171
158
  - !ruby/object:Gem::Version
172
- version: 2.6.0
159
+ version: 2.7.4
173
160
  required_rubygems_version: !ruby/object:Gem::Requirement
174
161
  requirements:
175
162
  - - ">="
@@ -1,16 +0,0 @@
1
- require "openssl"
2
- require "securecompare"
3
-
4
- module Increase
5
- class Webhooks
6
- def self.verify?(payload:, signature_header:, secret:, scheme: "v1")
7
- sig_values = signature_header.split(",").map { |pair| pair.split("=") }
8
- sig_values = sig_values.to_h
9
-
10
- signed_payload = sig_values["t"] + "." + payload.to_s
11
-
12
- expected_sig = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
13
- SecureCompare.compare(expected_sig, sig_values["v1"])
14
- end
15
- end
16
- end