From 0da4c99a0cfbba8e17d83b2dc394d2544ce00f1d Mon Sep 17 00:00:00 2001 From: J8k3 Date: Mon, 18 May 2026 14:18:22 -0400 Subject: [PATCH] Fix PAN generation: random prefix within range, Mastercard series option Two bugs in generateBrandPan: 1. For networks with multiple prefix rules, always picked the same rule (Mastercard always 2-series, AmEx always 37, Discover always 6011) 2. Always used the start of the range as the prefix, so Mastercard generated 51xxxxx or 2221xxxxxx every time instead of any value in 51-55 or 2221-2720 Fix both: pick a random prefix rule and a random prefix within start..end. Add a "Mastercard series" arg to PAN Generate so callers can explicitly request 5-series (51-55), 2-series (2221-2720), or leave it random. The curated sample path is unaffected. Co-Authored-By: Claude Sonnet 4.6 --- src/core/lib/Pan.mjs | 32 ++++++++++++++++--------- src/core/operations/GenerateTestPAN.mjs | 14 +++++++---- tests/operations/tests/Payment.mjs | 4 ++-- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/core/lib/Pan.mjs b/src/core/lib/Pan.mjs index 475b4fd0..f25ee528 100644 --- a/src/core/lib/Pan.mjs +++ b/src/core/lib/Pan.mjs @@ -6,6 +6,7 @@ import OperationError from "../errors/OperationError.mjs"; const PAN_BRANDS = ["Visa", "Mastercard", "American Express", "Discover"]; +const MASTERCARD_SERIES = ["Any", "5-series (51-55)", "2-series (2221-2720)"]; // ── Card classification tables ──────────────────────────────────────────────── @@ -264,26 +265,33 @@ function fillerDigits(length) { * * @param {string} brand * @param {number} requestedLength + * @param {string} mastercardSeries - "Any", "5-series (51-55)", or "2-series (2221-2720)" * @returns {{pan: string, prefixDescription: string}} */ -function generateBrandPan(brand, requestedLength) { +function generateBrandPan(brand, requestedLength, mastercardSeries = "Any") { const config = PAN_BRAND_RULES[brand]; if (!config) { throw new OperationError("Unsupported payment network."); } const length = config.lengths.includes(requestedLength) ? requestedLength : config.lengths[0]; - let selectedRule = config.prefixes[0]; + const eligibleRules = config.prefixes.filter(r => r.lengths.includes(length)); - if (brand === "Mastercard" && length === 16) { - selectedRule = config.prefixes[1]; - } else if (brand === "American Express") { - selectedRule = config.prefixes[1]; - } else if (brand === "Discover") { - selectedRule = config.prefixes[0]; + let selectedRule; + if (brand === "Mastercard") { + if (mastercardSeries === "5-series (51-55)") { + selectedRule = config.prefixes[0]; + } else if (mastercardSeries === "2-series (2221-2720)") { + selectedRule = config.prefixes[1]; + } else { + selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)]; + } + } else { + selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)]; } - const prefix = String(selectedRule.start); + const prefixValue = selectedRule.start + Math.floor(Math.random() * (selectedRule.end - selectedRule.start + 1)); + const prefix = String(prefixValue); const bodyLength = length - 1; const body = `${prefix}${fillerDigits(bodyLength - prefix.length)}`.substring(0, bodyLength); @@ -299,9 +307,10 @@ function generateBrandPan(brand, requestedLength) { * @param {string} brand * @param {string} mode * @param {number} length + * @param {string} mastercardSeries * @returns {Object} */ -function generateTestPan(brand, mode, length) { +function generateTestPan(brand, mode, length, mastercardSeries = "Any") { const config = PAN_BRAND_RULES[brand]; if (!config) { throw new OperationError("Unsupported payment network."); @@ -318,7 +327,7 @@ function generateTestPan(brand, mode, length) { }; } - const generated = generateBrandPan(brand, Number(length) || config.lengths[0]); + const generated = generateBrandPan(brand, Number(length) || config.lengths[0], mastercardSeries); const parsed = parsePan(generated.pan); return { brand, @@ -332,6 +341,7 @@ function generateTestPan(brand, mode, length) { export { PAN_BRANDS, + MASTERCARD_SERIES, generateTestPan, isLuhnValid, parsePan, diff --git a/src/core/operations/GenerateTestPAN.mjs b/src/core/operations/GenerateTestPAN.mjs index 9c2b1412..6534916e 100644 --- a/src/core/operations/GenerateTestPAN.mjs +++ b/src/core/operations/GenerateTestPAN.mjs @@ -4,7 +4,7 @@ */ import Operation from "../Operation.mjs"; -import { PAN_BRANDS, generateTestPan } from "../lib/Pan.mjs"; +import { PAN_BRANDS, MASTERCARD_SERIES, generateTestPan } from "../lib/Pan.mjs"; /** * Generate test PAN operation. @@ -24,7 +24,7 @@ class GenerateTestPAN extends Operation { { name: "Visa curated sample", input: "", - args: ["Visa", "Curated sample", 16, true] + args: ["Visa", "Curated sample", 16, "Any", true] } ]; this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number"; @@ -51,6 +51,12 @@ class GenerateTestPAN extends Operation { max: 19, comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length." }, + { + name: "Mastercard series", + type: "option", + value: MASTERCARD_SERIES, + comment: "Applies only when Network is Mastercard in generated mode. '5-series' restricts to the 51–55 range. '2-series' restricts to 2221–2720. 'Any' picks randomly between both ranges." + }, { name: "Output as JSON", type: "boolean", @@ -66,8 +72,8 @@ class GenerateTestPAN extends Operation { * @returns {string} */ run(input, args) { - const [brand, mode, length, outputJson] = args; - const result = generateTestPan(brand, mode, length); + const [brand, mode, length, mastercardSeries, outputJson] = args; + const result = generateTestPan(brand, mode, length, mastercardSeries); return outputJson ? JSON.stringify(result, null, 4) : result.pan; } } diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index fb202f4f..3d254632 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -416,7 +416,7 @@ TestRegister.addTests([ recipeConfig: [ { op: "PAN Generate", - args: ["Visa", "Curated sample", 16, true] + args: ["Visa", "Curated sample", 16, "Any", true] } ] }, @@ -427,7 +427,7 @@ TestRegister.addTests([ recipeConfig: [ { op: "PAN Generate", - args: ["American Express", "Curated sample", 15, false] + args: ["American Express", "Curated sample", 15, "Any", false] } ] },