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 +4 -4
- data/README.md +154 -21
- data/lib/credit_card_validations/brand_set.rb +63 -0
- data/lib/credit_card_validations/card.rb +8 -1
- data/lib/credit_card_validations/detector.rb +97 -54
- data/lib/credit_card_validations/factory.rb +63 -17
- data/lib/credit_card_validations/version.rb +1 -1
- data/lib/credit_card_validations.rb +10 -0
- metadata +8 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b16ea3c34b40baacacb68877a244e7c30710323e7949b6b626e5b327156198aa
|
|
4
|
+
data.tar.gz: 596b070c9b31cefa5aacde43cff367ac609237ccea747cd20e9e2e8c9f6a95ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
2
|
+
# Generates cards and card numbers that pass validation
|
|
3
3
|
#
|
|
4
|
-
# #
|
|
5
|
-
# CreditCardValidations::Factory.
|
|
4
|
+
# #random_number
|
|
5
|
+
# CreditCardValidations::Factory.random_number
|
|
6
6
|
# #or particular brand
|
|
7
|
-
# CreditCardValidations::Factory.
|
|
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
|
|
16
|
+
def random_number(brand = nil)
|
|
15
17
|
brand = Detector.brands.keys.sample if brand.nil?
|
|
16
|
-
|
|
18
|
+
spec = Detector.brands[brand]
|
|
19
|
+
if spec.nil?
|
|
17
20
|
raise Error.new('Unsupported brand')
|
|
18
21
|
end
|
|
19
|
-
generate(
|
|
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
|
|
72
|
+
number(rule[:prefixes].sample, rule[:length].sample)
|
|
24
73
|
end
|
|
25
74
|
|
|
26
|
-
|
|
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(
|
|
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
|
|
@@ -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:
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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:
|
|
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.
|
|
160
|
+
version: '3.3'
|
|
160
161
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
161
162
|
requirements:
|
|
162
163
|
- - ">="
|