gocardless 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
+