ibandit 0.2.1 → 0.3.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/lib/ibandit/iban.rb CHANGED
@@ -2,23 +2,25 @@ require 'yaml'
2
2
 
3
3
  module Ibandit
4
4
  class IBAN
5
- attr_reader :iban
6
- attr_reader :errors
7
-
8
- def self.structures
9
- @structures ||= YAML.load_file(
10
- File.expand_path('../../../data/structures.yml', __FILE__)
11
- )
12
- end
5
+ attr_reader :errors, :iban, :country_code, :check_digits, :bank_code,
6
+ :branch_code, :account_number
7
+
8
+ def initialize(argument)
9
+ if argument.is_a?(String)
10
+ @iban = argument.to_s.gsub(/\s+/, '').upcase
11
+ extract_local_details_from_iban!
12
+ elsif argument.is_a?(Hash)
13
+ build_iban_from_local_details(argument)
14
+ else
15
+ raise TypeError, 'Must pass an IBAN string or hash of local details'
16
+ end
13
17
 
14
- def initialize(iban)
15
- @iban = iban.to_s.gsub(/\s+/, '').upcase
16
18
  @errors = {}
17
19
  end
18
20
 
19
21
  def to_s(format = :compact)
20
22
  case format
21
- when :compact then iban
23
+ when :compact then iban.to_s
22
24
  when :formatted then formatted
23
25
  else raise ArgumentError, "invalid format '#{format}'"
24
26
  end
@@ -28,58 +30,25 @@ module Ibandit
28
30
  # Component parts #
29
31
  ###################
30
32
 
31
- def country_code
32
- iban[0, 2]
33
- end
34
-
35
- def check_digits
36
- iban[2, 2] || ''
37
- end
38
-
39
- def bank_code
40
- return '' unless structure
41
-
42
- iban.slice(
43
- structure[:bank_code_position] - 1,
44
- structure[:bank_code_length]
45
- ) || ''
46
- end
47
-
48
- def branch_code
49
- return '' unless structure && structure[:branch_code_length] > 0
50
-
51
- iban.slice(
52
- structure[:branch_code_position] - 1,
53
- structure[:branch_code_length]
54
- ) || ''
55
- end
56
-
57
- def account_number
58
- return '' unless structure
59
-
60
- iban.slice(
61
- structure[:account_number_position] - 1,
62
- structure[:account_number_length]
63
- ) || ''
64
- end
65
-
66
33
  def iban_national_id
67
- return '' unless structure
34
+ return unless decomposable?
68
35
 
69
- national_id = branch_code.nil? ? bank_code : bank_code + branch_code
36
+ national_id = bank_code.to_s
37
+ national_id += branch_code.to_s
70
38
  national_id.slice(0, structure[:iban_national_id_length])
71
39
  end
72
40
 
73
41
  def local_check_digits
74
- return '' unless structure && structure[:local_check_digit_position]
42
+ return unless decomposable? && structure[:local_check_digit_position]
75
43
 
76
- iban.slice(structure[:local_check_digit_position] - 1,
77
- structure[:local_check_digit_length]
78
- ) || ''
44
+ iban.slice(
45
+ structure[:local_check_digit_position] - 1,
46
+ structure[:local_check_digit_length]
47
+ )
79
48
  end
80
49
 
81
50
  def bban
82
- iban[4..-1] || ''
51
+ iban[4..-1] unless iban.nil?
83
52
  end
84
53
 
85
54
  ###############
@@ -92,12 +61,18 @@ module Ibandit
92
61
  valid_characters?,
93
62
  valid_check_digits?,
94
63
  valid_length?,
95
- valid_format?
64
+ valid_bank_code_length?,
65
+ valid_branch_code_length?,
66
+ valid_account_number_length?,
67
+ valid_format?,
68
+ valid_bank_code_format?,
69
+ valid_branch_code_format?,
70
+ valid_account_number_format?
96
71
  ].all?
97
72
  end
98
73
 
99
74
  def valid_country_code?
100
- if IBAN.structures.key?(country_code)
75
+ if Ibandit.structures.key?(country_code)
101
76
  true
102
77
  else
103
78
  @errors[:country_code] = "'#{country_code}' is not a valid " \
@@ -107,7 +82,7 @@ module Ibandit
107
82
  end
108
83
 
109
84
  def valid_check_digits?
110
- return unless valid_country_code? && valid_characters?
85
+ return unless decomposable? && valid_characters?
111
86
 
112
87
  expected_check_digits = CheckDigit.iban(country_code, bban)
113
88
  if check_digits == expected_check_digits
@@ -121,9 +96,9 @@ module Ibandit
121
96
  end
122
97
 
123
98
  def valid_length?
124
- return unless valid_country_code?
99
+ return unless valid_country_code? && !iban.nil?
125
100
 
126
- if iban.size == structure[:total_length]
101
+ if iban.length == structure[:total_length]
127
102
  true
128
103
  else
129
104
  @errors[:length] = "Length doesn't match SWIFT specification " \
@@ -133,7 +108,54 @@ module Ibandit
133
108
  end
134
109
  end
135
110
 
111
+ def valid_bank_code_length?
112
+ return unless valid_country_code?
113
+
114
+ if bank_code.nil? || bank_code.length == 0
115
+ @errors[:bank_code] = 'is required'
116
+ return false
117
+ end
118
+
119
+ return true if bank_code.length == structure[:bank_code_length]
120
+
121
+ @errors[:bank_code] = 'is the wrong length (should be ' \
122
+ "#{structure[:bank_code_length]} characters)"
123
+ false
124
+ end
125
+
126
+ def valid_branch_code_length?
127
+ return unless valid_country_code?
128
+ return true if branch_code.to_s.length == structure[:branch_code_length]
129
+
130
+ if structure[:branch_code_length] == 0
131
+ @errors[:branch_code] = "is not used in #{country_code}"
132
+ elsif branch_code.nil? || branch_code.length == 0
133
+ @errors[:branch_code] = 'is required'
134
+ else
135
+ @errors[:branch_code] = 'is the wrong length (should be ' \
136
+ "#{structure[:branch_code_length]} characters)"
137
+ end
138
+ false
139
+ end
140
+
141
+ def valid_account_number_length?
142
+ return unless valid_country_code?
143
+
144
+ if account_number.nil?
145
+ @errors[:account_number] = 'is required'
146
+ return false
147
+ end
148
+
149
+ return true if account_number.length == structure[:account_number_length]
150
+
151
+ @errors[:account_number] = 'is the wrong length (should be ' \
152
+ "#{structure[:account_number_length]} " \
153
+ 'characters)'
154
+ false
155
+ end
156
+
136
157
  def valid_characters?
158
+ return if iban.nil?
137
159
  if iban.scan(/[^A-Z0-9]/).any?
138
160
  @errors[:characters] = 'Non-alphanumeric characters found: ' \
139
161
  "#{iban.scan(/[^A-Z\d]/).join(' ')}"
@@ -154,18 +176,83 @@ module Ibandit
154
176
  end
155
177
  end
156
178
 
179
+ def valid_bank_code_format?
180
+ return unless valid_bank_code_length?
181
+
182
+ if bank_code =~ Regexp.new(structure[:bank_code_format])
183
+ true
184
+ else
185
+ @errors[:bank_code] = 'is invalid'
186
+ false
187
+ end
188
+ end
189
+
190
+ def valid_branch_code_format?
191
+ return unless valid_branch_code_length?
192
+ return true unless structure[:branch_code_format]
193
+
194
+ if branch_code =~ Regexp.new(structure[:branch_code_format])
195
+ true
196
+ else
197
+ @errors[:branch_code] = 'is invalid'
198
+ false
199
+ end
200
+ end
201
+
202
+ def valid_account_number_format?
203
+ return unless valid_account_number_length?
204
+
205
+ if account_number =~ Regexp.new(structure[:account_number_format])
206
+ true
207
+ else
208
+ @errors[:account_number] = 'is invalid'
209
+ false
210
+ end
211
+ end
212
+
157
213
  ###################
158
214
  # Private methods #
159
215
  ###################
160
216
 
161
217
  private
162
218
 
219
+ def decomposable?
220
+ [iban, country_code, bank_code, account_number].none?(&:nil?)
221
+ end
222
+
223
+ def build_iban_from_local_details(details_hash)
224
+ local_details = LocalDetailsCleaner.clean(details_hash)
225
+
226
+ @country_code = try_dup(local_details[:country_code])
227
+ @account_number = try_dup(local_details[:account_number])
228
+ @branch_code = try_dup(local_details[:branch_code])
229
+ @bank_code = try_dup(local_details[:bank_code])
230
+ @iban = IBANAssembler.assemble(local_details)
231
+ @check_digits = @iban.slice(2, 2) unless @iban.nil?
232
+ end
233
+
234
+ def extract_local_details_from_iban!
235
+ local_details = IBANSplitter.split(@iban)
236
+
237
+ @country_code = local_details[:country_code]
238
+ @check_digits = local_details[:check_digits]
239
+ @bank_code = local_details[:bank_code]
240
+ @branch_code = local_details[:branch_code]
241
+ @account_number = local_details[:account_number]
242
+ end
243
+
244
+ def try_dup(object)
245
+ object.dup
246
+ rescue TypeError
247
+ object
248
+ end
249
+
163
250
  def structure
164
- IBAN.structures[country_code]
251
+ Ibandit.structures[country_code]
165
252
  end
166
253
 
167
254
  def formatted
168
- iban.gsub(/(.{4})/, '\1 ').strip
255
+ iban.to_s.gsub(/(.{4})/, '\1 ').strip
169
256
  end
170
257
  end
171
258
  end
@@ -0,0 +1,117 @@
1
+ module Ibandit
2
+ module IBANAssembler
3
+ SUPPORTED_COUNTRY_CODES = %w(AT BE CY DE EE ES FI FR GB IE IT LT LU LV MC NL
4
+ PT SI SK SM).freeze
5
+
6
+ EXCEPTION_COUNTRY_CODES = %w(IT SM BE).freeze
7
+
8
+ def self.assemble(local_details)
9
+ country_code = local_details[:country_code]
10
+
11
+ return unless can_assemble?(local_details)
12
+
13
+ bban =
14
+ if EXCEPTION_COUNTRY_CODES.include?(country_code)
15
+ public_send(:"assemble_#{country_code.downcase}_bban", local_details)
16
+ else
17
+ assemble_general_bban(local_details)
18
+ end
19
+
20
+ assemble_iban(country_code, bban)
21
+ end
22
+
23
+ ##############################
24
+ # General case BBAN creation #
25
+ ##############################
26
+
27
+ def self.assemble_general_bban(opts)
28
+ [opts[:bank_code], opts[:branch_code], opts[:account_number]].join
29
+ end
30
+
31
+ ##################################
32
+ # Country-specific BBAN creation #
33
+ ##################################
34
+
35
+ def self.assemble_be_bban(opts)
36
+ # The first three digits of Belgian account numbers are the bank_code,
37
+ # but the account number is not considered complete without these three
38
+ # numbers and the IBAN structure file includes them in its definition of
39
+ # the account number. As a result, this method ignores all arguments
40
+ # other than the account number.
41
+ opts[:account_number]
42
+ end
43
+
44
+ def self.assemble_it_bban(opts)
45
+ # The Italian check digit is NOT included in the any of the other SWIFT
46
+ # elements, so should be passed explicitly or left blank for it to be
47
+ # calculated implicitly
48
+ partial_bban = [
49
+ opts[:bank_code],
50
+ opts[:branch_code],
51
+ opts[:account_number]
52
+ ].join
53
+
54
+ check_digit = opts[:check_digit] || CheckDigit.italian(partial_bban)
55
+
56
+ [check_digit, partial_bban].join
57
+ end
58
+
59
+ def self.assemble_sm_bban(opts)
60
+ # San Marino uses the same BBAN construction method as Italy
61
+ assemble_it_bban(opts)
62
+ end
63
+
64
+ ##################
65
+ # Helper methods #
66
+ ##################
67
+
68
+ def self.can_assemble?(local_details)
69
+ SUPPORTED_COUNTRY_CODES.include?(local_details[:country_code]) &&
70
+ valid_arguments?(local_details)
71
+ end
72
+
73
+ def self.valid_arguments?(local_details)
74
+ country_code = local_details[:country_code]
75
+
76
+ supplied = local_details.keys.select { |key| local_details[key] }
77
+ supplied.delete(:country_code)
78
+
79
+ allowed = allowed_fields(country_code)
80
+
81
+ required_fields(country_code).all? { |key| supplied.include?(key) } &&
82
+ supplied.all? { |key| allowed.include?(key) }
83
+ end
84
+
85
+ def self.required_fields(country_code)
86
+ case country_code
87
+ when 'AT', 'CY', 'DE', 'EE', 'FI', 'LT', 'LU', 'LV', 'NL', 'SI', 'SK'
88
+ %i(bank_code account_number)
89
+ when 'BE'
90
+ %i(account_number)
91
+ else
92
+ %i(bank_code branch_code account_number)
93
+ end
94
+ end
95
+
96
+ def self.allowed_fields(country_code)
97
+ # Some countries have additional optional fields
98
+ case country_code
99
+ when 'BE' then %i(bank_code account_number)
100
+ when 'CY' then %i(bank_code branch_code account_number)
101
+ when 'IT' then %i(bank_code branch_code account_number check_digit)
102
+ when 'SK' then %i(bank_code account_number account_number_prefix)
103
+ else required_fields(country_code)
104
+ end
105
+ end
106
+
107
+ def self.assemble_iban(country_code, bban)
108
+ [
109
+ country_code,
110
+ CheckDigit.iban(country_code, bban),
111
+ bban
112
+ ].join
113
+ rescue InvalidCharacterError
114
+ nil
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,66 @@
1
+ module Ibandit
2
+ module IBANSplitter
3
+ def self.split(iban)
4
+ {
5
+ country_code: country_code_from(iban),
6
+ check_digits: check_digits_from(iban),
7
+ bank_code: bank_code_from(iban),
8
+ branch_code: branch_code_from(iban),
9
+ account_number: account_number_from(iban)
10
+ }
11
+ end
12
+
13
+ ###################
14
+ # Component parts #
15
+ ###################
16
+
17
+ def self.country_code_from(iban)
18
+ return if iban.nil? || iban.empty?
19
+
20
+ iban.slice(0, 2)
21
+ end
22
+
23
+ def self.check_digits_from(iban)
24
+ return unless decomposable?(iban)
25
+
26
+ iban.slice(2, 2)
27
+ end
28
+
29
+ def self.bank_code_from(iban)
30
+ return unless decomposable?(iban)
31
+
32
+ iban.slice(
33
+ structure(iban)[:bank_code_position] - 1,
34
+ structure(iban)[:bank_code_length]
35
+ )
36
+ end
37
+
38
+ def self.branch_code_from(iban)
39
+ unless decomposable?(iban) && structure(iban)[:branch_code_length] > 0
40
+ return
41
+ end
42
+
43
+ iban.slice(
44
+ structure(iban)[:branch_code_position] - 1,
45
+ structure(iban)[:branch_code_length]
46
+ )
47
+ end
48
+
49
+ def self.account_number_from(iban)
50
+ return unless decomposable?(iban)
51
+
52
+ iban.slice(
53
+ structure(iban)[:account_number_position] - 1,
54
+ structure(iban)[:account_number_length]
55
+ )
56
+ end
57
+
58
+ def self.decomposable?(iban)
59
+ structure(iban) && iban.length == structure(iban)[:total_length]
60
+ end
61
+
62
+ def self.structure(iban)
63
+ Ibandit.structures[country_code_from(iban)]
64
+ end
65
+ end
66
+ end