@ccgenerator/test-cards 1.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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/index.d.ts +108 -0
- package/package.json +59 -0
- package/src/detect.js +58 -0
- package/src/generate.js +135 -0
- package/src/index.js +13 -0
- package/src/luhn.js +74 -0
- package/src/networks.js +138 -0
- package/src/random.js +72 -0
- package/src/validate.js +87 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package
|
|
5
|
+
follows [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] — 2026-08-23
|
|
8
|
+
|
|
9
|
+
First public release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `generate(network?, options?)` — one synthetic, Luhn-valid card with CVV and
|
|
14
|
+
expiry; `formatted`, `length` and `expiryYearsAhead` options.
|
|
15
|
+
- `generateMany(count, network?, options?)` — batches, deliberately without
|
|
16
|
+
deduplication.
|
|
17
|
+
- `validate(pan)` — structural validation with stable error codes
|
|
18
|
+
(`not_a_string`, `empty`, `non_digit`, `unknown_network`, `bad_length`,
|
|
19
|
+
`luhn_failed`).
|
|
20
|
+
- `detectBrand(pan)` — network detection that works on partial input.
|
|
21
|
+
- `luhnCheckDigit(payload)` / `isLuhnValid(pan)` — the checksum primitives.
|
|
22
|
+
- `networks` / `networkKeys` — the rule table itself: IIN prefixes, lengths,
|
|
23
|
+
CVV length and display grouping per network.
|
|
24
|
+
- Nine networks: Visa, Mastercard (including the 2-series), American Express,
|
|
25
|
+
Discover, JCB, Diners Club (14-digit), Maestro (16/19), UnionPay (16/19),
|
|
26
|
+
Troy.
|
|
27
|
+
- Hand-written TypeScript declarations (`index.d.ts`).
|
|
28
|
+
- Test suite on `node:test` — no framework, no dependencies.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CC Generator
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# @ccgenerator/test-cards
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@ccgenerator/test-cards)
|
|
4
|
+
[](https://github.com/ccgeneratororg/ccgenerator-npm/actions/workflows/test.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](./package.json)
|
|
7
|
+
|
|
8
|
+
Synthetic, Luhn-valid payment card numbers for testing card input.
|
|
9
|
+
|
|
10
|
+
Zero dependencies. No network calls. Works in Node and the browser.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { generate, validate, detectBrand } from '@ccgenerator/test-cards';
|
|
14
|
+
|
|
15
|
+
generate('mastercard');
|
|
16
|
+
// { network: 'mastercard', networkName: 'Mastercard', pan: '5312448763449521',
|
|
17
|
+
// cvv: '204', expiryMonth: '09', expiryYear: '2029' }
|
|
18
|
+
|
|
19
|
+
detectBrand('2223 0031 2200 3222'); // 'mastercard'
|
|
20
|
+
validate('4111111111111112').errors; // ['luhn_failed']
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What this is for
|
|
24
|
+
|
|
25
|
+
Exercising the card field. Does the input mask group digits correctly? Does the
|
|
26
|
+
brand icon switch on the right prefix? Does a 19-digit Maestro number survive the
|
|
27
|
+
form, and does a 15-digit Amex number get an Amex-length CVV field?
|
|
28
|
+
|
|
29
|
+
Those are the questions this package answers, and it is deliberately not useful
|
|
30
|
+
for anything else.
|
|
31
|
+
|
|
32
|
+
## What it is not
|
|
33
|
+
|
|
34
|
+
**The numbers are not real cards.** They satisfy the Luhn checksum and sit inside
|
|
35
|
+
published issuer identification ranges, which is exactly what a card field checks
|
|
36
|
+
— and that is where the resemblance stops. They correspond to no account at any
|
|
37
|
+
issuer, carry no balance, and cannot authorise a transaction. A payment gateway
|
|
38
|
+
declines them at the first hop.
|
|
39
|
+
|
|
40
|
+
**They are not gateway test cards either.** Stripe, PayPal, Adyen and the rest
|
|
41
|
+
publish their own fixed numbers that trigger specific sandbox behaviour, such as
|
|
42
|
+
a decline for insufficient funds or a 3-D Secure challenge. This package cannot
|
|
43
|
+
produce those, because they are assigned by the gateway, not derived. Use the
|
|
44
|
+
provider's own list for that: [ccgenerator.org/test-card-numbers](https://ccgenerator.org/test-card-numbers/)
|
|
45
|
+
collects them per gateway.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npm install --save-dev @ccgenerator/test-cards
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Node 18 or newer. ESM only.
|
|
54
|
+
|
|
55
|
+
## Recipes
|
|
56
|
+
|
|
57
|
+
### Table-driven form tests
|
|
58
|
+
|
|
59
|
+
The output shape is stable, so a batch drops straight into a parameterised
|
|
60
|
+
suite. With `node:test` (works the same in Vitest or Jest):
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { test } from 'node:test';
|
|
64
|
+
import assert from 'node:assert';
|
|
65
|
+
import { generateMany } from '@ccgenerator/test-cards';
|
|
66
|
+
import { formatCardInput } from '../src/card-field.js'; // your code
|
|
67
|
+
|
|
68
|
+
for (const { networkName, pan } of generateMany(100)) {
|
|
69
|
+
test(`${networkName} ${pan} survives the input mask`, () => {
|
|
70
|
+
assert.equal(formatCardInput(pan).replaceAll(' ', ''), pan);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
One hundred fresh numbers per run costs nothing and finds the seams a single
|
|
76
|
+
hard-coded `4111 1111 1111 1111` never will — the 15-digit Amex, the 14-digit
|
|
77
|
+
Diners, the 19-digit Maestro.
|
|
78
|
+
|
|
79
|
+
### Negative cases
|
|
80
|
+
|
|
81
|
+
A number that is *almost* right is the most useful test input there is. Break
|
|
82
|
+
the check digit deliberately:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { generate, validate } from '@ccgenerator/test-cards';
|
|
86
|
+
|
|
87
|
+
const good = generate('visa').pan;
|
|
88
|
+
const bad = good.slice(0, -1) + String((Number(good.at(-1)) + 1) % 10);
|
|
89
|
+
|
|
90
|
+
validate(bad).errors; // ['luhn_failed'] — your form should reject it too
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### End-to-end (Playwright, Cypress, …)
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { generate } from '@ccgenerator/test-cards';
|
|
97
|
+
|
|
98
|
+
test('checkout accepts a 19-digit Maestro', async ({ page }) => {
|
|
99
|
+
const card = generate('maestro', { length: 19 });
|
|
100
|
+
|
|
101
|
+
await page.goto('/checkout');
|
|
102
|
+
await page.fill('[name=cardnumber]', card.pan);
|
|
103
|
+
await page.fill('[name=expiry]', `${card.expiryMonth}/${card.expiryYear.slice(2)}`);
|
|
104
|
+
await page.fill('[name=cvc]', card.cvv);
|
|
105
|
+
|
|
106
|
+
await expect(page.locator('.brand-icon')).toHaveClass(/maestro/);
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This exercises *your* form, not a payment. If the page ends at a real gateway,
|
|
111
|
+
use that gateway's own sandbox numbers instead — see
|
|
112
|
+
[What it is not](#what-it-is-not).
|
|
113
|
+
|
|
114
|
+
### In the browser, no build step
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
<script type="module">
|
|
118
|
+
import { generate } from 'https://cdn.jsdelivr.net/npm/@ccgenerator/test-cards@1/+esm';
|
|
119
|
+
|
|
120
|
+
console.log(generate('visa', { formatted: true }).formatted);
|
|
121
|
+
</script>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### TypeScript
|
|
125
|
+
|
|
126
|
+
Types ship with the package — no `@types/…` install, no build step on our side:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { generate, networks, type NetworkKey } from '@ccgenerator/test-cards';
|
|
130
|
+
|
|
131
|
+
function cvvFieldSize(key: NetworkKey): number {
|
|
132
|
+
return networks[key].cvvLength;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
cvvFieldSize(generate().network); // 3 or 4
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
### `generate(network?, options?)`
|
|
141
|
+
|
|
142
|
+
Returns one `TestCard`. `network` is a key from the table below, or `'random'`
|
|
143
|
+
(the default).
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
generate('amex');
|
|
147
|
+
generate('visa', { formatted: true }); // adds `formatted: '4539 8776 1234 5678'`
|
|
148
|
+
generate('maestro', { length: 19 }); // pick one of the network's lengths
|
|
149
|
+
generate('visa', { expiryYearsAhead: 2 });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Throws `RangeError` on an unknown network, or on a length that network does not
|
|
153
|
+
issue — `generate('amex', { length: 16 })` is a mistake worth failing loudly.
|
|
154
|
+
|
|
155
|
+
### `generateMany(count, network?, options?)`
|
|
156
|
+
|
|
157
|
+
An array of `count` cards. Numbers are not deduplicated; collisions are
|
|
158
|
+
vanishingly unlikely at realistic counts, and discarding draws would bias the
|
|
159
|
+
output.
|
|
160
|
+
|
|
161
|
+
### `validate(pan)`
|
|
162
|
+
|
|
163
|
+
Structural validation. Separators are ignored.
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
validate('4111 1111 1111 1111');
|
|
167
|
+
// { valid: true, network: 'visa', pan: '4111111111111111',
|
|
168
|
+
// length: 16, luhn: true, errors: [] }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`errors` holds stable string codes rather than sentences, so you can map them to
|
|
172
|
+
your own copy: `not_a_string`, `empty`, `non_digit`, `unknown_network`,
|
|
173
|
+
`bad_length`, `luhn_failed`.
|
|
174
|
+
|
|
175
|
+
A number that passes Luhn but matches no known IIN range comes back with
|
|
176
|
+
`unknown_network` rather than being rejected outright. New ranges get allocated,
|
|
177
|
+
and any table like this one goes stale before the standard does.
|
|
178
|
+
|
|
179
|
+
### `detectBrand(pan)`
|
|
180
|
+
|
|
181
|
+
The network key, or `null`. Works on partial input, so it can drive a brand
|
|
182
|
+
indicator while the user is still typing:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
detectBrand('4'); // 'visa'
|
|
186
|
+
detectBrand('22'); // 'mastercard'
|
|
187
|
+
detectBrand(''); // null
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `luhnCheckDigit(payload)`
|
|
191
|
+
|
|
192
|
+
The digit that completes `payload` into a Luhn-valid number. `payload` is the
|
|
193
|
+
number *without* its final check digit.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
luhnCheckDigit('411111111111111'); // '1'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### `isLuhnValid(pan)`
|
|
200
|
+
|
|
201
|
+
Whether a number satisfies the checksum. Ignores spaces and hyphens; returns
|
|
202
|
+
`false` for anything else non-numeric rather than silently stripping it.
|
|
203
|
+
|
|
204
|
+
### `networks`, `networkKeys`
|
|
205
|
+
|
|
206
|
+
The rule table itself, if you need the lengths or CVV length for a network.
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
networks.amex.cvvLength; // 4
|
|
210
|
+
networks.amex.lengths; // [15]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Supported networks
|
|
214
|
+
|
|
215
|
+
Each network name links to a browser version of the generator, for when you
|
|
216
|
+
want a number without opening a REPL.
|
|
217
|
+
|
|
218
|
+
| Key | Network | Lengths | CVV |
|
|
219
|
+
|---|---|---|---|
|
|
220
|
+
| `visa` | [Visa](https://ccgenerator.org/visa-card-generator/) | 16 | 3 |
|
|
221
|
+
| `mastercard` | [Mastercard](https://ccgenerator.org/mastercard-generator/) | 16 | 3 |
|
|
222
|
+
| `amex` | [American Express](https://ccgenerator.org/american-express-card-generator/) | 15 | 4 |
|
|
223
|
+
| `discover` | [Discover](https://ccgenerator.org/discover-card-generator/) | 16 | 3 |
|
|
224
|
+
| `jcb` | [JCB](https://ccgenerator.org/jcb-card-generator/) | 16 | 3 |
|
|
225
|
+
| `diners` | [Diners Club](https://ccgenerator.org/diners-club-card-generator/) | 14 | 3 |
|
|
226
|
+
| `maestro` | [Maestro](https://ccgenerator.org/maestro-card-generator/) | 16, 19 | 3 |
|
|
227
|
+
| `unionpay` | [UnionPay](https://ccgenerator.org/unionpay-card-generator/) | 16, 19 | 3 |
|
|
228
|
+
| `troy` | [Troy](https://ccgenerator.org/troy-card-generator/) | 16 | 3 |
|
|
229
|
+
|
|
230
|
+
## Why the ranges are what they are
|
|
231
|
+
|
|
232
|
+
The rule table is where hand-rolled card fixtures usually go wrong, so the three
|
|
233
|
+
most common mistakes are worth spelling out:
|
|
234
|
+
|
|
235
|
+
- **Mastercard's 2-series** (222100–272099) has been live since 2017. Code that
|
|
236
|
+
only checks 51–55 rejects a real, in-issue Mastercard.
|
|
237
|
+
- **Maestro is not "50, 56–69".** That approximation swallows Discover's 6011,
|
|
238
|
+
65 and 644–649 and UnionPay's 62, so a generator built on it emits numbers
|
|
239
|
+
that are not Maestro at all. This package uses the allocations Maestro
|
|
240
|
+
actually issues on.
|
|
241
|
+
- **Not every network is 16 digits.** Amex is 15, classic Diners Club is 14, and
|
|
242
|
+
Maestro and UnionPay issue at 19 as well as 16 — the full picture is in
|
|
243
|
+
[card number length by network](https://ccgenerator.org/guides/card-number-length-by-network/).
|
|
244
|
+
A form that hard-codes 16 truncates real cards.
|
|
245
|
+
|
|
246
|
+
## Randomness
|
|
247
|
+
|
|
248
|
+
Card numbers are drawn from `crypto.getRandomValues()` with rejection sampling,
|
|
249
|
+
not `% range`. This is not a security property — nothing here protects anything.
|
|
250
|
+
It is a correctness one: a biased generator under-samples part of the range,
|
|
251
|
+
which is precisely the gap a test corpus exists to close.
|
|
252
|
+
|
|
253
|
+
## Tests
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
npm test
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
No test framework, no dependencies — `node:test` and `node:assert`. The suite
|
|
260
|
+
includes an exhaustive check that Luhn catches every single-digit error, and one
|
|
261
|
+
that pins its documented blind spot: a transposed `09` ↔ `90` passes, and any
|
|
262
|
+
claim that Luhn catches *all* transpositions is wrong.
|
|
263
|
+
|
|
264
|
+
## Related tools
|
|
265
|
+
|
|
266
|
+
The same rule table runs in two other places, for when you want the numbers
|
|
267
|
+
without writing code:
|
|
268
|
+
|
|
269
|
+
- **[Browser generator](https://ccgenerator.org/credit-card-number-generator/)** —
|
|
270
|
+
generate and copy test numbers for any of the nine networks, no install.
|
|
271
|
+
- **[Chrome extension](https://chromewebstore.google.com/detail/credit-card-generator-tes/jmjdiinigjlchmnjkccoljpcnbgbikfd)** —
|
|
272
|
+
generates test numbers, validates BINs, and fills checkout forms in one click
|
|
273
|
+
while you develop.
|
|
274
|
+
- **[Card validator](https://ccgenerator.org/credit-card-validator/)** — paste a
|
|
275
|
+
number, see the same structural checks `validate()` runs, with the failure
|
|
276
|
+
explained.
|
|
277
|
+
- **[Bulk generator](https://ccgenerator.org/bulk-credit-card-generator/)** — up
|
|
278
|
+
to 10,000 cards with reproducible seeds, exported as CSV, JSON, JSONL, SQL or
|
|
279
|
+
TSV, for fixtures that live outside JavaScript.
|
|
280
|
+
|
|
281
|
+
## Background
|
|
282
|
+
|
|
283
|
+
Longer write-ups of the mechanics behind this package:
|
|
284
|
+
|
|
285
|
+
- [The Luhn algorithm](https://ccgenerator.org/guides/luhn-algorithm/) — worked
|
|
286
|
+
example, reference implementation, and what it misses
|
|
287
|
+
- [Luhn algorithm code examples](https://ccgenerator.org/guides/luhn-algorithm-code-examples/) —
|
|
288
|
+
the check digit routine in JavaScript, TypeScript, Python, PHP and Ruby
|
|
289
|
+
- [Card number structure](https://ccgenerator.org/guides/credit-card-number-structure/) —
|
|
290
|
+
IIN, account identifier, check digit
|
|
291
|
+
- [Card brand detection regex](https://ccgenerator.org/guides/card-brand-detection-regex/)
|
|
292
|
+
- [Why test cards fail on real payment systems](https://ccgenerator.org/guides/why-test-cards-fail-on-real-payment-systems/)
|
|
293
|
+
|
|
294
|
+
## Contributing and security
|
|
295
|
+
|
|
296
|
+
Corrections to the IIN table are welcome — with a source; the bar is described
|
|
297
|
+
in [CONTRIBUTING.md](CONTRIBUTING.md). The package's security posture (no
|
|
298
|
+
dependencies, no scripts, no network, no real card data) is spelled out in
|
|
299
|
+
[SECURITY.md](SECURITY.md).
|
|
300
|
+
|
|
301
|
+
## License
|
|
302
|
+
|
|
303
|
+
[MIT](LICENSE)
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for @ccgenerator/test-cards.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written rather than emitted by tsc: the package is plain ESM with no
|
|
5
|
+
* build step, and a 90-line declaration file is cheaper to maintain than a
|
|
6
|
+
* toolchain. Keep this in step with src/index.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A network key accepted by `generate` and returned by `detectBrand`. */
|
|
10
|
+
export type NetworkKey =
|
|
11
|
+
| 'visa'
|
|
12
|
+
| 'mastercard'
|
|
13
|
+
| 'amex'
|
|
14
|
+
| 'discover'
|
|
15
|
+
| 'jcb'
|
|
16
|
+
| 'diners'
|
|
17
|
+
| 'maestro'
|
|
18
|
+
| 'unionpay'
|
|
19
|
+
| 'troy';
|
|
20
|
+
|
|
21
|
+
/** An inclusive numeric IIN range whose bounds have the same digit count. */
|
|
22
|
+
export interface PrefixRange {
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Prefix = string | PrefixRange;
|
|
28
|
+
|
|
29
|
+
export interface NetworkRule {
|
|
30
|
+
/** Display name, e.g. "American Express". */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Published IIN prefixes and ranges. */
|
|
33
|
+
prefixes: readonly Prefix[];
|
|
34
|
+
/** Digit counts this network issues. */
|
|
35
|
+
lengths: readonly number[];
|
|
36
|
+
/** CVV/CVC/CID length. */
|
|
37
|
+
cvvLength: number;
|
|
38
|
+
/** Digit grouping used by `format`. */
|
|
39
|
+
groups: readonly number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GenerateOptions {
|
|
43
|
+
/** Force one of the network's accepted lengths. */
|
|
44
|
+
length?: number;
|
|
45
|
+
/** Also return the number grouped for display. */
|
|
46
|
+
formatted?: boolean;
|
|
47
|
+
/** Upper bound for the generated expiry year. Default 5. */
|
|
48
|
+
expiryYearsAhead?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TestCard {
|
|
52
|
+
network: NetworkKey;
|
|
53
|
+
networkName: string;
|
|
54
|
+
/** Digits only. */
|
|
55
|
+
pan: string;
|
|
56
|
+
/** Present only when `formatted: true` was passed. */
|
|
57
|
+
formatted?: string;
|
|
58
|
+
cvv: string;
|
|
59
|
+
/** Zero-padded, "01"–"12". */
|
|
60
|
+
expiryMonth: string;
|
|
61
|
+
/** Four digits. */
|
|
62
|
+
expiryYear: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stable, machine-readable reasons a PAN failed validation. */
|
|
66
|
+
export type ValidationError =
|
|
67
|
+
| 'not_a_string'
|
|
68
|
+
| 'empty'
|
|
69
|
+
| 'non_digit'
|
|
70
|
+
| 'unknown_network'
|
|
71
|
+
| 'bad_length'
|
|
72
|
+
| 'luhn_failed';
|
|
73
|
+
|
|
74
|
+
export interface ValidationResult {
|
|
75
|
+
/** True only when every structural check passed. */
|
|
76
|
+
valid: boolean;
|
|
77
|
+
network: NetworkKey | null;
|
|
78
|
+
/** The PAN with separators removed. */
|
|
79
|
+
pan: string;
|
|
80
|
+
length: number;
|
|
81
|
+
luhn: boolean;
|
|
82
|
+
errors: ValidationError[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export declare function generate(
|
|
86
|
+
network?: NetworkKey | 'random',
|
|
87
|
+
options?: GenerateOptions
|
|
88
|
+
): TestCard;
|
|
89
|
+
|
|
90
|
+
export declare function generateMany(
|
|
91
|
+
count: number,
|
|
92
|
+
network?: NetworkKey | 'random',
|
|
93
|
+
options?: GenerateOptions
|
|
94
|
+
): TestCard[];
|
|
95
|
+
|
|
96
|
+
export declare function format(pan: string, groups: readonly number[]): string;
|
|
97
|
+
|
|
98
|
+
export declare function validate(pan: string): ValidationResult;
|
|
99
|
+
|
|
100
|
+
export declare function detectBrand(pan: string): NetworkKey | null;
|
|
101
|
+
|
|
102
|
+
export declare function luhnCheckDigit(payload: string): string;
|
|
103
|
+
|
|
104
|
+
export declare function isLuhnValid(pan: string): boolean;
|
|
105
|
+
|
|
106
|
+
export declare const networks: Readonly<Record<NetworkKey, NetworkRule>>;
|
|
107
|
+
|
|
108
|
+
export declare const networkKeys: readonly NetworkKey[];
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ccgenerator/test-cards",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Synthetic, Luhn-valid payment card numbers for testing card input. Zero dependencies, no network calls, works in Node and the browser.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"test-card",
|
|
7
|
+
"credit-card",
|
|
8
|
+
"luhn",
|
|
9
|
+
"payment",
|
|
10
|
+
"testing",
|
|
11
|
+
"qa",
|
|
12
|
+
"fixtures",
|
|
13
|
+
"test-data",
|
|
14
|
+
"card-validation",
|
|
15
|
+
"iin",
|
|
16
|
+
"bin",
|
|
17
|
+
"pan",
|
|
18
|
+
"card-number",
|
|
19
|
+
"sandbox",
|
|
20
|
+
"pci"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "CC Generator Editorial Team <hello@ccgenerator.org>",
|
|
24
|
+
"homepage": "https://ccgenerator.org/",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/ccgeneratororg/ccgenerator-npm.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/ccgeneratororg/ccgenerator-npm/issues"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "./src/index.js",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"default": "./src/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"types": "./index.d.ts",
|
|
41
|
+
"sideEffects": false,
|
|
42
|
+
"files": [
|
|
43
|
+
"src",
|
|
44
|
+
"index.d.ts",
|
|
45
|
+
"CHANGELOG.md",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node --test",
|
|
54
|
+
"prepublishOnly": "npm test"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network detection from an IIN prefix.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { networks, matchesPrefix, prefixWidth } from './networks.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Score a matching prefix so the most specific rule wins.
|
|
9
|
+
*
|
|
10
|
+
* Two components, in order: how many prefix digits are pinned down, then
|
|
11
|
+
* whether the rule is an exact prefix rather than a range. The second matters
|
|
12
|
+
* where two networks are allocated at the same depth — "62" is UnionPay's own
|
|
13
|
+
* two-digit allocation, so it beats a two-digit range that merely spans it.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('./networks.js').Prefix} prefix
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
function specificity(prefix) {
|
|
19
|
+
return prefixWidth(prefix) * 2 + (typeof prefix === 'string' ? 1 : 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Identify the card network from a PAN or a partial one.
|
|
24
|
+
*
|
|
25
|
+
* A number starting 6011 is Discover, not something matched by a shorter rule,
|
|
26
|
+
* because the four pinned digits outrank two. Ties beyond that fall to
|
|
27
|
+
* declaration order in the network table.
|
|
28
|
+
*
|
|
29
|
+
* Returns `null` for input that matches nothing — including empty input, which
|
|
30
|
+
* is the normal state of a card field before the first keystroke and not an
|
|
31
|
+
* error.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} pan Full or partial PAN. Spaces and hyphens are ignored.
|
|
34
|
+
* @returns {string | null} A network key such as "visa", or null.
|
|
35
|
+
*/
|
|
36
|
+
export function detectBrand(pan) {
|
|
37
|
+
if (typeof pan !== 'string') return null;
|
|
38
|
+
|
|
39
|
+
const digits = pan.replace(/[ -]/g, '');
|
|
40
|
+
if (!/^\d+$/.test(digits)) return null;
|
|
41
|
+
|
|
42
|
+
let best = null;
|
|
43
|
+
let bestScore = -1;
|
|
44
|
+
|
|
45
|
+
for (const [key, rule] of Object.entries(networks)) {
|
|
46
|
+
for (const prefix of rule.prefixes) {
|
|
47
|
+
if (!matchesPrefix(digits, prefix)) continue;
|
|
48
|
+
|
|
49
|
+
const score = specificity(prefix);
|
|
50
|
+
if (score > bestScore) {
|
|
51
|
+
best = key;
|
|
52
|
+
bestScore = score;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return best;
|
|
58
|
+
}
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic card generation.
|
|
3
|
+
*
|
|
4
|
+
* Everything produced here is structurally valid and financially inert: the
|
|
5
|
+
* numbers satisfy the Luhn checksum and sit inside published IIN ranges, and
|
|
6
|
+
* they correspond to no account at any issuer. That is the point — they are for
|
|
7
|
+
* exercising a card field, not for buying anything.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { networks, networkKeys } from './networks.js';
|
|
11
|
+
import { luhnCheckDigit } from './luhn.js';
|
|
12
|
+
import { randomInt, randomDigits, choose } from './random.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} GenerateOptions
|
|
16
|
+
* @property {number} [length] Force one of the network's accepted lengths.
|
|
17
|
+
* @property {boolean} [formatted] Also return the number grouped for display.
|
|
18
|
+
* @property {number} [expiryYearsAhead] Upper bound for the expiry year, default 5.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} TestCard
|
|
23
|
+
* @property {string} network Network key, e.g. "mastercard".
|
|
24
|
+
* @property {string} networkName Display name, e.g. "Mastercard".
|
|
25
|
+
* @property {string} pan Digits only.
|
|
26
|
+
* @property {string} [formatted] Grouped PAN, when `formatted` was requested.
|
|
27
|
+
* @property {string} cvv 3 or 4 digits, per network.
|
|
28
|
+
* @property {string} expiryMonth Zero-padded, "01"–"12".
|
|
29
|
+
* @property {string} expiryYear Four digits.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pick a concrete prefix from a network's rules.
|
|
34
|
+
*
|
|
35
|
+
* @param {import('./networks.js').Prefix[]} prefixes
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function choosePrefix(prefixes) {
|
|
39
|
+
const prefix = choose(prefixes);
|
|
40
|
+
if (typeof prefix === 'string') return prefix;
|
|
41
|
+
|
|
42
|
+
// Preserve the digit count: 300–305 must yield "300", never "3".
|
|
43
|
+
return String(randomInt(prefix.from, prefix.to)).padStart(String(prefix.to).length, '0');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Group a PAN for display, e.g. "4111 1111 1111 1111".
|
|
48
|
+
*
|
|
49
|
+
* @param {string} pan
|
|
50
|
+
* @param {readonly number[]} groups
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function format(pan, groups) {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let at = 0;
|
|
56
|
+
|
|
57
|
+
for (const size of groups) {
|
|
58
|
+
if (at >= pan.length) break;
|
|
59
|
+
chunks.push(pan.slice(at, at + size));
|
|
60
|
+
at += size;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (at < pan.length) chunks.push(pan.slice(at));
|
|
64
|
+
|
|
65
|
+
return chunks.join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate one synthetic card.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} [network] Network key. Omitted or "random" picks one.
|
|
72
|
+
* @param {GenerateOptions} [options]
|
|
73
|
+
* @returns {TestCard}
|
|
74
|
+
* @throws {RangeError} On an unknown network or a length that network never issues.
|
|
75
|
+
*/
|
|
76
|
+
export function generate(network = 'random', options = {}) {
|
|
77
|
+
const key = network === 'random' ? choose(networkKeys) : network;
|
|
78
|
+
const rule = networks[key];
|
|
79
|
+
|
|
80
|
+
if (!rule) {
|
|
81
|
+
throw new RangeError(
|
|
82
|
+
`unknown network "${network}" — expected one of: ${networkKeys.join(', ')}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const length = options.length ?? choose(rule.lengths);
|
|
87
|
+
if (!rule.lengths.includes(length)) {
|
|
88
|
+
throw new RangeError(
|
|
89
|
+
`${rule.name} does not issue ${length}-digit numbers — accepted: ${rule.lengths.join(', ')}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prefix = choosePrefix(rule.prefixes);
|
|
94
|
+
const payload = prefix + randomDigits(length - prefix.length - 1);
|
|
95
|
+
const pan = payload + luhnCheckDigit(payload);
|
|
96
|
+
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const yearsAhead = options.expiryYearsAhead ?? 5;
|
|
99
|
+
|
|
100
|
+
/** @type {TestCard} */
|
|
101
|
+
const card = {
|
|
102
|
+
network: key,
|
|
103
|
+
networkName: rule.name,
|
|
104
|
+
pan,
|
|
105
|
+
cvv: randomDigits(rule.cvvLength),
|
|
106
|
+
expiryMonth: String(randomInt(1, 12)).padStart(2, '0'),
|
|
107
|
+
expiryYear: String(now.getFullYear() + randomInt(1, yearsAhead)),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (options.formatted) {
|
|
111
|
+
card.formatted = format(pan, rule.groups);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return card;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate `count` cards.
|
|
119
|
+
*
|
|
120
|
+
* Numbers are not deduplicated: collisions are astronomically unlikely at any
|
|
121
|
+
* realistic count, and silently discarding draws would bias the output.
|
|
122
|
+
*
|
|
123
|
+
* @param {number} count
|
|
124
|
+
* @param {string} [network]
|
|
125
|
+
* @param {GenerateOptions} [options]
|
|
126
|
+
* @returns {TestCard[]}
|
|
127
|
+
* @throws {RangeError} If `count` is not a positive integer.
|
|
128
|
+
*/
|
|
129
|
+
export function generateMany(count, network = 'random', options = {}) {
|
|
130
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
131
|
+
throw new RangeError('count must be a positive integer');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return Array.from({ length: count }, () => generate(network, options));
|
|
135
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ccgenerator/test-cards
|
|
3
|
+
*
|
|
4
|
+
* Synthetic, Luhn-valid payment card numbers for testing card input. No network
|
|
5
|
+
* calls, no dependencies, no real card data — see the README for what this is
|
|
6
|
+
* and is not for.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { generate, generateMany, format } from './generate.js';
|
|
10
|
+
export { validate } from './validate.js';
|
|
11
|
+
export { detectBrand } from './detect.js';
|
|
12
|
+
export { luhnCheckDigit, isLuhnValid } from './luhn.js';
|
|
13
|
+
export { networks, networkKeys } from './networks.js';
|
package/src/luhn.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Luhn mod-10 checksum (ISO/IEC 7812-1, Annex B).
|
|
3
|
+
*
|
|
4
|
+
* Both functions walk the digits right to left, doubling every second one and
|
|
5
|
+
* subtracting 9 from any result above 9. They are kept as two functions rather
|
|
6
|
+
* than one because the check-digit case operates on a payload with the final
|
|
7
|
+
* position still empty, and folding that into the validator with a flag makes
|
|
8
|
+
* both harder to read than they need to be.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DIGITS = /^\d+$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sum a digit string under the Luhn weighting, doubling from the right.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} digits
|
|
17
|
+
* @returns {number}
|
|
18
|
+
*/
|
|
19
|
+
function luhnSum(digits) {
|
|
20
|
+
let sum = 0;
|
|
21
|
+
let double = false;
|
|
22
|
+
|
|
23
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
24
|
+
let value = digits.charCodeAt(i) - 48;
|
|
25
|
+
if (double) {
|
|
26
|
+
value *= 2;
|
|
27
|
+
if (value > 9) value -= 9;
|
|
28
|
+
}
|
|
29
|
+
sum += value;
|
|
30
|
+
double = !double;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return sum;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The digit that completes `payload` into a Luhn-valid number.
|
|
38
|
+
*
|
|
39
|
+
* `payload` is the number *without* its final check digit — for a 16-digit PAN
|
|
40
|
+
* that is 15 digits.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} payload Digits only.
|
|
43
|
+
* @returns {string} A single digit, "0"–"9".
|
|
44
|
+
* @throws {TypeError} If `payload` is not a non-empty digit string.
|
|
45
|
+
*/
|
|
46
|
+
export function luhnCheckDigit(payload) {
|
|
47
|
+
if (typeof payload !== 'string' || !DIGITS.test(payload)) {
|
|
48
|
+
throw new TypeError('luhnCheckDigit expects a non-empty string of digits');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Appending "0" puts the payload digits in the parity they will have once the
|
|
52
|
+
// real check digit occupies the last position.
|
|
53
|
+
return String((10 - (luhnSum(payload + '0') % 10)) % 10);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a number satisfies the Luhn checksum.
|
|
58
|
+
*
|
|
59
|
+
* Separators are ignored, so "4111 1111 1111 1111" and "4111-1111-1111-1111"
|
|
60
|
+
* both work. A string containing anything other than digits, spaces and hyphens
|
|
61
|
+
* is rejected outright rather than silently stripped — a letter in a PAN is a
|
|
62
|
+
* data-entry bug worth surfacing, not whitespace worth forgiving.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} pan
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function isLuhnValid(pan) {
|
|
68
|
+
if (typeof pan !== 'string') return false;
|
|
69
|
+
|
|
70
|
+
const digits = pan.replace(/[ -]/g, '');
|
|
71
|
+
if (!DIGITS.test(digits)) return false;
|
|
72
|
+
|
|
73
|
+
return luhnSum(digits) % 10 === 0;
|
|
74
|
+
}
|
package/src/networks.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card network rules: the IIN ranges, accepted lengths, CVV length and display
|
|
3
|
+
* grouping for each network the package supports.
|
|
4
|
+
*
|
|
5
|
+
* This table is the same one the generators on ccgenerator.org run on
|
|
6
|
+
* (layouts/partials/card-generator.html). Ranges come from ISO/IEC 7812-1 and
|
|
7
|
+
* the networks' own published IIN allocations. Two of them are worth calling
|
|
8
|
+
* out because they are the most commonly wrong entries in other people's
|
|
9
|
+
* tables:
|
|
10
|
+
*
|
|
11
|
+
* - Mastercard's 2-series (222100–272099) has been live since 2017. Code that
|
|
12
|
+
* only checks 51–55 rejects a real, in-issue Mastercard.
|
|
13
|
+
* - Maestro and UnionPay issue at 19 digits as well as 16. A form that hard
|
|
14
|
+
* codes 16 truncates them.
|
|
15
|
+
*
|
|
16
|
+
* `prefixes` entries are either a literal digit string or a numeric range whose
|
|
17
|
+
* bounds have the same digit count.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{from: number, to: number}} PrefixRange
|
|
22
|
+
* @typedef {string | PrefixRange} Prefix
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const networks = Object.freeze({
|
|
26
|
+
visa: {
|
|
27
|
+
name: 'Visa',
|
|
28
|
+
prefixes: ['4'],
|
|
29
|
+
lengths: [16],
|
|
30
|
+
cvvLength: 3,
|
|
31
|
+
groups: [4, 4, 4, 4],
|
|
32
|
+
},
|
|
33
|
+
mastercard: {
|
|
34
|
+
name: 'Mastercard',
|
|
35
|
+
prefixes: [{ from: 51, to: 55 }, { from: 2221, to: 2720 }],
|
|
36
|
+
lengths: [16],
|
|
37
|
+
cvvLength: 3,
|
|
38
|
+
groups: [4, 4, 4, 4],
|
|
39
|
+
},
|
|
40
|
+
amex: {
|
|
41
|
+
name: 'American Express',
|
|
42
|
+
prefixes: ['34', '37'],
|
|
43
|
+
lengths: [15],
|
|
44
|
+
cvvLength: 4,
|
|
45
|
+
groups: [4, 6, 5],
|
|
46
|
+
},
|
|
47
|
+
discover: {
|
|
48
|
+
name: 'Discover',
|
|
49
|
+
prefixes: ['6011', '65', { from: 644, to: 649 }],
|
|
50
|
+
lengths: [16],
|
|
51
|
+
cvvLength: 3,
|
|
52
|
+
groups: [4, 4, 4, 4],
|
|
53
|
+
},
|
|
54
|
+
jcb: {
|
|
55
|
+
name: 'JCB',
|
|
56
|
+
prefixes: [{ from: 3528, to: 3589 }],
|
|
57
|
+
lengths: [16],
|
|
58
|
+
cvvLength: 3,
|
|
59
|
+
groups: [4, 4, 4, 4],
|
|
60
|
+
},
|
|
61
|
+
diners: {
|
|
62
|
+
name: 'Diners Club',
|
|
63
|
+
prefixes: [{ from: 300, to: 305 }, '36', '38', '39'],
|
|
64
|
+
lengths: [14],
|
|
65
|
+
cvvLength: 3,
|
|
66
|
+
groups: [4, 6, 4],
|
|
67
|
+
},
|
|
68
|
+
maestro: {
|
|
69
|
+
name: 'Maestro',
|
|
70
|
+
// Not the "50, 56–69" approximation that circulates widely. That range
|
|
71
|
+
// swallows 6011 and 65 (Discover), 644–649 (Discover) and 62 (UnionPay), so
|
|
72
|
+
// a generator built on it emits numbers that are not Maestro at all. These
|
|
73
|
+
// are the allocations Maestro actually issues on.
|
|
74
|
+
prefixes: ['50', { from: 56, to: 58 }, '6304', '6759', { from: 6761, to: 6763 }],
|
|
75
|
+
lengths: [16, 19],
|
|
76
|
+
cvvLength: 3,
|
|
77
|
+
groups: [4, 4, 4, 4, 3],
|
|
78
|
+
},
|
|
79
|
+
unionpay: {
|
|
80
|
+
name: 'UnionPay',
|
|
81
|
+
prefixes: ['62', '81'],
|
|
82
|
+
lengths: [16, 19],
|
|
83
|
+
cvvLength: 3,
|
|
84
|
+
groups: [4, 4, 4, 4, 3],
|
|
85
|
+
},
|
|
86
|
+
troy: {
|
|
87
|
+
name: 'Troy',
|
|
88
|
+
prefixes: ['9792'],
|
|
89
|
+
lengths: [16],
|
|
90
|
+
cvvLength: 3,
|
|
91
|
+
groups: [4, 4, 4, 4],
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Every supported network key. */
|
|
96
|
+
export const networkKeys = Object.freeze(Object.keys(networks));
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a digit string starts inside a prefix rule.
|
|
100
|
+
*
|
|
101
|
+
* Partial input matches: "42" is enough to identify Visa, and "2" is enough to
|
|
102
|
+
* keep Mastercard in play without committing to it, which is what a brand
|
|
103
|
+
* indicator in a card field needs while the user is still typing.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} digits
|
|
106
|
+
* @param {Prefix} prefix
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export function matchesPrefix(digits, prefix) {
|
|
110
|
+
if (typeof prefix === 'string') {
|
|
111
|
+
const width = Math.min(digits.length, prefix.length);
|
|
112
|
+
return digits.slice(0, width) === prefix.slice(0, width);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const width = String(prefix.to).length;
|
|
116
|
+
const head = digits.slice(0, width);
|
|
117
|
+
if (head.length === 0) return false;
|
|
118
|
+
|
|
119
|
+
// Compare at the length actually available: a partial "22" against 2221–2720
|
|
120
|
+
// is checked as 22 against 22–27.
|
|
121
|
+
const scale = 10 ** (width - head.length);
|
|
122
|
+
const from = Math.floor(prefix.from / scale);
|
|
123
|
+
const to = Math.floor(prefix.to / scale);
|
|
124
|
+
const value = Number(head);
|
|
125
|
+
|
|
126
|
+
return value >= from && value <= to;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How many digits of a prefix rule are known — used to rank competing matches
|
|
131
|
+
* so the most specific network wins.
|
|
132
|
+
*
|
|
133
|
+
* @param {Prefix} prefix
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
export function prefixWidth(prefix) {
|
|
137
|
+
return typeof prefix === 'string' ? prefix.length : String(prefix.to).length;
|
|
138
|
+
}
|
package/src/random.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniform random integers, from Web Crypto where it exists.
|
|
3
|
+
*
|
|
4
|
+
* Rejection sampling rather than `% range`: taking a modulus of a 32-bit value
|
|
5
|
+
* biases the low end of the range whenever the range does not divide 2^32
|
|
6
|
+
* evenly. Values at or above the largest exact multiple of `range` are discarded
|
|
7
|
+
* and redrawn, which keeps every value equally likely.
|
|
8
|
+
*
|
|
9
|
+
* This is not a security requirement — nothing here protects anything, and the
|
|
10
|
+
* output is synthetic test data by definition. It is a correctness requirement:
|
|
11
|
+
* a biased generator produces test corpora that under-sample part of the range,
|
|
12
|
+
* which is exactly the kind of gap a test corpus exists to close.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* globals crypto */
|
|
16
|
+
const source =
|
|
17
|
+
typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function'
|
|
18
|
+
? crypto
|
|
19
|
+
: null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Uniform integer in [min, max], inclusive.
|
|
23
|
+
*
|
|
24
|
+
* @param {number} min
|
|
25
|
+
* @param {number} max
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function randomInt(min, max) {
|
|
29
|
+
const range = max - min + 1;
|
|
30
|
+
|
|
31
|
+
if (!source) {
|
|
32
|
+
// Only reached on runtimes with no Web Crypto at all. Node has had it
|
|
33
|
+
// globally since v19, and every browser this package targets has it.
|
|
34
|
+
return Math.floor(Math.random() * range) + min;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const limit = Math.floor(0x100000000 / range) * range;
|
|
38
|
+
const buffer = new Uint32Array(1);
|
|
39
|
+
let value;
|
|
40
|
+
|
|
41
|
+
do {
|
|
42
|
+
source.getRandomValues(buffer);
|
|
43
|
+
value = buffer[0];
|
|
44
|
+
} while (value >= limit);
|
|
45
|
+
|
|
46
|
+
return min + (value % range);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A string of `length` random digits.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} length
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function randomDigits(length) {
|
|
56
|
+
let digits = '';
|
|
57
|
+
for (let i = 0; i < length; i += 1) {
|
|
58
|
+
digits += String(randomInt(0, 9));
|
|
59
|
+
}
|
|
60
|
+
return digits;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A uniformly chosen element of `items`.
|
|
65
|
+
*
|
|
66
|
+
* @template T
|
|
67
|
+
* @param {readonly T[]} items
|
|
68
|
+
* @returns {T}
|
|
69
|
+
*/
|
|
70
|
+
export function choose(items) {
|
|
71
|
+
return items[randomInt(0, items.length - 1)];
|
|
72
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAN validation: structure only.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here says a card exists, has funds, or would authorise. A PAN can
|
|
5
|
+
* pass every check in this file and still be declined for a dozen reasons no
|
|
6
|
+
* client can see. Structural validation is exactly the job of the card field in
|
|
7
|
+
* a checkout form, and nothing more.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { networks } from './networks.js';
|
|
11
|
+
import { isLuhnValid } from './luhn.js';
|
|
12
|
+
import { detectBrand } from './detect.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} ValidationResult
|
|
16
|
+
* @property {boolean} valid True only when every structural check passed.
|
|
17
|
+
* @property {string | null} network Detected network key, or null.
|
|
18
|
+
* @property {string} pan The PAN with separators removed.
|
|
19
|
+
* @property {number} length Digit count.
|
|
20
|
+
* @property {boolean} luhn Whether the Luhn checksum holds.
|
|
21
|
+
* @property {string[]} errors Machine-readable reasons, empty when valid.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate the structure of a PAN.
|
|
26
|
+
*
|
|
27
|
+
* The `errors` array is deliberately made of stable string codes rather than
|
|
28
|
+
* sentences, so callers can map them to their own copy and their own
|
|
29
|
+
* translations:
|
|
30
|
+
*
|
|
31
|
+
* not_a_string, empty, non_digit, unknown_network,
|
|
32
|
+
* bad_length, luhn_failed
|
|
33
|
+
*
|
|
34
|
+
* ISO/IEC 7812-1 allows 12 to 19 digits, so a number that matches no known IIN
|
|
35
|
+
* range is reported as `unknown_network` rather than rejected outright — new
|
|
36
|
+
* ranges are allocated, and this table will be out of date before the standard
|
|
37
|
+
* is.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} pan
|
|
40
|
+
* @returns {ValidationResult}
|
|
41
|
+
*/
|
|
42
|
+
export function validate(pan) {
|
|
43
|
+
/** @type {ValidationResult} */
|
|
44
|
+
const result = {
|
|
45
|
+
valid: false,
|
|
46
|
+
network: null,
|
|
47
|
+
pan: '',
|
|
48
|
+
length: 0,
|
|
49
|
+
luhn: false,
|
|
50
|
+
errors: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (typeof pan !== 'string') {
|
|
54
|
+
result.errors.push('not_a_string');
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const digits = pan.replace(/[ -]/g, '');
|
|
59
|
+
result.pan = digits;
|
|
60
|
+
result.length = digits.length;
|
|
61
|
+
|
|
62
|
+
if (digits.length === 0) {
|
|
63
|
+
result.errors.push('empty');
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!/^\d+$/.test(digits)) {
|
|
68
|
+
result.errors.push('non_digit');
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
result.luhn = isLuhnValid(digits);
|
|
73
|
+
result.network = detectBrand(digits);
|
|
74
|
+
|
|
75
|
+
if (!result.network) {
|
|
76
|
+
result.errors.push('unknown_network');
|
|
77
|
+
} else if (!networks[result.network].lengths.includes(digits.length)) {
|
|
78
|
+
result.errors.push('bad_length');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!result.luhn) {
|
|
82
|
+
result.errors.push('luhn_failed');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result.valid = result.errors.length === 0;
|
|
86
|
+
return result;
|
|
87
|
+
}
|