bambora-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/linter.yml +13 -0
  3. data/.github/workflows/ruby.yml +34 -0
  4. data/.gitignore +11 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +108 -0
  7. data/.ruby-version +1 -0
  8. data/CODE_OF_CONDUCT.md +74 -0
  9. data/Gemfile +8 -0
  10. data/Gemfile.lock +93 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +198 -0
  13. data/Rakefile +8 -0
  14. data/bambora-client.gemspec +55 -0
  15. data/bin/console +12 -0
  16. data/bin/setup +8 -0
  17. data/lib/bambora/adapters/json_response.rb +7 -0
  18. data/lib/bambora/adapters/multipart_mixed_request.rb +23 -0
  19. data/lib/bambora/adapters/query_string_response.rb +20 -0
  20. data/lib/bambora/adapters/response.rb +44 -0
  21. data/lib/bambora/bank/adapters/payment_profile_response.rb +36 -0
  22. data/lib/bambora/bank/batch_report_messages.rb +81 -0
  23. data/lib/bambora/bank/batch_report_resource.rb +79 -0
  24. data/lib/bambora/bank/builders/payment_profile_params.rb +39 -0
  25. data/lib/bambora/bank/payment_profile_resource.rb +81 -0
  26. data/lib/bambora/builders/batch_payment_csv.rb +41 -0
  27. data/lib/bambora/builders/headers.rb +43 -0
  28. data/lib/bambora/builders/www_form_parameters.rb +33 -0
  29. data/lib/bambora/builders/xml_request_body.rb +19 -0
  30. data/lib/bambora/client.rb +215 -0
  31. data/lib/bambora/client/version.rb +7 -0
  32. data/lib/bambora/factories/response_adapter_factory.rb +21 -0
  33. data/lib/bambora/rest/batch_payment_file_upload_client.rb +58 -0
  34. data/lib/bambora/rest/client.rb +63 -0
  35. data/lib/bambora/rest/json_client.rb +109 -0
  36. data/lib/bambora/rest/www_form_client.rb +38 -0
  37. data/lib/bambora/rest/xml_client.rb +35 -0
  38. data/lib/bambora/v1/batch_payment_resource.rb +52 -0
  39. data/lib/bambora/v1/payment_resource.rb +82 -0
  40. data/lib/bambora/v1/profile_resource.rb +85 -0
  41. metadata +256 -0
@@ -0,0 +1,8 @@
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
+ task default: :spec
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ lib = File.expand_path('lib', __dir__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'bambora/client/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'bambora-client'
8
+ spec.version = Bambora::Client::VERSION
9
+ spec.authors = ['Cassidy K']
10
+ spec.email = ['hello@cassidy.codes', 'tech@himama.com']
11
+
12
+ spec.summary = 'A thread-safe client for the Bambora/Beanstream API.'
13
+ spec.description = 'The official beanstream-ruby gem is not thread-safe. This thread-safe client works in '\
14
+ 'environments like Sidekiq and Puma.'
15
+ spec.homepage = 'https://github.com/HiMamaInc/bambora-client'
16
+ spec.license = 'MIT'
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ if spec.respond_to?(:metadata)
21
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
22
+
23
+ spec.metadata['homepage_uri'] = spec.homepage
24
+ spec.metadata['source_code_uri'] = 'https://github.com/HiMamaInc/bambora-client'
25
+ spec.metadata['changelog_uri'] = 'https://github.com/HiMamaInc/bambora-client/releases'
26
+ else
27
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
28
+ 'public gem pushes.'
29
+ end
30
+
31
+ # Specify which files should be added to the gem when it is released.
32
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
33
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
34
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
35
+ end
36
+ spec.bindir = 'exe'
37
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
38
+ spec.require_paths = ['lib']
39
+
40
+ spec.required_ruby_version = '>= 2.4.6'
41
+
42
+ spec.add_dependency 'excon', '< 1.0'
43
+ spec.add_dependency 'faraday', '< 1.0'
44
+ spec.add_dependency 'gyoku', '~> 1.0'
45
+ spec.add_dependency 'multiparty', '~> 0'
46
+
47
+ spec.add_development_dependency 'bundler', '~> 2'
48
+ spec.add_development_dependency 'pry', '~> 0.12.0'
49
+ spec.add_development_dependency 'pry-byebug', '~> 3.7'
50
+ spec.add_development_dependency 'rake', '~> 10.0'
51
+ spec.add_development_dependency 'rspec', '~> 3.0'
52
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4.1'
53
+ spec.add_development_dependency 'rubocop', '~> 0.74.0'
54
+ spec.add_development_dependency 'webmock', '~> 3.7'
55
+ end
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'bambora/client'
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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ ##
5
+ # Parses a JSON response into a Hash
6
+ class JSONResponse < Response; end
7
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Adapters
5
+ ##
6
+ # Creates headers and a body for a multipart/mixed request with a file and a JSON body.
7
+ class MultipartMixedRequest
8
+ attr_reader :multiparty
9
+
10
+ def initialize(options = {})
11
+ @multiparty = Multiparty.new { |party| party.parts = options[:multipart_args] }
12
+ end
13
+
14
+ def content_type
15
+ multiparty.header.sub(/^Content-Type: /, '').strip
16
+ end
17
+
18
+ def body
19
+ "#{multiparty.body}\r\n"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ ##
5
+ # Parses a query string response into a Hash
6
+ class QueryStringResponse < Response
7
+ def to_h
8
+ parsed_response = super
9
+ return error_response if parsed_response.values.flatten.empty? # We didn't get a query string back.
10
+
11
+ parsed_response.each_with_object({}) { |(key, val), obj| obj[key] = val.length == 1 ? val.first : val }
12
+ end
13
+
14
+ private
15
+
16
+ def parser
17
+ CGI
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ ##
5
+ # Parses a response into a Hash. Uses JSON to parse by default.
6
+ class Response
7
+ DEFAULT_PARSER = JSON
8
+
9
+ attr_reader :response
10
+
11
+ def initialize(response)
12
+ @response = response
13
+ end
14
+
15
+ def to_h
16
+ deep_transform_keys_in_object(parser.parse(response.body), &:to_sym)
17
+ rescue JSON::ParserError
18
+ error_response
19
+ end
20
+
21
+ private
22
+
23
+ def deep_transform_keys_in_object(object, &block)
24
+ case object
25
+ when Hash
26
+ object.each_with_object({}) do |(key, value), result|
27
+ result[yield(key)] = deep_transform_keys_in_object(value, &block)
28
+ end
29
+ when Array
30
+ object.map { |e| deep_transform_keys_in_object(e, &block) }
31
+ else
32
+ object
33
+ end
34
+ end
35
+
36
+ def error_response
37
+ { status: response.status, body: response.body }
38
+ end
39
+
40
+ def parser
41
+ DEFAULT_PARSER
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Bank
5
+ module Adapters
6
+ ##
7
+ # Transforms hash keys from camelCase to snake_case and strips vendor-specific prefixes.
8
+ class PaymentProfileResponse
9
+ attr_reader :response
10
+
11
+ def initialize(response)
12
+ @response = response
13
+ end
14
+
15
+ def to_h
16
+ parsed_query_string.each_with_object({}) do |(key, val), obj|
17
+ obj[transform(key)] = val
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def parsed_query_string
24
+ Bambora::QueryStringResponse.new(response).to_h
25
+ end
26
+
27
+ def transform(camel_case_word)
28
+ word = camel_case_word.to_s
29
+ word.gsub!(/([a-z])([A-Z\d])/, '\1_\2')
30
+ word.downcase!
31
+ word.sub(/^ord_/, '').to_sym
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Bank
5
+ module BatchReportMessages
6
+ ##
7
+ # Adds message text to the response as per the Bambora Docs:
8
+ # https://help.na.bambora.com/hc/en-us/articles/115010510248-Batch-reporting
9
+ MESSAGES = {
10
+ '1' => 'Invalid bank number',
11
+ '2' => 'Invalid branch number',
12
+ '3' => 'Invalid account number',
13
+ '4' => 'Invalid transaction amount',
14
+ '5' => 'Reference number too long',
15
+ '6' => 'Invalid due date',
16
+ '7' => 'Due date out of valid date range',
17
+ '8' => 'Customer name truncated to 32 characters',
18
+ '9' => 'Customer name missing',
19
+ '10' => 'Duplicate transaction matching bank account',
20
+ '11' => 'Zero, negative or non-numeric amount',
21
+ '12' => 'Invalid bank and/or branch number',
22
+ '13' => 'Payee/drawee name cannot be spaces',
23
+ '14' => 'Invalid payment code',
24
+ '15' => 'Invalid transaction type',
25
+ '16' => 'Account Closed',
26
+ '17' => 'NSF – Debit declined due to insufficient funds.',
27
+ '18' => 'Transaction rejected by Bank',
28
+ '19' => 'Invalid bank, branch, or account number',
29
+ '20' => 'Refused by payor',
30
+ '21' => 'Funds not cleared',
31
+ '22' => 'Account Frozen',
32
+ '23' => 'Payment Stopped',
33
+ '24' => 'Transaction Cancelled',
34
+ '25' => 'Cannot Trace',
35
+ '26' => 'Incorrect Payor/Payee Name',
36
+ '27' => 'Payor/Payee Deceased',
37
+ '28' => 'Invalid transit routing number',
38
+ '29' => 'Invalid Account Type',
39
+ '30' => 'Transaction type not permitted',
40
+ '31' => 'No Checking Privileges',
41
+ '33' => 'Edit Reject',
42
+ '35' => 'Reserved Return Code',
43
+ '36' => 'Payment Recalled',
44
+ '38' => 'Not in accordance with agreement – Personal',
45
+ '39' => 'Agreement revoked – Personal',
46
+ '40' => 'No pre-notification – Personal',
47
+ '41' => 'Not in accordance with agreement – Business',
48
+ '42' => 'Agreement revoked – Business',
49
+ '43' => 'No pre-notification – Business',
50
+ '44' => 'Customer Initiated Return Credit Only',
51
+ '45' => 'Currency/Account Mismatch',
52
+ '46' => 'No Debit Allowed',
53
+ '47' => 'Interbank – Returned Item',
54
+ '48' => 'Routing as entered, account modified',
55
+ '49' => 'Routing as entered, repair of account unknown',
56
+ '50' => 'Routing as entered, account unknown',
57
+ '51' => 'Routing number modified, account as entered',
58
+ '52' => 'Routing number modified, account modified',
59
+ '53' => 'Routing number modified, repair of account unknown',
60
+ '54' => 'Routing number modified, account unknown',
61
+ '55' => 'ACH Unavailable for account',
62
+ '56' => 'Customer code invalid/missing payment info',
63
+ '58' => 'Profile status is closed or disabled',
64
+ '59' => 'Invalid SEC code',
65
+ '60' => 'Invalid Account Identifier',
66
+ '61' => 'Invalid Account Identifier',
67
+ '62' => 'Reference Number is Missing',
68
+ '63' => 'Invalid Customer Country Code',
69
+ '64' => 'Invalid Bank Country Code',
70
+ '65' => 'Invalid Bank Name',
71
+ '66' => 'Bank Name is Missing',
72
+ '67' => 'Addendum not allowed, too long, or has invalid characters',
73
+ '68' => 'Invalid Bank Descriptor',
74
+ '69' => 'Invalid Customer Name',
75
+ '70' => 'Transaction rejected - contact support',
76
+ '71' => 'Refund Request by End Customer',
77
+ '72' => 'Blocked due to a Notice of Change',
78
+ }.freeze
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Bank
5
+ ##
6
+ # For making requests to the /scripts/reporting/report.aspx endpoint
7
+ #
8
+ # @see https://dev.na.bambora.com/docs/guides/batch_payment/report/
9
+ class BatchReportResource
10
+ include Bambora::Bank::BatchReportMessages
11
+
12
+ DEFAULT_REQUEST_PARAMS = {
13
+ rpt_format: 'JSON',
14
+ rpt_version: '2.0',
15
+ session_source: 'external',
16
+ }.freeze
17
+
18
+ attr_reader :client, :api_key, :sub_path, :version
19
+
20
+ ##
21
+ # Instantiate an interface to make requests against Bambora's Profiles API.
22
+ #
23
+ # @example
24
+ #
25
+ # client = Bambora::Rest::XMLClient(base_url: '...', merchant_id: '...')
26
+ # profiles = Bambora::Bank::BatchReportResource(client: client, api_key: '...')
27
+ #
28
+ # # Start making requests ...
29
+ #
30
+ # @param client [Bambora::Rest::XMLClient] An instance of Bambora::Rest::XMLClient, used to make network requests.
31
+ # @param api_key [String] An API key for this endpoint. This is also known as the "Pass Code"
32
+ # @param version [String] The Service Version you are requesting from the server.
33
+ def initialize(client:, api_key:)
34
+ @client = client
35
+ @api_key = api_key
36
+ @sub_path = '/scripts/reporting/report.aspx'
37
+ end
38
+
39
+ ##
40
+ # Create a Bank Payment Profile
41
+ #
42
+ # @example
43
+ # data = {
44
+ # rpt_filter_by_1: 'batch_id',
45
+ # rpt_filter_value_1: 1,
46
+ # rpt_operation_type_1: 'EQ',
47
+ # rpt_from_date_time: '2019-12-18 00:00:00',
48
+ # rpt_to_date_time: '2019-12-18 23:59:59',
49
+ # service_name: 'BatchPaymentsEFT',
50
+ # }
51
+ #
52
+ # payment_profile_resource.show(data)
53
+ #
54
+ # @params profile_data [Hash] with values as noted in the example.
55
+ def show(report_data)
56
+ add_messages_to_response(
57
+ client.post(path: sub_path, body: batch_report_body(report_data)),
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def add_messages_to_response(response)
64
+ response.dig(:response, :record).map! do |record|
65
+ record.merge!(messages: record[:messageId].split(',').map { |id| MESSAGES[id] })
66
+ end
67
+ response
68
+ end
69
+
70
+ def batch_report_body(request_data)
71
+ DEFAULT_REQUEST_PARAMS.merge(request_data).merge(
72
+ merchant_id: client.merchant_id,
73
+ pass_code: api_key,
74
+ sub_merchant_id: client.sub_merchant_id,
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Bank
5
+ module Builders
6
+ ##
7
+ # Builds a request body for the Bank Payment Profile endpoint from a Hash
8
+ class PaymentProfileParams
9
+ CONTACT_PARAMS =
10
+ %w[name email_address phone_number address_1 address_2 city postal_code province country].freeze
11
+
12
+ class << self
13
+ ##
14
+ # Converts a snake_case hash to camelCase keys with vendor-specific prefixes.
15
+ # See tests for examples.
16
+ #
17
+ # @params params [Hash]
18
+ def build(params)
19
+ params.each_with_object({}) do |(key, value), obj|
20
+ obj[transform_key(key)] = value
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def transform_key(key)
27
+ key = key.to_s
28
+ key = "ord_#{key}" if CONTACT_PARAMS.include?(key)
29
+
30
+ key.split('_').map.with_index do |word, index|
31
+ word.capitalize! unless index.zero?
32
+ word
33
+ end.join
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bambora
4
+ module Bank
5
+ ##
6
+ # For making requests to the /scripts/payment_profile.asp endpoint
7
+ #
8
+ # @see https://help.na.bambora.com/hc/en-us/articles/115010346067-Secure-Payment-Profiles-Batch-Payments
9
+ class PaymentProfileResource
10
+ DEFAULT_VERSION = 1.0
11
+ DEFAULT_RESPONSE_FORMAT = 'QS'
12
+ CREATE_OPERATION_TYPE = 'N'
13
+
14
+ attr_reader :client, :api_key, :sub_path, :version
15
+
16
+ ##
17
+ # Instantiate an interface to make requests against Bambora's Profiles API.
18
+ #
19
+ # @example
20
+ #
21
+ # client = Bambora::Rest::WWWFormClient(base_url: '...', merchant_id: '...')
22
+ # profiles = Bambora::Bank::PaymentProfileResource(client: client, api_key: '...')
23
+ #
24
+ # # Start making requests ...
25
+ #
26
+ # @param client [Bambora::Rest::WWWFormClient] An instance of Bambora::Rest::WWWFormClient, used to make network
27
+ # requests.
28
+ # @param api_key [String] An API key for this endpoint. This is also known as the "Pass Code"
29
+ # @param version [String] The Service Version you are requesting from the server.
30
+ def initialize(client:, api_key:, version: DEFAULT_VERSION)
31
+ @client = client
32
+ @api_key = api_key
33
+ @version = version
34
+ @sub_path = '/scripts/payment_profile.asp'
35
+ end
36
+
37
+ ##
38
+ # Create a Bank Payment Profile
39
+ #
40
+ # @example
41
+ # data = {
42
+ # customer_code: '1234',
43
+ # bank_account_type: 'CA',
44
+ # bank_account_holder: 'All-Maudra Mayrin',
45
+ # institution_number: '123',
46
+ # branch_number: '12345',
47
+ # account_number: '123456789',
48
+ # name: 'Hup Podling',
49
+ # email_address: 'Brea Princess of Vapra',
50
+ # phone_number: '1231231234',
51
+ # address_1: 'The Castle',
52
+ # city: "Ha'rar",
53
+ # postal_code: 'H0H 0H0',
54
+ # province: 'Vapra',
55
+ # country: 'Thra',
56
+ # }
57
+ #
58
+ # payment_profile_resource.create(data)
59
+ #
60
+ # @params profile_data [Hash] with values as noted in the example.
61
+ def create(profile_data)
62
+ client.post(path: sub_path, body: payment_profile_body(profile_data))
63
+ end
64
+
65
+ private
66
+
67
+ def payment_profile_body(profile_data)
68
+ Bambora::Bank::Builders::PaymentProfileParams.build(
69
+ profile_data.merge(
70
+ pass_code: api_key,
71
+ merchant_id: client.merchant_id,
72
+ sub_merchant_id: client.sub_merchant_id,
73
+ service_version: version,
74
+ response_format: DEFAULT_RESPONSE_FORMAT,
75
+ operation_type: CREATE_OPERATION_TYPE,
76
+ ),
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end