credit_card_validations 8.0.0 → 9.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +190 -77
  3. data/lib/active_model/credit_card_cvv_validator.rb +35 -0
  4. data/lib/active_model/credit_card_expiration_validator.rb +26 -0
  5. data/lib/credit_card_validations/card.rb +61 -0
  6. data/lib/credit_card_validations/detector.rb +92 -1
  7. data/lib/credit_card_validations/expiration.rb +63 -0
  8. data/lib/credit_card_validations/luhn.rb +12 -7
  9. data/lib/credit_card_validations/plugins/cabal.rb +5 -0
  10. data/lib/credit_card_validations/plugins/carnet.rb +30 -0
  11. data/lib/credit_card_validations/plugins/cartes_bancaires.rb +21 -0
  12. data/lib/credit_card_validations/plugins/dankort.rb +7 -0
  13. data/lib/credit_card_validations/plugins/dinacard.rb +5 -0
  14. data/lib/credit_card_validations/plugins/elo.rb +9 -0
  15. data/lib/credit_card_validations/plugins/girocard.rb +8 -0
  16. data/lib/credit_card_validations/plugins/hiper.rb +8 -0
  17. data/lib/credit_card_validations/plugins/hipercard.rb +7 -0
  18. data/lib/credit_card_validations/plugins/humocard.rb +5 -0
  19. data/lib/credit_card_validations/plugins/mada.rb +17 -0
  20. data/lib/credit_card_validations/plugins/mir.rb +7 -0
  21. data/lib/credit_card_validations/plugins/naranja.rb +6 -0
  22. data/lib/credit_card_validations/plugins/rupay.rb +8 -0
  23. data/lib/credit_card_validations/plugins/solo.rb +9 -0
  24. data/lib/credit_card_validations/plugins/switch.rb +9 -0
  25. data/lib/credit_card_validations/plugins/troy.rb +5 -0
  26. data/lib/credit_card_validations/plugins/uatp.rb +5 -0
  27. data/lib/credit_card_validations/plugins/uzcard.rb +5 -0
  28. data/lib/credit_card_validations/plugins/verve.rb +13 -0
  29. data/lib/credit_card_validations/plugins/voyager.rb +5 -0
  30. data/lib/credit_card_validations/plugins/vpay.rb +8 -0
  31. data/lib/credit_card_validations/version.rb +1 -1
  32. data/lib/credit_card_validations.rb +5 -1
  33. data/lib/data/brands.yaml +38 -237
  34. metadata +30 -5
  35. data/Changelog.md +0 -156
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0ae5fba9ad995519d40f9d8468530f6e8bdb3e05914faa808ea4c61e21d6273
4
- data.tar.gz: dd7573a8bc538fefd33091bbd93e32a7f9d038dc94ac84215ac128d830569855
3
+ metadata.gz: d26fc84c0dd18fbfcacaeead238fe16ecefe51486e4122e63c3dcf4836fd4ce6
4
+ data.tar.gz: a1bad2adea7168439991cf98bb68a25a2d62c4372602cbe54743f23eda12c11e
5
5
  SHA512:
6
- metadata.gz: 0d014063aa741929ce377320512b2f13b041d824087328aea8bbdbb0c7aa70ebcaab0e1dfbbaba16273defe63074cd26a7b96c044045b99651701bcfd5272dfc
7
- data.tar.gz: 367f8adf1f14dd8b589711ae6d18043c2a355be1e61796b693509b635e49b6f04ba02c237fa29526cfb3e14b42a68e6adffbe4c00fac00278d3d548d5211ac65
6
+ metadata.gz: 14d43e6661c30034f8f52f4bf0558eb4704843ad048dfb79943a0822f4f1d6b221d31a98bd1b73dd23cd3a6d76904fdc88b6758abfb5ea1a701da001e938b73a
7
+ data.tar.gz: c41da3b3de8f45d93413a41c4fe88c8e543eea2c8e149ea8500d594ee1c40802c2554cb7f72f87e732d600b7aded5d69478f9ff4a9acebea9bbed85edc35873f
data/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  [![Gem Version](http://img.shields.io/gem/v/credit_card_validations.svg)](https://rubygems.org/gems/credit_card_validations)
4
4
  [![License](http://img.shields.io/:license-mit-blue.svg)](http://didww.mit-license.org)
5
+ ![Coverage](https://didww.github.io/credit_card_validations/badge.svg)
5
6
 
6
7
 
7
- Gem adds validator to check whether or not a given number actually falls within the ranges of possible numbers prior to performing such verification, and, as such, CreditCardValidations simply verifies that the credit card number provided is well-formed.
8
+ Gem adds a validator to check whether a given number actually falls within the ranges of possible numbers prior to performing verification `CreditCardValidations` verifies that the credit card number provided is well-formed.
8
9
 
9
- More info about card BIN numbers http://en.wikipedia.org/wiki/Bank_card_number
10
+ More info about card BIN numbers: http://en.wikipedia.org/wiki/Bank_card_number
10
11
 
11
12
  ## Installation
12
13
 
@@ -28,135 +29,250 @@ Or install it yourself as:
28
29
  $ gem install credit_card_validations
29
30
  ```
30
31
 
31
- ## Usage
32
+ ## Default brands
33
+
34
+ These brands are detected out of the box. They are the international majors that most acquirers, gateways, and payment forms care about:
35
+
36
+ | Name | Key |
37
+ --------------------- | ------------|
38
+ [American Express](http://en.wikipedia.org/wiki/American_Express) | `:amex`
39
+ [China UnionPay](http://en.wikipedia.org/wiki/China_UnionPay) | `:unionpay`
40
+ [Diners Club](http://en.wikipedia.org/wiki/Diners_Club_International) | `:diners`
41
+ [Discover](http://en.wikipedia.org/wiki/Discover_Card) | `:discover`
42
+ [JCB](http://en.wikipedia.org/wiki/Japan_Credit_Bureau) | `:jcb`
43
+ [Maestro](http://en.wikipedia.org/wiki/Maestro_%28debit_card%29) | `:maestro`
44
+ [MasterCard](http://en.wikipedia.org/wiki/MasterCard) | `:mastercard`
45
+ [Visa](http://en.wikipedia.org/wiki/Visa_Inc.) | `:visa`
46
+
47
+ ## Opt-in plugins
48
+
49
+ Everything else is detected only when its plugin is explicitly required. Plugins add no startup cost, no API surface, and no chance of misdetection to apps that don't accept the brand.
50
+
51
+ ### Active regional and specialty networks
52
+
53
+ | Name | Key |
54
+ --------------------- | ------------|
55
+ [Cabal](https://en.wikipedia.org/wiki/Cabal_(debit_card)) | `:cabal`
56
+ [Carnet](https://en.wikipedia.org/wiki/Carnet_(card)) | `:carnet`
57
+ [Cartes Bancaires](https://en.wikipedia.org/wiki/Cartes_Bancaires) | `:cartes_bancaires`
58
+ [Dankort](http://en.wikipedia.org/wiki/Dankort) | `:dankort`
59
+ [DinaCard](https://en.wikipedia.org/wiki/DinaCard) | `:dinacard`
60
+ [Elo](https://pt.wikipedia.org/wiki/Elo_Participa%C3%A7%C3%B5es_S/A) | `:elo`
61
+ [Girocard](https://en.wikipedia.org/wiki/Girocard) | `:girocard`
62
+ [Hiper](https://en.wikipedia.org/wiki/Itau_Unibanco) | `:hiper`
63
+ [Hipercard](http://pt.wikipedia.org/wiki/Hipercard) | `:hipercard`
64
+ [Humo](https://en.wikipedia.org/wiki/Humo_(payment_system)) | `:humocard`
65
+ [Mada](https://en.wikipedia.org/wiki/Mada_(payment_system)) | `:mada`
66
+ [MIR](http://www.nspk.ru/en/cards-mir/) | `:mir`
67
+ [Naranja](https://en.wikipedia.org/wiki/Tarjeta_Naranja) | `:naranja`
68
+ [RuPay](http://en.wikipedia.org/wiki/RuPay) | `:rupay`
69
+ [Troy](https://en.wikipedia.org/wiki/Troy_(payment_system)) | `:troy`
70
+ [UATP](https://en.wikipedia.org/wiki/Universal_Air_Travel_Plan) | `:uatp`
71
+ [Uzcard](https://en.wikipedia.org/wiki/Uzcard) | `:uzcard`
72
+ [V Pay](https://en.wikipedia.org/wiki/V_Pay) | `:vpay`
73
+ [Verve](https://en.wikipedia.org/wiki/Verve_(payment_card)) | `:verve`
74
+ [Voyager](https://en.wikipedia.org/wiki/Voyager_card) | `:voyager`
75
+
76
+ ### Legacy / withdrawn networks
77
+
78
+ | Name | Key | Status |
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 |
81
+ [EnRoute](https://en.wikipedia.org/wiki/EnRoute_(credit_card)) | `:en_route` | Withdrawn 1989 |
82
+ [Laser](https://en.wikipedia.org/wiki/Laser_%28debit_card%29) | `:laser` | Withdrawn 2014 |
83
+ [Solo](https://en.wikipedia.org/wiki/Solo_(debit_card)) | `:solo` | Withdrawn 2011 |
84
+ [Switch](https://en.wikipedia.org/wiki/Switch_(debit_card)) | `:switch` | Withdrawn 2002 |
85
+
86
+ ### Loading plugins
87
+
88
+ ```ruby
89
+ # in an initializer or before first use
90
+ require 'credit_card_validations/plugins/mir'
91
+ require 'credit_card_validations/plugins/elo'
92
+ require 'credit_card_validations/plugins/hipercard'
93
+ # ... whichever brands the app actually accepts
94
+ ```
95
+
96
+ ## Migrating from v8.x → v9.0
32
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.
33
99
 
34
- The following issuing institutes are accepted:
35
-
36
- | Name | Key |
37
- --------------------- | ------------|
38
- [American Express](http://en.wikipedia.org/wiki/American_Express) | :amex
39
- [China UnionPay](http://en.wikipedia.org/wiki/China_UnionPay) | :unionpay
40
- [Dankort](http://en.wikipedia.org/wiki/Dankort) | :dankort
41
- [Diners Club](http://en.wikipedia.org/wiki/Diners_Club_International) | :diners
42
- [Elo](https://pt.wikipedia.org/wiki/Elo_Participa%C3%A7%C3%B5es_S/A) | :elo
43
- [Discover](http://en.wikipedia.org/wiki/Discover_Card) | :discover
44
- [Hipercard](http://pt.wikipedia.org/wiki/Hipercard) | :hipercard
45
- [JCB](http://en.wikipedia.org/wiki/Japan_Credit_Bureau) | :jcb
46
- [Maestro](http://en.wikipedia.org/wiki/Maestro_%28debit_card%29) | :maestro
47
- [MasterCard](http://en.wikipedia.org/wiki/MasterCard) | :mastercard
48
- [MIR](http://www.nspk.ru/en/cards-mir/) | :mir
49
- [Rupay](http://en.wikipedia.org/wiki/RuPay) | :rupay
50
- [Solo](http://en.wikipedia.org/wiki/Solo_(debit_card)) | :solo
51
- [Switch](http://en.wikipedia.org/wiki/Switch_(debit_card)) | :switch
52
- [Visa](http://en.wikipedia.org/wiki/Visa_Inc.) | :visa
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 |
53
109
 
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:
54
111
 
112
+ ```ruby
113
+ # config/initializers/credit_card_validations.rb
114
+ require 'credit_card_validations/plugins/mir'
115
+ require 'credit_card_validations/plugins/elo'
116
+ # ...
117
+ ```
55
118
 
56
- The following are supported with plugins
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.
57
120
 
58
- | Name | Key |
59
- --------------------- | ------------|
60
- [Diners Club US](http://en.wikipedia.org/wiki/Diners_Club_International#MasterCard_alliance) | :diners_us
61
- [EnRoute](https://en.wikipedia.org/wiki/EnRoute_(credit_card)) | :en_route
62
- [Laser](https://en.wikipedia.org/wiki/Laser_%28debit_card%29) | :laser
121
+ ### Other breaking changes in v9.0
63
122
 
123
+ - **`Hipercard` cleaned up.** Length changed from 19 to 16 (which is the issued length); legacy `637*` prefixes that actually belong to Hiper were dropped. If you used Hipercard before, your detection now matches the brand's real spec.
124
+ - **`Discover` cleaned up.** Diners-only prefixes (`300-305, 3095, 36, 38, 39`) were dropped from Discover. Diners cards now correctly detect as `:diners` instead of `:discover`. Apps that branched on `:discover` for routing should branch on `[:diners, :discover]`.
125
+ - **`Luhn.valid?` is now strict.** It accepts a digit-only string and returns `false` for `nil`, empty input, or any non-digit character. User-facing input handling moved into `Detector#initialize`, which strips whitespace and dashes before delegating.
126
+ - **Brand YAML is loaded via `YAML.safe_load_file`.** Custom brand sources may need to declare `permitted_classes: [Symbol]` if they relied on extra Ruby objects.
64
127
 
128
+ ## Usage
65
129
 
66
- ### Examples using string monkey patch
130
+ ### String monkey patch
67
131
 
68
132
  ```ruby
69
133
  require 'credit_card_validations/string'
70
- '5274 5763 9425 9961'.credit_card_brand #=> :mastercard
71
- '5274 5763 9425 9961'.credit_card_brand_name #=> "MasterCard"
72
- '5274 5763 9425 9961'.valid_credit_card_brand?(:mastercard, :visa) #=> true
73
- '5274 5763 9425 9961'.valid_credit_card_brand?(:amex) #=> false
74
- '5274 5763 9425 9961'.valid_credit_card_brand?('MasterCard') #=> true
134
+ '5274 5763 9425 9961'.credit_card_brand #=> :mastercard
135
+ '5274 5763 9425 9961'.credit_card_brand_name #=> "MasterCard"
136
+ '5274 5763 9425 9961'.valid_credit_card_brand?(:mastercard, :visa) #=> true
137
+ '5274 5763 9425 9961'.valid_credit_card_brand?(:amex) #=> false
138
+ '5274 5763 9425 9961'.valid_credit_card_brand?('MasterCard') #=> true
75
139
  ```
76
140
 
77
- ### ActiveModel support
141
+ ### ActiveModel validators
78
142
 
79
- only for certain brands
143
+ Restrict to a brand list:
80
144
 
81
145
  ```ruby
82
146
  class CreditCardModel
83
147
  attr_accessor :number
84
148
  include ActiveModel::Validations
85
- validates :number, credit_card_number: {brands: [:amex, :maestro]}
149
+ validates :number, credit_card_number: { brands: [:amex, :maestro] }
86
150
  end
87
151
  ```
88
152
 
89
- for all known brands
153
+ Accept any known brand:
90
154
 
91
155
  ```ruby
92
156
  validates :number, presence: true, credit_card_number: true
93
157
  ```
94
158
 
95
- ### Examples using CreditCardValidations::Detector class
159
+ ### CVV validator
160
+
161
+ CVV against a brand pulled from another attribute (the PAN):
96
162
 
97
163
  ```ruby
98
- number = "4111111111111111"
99
- detector = CreditCardValidations::Detector.new(number)
100
- detector.brand #:visa
101
- detector.visa? #true
102
- detector.valid?(:mastercard,:maestro) #false
103
- detector.valid?(:visa, :mastercard) #true
104
- detector.issuer_category #"Banking and financial"
164
+ class Payment
165
+ include ActiveModel::Validations
166
+ attr_accessor :card_number, :cvv
167
+
168
+ validates :card_number, credit_card_number: true
169
+ validates :cvv, credit_card_cvv: { brand_from: :card_number }
170
+ end
105
171
  ```
106
172
 
107
- ### Also You can add your own brand rules to detect other credit card brands/types
108
- passing name,length(integer/array of integers) and prefix(string/array of strings)
109
- Example
173
+ CVV against a literal brand:
110
174
 
111
175
  ```ruby
112
- CreditCardValidations.add_brand(:voyager, {length: 15, prefixes: '86'})
113
- voyager_test_card_number = '869926275400212'
114
- CreditCardValidations::Detector.new(voyager_test_card_number).brand #:voyager
115
- CreditCardValidations::Detector.new(voyager_test_card_number).voyager? #true
176
+ validates :cvv, credit_card_cvv: { brand: :amex }
116
177
  ```
117
178
 
118
- ### Remove brands also supported
179
+ ### Expiration validator
180
+
181
+ A single string attribute (`MM/YY`, `MM/YYYY`, `MMYY`, ...):
119
182
 
120
183
  ```ruby
121
- CreditCardValidations::Detector.delete_brand(:maestro)
184
+ validates :expiration, credit_card_expiration: true
122
185
  ```
123
186
 
124
- ### Check luhn
187
+ Two separate fields (typical month + year dropdowns) — use the `Expiration` class in a `validate` block:
125
188
 
126
189
  ```ruby
127
- CreditCardValidations::Detector.new(@credit_card_number).valid_luhn?
128
- #or
129
- CreditCardValidations::Luhn.valid?(@credit_card_number)
190
+ class Payment
191
+ attr_accessor :exp_month, :exp_year
192
+
193
+ validate do
194
+ exp = CreditCardValidations::Expiration.new(exp_month, exp_year)
195
+ errors.add(:exp_month, :invalid) unless exp.valid?
196
+ end
197
+ end
130
198
  ```
131
199
 
132
- ### Generate credit card numbers that pass validation
200
+ ### `CreditCardValidations::Card`
201
+
202
+ A composite model wrapping `Detector` and `Expiration` behind a single ActiveModel-aware object:
133
203
 
134
204
  ```ruby
135
- CreditCardValidations::Factory.random(:amex)
136
- # => "348051773827666"
137
- CreditCardValidations::Factory.random(:maestro)
138
- # => "6010430241237266856"
205
+ card = CreditCardValidations::Card.new(
206
+ number: '4111 1111 1111 1111',
207
+ month: 12, year: 2027,
208
+ verification_value: '123',
209
+ name: 'John Smith'
210
+ )
211
+ card.valid? # => true
212
+ card.brand # => :visa
213
+ card.display_number # => "************1111"
214
+ card.last_digits # => "1111"
215
+ card.expired? # => false
216
+ card.formatted_number # => "4111 1111 1111 1111"
139
217
  ```
140
218
 
141
- ### Plugins
219
+ ### Using `Detector` directly
142
220
 
143
221
  ```ruby
144
- require 'credit_card_validations/plugins/en_route'
145
- require 'credit_card_validations/plugins/laser'
146
- require 'credit_card_validations/plugins/diners_us'
222
+ number = '4111111111111111'
223
+ detector = CreditCardValidations::Detector.new(number)
224
+
225
+ detector.brand # => :visa
226
+ detector.visa? # => true
227
+ detector.valid?(:mastercard, :maestro) # => false
228
+ detector.valid?(:visa, :mastercard) # => true
229
+ detector.issuer_category # => "Banking and financial"
230
+ detector.last4 # => "1111"
231
+ detector.masked # => "************1111"
232
+ detector.formatted # => "4111 1111 1111 1111"
233
+ detector.possible_brands # => [:visa] (during live input)
234
+ detector.valid_cvv?('123') # => true
147
235
  ```
148
236
 
237
+ ### Adding a custom brand at runtime
149
238
 
150
- ### Configuration
239
+ ```ruby
240
+ CreditCardValidations.add_brand(:voyager, { length: 15, prefixes: '86' })
241
+ CreditCardValidations::Detector.new('869926275400212').voyager? # => true
242
+ ```
243
+
244
+ ### Removing a brand at runtime
245
+
246
+ ```ruby
247
+ CreditCardValidations::Detector.delete_brand(:maestro)
248
+ ```
151
249
 
152
- In order to override default data source you can copy [original one](https://github.com/didww/credit_card_validations/blob/master/lib/data/brands.yaml) , change it and configure during rails initializer
250
+ ### Luhn check
153
251
 
154
252
  ```ruby
155
- CreditCardValidations.configure do |config|
156
- config.source = '/path/to/my_brands.yml'
157
- end
253
+ CreditCardValidations::Detector.new(number).valid_luhn?
254
+ # or, on a clean digit string:
255
+ CreditCardValidations::Luhn.valid?(number)
158
256
  ```
159
257
 
258
+ ### Generating Luhn-valid test numbers
259
+
260
+ ```ruby
261
+ CreditCardValidations::Factory.random(:amex)
262
+ # => "348051773827666"
263
+ CreditCardValidations::Factory.random(:maestro)
264
+ # => "6010430241237266856"
265
+ ```
266
+
267
+ ## Configuration
268
+
269
+ 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:
270
+
271
+ ```ruby
272
+ CreditCardValidations.configure do |config|
273
+ config.source = '/path/to/my_brands.yml'
274
+ end
275
+ ```
160
276
 
161
277
  ## Contributing
162
278
 
@@ -164,7 +280,4 @@ In order to override default data source you can copy [original one](https://git
164
280
  2. Create your feature branch (`git checkout -b my-new-feature`)
165
281
  3. Commit your changes (`git commit -am 'Add some feature'`)
166
282
  4. Push to the branch (`git push origin my-new-feature`)
167
- 5. Create new Pull Request
168
-
169
-
170
-
283
+ 5. Open a Pull Request
@@ -0,0 +1,35 @@
1
+ # == ActiveModel CreditCardCvvValidator
2
+ #
3
+ # Validates a card verification value (CVV / CVC / CID) against either a
4
+ # brand passed explicitly, or a brand derived from another attribute.
5
+ #
6
+ # class Payment
7
+ # include ActiveModel::Validations
8
+ # attr_accessor :card_number, :cvv
9
+ #
10
+ # validates :cvv, credit_card_cvv: { brand_from: :card_number }
11
+ # end
12
+ #
13
+ # class Payment
14
+ # validates :cvv, credit_card_cvv: { brand: :amex }
15
+ # end
16
+ #
17
+ module ActiveModel
18
+ module Validations
19
+ class CreditCardCvvValidator < EachValidator
20
+ def validate_each(record, attribute, value)
21
+ brand = resolve_brand(record)
22
+ return if brand && CreditCardValidations::Detector.valid_cvv?(value, brand)
23
+ record.errors.add(attribute, options[:message] || :invalid)
24
+ end
25
+
26
+ private
27
+
28
+ def resolve_brand(record)
29
+ return options[:brand] if options[:brand]
30
+ pan = record.public_send(options[:brand_from]).to_s if options[:brand_from]
31
+ CreditCardValidations::Detector.new(pan).brand if pan
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # == ActiveModel CreditCardExpirationValidator
2
+ #
3
+ # Validates a card expiration date held in a single attribute as a parseable
4
+ # string (MM/YY, MM/YYYY, MMYY, ...).
5
+ #
6
+ # class Payment
7
+ # include ActiveModel::Validations
8
+ # attr_accessor :expiration
9
+ #
10
+ # validates :expiration, credit_card_expiration: true
11
+ # end
12
+ #
13
+ # For forms with separate month + year fields, use the Expiration class
14
+ # directly in a plain validate block (see README).
15
+ #
16
+ module ActiveModel
17
+ module Validations
18
+ class CreditCardExpirationValidator < EachValidator
19
+ def validate_each(record, attribute, value)
20
+ expiration = CreditCardValidations::Expiration.parse(value)
21
+ return if expiration && expiration.valid?
22
+ record.errors.add(attribute, options[:message] || :invalid)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ require 'active_model'
2
+
3
+ # == CreditCardValidations::Card
4
+ #
5
+ # Slim alternative to ActiveMerchant::Billing::CreditCard for the validation
6
+ # use case. Wraps PAN, expiration, verification value and cardholder name
7
+ # behind a single ActiveModel-aware object.
8
+ #
9
+ # card = CreditCardValidations::Card.new(
10
+ # number: '4111 1111 1111 1111',
11
+ # month: 12, year: 2027,
12
+ # verification_value: '123',
13
+ # name: 'John Smith'
14
+ # )
15
+ # card.valid? # => true
16
+ # card.brand # => :visa
17
+ # card.display_number # => "************1111"
18
+ # card.last_digits # => "1111"
19
+ # card.expired? # => false
20
+ # card.formatted_number # => "4111 1111 1111 1111"
21
+ #
22
+ module CreditCardValidations
23
+ class Card
24
+ include ActiveModel::Model
25
+
26
+ attr_accessor :number, :month, :year, :verification_value, :name
27
+
28
+ validates :number, credit_card_number: true
29
+ validates :verification_value, credit_card_cvv: { brand_from: :number }
30
+ validate :expiration_must_be_valid
31
+
32
+ def brand = detector.brand
33
+ def brand_name = detector.brand_name
34
+ def last_digits = detector.last4
35
+ def display_number = detector.masked
36
+ def formatted_number = detector.formatted
37
+
38
+ def expiration
39
+ return nil unless month && year
40
+ Expiration.new(month, year)
41
+ end
42
+
43
+ def expired?
44
+ exp = expiration
45
+ exp.nil? || exp.expired?
46
+ end
47
+
48
+ def detector
49
+ @detector ||= Detector.new(number)
50
+ end
51
+
52
+ private
53
+
54
+ def expiration_must_be_valid
55
+ return if month.blank? && year.blank?
56
+ exp = expiration
57
+ errors.add(:base, :expired) if exp && exp.expired?
58
+ errors.add(:month, :invalid) if exp && !(1..12).cover?(exp.month)
59
+ end
60
+ end
61
+ end
@@ -9,10 +9,17 @@ 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
+
12
19
  attr_reader :number
13
20
 
14
21
  def initialize(number)
15
- @number = number.to_s.tr('- ', '')
22
+ @number = number.to_s.gsub(/[\s\-]/, '')
16
23
  end
17
24
 
18
25
  # credit card number validation
@@ -50,8 +57,62 @@ module CreditCardValidations
50
57
  self.class.brand_name(brand)
51
58
  end
52
59
 
60
+ # Last four digits of the PAN, or nil if the PAN has fewer than 4 digits.
61
+ def last4
62
+ number.length >= 4 ? number[-4, 4] : nil
63
+ end
64
+
65
+ # PAN with every digit but the last 4 replaced by mask_char.
66
+ # Returns the original number when shorter than 4 digits — never raises.
67
+ def masked(mask_char = '*')
68
+ return number if number.length < 4
69
+ mask_char.to_s[0] * (number.length - 4) + last4.to_s
70
+ end
71
+
72
+ # All brands whose prefixes can still match the (possibly partial) PAN.
73
+ # Length and Luhn are not checked — useful for live UX before the user
74
+ # finishes typing.
75
+ def possible_brands
76
+ return [] if number.empty?
77
+ self.class.brands.each_with_object([]) do |(key, brand), acc|
78
+ next unless brand.fetch(:rules).any? do |rule|
79
+ rule[:prefixes].any? do |prefix|
80
+ n = [number.length, prefix.length].min
81
+ number[0, n] == prefix[0, n]
82
+ end
83
+ end
84
+ acc << key
85
+ end
86
+ end
87
+
88
+ # Human-readable PAN grouped per network convention. Falls back to the
89
+ # first possible brand while the user is still typing.
90
+ def formatted(separator = ' ')
91
+ groups_for(brand || possible_brands.first).each_with_object([]) do |size, acc|
92
+ slice = number[acc.join.length, size]
93
+ acc << slice if slice && !slice.empty?
94
+ end.join(separator)
95
+ end
96
+
97
+ # Validates the card verification value against the detected brand's
98
+ # declared :code size. Returns false when the brand cannot be determined
99
+ # from the PAN or the input has the wrong shape. Raises when a detected
100
+ # brand is missing :code in the registry.
101
+ def valid_cvv?(code)
102
+ self.class.valid_cvv?(code, brand)
103
+ end
104
+
53
105
  protected
54
106
 
107
+ def groups_for(detected_brand)
108
+ segments = self.class.brands.dig(detected_brand, :options, :segments)
109
+ return segments if segments
110
+ groups = Array.new(number.length / 4, 4)
111
+ remainder = number.length % 4
112
+ groups << remainder if remainder.positive?
113
+ groups
114
+ end
115
+
55
116
  def resolve_keys(*keys)
56
117
  brand_keys = keys.map do |el|
57
118
  if el.is_a? String
@@ -60,9 +121,25 @@ module CreditCardValidations
60
121
  end
61
122
  el.downcase
62
123
  end
124
+ brand_keys.each { |k| autoload_legacy_plugin(k) }
63
125
  self.brands.slice(*brand_keys)
64
126
  end
65
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"
141
+ end
142
+
66
143
  def matches_brand?(brand)
67
144
  rules = brand.fetch(:rules)
68
145
  options = brand.fetch(:options, {})
@@ -83,12 +160,26 @@ module CreditCardValidations
83
160
  !brands[key].fetch(:options, {}).fetch(:skip_luhn, false)
84
161
  end
85
162
 
163
+ # Class-level CVV check: validates a code against an explicit brand,
164
+ # without needing a Detector instance. Useful when only the brand is
165
+ # known (form input bound to a brand select, separate CVV field, etc.).
166
+ def valid_cvv?(code, brand)
167
+ return false if code.nil? || brand.nil? || !code.to_s.match?(/\A\d+\z/)
168
+ spec = brands.dig(brand, :options, :code)
169
+ raise Error, "brand #{brand.inspect} has no :code option" if spec.nil?
170
+ code.to_s.length == spec[:size]
171
+ end
172
+
86
173
  #
87
174
  # add brand
88
175
  #
89
176
  # CreditCardValidations.add_brand(:en_route, {length: 15, prefixes: ['2014', '2149']}, {skip_luhn: true}) #skip luhn
90
177
  #
91
178
  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)
92
183
 
93
184
  brands[key] = {rules: [], options: options || {}}
94
185