gocardless 0.1.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/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ lib/*seed*.rb
2
+ lib/example.rb
3
+ lib/*test*.rb
4
+ doc/
5
+ Gemfile.lock
6
+ gocardless*.gem
7
+ .yardoc
8
+ .yardopts
9
+ gocardless-*.gem
10
+ gocardless-ruby.zip
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source :rubygems
2
+
3
+ gemspec
4
+
5
+ group :development do
6
+ gem "guard", "~> 0.8.8"
7
+ if RUBY_PLATFORM.downcase.include?("darwin")
8
+ gem "guard-rspec", "~> 0.5.4"
9
+ gem "rb-fsevent", "~> 0.4.3.1"
10
+ gem "growl_notify", "~> 0.0.3"
11
+ end
12
+ end
data/Guardfile ADDED
@@ -0,0 +1,6 @@
1
+ guard 'rspec', :version => 2, :cli => '--color --format doc' do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/gocardless/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
4
+ watch('lib/gocardless.rb') { "spec/gocardless_spec.rb" }
5
+ watch('spec/spec_helper.rb') { "spec" }
6
+ end
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2011 GoCardless
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,15 @@
1
+ ![GoCardless](https://gocardless.com/resources/logo.png)
2
+
3
+ ## GoCardless Ruby Client Library
4
+
5
+ The GoCardless Ruby client provides a simple Ruby interface to the GoCardless
6
+ API.
7
+
8
+ If you want to use the library as an individual merchant, refer to the
9
+ [merchant guide](https://gocardless.com/docs/ruby/merchant_client_guide). If
10
+ you want to support multiple merchant accounts, see the
11
+ [partner guide](https://gocardless.com/docs/ruby/partner_client_guide).
12
+
13
+ The full API reference is available at on
14
+ [rubydoc.info](http://rubydoc.info/github/gocardless/gocardless-ruby/master/frames).
15
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'yard'
2
+
3
+ YARD::Rake::YardocTask.new do |t|
4
+ t.files = ['lib/**/*.rb'].reject { |f| f.match(/seed|example/) }
5
+ end
6
+
@@ -0,0 +1,22 @@
1
+ require File.expand_path('../lib/gocardless/version', __FILE__)
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.add_runtime_dependency "oauth2", "~> 0.5.0.rc1"
5
+ gem.add_runtime_dependency "json", "~> 1.5.3"
6
+
7
+ gem.add_development_dependency 'rspec', '~> 2.6'
8
+ gem.add_development_dependency 'mocha', '~> 0.9.12'
9
+ gem.add_development_dependency "yard", "~> 0.7.3"
10
+ gem.add_development_dependency "redcarpet", "~> 1.17.2"
11
+
12
+ gem.authors = ["Harry Marr"]
13
+ gem.description = %q{A Ruby wrapper for the GoCardless API}
14
+ gem.email = ['harry@gocardless.com']
15
+ gem.files = `git ls-files`.split("\n")
16
+ gem.homepage = 'https://github.com/gocardless/gocardless-ruby'
17
+ gem.name = 'gocardless'
18
+ gem.require_paths = ['lib']
19
+ gem.summary = %q{Ruby wrapper for the GoCardless API}
20
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
21
+ gem.version = GoCardless::VERSION.dup
22
+ end
data/lib/gocardless.rb ADDED
@@ -0,0 +1,32 @@
1
+ module GoCardless
2
+ require 'gocardless/errors'
3
+ require 'gocardless/utils'
4
+ require 'gocardless/resource'
5
+ require 'gocardless/subscription'
6
+ require 'gocardless/pre_authorization'
7
+ require 'gocardless/user'
8
+ require 'gocardless/bill'
9
+ require 'gocardless/payment'
10
+ require 'gocardless/merchant'
11
+ require 'gocardless/client'
12
+
13
+ class << self
14
+ attr_accessor :environment
15
+ attr_reader :account_details, :client
16
+
17
+ def account_details=(details)
18
+ raise ClientError.new("You must provide a token") unless details[:token]
19
+ @account_details = details
20
+ @client = Client.new(details)
21
+ end
22
+
23
+ %w(new_subscription_url new_pre_authorization_url new_bill_url confirm_resource).each do |name|
24
+ class_eval <<-EOM
25
+ def #{name}(*args)
26
+ raise ClientError.new('Need to set account_details first') unless @client
27
+ @client.send(:#{name}, *args)
28
+ end
29
+ EOM
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ module GoCardless
2
+ class Bill < Resource
3
+ self.endpoint = '/bills/:id'
4
+
5
+ creatable
6
+
7
+ attr_accessor :amount
8
+ attr_accessor :source_type
9
+ attr_accessor :description
10
+
11
+ # @attribute source_id
12
+ # @return [Integer] the ID of the bill's source (eg subscription, pre_authorization)
13
+ attr_accessor :source_id
14
+
15
+ reference_accessor :merchant_id, :user_id, :payment_id
16
+ date_accessor :created_at
17
+
18
+ def source
19
+ klass = GoCardless.const_get(Utils.camelize(source_type.to_s))
20
+ klass.find_with_client(client, @source_id)
21
+ end
22
+
23
+ def source=(obj)
24
+ klass = obj.class.to_s.split(':').last
25
+ if !%w{Subscription PreAuthorization}.include?(klass)
26
+ raise ArgumentError, ("Object must be an instance of Subscription or "
27
+ "PreAuthorization")
28
+ end
29
+ @source_id = obj.id
30
+ @source_type = Utils.underscore(klass)
31
+ end
32
+
33
+ def save
34
+ save_data({
35
+ :bill => {
36
+ :pre_authorization_id => self.source_id,
37
+ :amount => self.amount,
38
+ :description => self.description,
39
+ }
40
+ })
41
+ self
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,352 @@
1
+ require 'rubygems'
2
+ require 'json'
3
+ require 'oauth2'
4
+ require 'openssl'
5
+ require 'uri'
6
+ require 'cgi'
7
+ require 'time'
8
+
9
+ module GoCardless
10
+ class Client
11
+ BASE_URLS = {
12
+ :production => 'https://gocardless.com',
13
+ :sandbox => 'https://sandbox.gocardless.com',
14
+ }
15
+ API_PATH = '/api/v1'
16
+
17
+ class << self
18
+ def base_url=(url)
19
+ @base_url = url.sub(%r|/$|, '')
20
+ end
21
+
22
+ def base_url
23
+ @base_url || BASE_URLS[GoCardless.environment || :production]
24
+ end
25
+
26
+ def api_url
27
+ "#{base_url}#{API_PATH}"
28
+ end
29
+ end
30
+
31
+ def initialize(args = {})
32
+ Utils.symbolize_keys! args
33
+ @app_id = args[:app_id]
34
+ @app_secret = args[:app_secret]
35
+ raise ClientError.new("You must provide an app_id") unless @app_id
36
+ raise ClientError.new("You must provide an app_secret") unless @app_secret
37
+
38
+ @oauth_client = OAuth2::Client.new(@app_id, @app_secret,
39
+ :site => self.class.base_url,
40
+ :token_url => '/oauth/access_token')
41
+
42
+ self.access_token = args[:token] if args[:token]
43
+ end
44
+
45
+ # Generate the OAuth authorize url
46
+ # @param [Hash] options parameters to be included in the url.
47
+ # +:redirect_uri+ is required.
48
+ # @return [String] the authorize url
49
+ def authorize_url(options)
50
+ raise ArgumentError, ':redirect_uri required' unless options[:redirect_uri]
51
+ params = {
52
+ :client_id => @app_id,
53
+ :response_type => 'code',
54
+ :scope => 'manage_merchant'
55
+ }
56
+ @oauth_client.authorize_url(params.merge(options))
57
+ end
58
+ alias :new_merchant_url :authorize_url
59
+
60
+ # @method fetch_access_token(auth_code, options)
61
+ # @param [String] auth_code to exchange for the access_token
62
+ # @return [String] the access_token required to make API calls to resources
63
+ def fetch_access_token(auth_code, options)
64
+ raise ArgumentError, ':redirect_uri required' unless options[:redirect_uri]
65
+ @access_token = @oauth_client.auth_code.get_token(auth_code, options)
66
+ self.access_token
67
+ end
68
+
69
+ # @return [String] a serialized form of the access token with its scope
70
+ def access_token
71
+ if @access_token
72
+ scope = @access_token.params[:scope] || @access_token.params['scope']
73
+ "#{@access_token.token} #{scope}".strip
74
+ end
75
+ end
76
+
77
+ # Set the client's access token
78
+ #
79
+ # @param [String] token a string with format <code>"#{token} #{scope}"</code>
80
+ # (as returned by {#access_token})
81
+ def access_token=(token)
82
+ token, scope = token.split(' ', 2)
83
+ if scope.nil?
84
+ raise ArgumentError, ('Access token missing scope. Use format '
85
+ '<token> <scope>')
86
+ end
87
+ @access_token = OAuth2::AccessToken.new(@oauth_client, token)
88
+ @access_token.params['scope'] = scope
89
+ end
90
+
91
+ # Issue an GET request to the API server
92
+ #
93
+ # @note this method is for internal use
94
+ # @param [String] path the path that will be added to the API prefix
95
+ # @param [Hash] params query string parameters
96
+ # @return [Hash] hash the parsed response data
97
+ def api_get(path, params = {})
98
+ request(:get, "#{API_PATH}#{path}", :params => params).parsed
99
+ end
100
+
101
+ # Issue a POST request to the API server
102
+ #
103
+ # @note this method is for internal use
104
+ # @param [String] path the path that will be added to the API prefix
105
+ # @param [Hash] data a hash of data that will be sent as the request body
106
+ # @return [Hash] hash the parsed response data
107
+ def api_post(path, data = {})
108
+ request(:post, "#{API_PATH}#{path}", :data => data).parsed
109
+ end
110
+
111
+ # Issue a PUT request to the API server
112
+ #
113
+ # @note this method is for internal use
114
+ # @param [String] path the path that will be added to the API prefix
115
+ # @param [Hash] data a hash of data that will be sent as the request body
116
+ # @return [Hash] hash the parsed response data
117
+ def api_put(path, data = {})
118
+ request(:put, "#{API_PATH}#{path}", :data => data).parsed
119
+ end
120
+
121
+ # @method merchant
122
+ # @return [Merchant] the merchant associated with the client's access token
123
+ def merchant
124
+ raise ClientError, 'Access token missing' unless @access_token
125
+ Merchant.new_with_client(self, api_get("/merchants/#{merchant_id}"))
126
+ end
127
+
128
+ # @method subscripton(id)
129
+ # @param [String] id of the subscription
130
+ # @return [Subscription] the subscription matching the id requested
131
+ def subscription(id)
132
+ Subscription.find_with_client(self, id)
133
+ end
134
+
135
+ # @method pre_authorization(id)
136
+ # @param [String] id of the pre_authorization
137
+ # @return [PreAuthorization] the pre_authorization matching the id requested
138
+ def pre_authorization(id)
139
+ PreAuthorization.find_with_client(self, id)
140
+ end
141
+
142
+ # @method user(id)
143
+ # @param [String] id of the user
144
+ # @return [User] the User matching the id requested
145
+ def user(id)
146
+ User.find_with_client(self, id)
147
+ end
148
+
149
+ # @method bill(id)
150
+ # @param [String] id of the bill
151
+ # @return [Bill] the Bill matching the id requested
152
+ def bill(id)
153
+ Bill.find_with_client(self, id)
154
+ end
155
+
156
+ # @method payment(id)
157
+ # @param [String] id of the payment
158
+ # @return [Payment] the payment matching the id requested
159
+ def payment(id)
160
+ Payment.find_with_client(self, id)
161
+ end
162
+
163
+ # Create a new bill under a given pre-authorization
164
+ # @see PreAuthorization#create_bill
165
+ #
166
+ # @param [Hash] attrs must include +:pre_authorization_id+ and +:amount+
167
+ # @return [Bill] the created bill object
168
+ def create_bill(attrs)
169
+ Bill.new_with_client(self, attrs).save
170
+ end
171
+
172
+ # Generate the URL for creating a new subscription. The parameters passed
173
+ # in define various attributes of the subscription. Redirecting a user to
174
+ # the resulting URL will show them a page where they can approve or reject
175
+ # the subscription described by the parameters. Note that this method
176
+ # automatically includes the nonce, timestamp and signature.
177
+ #
178
+ # @param [Hash] params the subscription parameters
179
+ # @return [String] the generated URL
180
+ def new_subscription_url(params)
181
+ new_limit_url(:subscription, params)
182
+ end
183
+
184
+ # Generate the URL for creating a new pre authorization. The parameters
185
+ # passed in define various attributes of the pre authorization. Redirecting
186
+ # a user to the resulting URL will show them a page where they can approve
187
+ # or reject the pre authorization described by the parameters. Note that
188
+ # this method automatically includes the nonce, timestamp and signature.
189
+ #
190
+ # @param [Hash] params the pre authorization parameters
191
+ # @return [String] the generated URL
192
+ def new_pre_authorization_url(params)
193
+ new_limit_url(:pre_authorization, params)
194
+ end
195
+
196
+ # Generate the URL for creating a new bill. The parameters passed in define
197
+ # various attributes of the bill. Redirecting a user to the resulting URL
198
+ # will show them a page where they can approve or reject the bill described
199
+ # by the parameters. Note that this method automatically includes the
200
+ # nonce, timestamp and signature.
201
+ #
202
+ # @param [Hash] params the bill parameters
203
+ # @return [String] the generated URL
204
+ def new_bill_url(params)
205
+ new_limit_url(:bill, params)
206
+ end
207
+
208
+ # Confirm a newly-created subscription, pre-authorzation or one-off
209
+ # payment. This method also checks that the resource response data includes
210
+ # a valid signature and will raise a {SignatureError} if the signature is
211
+ # invalid.
212
+ #
213
+ # @param [Hash] params the response parameters returned by the API server
214
+ # @return [Resource] the confirmed resource object
215
+ def confirm_resource(params)
216
+ params = Utils.symbolize_keys(params)
217
+ # Only pull out the relevant parameters, other won't be included in the
218
+ # signature so will cause false negatives
219
+ keys = [:resource_id, :resource_type, :resource_uri, :state, :signature]
220
+ params = Hash[params.select { |k,v| keys.include? k }]
221
+ (keys - [:state]).each do |key|
222
+ raise ArgumentError, "Parameters missing #{key}" if !params.key?(key)
223
+ end
224
+
225
+ if signature_valid?(params)
226
+ data = {
227
+ :resource_id => params[:resource_id],
228
+ :resource_type => params[:resource_type],
229
+ }
230
+
231
+ credentials = Base64.encode64("#{@app_id}:#{@app_secret}")
232
+ credentials = credentials.gsub(/\s/, '')
233
+ headers = {
234
+ 'Authorization' => "Basic #{credentials}"
235
+ }
236
+ request(:post, "#{self.class.api_url}/confirm", :data => data,
237
+ :headers => headers)
238
+
239
+ # Initialize the correct class according to the resource's type
240
+ klass = GoCardless.const_get(Utils.camelize(params[:resource_type]))
241
+ klass.find_with_client(self, params[:resource_id])
242
+ else
243
+ raise SignatureError, 'An invalid signature was detected'
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ # Convert a hash into query-string style parameters
250
+ def encode_params(params, ns = nil)
251
+ params.map do |key,val|
252
+ key = ns ? "#{ns}[#{key.is_a?(Integer) ? '' : key.to_s}]" : key.to_s
253
+ case val
254
+ when Hash
255
+ encode_params(val, key)
256
+ when Array
257
+ encode_params(Hash[(1..val.length).zip(val)], key)
258
+ else
259
+ "#{CGI.escape(key)}=#{CGI.escape(val.to_s)}"
260
+ end
261
+ end.sort * '&'
262
+ end
263
+
264
+ # Send a request to the GoCardless API servers
265
+ #
266
+ # @param [Symbol] method the HTTP method to use (e.g. +:get+, +:post+)
267
+ # @param [String] path the path fragment of the URL
268
+ # @option [String] body the request body
269
+ # @option [Hash] params query string parameters
270
+ def request(method, path, opts = {})
271
+ raise ClientError, 'Access token missing' unless @access_token
272
+ opts[:headers] = {} if opts[:headers].nil?
273
+ opts[:headers]['Accept'] = 'application/json'
274
+ opts[:headers]['Content-Type'] = 'application/json' unless method == :get
275
+ opts[:body] = JSON.generate(opts[:data]) if !opts[:data].nil?
276
+ header_keys = opts[:headers].keys.map(&:to_s)
277
+ if header_keys.map(&:downcase).include?('authorization')
278
+ @oauth_client.request(method, path, opts)
279
+ else
280
+ @access_token.send(method, path, opts)
281
+ end
282
+ rescue OAuth2::Error => err
283
+ raise GoCardless::ApiError.new(err.response)
284
+ end
285
+
286
+ # Add a signature to a Hash of parameters. The signature will be generated
287
+ # from the app secret and the provided parameters, and should be used
288
+ # whenever signed data needs to be sent to GoCardless (e.g. when creating
289
+ # a new subscription). The signature will be added to the hash under the
290
+ # key +:signature+.
291
+ #
292
+ # @param [Hash] params the parameters to sign
293
+ # @return [Hash] the parameters with the new +:signature+ key
294
+ def sign_params(params)
295
+ msg = encode_params(params)
296
+ digest = OpenSSL::Digest::Digest.new('sha256')
297
+ params[:signature] = OpenSSL::HMAC.hexdigest(digest, @app_secret, msg)
298
+ params
299
+ end
300
+
301
+ # Check if a hash's :signature is valid
302
+ #
303
+ # @param [Hash] params the parameters to check
304
+ # @return [Boolean] whether or not the signature is valid
305
+ def signature_valid?(params)
306
+ params = params.clone
307
+ signature = params.delete(:signature)
308
+ sign_params(params)[:signature] == signature
309
+ end
310
+
311
+ # Generate a random base64-encoded string
312
+ #
313
+ # @return [String] a randomly generated string
314
+ def generate_nonce
315
+ Base64.encode64((0...45).map { rand(256).chr }.join).strip
316
+ end
317
+
318
+ # Generate the URL for creating a limit of type +type+, including the
319
+ # provided params, nonce, timestamp and signature
320
+ #
321
+ # @param [Symbol] type the limit type (+:subscription+, etc)
322
+ # @param [Hash] params the bill parameters
323
+ # @return [String] the generated URL
324
+ def new_limit_url(type, limit_params)
325
+ url = URI.parse("#{self.class.base_url}/connect/#{type}s/new")
326
+
327
+ limit_params[:merchant_id] = merchant_id
328
+ redirect_uri = limit_params.delete(:redirect_uri)
329
+
330
+ params = {
331
+ :nonce => generate_nonce,
332
+ :timestamp => Time.now.getutc.strftime('%Y-%m-%dT%H:%M:%SZ'),
333
+ :client_id => @app_id,
334
+ type => limit_params,
335
+ }
336
+ params[:redirect_uri] = redirect_uri unless redirect_uri.nil?
337
+
338
+ sign_params(params)
339
+
340
+ url.query = encode_params(params)
341
+ url.to_s
342
+ end
343
+
344
+ def merchant_id
345
+ raise ClientError, 'Access token missing' unless @access_token
346
+ scope = @access_token.params['scope'].split
347
+ perm = scope.select {|p| p.start_with?('manage_merchant:') }.first
348
+ perm.split(':')[1]
349
+ end
350
+ end
351
+ end
352
+