credit_card_validations 9.0.0 → 10.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d26fc84c0dd18fbfcacaeead238fe16ecefe51486e4122e63c3dcf4836fd4ce6
4
- data.tar.gz: a1bad2adea7168439991cf98bb68a25a2d62c4372602cbe54743f23eda12c11e
3
+ metadata.gz: b16ea3c34b40baacacb68877a244e7c30710323e7949b6b626e5b327156198aa
4
+ data.tar.gz: 596b070c9b31cefa5aacde43cff367ac609237ccea747cd20e9e2e8c9f6a95ac
5
5
  SHA512:
6
- metadata.gz: 14d43e6661c30034f8f52f4bf0558eb4704843ad048dfb79943a0822f4f1d6b221d31a98bd1b73dd23cd3a6d76904fdc88b6758abfb5ea1a701da001e938b73a
7
- data.tar.gz: c41da3b3de8f45d93413a41c4fe88c8e543eea2c8e149ea8500d594ee1c40802c2554cb7f72f87e732d600b7aded5d69478f9ff4a9acebea9bbed85edc35873f
6
+ metadata.gz: 7bd5c35081bc1085c11232c7c13d14befed6edb45b7b4faca8ac27b8b0b39b5bacd61e3ed8e4ecda977c34e50b0546f2904883602c12ea35a7d82adc942d1260
7
+ data.tar.gz: 9b8df4c6df565cf26f7084683a0eb68bc40e2dfbf06b86131103f92360b681772aa938d72e7389821463789bdf90bfec95713b2aef82c5cec8a7cf4540dc7670
data/README.md CHANGED
@@ -77,7 +77,7 @@ Everything else is detected only when its plugin is explicitly required. Plugins
77
77
 
78
78
  | Name | Key | Status |
79
79
  --------------------- | ------------| ------|
80
- [Diners Club US](http://en.wikipedia.org/wiki/Diners_Club_International#MasterCard_alliance) | `:diners_us` | Merged into Discover for US routing in 2008 |
80
+ [Diners Club US](http://en.wikipedia.org/wiki/Diners_Club_International#MasterCard_alliance) | `:diners_us` | Issued on MasterCard BINs since 2004 |
81
81
  [EnRoute](https://en.wikipedia.org/wiki/EnRoute_(credit_card)) | `:en_route` | Withdrawn 1989 |
82
82
  [Laser](https://en.wikipedia.org/wiki/Laser_%28debit_card%29) | `:laser` | Withdrawn 2014 |
83
83
  [Solo](https://en.wikipedia.org/wiki/Solo_(debit_card)) | `:solo` | Withdrawn 2011 |
@@ -93,30 +93,78 @@ require 'credit_card_validations/plugins/hipercard'
93
93
  # ... whichever brands the app actually accepts
94
94
  ```
95
95
 
96
- ## Migrating from v8.x → v9.0
97
-
98
- Seven brands moved from the default brand set to opt-in plugins in v9.0. The auto-require shim keeps existing code working for one major version with a one-time deprecation warning per brand.
96
+ ## Migrating from v9.x → v10.0 (unreleased)
99
97
 
100
- | Brand | Status | Auto-loaded until |
101
- |---|---|---|
102
- | `:dankort` | Active (Denmark) | v10.0 |
103
- | `:elo` | Active (Brazil) | v10.0 |
104
- | `:hipercard` | Active (Brazil) | v10.0 |
105
- | `:mir` | Active (Russia) | v10.0 |
106
- | `:rupay` | Active (India) | v10.0 |
107
- | `:solo` | Withdrawn 2011 | v10.0 |
108
- | `:switch` | Withdrawn 2002 | v10.0 |
98
+ On `master`, ahead of the v10.0 release, the auto-require shim is gone. In v9.0 the seven brands below moved out of the
99
+ default set, and referencing one of them still worked — the plugin was loaded
100
+ for you and a deprecation warning was printed once. v10.0 removes that.
109
101
 
110
- If your code references any of these brands by symbol, add the matching `require` to your initializer to silence the warning and survive v10:
102
+ If your code names any of these brands, add the matching `require`:
111
103
 
112
104
  ```ruby
113
105
  # config/initializers/credit_card_validations.rb
114
106
  require 'credit_card_validations/plugins/mir'
115
107
  require 'credit_card_validations/plugins/elo'
116
- # ...
108
+ # ... whichever brands the app actually accepts
109
+ ```
110
+
111
+ Without it the brand is simply unknown. **Nothing warns** — the v9 deprecation
112
+ warning is gone too — and the read-only paths stay quiet, so a missing
113
+ `require` will not announce itself where you are most likely to notice it:
114
+
115
+ ```ruby
116
+ Detector.new('2202 1234 1234 1234').brand #=> nil
117
+ Detector.new('2202 1234 1234 1234').valid?(:mir) #=> false
118
+ Detector.new('2202 1234 1234 1234').brand_name #=> nil
119
+ Detector.new('2202 1234 1234 1234').possible_brands #=> []
120
+ Detector.brand_name(:mir) #=> nil
121
+ Detector.valid_cvv?('123', :mir) #=> false
122
+ Detector.has_luhn_check_rule?(:mir) #=> true (nothing opted out)
123
+ '2202 1234 1234 1234'.credit_card_brand #=> nil
124
+ '2202 1234 1234 1234'.credit_card_brand_name #=> nil
125
+ '2202 1234 1234 1234'.valid_credit_card_brand?(:mir) #=> false
117
126
  ```
118
127
 
119
- When v10 lands, the auto-load disappears. Code that names these brands without a matching `require` will see them as unknown — `Detector#brand` returns `nil`, predicate methods (`mir?`, `elo?`, …) are not defined, and `valid?(:mir)` returns false.
128
+ Four paths do raise, because there is nothing sensible to return:
129
+
130
+ ```ruby
131
+ Detector.new(pan).mir? # NoMethodError — the predicate is never defined
132
+ CreditCardValidations::Factory.random(:mir) # CreditCardValidations::Error: Unsupported brand
133
+ Detector.add_rule(:mir, 16, ['2202']) # CreditCardValidations::Error: brand mir is undefined
134
+ Detector::LEGACY_PLUGIN_BRANDS # NameError — see below
135
+ ```
136
+
137
+ ActiveModel validators never raise on a missing plugin; the record is just
138
+ invalid. Which means a PAN field restricted to a plugin brand **rejects every
139
+ number you give it**, including a valid card of that brand:
140
+
141
+ ```ruby
142
+ validates :number, credit_card_number: { brands: [:mir] } # nothing passes
143
+ validates :cvv, credit_card_cvv: { brand: :mir } # nothing passes
144
+ ```
145
+
146
+ `Detector::LEGACY_PLUGIN_BRANDS` was a public constant in v9 and is gone. An
147
+ app that read it to build its own require list now gets `NameError`; replace it
148
+ with a literal list of the brands you accept.
149
+
150
+ So check the brand list against your initializer rather than waiting for an
151
+ error — in the paths an app exercises most, there will not be one.
152
+
153
+ | Brand | Status |
154
+ |---|---|
155
+ | `:dankort` | Active (Denmark) |
156
+ | `:elo` | Active (Brazil) |
157
+ | `:hipercard` | Active (Brazil) |
158
+ | `:mir` | Active (Russia) |
159
+ | `:rupay` | Active (India) |
160
+ | `:solo` | Withdrawn 2011 |
161
+ | `:switch` | Withdrawn 2002 |
162
+
163
+ ## Migrating from v8.x → v9.0
164
+
165
+ Seven brands moved from the default brand set to opt-in plugins in v9.0. Up to
166
+ v9.x an auto-require shim kept existing code working with a one-time
167
+ deprecation warning per brand; it is removed on master for v10.0, see above.
120
168
 
121
169
  ### Other breaking changes in v9.0
122
170
 
@@ -234,6 +282,38 @@ detector.possible_brands # => [:visa] (during live input)
234
282
  detector.valid_cvv?('123') # => true
235
283
  ```
236
284
 
285
+ ### Detecting against a fixed brand list
286
+
287
+ `#valid?` and `#brand` take a brand list per call. `with_brands` holds that
288
+ list instead, and applies it to the methods that take no brand list of their
289
+ own — `possible_brands`, `brand_name`, `formatted`, `valid_cvv?`:
290
+
291
+ ```ruby
292
+ set = CreditCardValidations.with_brands(:visa, :mastercard)
293
+
294
+ set.brands # => [:visa, :mastercard]
295
+ set.detect('4111111111111111').brand # => :visa
296
+ set.detect('348051773827666').brand # => nil
297
+ set.detect('348051773827666').valid? # => false
298
+ set.detect('34').possible_brands # => []
299
+ ```
300
+
301
+ `detect` returns a `Detector`, so the whole instance API works on it.
302
+
303
+ Keys are brand keys, as symbols or strings, in any case. An unknown key raises
304
+ `CreditCardValidations::Error` rather than being dropped, since a dropped brand
305
+ would mean a valid card is quietly rejected; an empty list raises for the same
306
+ reason.
307
+
308
+ A set narrows which brands are considered, not what they are — definitions
309
+ are read from `Detector.brands` at every lookup, so `add_brand`, `add_rule`,
310
+ `delete_brand` and a replaced brand source all apply to an existing set.
311
+
312
+ Which predicate methods exist stays global: `add_brand` defines them on
313
+ `Detector`, so a detector from a visa-only set still responds to
314
+ `mastercard?` (with `false`), and `delete_brand(:mastercard)` undefines it
315
+ everywhere. Use `valid?(:mastercard)` where that matters.
316
+
237
317
  ### Adding a custom brand at runtime
238
318
 
239
319
  ```ruby
@@ -241,6 +321,31 @@ CreditCardValidations.add_brand(:voyager, { length: 15, prefixes: '86' })
241
321
  CreditCardValidations::Detector.new('869926275400212').voyager? # => true
242
322
  ```
243
323
 
324
+ A prefix is the literal digits it spells — list a range out (`%w[51 52 53]`),
325
+ rather than writing `5[1-3]`, which would only ever match that text.
326
+
327
+ `add_brand` adds; it does not redefine. A brand that is already registered
328
+ raises `CreditCardValidations::Error`, because the call replaces the whole
329
+ entry — the rules, and with them the brand's name, CVV size, segments and
330
+ `skip_luhn` opt-out — so a call that only meant to change the rules used to
331
+ leave a brand that no longer detected its own cards.
332
+
333
+ To widen a registered brand, add a rule:
334
+
335
+ ```ruby
336
+ CreditCardValidations::Detector.add_rule(:visa, 16, ['9'])
337
+ ```
338
+
339
+ Keys are converted to symbols, so `add_brand`, `add_rule` and `delete_brand`
340
+ all take `:visa` or `"visa"` alike.
341
+
342
+ To replace one, remove it first:
343
+
344
+ ```ruby
345
+ CreditCardValidations::Detector.delete_brand(:visa)
346
+ CreditCardValidations.add_brand(:visa, { length: 16, prefixes: '9' })
347
+ ```
348
+
244
349
  ### Removing a brand at runtime
245
350
 
246
351
  ```ruby
@@ -255,15 +360,43 @@ CreditCardValidations::Detector.new(number).valid_luhn?
255
360
  CreditCardValidations::Luhn.valid?(number)
256
361
  ```
257
362
 
258
- ### Generating Luhn-valid test numbers
363
+ ### Generating test numbers and test cards
364
+
365
+ ```ruby
366
+ CreditCardValidations::Factory.random_number(:visa) #=> "4012888888881881"
367
+
368
+ card = CreditCardValidations::Factory.random_card(:amex)
369
+ card.valid? #=> true
370
+ card.brand #=> :amex
371
+ card.verification_value #=> "8812" (4 for Amex, 3 for the other bundled brands)
372
+ ```
373
+
374
+ `random_number` returns a Luhn-valid PAN the detector accepts for the brand,
375
+ including for brands that declare `skip_luhn` — that flag means detection
376
+ tolerates a missing check digit, not that real cards lack one, and a computed
377
+ digit passes either way. `Factory.random` is kept as an alias of
378
+ `random_number`.
379
+
380
+ **Breaking:** the low-level `Factory.number(prefix, length, skip_luhn)` lost
381
+ its third argument and is now `number(prefix, length)`. The argument only
382
+ selected the behaviour described above, so there is nothing for it to mean.
383
+
384
+ `random_card` returns a [`Card`](#creditcardvalidationscard) with a number, an
385
+ expiration 1-60 months out and a verification value. The number comes from the
386
+ requested brand's rules; the CVV is sized from the brand the PAN **detects**
387
+ as, since that is the one `Card` validates against. Where ranges overlap those
388
+ are not the same brand, and `card.brand` reports the detected one.
389
+
390
+ Called with no brand, `random_card` draws only among brands that declare a
391
+ `:code`:
259
392
 
260
393
  ```ruby
261
- CreditCardValidations::Factory.random(:amex)
262
- # => "348051773827666"
263
- CreditCardValidations::Factory.random(:maestro)
264
- # => "6010430241237266856"
394
+ CreditCardValidations::Factory.random_card.valid? #=> true
265
395
  ```
266
396
 
397
+ `random_card` needs the brand to declare a `:code` size, and raises
398
+ `CreditCardValidations::Error` otherwise.
399
+
267
400
  ## Configuration
268
401
 
269
402
  To override the default brand source, copy [the bundled `brands.yaml`](https://github.com/didww/credit_card_validations/blob/master/lib/data/brands.yaml), edit it, and point the gem at it in a Rails initializer:
@@ -0,0 +1,63 @@
1
+ module CreditCardValidations
2
+ # A fixed list of brands to detect against, so the list is named once
3
+ # instead of being repeated at every call site.
4
+ #
5
+ # set = CreditCardValidations.with_brands(:visa, :mastercard)
6
+ # set.detect(pan).brand # same as Detector.new(pan).brand(:visa, :mastercard)
7
+ # set.detect(pan).possible_brands # ...but this one has no per-call form
8
+ #
9
+ # Build one with CreditCardValidations.with_brands.
10
+ #
11
+ # A set narrows *which* brands are considered. It does not copy *what* they
12
+ # are: the definitions are read from Detector.brands on every lookup, so a
13
+ # set never disagrees with the rest of the process about what a Visa number
14
+ # looks like. Two objects giving different answers for one card would be a
15
+ # worse failure than the stale global registry a snapshot would protect
16
+ # against.
17
+ class BrandSet
18
+
19
+ def initialize(keys)
20
+ raise Error, 'with_brands needs at least one brand' if keys.empty?
21
+
22
+ # Brand keys only, in any case: a list of brands usually arrives as
23
+ # strings from a config file or an environment variable.
24
+ @brands = keys.map { |key| key.to_s.downcase.to_sym }.freeze
25
+ missing = @brands - Detector.brands.keys
26
+ unless missing.empty?
27
+ raise Error, "unknown brand(s) #{missing.map(&:inspect).join(', ')}; " \
28
+ "registered: #{Detector.brands.keys.map(&:inspect).join(', ')}" \
29
+ ' (a plugin brand needs its plugin required first)'
30
+ end
31
+
32
+ # A Detector subclass whose registry is the global one, narrowed to this
33
+ # set. class_attribute gives every subclass its own `brands`, and every
34
+ # lookup in Detector -- the class methods and the instance ones alike --
35
+ # goes through it, so the whole API scopes with nothing added to
36
+ # Detector itself. The reader is a method rather than a stored hash so
37
+ # that it stays live.
38
+ scoped_keys = @brands
39
+ @detector_class = Class.new(Detector)
40
+ @detector_class.define_singleton_method(:brands) do
41
+ # Registry order, not the order the set was built with: Hash#slice
42
+ # answers in argument order, Array#& in the receiver's.
43
+ registry = Detector.brands
44
+ registry.slice(*(registry.keys & scoped_keys))
45
+ end
46
+ end
47
+
48
+ # Keys of the brands in this set, in the order they were requested.
49
+ # Frozen: this is the set's own list, not a copy to edit.
50
+ attr_reader :brands
51
+
52
+ # A Detector that sees only this set's brands. It is a Detector, so the
53
+ # whole instance API works on it.
54
+ def detect(number)
55
+ detector_class.new(number)
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :detector_class
61
+
62
+ end
63
+ end
@@ -23,7 +23,14 @@ module CreditCardValidations
23
23
  class Card
24
24
  include ActiveModel::Model
25
25
 
26
- attr_accessor :number, :month, :year, :verification_value, :name
26
+ attr_reader :number
27
+ attr_accessor :month, :year, :verification_value, :name
28
+
29
+ def number=(value)
30
+ @number = value
31
+ @detector = nil
32
+ value
33
+ end
27
34
 
28
35
  validates :number, credit_card_number: true
29
36
  validates :verification_value, credit_card_cvv: { brand_from: :number }
@@ -9,13 +9,6 @@ module CreditCardValidations
9
9
  class_attribute :brands
10
10
  self.brands = {}
11
11
 
12
- # Brands that were part of the default set up to v8.x and moved to
13
- # opt-in plugins in v9.0. The shim below auto-loads the plugin on first
14
- # reference and emits a one-time deprecation warning. To be removed in
15
- # v10.0 — users should add explicit `require` statements by then.
16
- LEGACY_PLUGIN_BRANDS = %i[mir rupay elo dankort hipercard solo switch].freeze
17
- @@legacy_autoloaded = {}
18
-
19
12
  attr_reader :number
20
13
 
21
14
  def initialize(number)
@@ -32,20 +25,32 @@ module CreditCardValidations
32
25
  valid_number?(*keys)
33
26
  end
34
27
 
28
+ # The brand with the longest matched prefix, or nil if none matches.
29
+ #
30
+ # Among equal matched lengths the brand that was registered first wins --
31
+ # the comparison below is strictly greater, so a later brand never
32
+ # displaces an earlier one. That order is not arbitrary: the default
33
+ # brands are always in the registry before any plugin, since a plugin
34
+ # cannot be required before the gem itself, and add_brand appends after
35
+ # both. So a plugin never takes a PAN away from a default brand on a tie,
36
+ # and your own brand never takes one away from a shipped one.
35
37
  def valid_number?(*keys)
36
38
  selected_brands = keys.blank? ? self.brands : resolve_keys(*keys)
37
- if selected_brands.any?
38
- matched_brands = []
39
- selected_brands.each do |key, brand|
40
- match_data = matches_brand?(brand)
41
- matched_brands << {brand: key, matched_prefix_length: match_data.to_s.length} if match_data
42
- end
43
-
44
- if matched_brands.present?
45
- return matched_brands.sort{|a, b| a[:matched_prefix_length] <=> b[:matched_prefix_length]}.last[:brand]
46
- end
39
+ best_brand = nil
40
+ # Below zero, not zero: a brand registered with an empty prefix matches
41
+ # with length zero, and that still has to beat "nothing matched".
42
+ best_length = -1
43
+
44
+ selected_brands.each do |key, brand|
45
+ match_data = matches_brand?(brand)
46
+ next unless match_data
47
+ next unless match_data.to_s.length > best_length
48
+
49
+ best_brand = key
50
+ best_length = match_data.to_s.length
47
51
  end
48
- nil
52
+
53
+ best_brand
49
54
  end
50
55
 
51
56
  #check if luhn valid
@@ -121,50 +126,51 @@ module CreditCardValidations
121
126
  end
122
127
  el.downcase
123
128
  end
124
- brand_keys.each { |k| autoload_legacy_plugin(k) }
125
- self.brands.slice(*brand_keys)
126
- end
127
-
128
- def autoload_legacy_plugin(key)
129
- return unless LEGACY_PLUGIN_BRANDS.include?(key)
130
- return if self.class.brands.key?(key)
131
- return if @@legacy_autoloaded[key]
132
- @@legacy_autoloaded[key] = true
133
-
134
- Warning.warn(
135
- "[credit_card_validations] :#{key} was moved to a plugin in v9.0. " \
136
- "Auto-loading 'credit_card_validations/plugins/#{key}' for backward " \
137
- "compatibility. Add `require 'credit_card_validations/plugins/#{key}'` " \
138
- "to your initializer to silence; auto-load is removed in v10.\n"
139
- )
140
- load "credit_card_validations/plugins/#{key}.rb"
129
+ # Hash#slice returns its keys in the order of its arguments, which would
130
+ # hand valid_number? the caller's order and let that decide a tie. Array#&
131
+ # answers in the receiver's order, so intersecting the registry's own keys
132
+ # first puts them back in registry order -- and keeps the lookup O(keys)
133
+ # rather than scanning the whole registry.
134
+ registry = self.brands
135
+ registry.slice(*(registry.keys & brand_keys))
141
136
  end
142
137
 
138
+ # The longest match among the brand's rules, or false. add_brand takes a
139
+ # list of rules and more than one can accept the same length, so returning
140
+ # the first match let an earlier short prefix hide a later long one -- and
141
+ # the brand then lost a comparison it should have won.
143
142
  def matches_brand?(brand)
144
- rules = brand.fetch(:rules)
145
143
  options = brand.fetch(:options, {})
144
+ return false unless options[:skip_luhn] || valid_luhn?
146
145
 
147
- rules.each do |rule|
148
- if (options[:skip_luhn] || valid_luhn?) &&
149
- rule[:length].include?(number.length) &&
150
- match_data = number.match(rule[:regexp])
151
- return match_data
152
- end
146
+ longest = false
147
+ brand.fetch(:rules).each do |rule|
148
+ next unless rule[:length].include?(number.length)
149
+ next unless (match_data = number.match(rule[:regexp]))
150
+ next if longest && match_data.to_s.length <= longest.to_s.length
151
+
152
+ longest = match_data
153
153
  end
154
- false
154
+ longest
155
155
  end
156
156
 
157
157
  class << self
158
158
 
159
+ # :skip_luhn is an opt-out, so anything without one -- including a brand
160
+ # the registry does not know -- is Luhn-checked.
159
161
  def has_luhn_check_rule?(key)
160
- !brands[key].fetch(:options, {}).fetch(:skip_luhn, false)
162
+ !brands.dig(key, :options, :skip_luhn)
161
163
  end
162
164
 
163
165
  # Class-level CVV check: validates a code against an explicit brand,
164
166
  # without needing a Detector instance. Useful when only the brand is
165
167
  # known (form input bound to a brand select, separate CVV field, etc.).
168
+ # An unknown brand -- including a plugin brand whose file was never
169
+ # required -- is false, not an error. A brand that *is* registered but
170
+ # declares no :code raises, since that is registry data the caller owns.
166
171
  def valid_cvv?(code, brand)
167
172
  return false if code.nil? || brand.nil? || !code.to_s.match?(/\A\d+\z/)
173
+ return false unless brands.key?(brand)
168
174
  spec = brands.dig(brand, :options, :code)
169
175
  raise Error, "brand #{brand.inspect} has no :code option" if spec.nil?
170
176
  code.to_s.length == spec[:size]
@@ -176,17 +182,34 @@ module CreditCardValidations
176
182
  # CreditCardValidations.add_brand(:en_route, {length: 15, prefixes: ['2014', '2149']}, {skip_luhn: true}) #skip luhn
177
183
  #
178
184
  def add_brand(key, rules, options = {})
179
- # Mark legacy plugin brands as handled so the v9 auto-require shim
180
- # never re-loads them after the user takes any explicit action
181
- # (require, add_brand, or a later delete_brand).
182
- @@legacy_autoloaded[key] = true if LEGACY_PLUGIN_BRANDS.include?(key)
185
+ # The registry is keyed by symbol, and delete_brand converts. Without
186
+ # the same conversion here a string key passed the check below and
187
+ # wrote a second entry that resolve_keys could not detect with and
188
+ # delete_brand could not remove.
189
+ key = key.to_sym
183
190
 
184
- brands[key] = {rules: [], options: options || {}}
191
+ # Adds a brand; it does not redefine one. The write below replaces the
192
+ # whole entry, so redefining dropped everything the call did not
193
+ # mention -- the brand's name, CVV size, segments and skip_luhn
194
+ # opt-out -- and the brand stopped detecting its own cards, silently.
195
+ # Use add_rule to widen a registered brand, or delete_brand first to
196
+ # replace it on purpose.
197
+ if brands.key?(key)
198
+ raise Error.new("brand #{key.inspect} is already registered; " \
199
+ 'use add_rule to widen it, or delete_brand first to replace it')
200
+ end
185
201
 
202
+ entry = {rules: [], options: options || {}}
186
203
  Array.wrap(rules).each do |rule|
187
- add_rule(key, rule[:length], rule[:prefixes])
204
+ length, prefixes = Array(rule[:length]), Array(rule[:prefixes])
205
+ entry[:rules] << {length: length, regexp: compile_regexp(prefixes), prefixes: prefixes}
188
206
  end
189
207
 
208
+ # One reference assignment, after the entry is complete: a reader
209
+ # iterating the old hash never sees a half-built brand and never
210
+ # trips Hash's "can't add a new key during iteration" guard.
211
+ self.brands = brands.merge(key => entry)
212
+
190
213
  define_brand_method(key)
191
214
 
192
215
  end
@@ -210,16 +233,19 @@ module CreditCardValidations
210
233
  def delete_brand(key)
211
234
  key = key.to_sym
212
235
  undef_brand_method(key)
213
- brands.reject! { |k, _| k == key }
236
+ self.brands = brands.except(key)
214
237
  end
215
238
 
216
239
  #create rule for detecting brand
217
240
  def add_rule(key, length, prefixes)
241
+ key = key.to_sym
218
242
  unless brands.has_key?(key)
219
243
  raise Error.new("brand #{key} is undefined, please use #add_brand method")
220
244
  end
221
245
  length, prefixes = Array(length), Array(prefixes)
222
- brands[key][:rules] << {length: length, regexp: compile_regexp(prefixes), prefixes: prefixes}
246
+ rule = {length: length, regexp: compile_regexp(prefixes), prefixes: prefixes}
247
+ entry = brands[key].merge(rules: brands[key][:rules] + [rule])
248
+ self.brands = brands.merge(key => entry)
223
249
  end
224
250
 
225
251
  protected
@@ -235,9 +261,26 @@ module CreditCardValidations
235
261
  undef_method "#{key}?".to_sym if method_defined? "#{key}?".to_sym
236
262
  end
237
263
 
238
- #create regexp by array of prefixes
264
+ # Regexp by array of prefixes, longest first.
265
+ #
266
+ # Alternation is first-match, left-to-right, not longest-match: in
267
+ # ^((677)|(6771)) a 6771... PAN matches "677", so the brand reports a
268
+ # shorter prefix than the one it declares and loses comparisons it
269
+ # should win. Ordering by length makes the match the longest prefix the
270
+ # brand actually declares, which is what valid_number? compares.
271
+ #
272
+ # The index keeps the order total, so the same prefix list always
273
+ # compiles to the same regexp -- sort_by alone is not stable.
239
274
  def compile_regexp(prefixes)
240
- Regexp.new("^((#{prefixes.join(")|(")}))")
275
+ ordered = prefixes.each_with_index.sort_by { |prefix, i| [-prefix.to_s.length, i] }.map(&:first)
276
+ # Escaped, so a prefix is the literal digits it looks like. Without it
277
+ # a prefix could carry regexp syntax, its matched length would stop
278
+ # equalling its own length, and the ordering above -- which exists to
279
+ # report the longest match -- would rank by the wrong number.
280
+ # possible_brands compares prefixes as plain strings, so this also
281
+ # brings the two closer together.
282
+ pattern = ordered.map { |prefix| Regexp.escape(prefix.to_s) }.join(')|(')
283
+ Regexp.new("^((#{pattern}))")
241
284
  end
242
285
 
243
286
  end
@@ -1,40 +1,86 @@
1
1
  # == CreditCardValidations Factory
2
- # Generates card number that passes validation
2
+ # Generates cards and card numbers that pass validation
3
3
  #
4
- # #random
5
- # CreditCardValidations::Factory.random
4
+ # #random_number
5
+ # CreditCardValidations::Factory.random_number
6
6
  # #or particular brand
7
- # CreditCardValidations::Factory.random(:maestro) # "6010430241237266856"
7
+ # CreditCardValidations::Factory.random_number(:maestro) # "6010430241237266856"
8
8
  #
9
+ # #random_card
10
+ # CreditCardValidations::Factory.random_card(:amex) # #<CreditCardValidations::Card>
9
11
  #
10
12
  #
11
13
  module CreditCardValidations
12
14
  class Factory
13
15
  class << self
14
- def random(brand = nil)
16
+ def random_number(brand = nil)
15
17
  brand = Detector.brands.keys.sample if brand.nil?
16
- if Detector.brands[brand].nil?
18
+ spec = Detector.brands[brand]
19
+ if spec.nil?
17
20
  raise Error.new('Unsupported brand')
18
21
  end
19
- generate(Detector.brands[brand][:rules].sample)
22
+ generate(spec[:rules].sample)
23
+ end
24
+
25
+ # Released as the only generator up to v9; keep it working.
26
+ alias_method :random, :random_number
27
+
28
+ # Whole test card, not just a PAN: valid number, expiration 1-60 months out
29
+ # and a verification value of the size the brand declares.
30
+ #
31
+ # card = CreditCardValidations::Factory.random_card(:amex)
32
+ # card.valid? # => true
33
+ # card.verification_value # => "8812"
34
+ #
35
+ # Needs the brand to declare a :code size and raises
36
+ # CreditCardValidations::Error otherwise.
37
+ def random_card(brand = nil)
38
+ if brand.nil?
39
+ brand = Detector.brands.keys.select { |key| cvv_size(key) }.sample
40
+ raise Error.new('no registered brand declares a :code size') if brand.nil?
41
+ end
42
+
43
+ # Saying a brand has no :code option would imply it is registered.
44
+ raise Error.new('Unsupported brand') if Detector.brands[brand].nil?
45
+ raise Error.new("brand #{brand.inspect} has no :code option") if cvv_size(brand).nil?
46
+
47
+ # Card checks the CVV against the brand *detected* from the PAN, not the
48
+ # one we asked for, so the size has to come from there. Which brand wins
49
+ # an overlap is Detector's business, not the factory's.
50
+ number = random_number(brand)
51
+ detected = Detector.new(number).brand
52
+ size = cvv_size(detected)
53
+ if size.nil?
54
+ raise Error.new("the number generated for #{brand.inspect} detects as " \
55
+ "#{detected.inspect}, which has no :code option")
56
+ end
57
+
58
+ # One month out at least: a card is live through the last day of its
59
+ # stated month, so zero would mean "about to expire".
60
+ expires_on = Date.today.next_month(rand(1..60))
61
+ Card.new(number: number,
62
+ month: expires_on.month,
63
+ year: expires_on.year,
64
+ verification_value: Array.new(size) { rand(10) }.join)
65
+ end
66
+
67
+ def cvv_size(brand)
68
+ Detector.brands.dig(brand, :options, :code, :size)
20
69
  end
21
70
 
22
71
  def generate(rule)
23
- number(rule[:prefixes].sample, rule[:length].sample, rule.fetch(:options, {})[:skip_luhn])
72
+ number(rule[:prefixes].sample, rule[:length].sample)
24
73
  end
25
74
 
26
- def number(prefix, length, skip_luhn = false)
75
+ # Computes the check digit for skip_luhn brands too. That flag says
76
+ # detection *tolerates* a missing check digit, not that real cards lack
77
+ # one -- all 55 UnionPay PANs in the fixtures carry a valid one.
78
+ def number(prefix, length)
27
79
  number = prefix.dup
28
80
  1.upto(length - (prefix.length + 1)) do
29
- number << "#{rand(9)}"
30
- end
31
- #if skip luhn
32
- if skip_luhn
33
- number += "#{rand(9)}"
34
- else
35
- number += last_digit(number).to_s
81
+ number << "#{rand(10)}"
36
82
  end
37
- number
83
+ number + last_digit(number).to_s
38
84
  end
39
85
 
40
86
  #extracted from darkcoding-credit-card
@@ -1,3 +1,3 @@
1
1
  module CreditCardValidations
2
- VERSION = '9.0.0'
2
+ VERSION = '10.0.0'
3
3
  end
@@ -18,6 +18,7 @@ module CreditCardValidations
18
18
  autoload :Mmi, 'credit_card_validations/mmi'
19
19
  autoload :Expiration, 'credit_card_validations/expiration'
20
20
  autoload :Card, 'credit_card_validations/card'
21
+ autoload :BrandSet, 'credit_card_validations/brand_set'
21
22
 
22
23
  attr_accessor :configuration
23
24
 
@@ -38,6 +39,15 @@ module CreditCardValidations
38
39
  Detector.add_brand(key, rules, options)
39
40
  end
40
41
 
42
+ # Detect against a fixed list of brands, named once instead of repeated at
43
+ # every call site. See BrandSet.
44
+ #
45
+ # set = CreditCardValidations.with_brands(:visa, :mastercard)
46
+ # set.detect('4111 1111 1111 1111').brand #=> :visa
47
+ def self.with_brands(*keys)
48
+ BrandSet.new(keys.flatten)
49
+ end
50
+
41
51
  def self.source
42
52
  configuration.source || File.join(File.join(File.dirname(__FILE__)), 'data', 'brands.yaml')
43
53
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: credit_card_validations
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.0.0
4
+ version: 10.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.1'
18
+ version: '8.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.1'
25
+ version: '8.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '7.1'
32
+ version: '8.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '7.1'
39
+ version: '8.0'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: minitest
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -106,6 +106,7 @@ files:
106
106
  - lib/active_model/credit_card_expiration_validator.rb
107
107
  - lib/active_model/credit_card_number_validator.rb
108
108
  - lib/credit_card_validations.rb
109
+ - lib/credit_card_validations/brand_set.rb
109
110
  - lib/credit_card_validations/card.rb
110
111
  - lib/credit_card_validations/configuration.rb
111
112
  - lib/credit_card_validations/detector.rb
@@ -142,7 +143,7 @@ files:
142
143
  - lib/credit_card_validations/string.rb
143
144
  - lib/credit_card_validations/version.rb
144
145
  - lib/data/brands.yaml
145
- homepage: http://didww.github.io/credit_card_validations/
146
+ homepage: https://github.com/didww/credit_card_validations
146
147
  licenses:
147
148
  - MIT
148
149
  metadata:
@@ -156,7 +157,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
156
157
  requirements:
157
158
  - - ">="
158
159
  - !ruby/object:Gem::Version
159
- version: '3.1'
160
+ version: '3.3'
160
161
  required_rubygems_version: !ruby/object:Gem::Requirement
161
162
  requirements:
162
163
  - - ">="