@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 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
+ [![npm version](https://img.shields.io/npm/v/%40ccgenerator%2Ftest-cards)](https://www.npmjs.com/package/@ccgenerator/test-cards)
4
+ [![CI](https://github.com/ccgeneratororg/ccgenerator-npm/actions/workflows/test.yml/badge.svg)](https://github.com/ccgeneratororg/ccgenerator-npm/actions/workflows/test.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+ [![dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](./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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }