credit_card_validations 8.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ea48b31cccd9e050dd0e59cdd71bb784cf319d355dfa2eb0a185635308b1b81
4
- data.tar.gz: 3c0358bce72eeb6f265ffb0130dc3a5a843dbf326ef989c118d827c025b8e0c3
3
+ metadata.gz: d26fc84c0dd18fbfcacaeead238fe16ecefe51486e4122e63c3dcf4836fd4ce6
4
+ data.tar.gz: a1bad2adea7168439991cf98bb68a25a2d62c4372602cbe54743f23eda12c11e
5
5
  SHA512:
6
- metadata.gz: 62586d60302f937ec7f47a98440d89767fba557506a5568733e498e2c41d491d5d8f283923590808abc4f1ab3ebe3466dcadb8fbd4a21b721d07337ea4e34133
7
- data.tar.gz: 366c5f5239778f08035b207b9d1e88e2f3c5f747913b3d86c79b1555fd571a640d16eca7121e78b62fe93f87298a05b9bb9161c7acfe1dcdad2e6a6b100662b2
6
+ metadata.gz: 14d43e6661c30034f8f52f4bf0558eb4704843ad048dfb79943a0822f4f1d6b221d31a98bd1b73dd23cd3a6d76904fdc88b6758abfb5ea1a701da001e938b73a
7
+ data.tar.gz: c41da3b3de8f45d93413a41c4fe88c8e543eea2c8e149ea8500d594ee1c40802c2554cb7f72f87e732d600b7aded5d69478f9ff4a9acebea9bbed85edc35873f
data/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
  ![Coverage](https://didww.github.io/credit_card_validations/badge.svg)
6
6
 
7
7
 
8
- 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.
9
9
 
10
- 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
11
11
 
12
12
  ## Installation
13
13
 
@@ -29,158 +29,250 @@ Or install it yourself as:
29
29
  $ gem install credit_card_validations
30
30
  ```
31
31
 
32
- ## Usage
32
+ ## Default brands
33
33
 
34
+ These brands are detected out of the box. They are the international majors that most acquirers, gateways, and payment forms care about:
34
35
 
35
- The following issuing institutes are accepted:
36
-
37
- | Name | Key |
38
- --------------------- | ------------|
39
- [American Express](http://en.wikipedia.org/wiki/American_Express) | :amex
40
- [China UnionPay](http://en.wikipedia.org/wiki/China_UnionPay) | :unionpay
41
- [Dankort](http://en.wikipedia.org/wiki/Dankort) | :dankort
42
- [Diners Club](http://en.wikipedia.org/wiki/Diners_Club_International) | :diners
43
- [Elo](https://pt.wikipedia.org/wiki/Elo_Participa%C3%A7%C3%B5es_S/A) | :elo
44
- [Discover](http://en.wikipedia.org/wiki/Discover_Card) | :discover
45
- [Hipercard](http://pt.wikipedia.org/wiki/Hipercard) | :hipercard
46
- [JCB](http://en.wikipedia.org/wiki/Japan_Credit_Bureau) | :jcb
47
- [Maestro](http://en.wikipedia.org/wiki/Maestro_%28debit_card%29) | :maestro
48
- [MasterCard](http://en.wikipedia.org/wiki/MasterCard) | :mastercard
49
- [MIR](http://www.nspk.ru/en/cards-mir/) | :mir
50
- [Rupay](http://en.wikipedia.org/wiki/RuPay) | :rupay
51
- [Solo](http://en.wikipedia.org/wiki/Solo_(debit_card)) | :solo
52
- [Switch](http://en.wikipedia.org/wiki/Switch_(debit_card)) | :switch
53
- [Visa](http://en.wikipedia.org/wiki/Visa_Inc.) | :visa
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`
54
46
 
47
+ ## Opt-in plugins
55
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.
56
50
 
57
- The following are supported with plugins
51
+ ### Active regional and specialty networks
58
52
 
59
53
  | Name | Key |
60
54
  --------------------- | ------------|
61
- [Cabal](https://en.wikipedia.org/wiki/Cabal_(debit_card)) | :cabal
62
- [DinaCard](https://en.wikipedia.org/wiki/DinaCard) | :dinacard
63
- [Diners Club US](http://en.wikipedia.org/wiki/Diners_Club_International#MasterCard_alliance) | :diners_us
64
- [EnRoute](https://en.wikipedia.org/wiki/EnRoute_(credit_card)) | :en_route
65
- [Girocard](https://en.wikipedia.org/wiki/Girocard) | :girocard
66
- [Hiper](https://en.wikipedia.org/wiki/Itau_Unibanco) | :hiper
67
- [Humo](https://en.wikipedia.org/wiki/Humo_(payment_system)) | :humocard
68
- [Laser](https://en.wikipedia.org/wiki/Laser_%28debit_card%29) | :laser
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
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
75
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.
76
99
 
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 |
77
109
 
78
- ### Examples using string monkey patch
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:
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
+ ```
118
+
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.
120
+
121
+ ### Other breaking changes in v9.0
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.
127
+
128
+ ## Usage
129
+
130
+ ### String monkey patch
79
131
 
80
132
  ```ruby
81
133
  require 'credit_card_validations/string'
82
- '5274 5763 9425 9961'.credit_card_brand #=> :mastercard
83
- '5274 5763 9425 9961'.credit_card_brand_name #=> "MasterCard"
84
- '5274 5763 9425 9961'.valid_credit_card_brand?(:mastercard, :visa) #=> true
85
- '5274 5763 9425 9961'.valid_credit_card_brand?(:amex) #=> false
86
- '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
87
139
  ```
88
140
 
89
- ### ActiveModel support
141
+ ### ActiveModel validators
90
142
 
91
- only for certain brands
143
+ Restrict to a brand list:
92
144
 
93
145
  ```ruby
94
146
  class CreditCardModel
95
147
  attr_accessor :number
96
148
  include ActiveModel::Validations
97
- validates :number, credit_card_number: {brands: [:amex, :maestro]}
149
+ validates :number, credit_card_number: { brands: [:amex, :maestro] }
98
150
  end
99
151
  ```
100
152
 
101
- for all known brands
153
+ Accept any known brand:
102
154
 
103
155
  ```ruby
104
156
  validates :number, presence: true, credit_card_number: true
105
157
  ```
106
158
 
107
- ### Examples using CreditCardValidations::Detector class
159
+ ### CVV validator
160
+
161
+ CVV against a brand pulled from another attribute (the PAN):
108
162
 
109
163
  ```ruby
110
- number = "4111111111111111"
111
- detector = CreditCardValidations::Detector.new(number)
112
- detector.brand #:visa
113
- detector.visa? #true
114
- detector.valid?(:mastercard,:maestro) #false
115
- detector.valid?(:visa, :mastercard) #true
116
- 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
117
171
  ```
118
172
 
119
- ### Also You can add your own brand rules to detect other credit card brands/types
120
- passing name,length(integer/array of integers) and prefix(string/array of strings)
121
- Example
173
+ CVV against a literal brand:
122
174
 
123
175
  ```ruby
124
- CreditCardValidations.add_brand(:voyager, {length: 15, prefixes: '86'})
125
- voyager_test_card_number = '869926275400212'
126
- CreditCardValidations::Detector.new(voyager_test_card_number).brand #:voyager
127
- CreditCardValidations::Detector.new(voyager_test_card_number).voyager? #true
176
+ validates :cvv, credit_card_cvv: { brand: :amex }
128
177
  ```
129
178
 
130
- ### Remove brands also supported
179
+ ### Expiration validator
180
+
181
+ A single string attribute (`MM/YY`, `MM/YYYY`, `MMYY`, ...):
131
182
 
132
183
  ```ruby
133
- CreditCardValidations::Detector.delete_brand(:maestro)
184
+ validates :expiration, credit_card_expiration: true
134
185
  ```
135
186
 
136
- ### Check luhn
187
+ Two separate fields (typical month + year dropdowns) — use the `Expiration` class in a `validate` block:
137
188
 
138
189
  ```ruby
139
- CreditCardValidations::Detector.new(@credit_card_number).valid_luhn?
140
- #or
141
- 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
142
198
  ```
143
199
 
144
- ### 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:
145
203
 
146
204
  ```ruby
147
- CreditCardValidations::Factory.random(:amex)
148
- # => "348051773827666"
149
- CreditCardValidations::Factory.random(:maestro)
150
- # => "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"
151
217
  ```
152
218
 
153
- ### Plugins
219
+ ### Using `Detector` directly
154
220
 
155
221
  ```ruby
156
- require 'credit_card_validations/plugins/en_route'
157
- require 'credit_card_validations/plugins/laser'
158
- 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
235
+ ```
236
+
237
+ ### Adding a custom brand at runtime
159
238
 
160
- require 'credit_card_validations/plugins/cabal'
161
- require 'credit_card_validations/plugins/dinacard'
162
- require 'credit_card_validations/plugins/girocard'
163
- require 'credit_card_validations/plugins/hiper'
164
- require 'credit_card_validations/plugins/humocard'
165
- require 'credit_card_validations/plugins/troy'
166
- require 'credit_card_validations/plugins/uatp'
167
- require 'credit_card_validations/plugins/uzcard'
168
- require 'credit_card_validations/plugins/verve'
169
- require 'credit_card_validations/plugins/voyager'
170
- require 'credit_card_validations/plugins/vpay'
239
+ ```ruby
240
+ CreditCardValidations.add_brand(:voyager, { length: 15, prefixes: '86' })
241
+ CreditCardValidations::Detector.new('869926275400212').voyager? # => true
171
242
  ```
172
243
 
244
+ ### Removing a brand at runtime
173
245
 
174
- ### Configuration
246
+ ```ruby
247
+ CreditCardValidations::Detector.delete_brand(:maestro)
248
+ ```
175
249
 
176
- 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
177
251
 
178
252
  ```ruby
179
- CreditCardValidations.configure do |config|
180
- config.source = '/path/to/my_brands.yml'
181
- end
253
+ CreditCardValidations::Detector.new(number).valid_luhn?
254
+ # or, on a clean digit string:
255
+ CreditCardValidations::Luhn.valid?(number)
182
256
  ```
183
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
+ ```
184
276
 
185
277
  ## Contributing
186
278
 
@@ -188,7 +280,4 @@ In order to override default data source you can copy [original one](https://git
188
280
  2. Create your feature branch (`git checkout -b my-new-feature`)
189
281
  3. Commit your changes (`git commit -am 'Add some feature'`)
190
282
  4. Push to the branch (`git push origin my-new-feature`)
191
- 5. Create new Pull Request
192
-
193
-
194
-
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