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] } ] },