diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md
index cacd7866..35741b52 100644
--- a/PAYMENT_RECIPES.md
+++ b/PAYMENT_RECIPES.md
@@ -101,6 +101,8 @@ Important assumptions:
## 5) Generate / Verify Card Validation Data
Operations:
+- `Generate Test PAN`
+- `Parse PAN`
- `Generate Card Validation Data`
- `Verify Card Validation Data`
@@ -117,6 +119,15 @@ Important assumptions:
- iCVV forces service code `999`
- this is a clear-key software emulation of common card-validation flows
+Recommended chain:
+- `Generate Test PAN` -> `Parse PAN` -> `Generate Card Validation Data`
+
+Use `Generate Test PAN` when:
+- you want a Visa, Mastercard, American Express, or Discover PAN to feed into later recipes
+
+Use `Parse PAN` when:
+- you want to confirm network, IIN, length, and Luhn validity before continuing
+
## 6) Generate / Translate / Verify Payment PIN Data
Operations:
- `Generate Payment PIN Data`
@@ -273,7 +284,19 @@ Flow:
- keep issuer validation data, PAN, PVKI, decimalization table, and PVK in the args
- use the JSON output when you need to inspect how the verification artifact was assembled
-## H) AS2805 KEK Validation
+## H) Brand Test Card Setup
+Operations:
+- `Generate Test PAN`
+- `Parse PAN`
+- `Generate Card Validation Data`
+- `Generate Payment PIN Data`
+
+Flow:
+- generate a curated or locally generated brand-valid PAN
+- parse it to confirm brand and Luhn validity
+- feed the PAN into CVV, PIN, EMV, or parser recipes
+
+## I) AS2805 KEK Validation
Operations:
- `Generate AS2805 KEK Validation`
- `Calculate Payment KCV`
diff --git a/PAYMENT_SIM_RECIPES.md b/PAYMENT_SIM_RECIPES.md
index 42778bb0..b9f36593 100644
--- a/PAYMENT_SIM_RECIPES.md
+++ b/PAYMENT_SIM_RECIPES.md
@@ -40,6 +40,8 @@ This list targets software-only development and testing environments.
4. Session derivation input normalization checks.
5. Cryptogram preimage assembly validation recipes.
6. PAN parser and network classifier recipes for Visa (`4`, typically 13/16/19 digits), Mastercard (`51`-`55`, `2221`-`2720`, 16 digits), American Express (`34`, `37`, 15 digits), and Discover (`6011`, `644`-`649`, `65`, and `622126`-`622925`, typically 16-19 digits), including Luhn validation and issuer-range explanation.
+Status:
+`Generate Test PAN` and `Parse PAN` are now implemented. Remaining follow-on work is richer test-card-profile generation around expiry, CVV, service code, AVS, and EMV context.
## AWS Payment Cryptography Candidate Recipes
1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA.
diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index ff16bebc..6167b11f 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -579,6 +579,8 @@
"Verify EMV ARQC",
"Generate EMV ARPC",
"Generate EMV MAC For PIN Change",
+ "Generate Test PAN",
+ "Parse PAN",
"Generate Card Validation Data",
"Verify Card Validation Data",
"Generate Payment PIN Data",
diff --git a/src/core/lib/Pan.mjs b/src/core/lib/Pan.mjs
new file mode 100644
index 00000000..008e7a54
--- /dev/null
+++ b/src/core/lib/Pan.mjs
@@ -0,0 +1,288 @@
+/**
+ * @license Apache-2.0
+ */
+
+import OperationError from "../errors/OperationError.mjs";
+
+const PAN_BRANDS = ["Visa", "Mastercard", "American Express", "Discover"];
+
+const PAN_BRAND_RULES = {
+ "Visa": {
+ lengths: [13, 16, 19],
+ curatedPan: "4024140000000131",
+ curatedSource: "Public Visa test PAN published in Mastercard AVS scenario documentation.",
+ prefixes: [
+ {
+ start: 4,
+ end: 4,
+ lengths: [13, 16, 19],
+ description: "Visa cards begin with 4."
+ }
+ ]
+ },
+ "Mastercard": {
+ lengths: [16],
+ curatedPan: "5204749999994311",
+ curatedSource: "Public Mastercard test PAN published in Mastercard AVS scenario documentation.",
+ prefixes: [
+ {
+ start: 51,
+ end: 55,
+ lengths: [16],
+ description: "Mastercard 2-series legacy range 51 through 55."
+ },
+ {
+ start: 2221,
+ end: 2720,
+ lengths: [16],
+ description: "Mastercard 2-series range 2221 through 2720."
+ }
+ ]
+ },
+ "American Express": {
+ lengths: [15],
+ curatedPan: "371449635398431",
+ curatedSource: "Representative Amex-style test PAN included as a deterministic sample because no openly published public Amex network sample was verified here.",
+ prefixes: [
+ {
+ start: 34,
+ end: 34,
+ lengths: [15],
+ description: "American Express cards begin with 34 or 37 and use 15 digits."
+ },
+ {
+ start: 37,
+ end: 37,
+ lengths: [15],
+ description: "American Express cards begin with 34 or 37 and use 15 digits."
+ }
+ ]
+ },
+ "Discover": {
+ lengths: [16, 17, 18, 19],
+ curatedPan: "6011000991543426",
+ curatedSource: "Public Discover POS test PAN published by Discover Global Network.",
+ prefixes: [
+ {
+ start: 6011,
+ end: 6011,
+ lengths: [16, 17, 18, 19],
+ description: "Discover range 6011."
+ },
+ {
+ start: 644,
+ end: 649,
+ lengths: [16, 17, 18, 19],
+ description: "Discover range 644 through 649."
+ },
+ {
+ start: 65,
+ end: 65,
+ lengths: [16, 17, 18, 19],
+ description: "Discover range 65."
+ },
+ {
+ start: 622126,
+ end: 622925,
+ lengths: [16, 17, 18, 19],
+ description: "Discover range 622126 through 622925."
+ }
+ ]
+ }
+};
+
+/**
+ * Normalizes a PAN.
+ *
+ * @param {string} pan
+ * @returns {string}
+ */
+function normalizePan(pan) {
+ const normalized = (pan || "").replace(/\s+/g, "");
+ if (!/^\d{12,19}$/.test(normalized)) {
+ throw new OperationError("PAN must be 12 to 19 digits.");
+ }
+ return normalized;
+}
+
+/**
+ * Calculates a Luhn check digit for a numeric body.
+ *
+ * @param {string} body
+ * @returns {number}
+ */
+function luhnCheckDigit(body) {
+ let sum = 0;
+ let doubleDigit = true;
+
+ for (let i = body.length - 1; i >= 0; i--) {
+ let digit = parseInt(body.charAt(i), 10);
+ if (doubleDigit) {
+ digit *= 2;
+ if (digit > 9) digit -= 9;
+ }
+ sum += digit;
+ doubleDigit = !doubleDigit;
+ }
+
+ return (10 - (sum % 10)) % 10;
+}
+
+/**
+ * Returns whether a full PAN passes Luhn validation.
+ *
+ * @param {string} pan
+ * @returns {boolean}
+ */
+function isLuhnValid(pan) {
+ const normalized = normalizePan(pan);
+ const body = normalized.slice(0, -1);
+ return luhnCheckDigit(body) === parseInt(normalized.slice(-1), 10);
+}
+
+/**
+ * Returns the first matching brand rule for a PAN.
+ *
+ * @param {string} pan
+ * @returns {{brand: string, rule: Object}|null}
+ */
+function matchPanBrand(pan) {
+ for (const brand of PAN_BRANDS) {
+ const config = PAN_BRAND_RULES[brand];
+ for (const rule of config.prefixes) {
+ const prefixLength = String(rule.start).length;
+ if (!rule.lengths.includes(pan.length)) continue;
+ const prefix = parseInt(pan.substring(0, prefixLength), 10);
+ if (prefix >= rule.start && prefix <= rule.end) {
+ return { brand, rule };
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Parses a PAN and returns payment-network details.
+ *
+ * @param {string} pan
+ * @returns {Object}
+ */
+function parsePan(pan) {
+ const normalized = normalizePan(pan);
+ const match = matchPanBrand(normalized);
+
+ return {
+ pan: normalized,
+ network: match ? match.brand : "Unknown",
+ majorIndustryIdentifier: normalized.charAt(0),
+ issuerIdentificationNumber: normalized.substring(0, Math.min(8, normalized.length)),
+ length: normalized.length,
+ luhnValid: isLuhnValid(normalized),
+ matchedRule: match ? {
+ rangeStart: String(match.rule.start),
+ rangeEnd: String(match.rule.end),
+ lengths: match.rule.lengths,
+ description: match.rule.description
+ } : null
+ };
+}
+
+/**
+ * Appends a Luhn check digit to a numeric PAN body.
+ *
+ * @param {string} body
+ * @returns {string}
+ */
+function finalizePan(body) {
+ return `${body}${luhnCheckDigit(body)}`;
+}
+
+/**
+ * Generates a numeric filler string.
+ *
+ * @param {number} length
+ * @returns {string}
+ */
+function fillerDigits(length) {
+ const seed = "12345678901234567890";
+ return seed.repeat(Math.ceil(length / seed.length)).substring(0, length);
+}
+
+/**
+ * Generates a deterministic brand-valid PAN.
+ *
+ * @param {string} brand
+ * @param {number} requestedLength
+ * @returns {{pan: string, prefixDescription: string}}
+ */
+function generateBrandPan(brand, requestedLength) {
+ 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];
+
+ 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];
+ }
+
+ const prefix = String(selectedRule.start);
+ const bodyLength = length - 1;
+ const body = `${prefix}${fillerDigits(bodyLength - prefix.length)}`.substring(0, bodyLength);
+
+ return {
+ pan: finalizePan(body),
+ prefixDescription: selectedRule.description
+ };
+}
+
+/**
+ * Generates a test PAN.
+ *
+ * @param {string} brand
+ * @param {string} mode
+ * @param {number} length
+ * @returns {Object}
+ */
+function generateTestPan(brand, mode, length) {
+ const config = PAN_BRAND_RULES[brand];
+ if (!config) {
+ throw new OperationError("Unsupported payment network.");
+ }
+
+ if (mode === "Curated sample") {
+ const parsed = parsePan(config.curatedPan);
+ return {
+ brand,
+ mode,
+ pan: config.curatedPan,
+ source: config.curatedSource,
+ ...parsed
+ };
+ }
+
+ const generated = generateBrandPan(brand, Number(length) || config.lengths[0]);
+ const parsed = parsePan(generated.pan);
+ return {
+ brand,
+ mode,
+ pan: generated.pan,
+ source: "Generated locally from public network prefix and length rules, then Luhn-completed.",
+ generationRule: generated.prefixDescription,
+ ...parsed
+ };
+}
+
+export {
+ PAN_BRANDS,
+ generateTestPan,
+ isLuhnValid,
+ parsePan,
+};
diff --git a/src/core/operations/GenerateTestPAN.mjs b/src/core/operations/GenerateTestPAN.mjs
new file mode 100644
index 00000000..6f10b73c
--- /dev/null
+++ b/src/core/operations/GenerateTestPAN.mjs
@@ -0,0 +1,74 @@
+/**
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import { PAN_BRANDS, generateTestPan } from "../lib/Pan.mjs";
+
+/**
+ * Generate test PAN operation.
+ */
+class GenerateTestPAN extends Operation {
+ /**
+ * GenerateTestPAN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Generate Test PAN";
+ this.module = "Payment";
+ this.description = "Generate a brand-valid payment card number for test workflows.
Input: ignored.
Arguments: choose the payment network, decide whether to use a curated sample or a locally generated brand-valid PAN, and choose the target length when the network supports multiple lengths.
This operation is intended for recipe chaining into card-validation, PIN, EMV, and parser flows.";
+ this.inlineHelp = "Input: ignored.
Args: choose the network, sample mode, and target length.";
+ this.testDataSamples = [
+ {
+ name: "Visa curated sample",
+ input: "",
+ args: ["Visa", "Curated sample", 16, true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Network",
+ type: "option",
+ value: PAN_BRANDS,
+ comment: "Choose the payment network whose public numbering rules should be applied."
+ },
+ {
+ name: "Sample mode",
+ type: "option",
+ value: ["Curated sample", "Generated valid PAN"],
+ comment: "Curated sample returns a fixed network sample when available. Generated mode builds a deterministic network-valid PAN from public prefix and length rules and then applies Luhn."
+ },
+ {
+ name: "Target length",
+ type: "number",
+ value: 16,
+ min: 13,
+ max: 19,
+ comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: true,
+ comment: "When enabled, returns the PAN plus the detected network, IIN, Luhn status, and source note."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [brand, mode, length, outputJson] = args;
+ const result = generateTestPan(brand, mode, length);
+ return outputJson ? JSON.stringify(result, null, 4) : result.pan;
+ }
+}
+
+export default GenerateTestPAN;
diff --git a/src/core/operations/ParsePAN.mjs b/src/core/operations/ParsePAN.mjs
new file mode 100644
index 00000000..17a0ac55
--- /dev/null
+++ b/src/core/operations/ParsePAN.mjs
@@ -0,0 +1,44 @@
+/**
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import { parsePan } from "../lib/Pan.mjs";
+
+/**
+ * Parse PAN operation.
+ */
+class ParsePAN extends Operation {
+ /**
+ * ParsePAN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Parse PAN";
+ this.module = "Payment";
+ this.description = "Paste a payment card number into the input field and classify it by public network rules.
Input: PAN digits.
Arguments: none.
This parser identifies Visa, Mastercard, American Express, and Discover based on public prefix and length rules, and reports Luhn validity.";
+ this.inlineHelp = "Input: PAN digits only.
Args: none.";
+ this.testDataSamples = [
+ {
+ name: "Discover sample",
+ input: "6011000991543426",
+ args: []
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [];
+ }
+
+ /**
+ * @param {string} input
+ * @returns {string}
+ */
+ run(input) {
+ return JSON.stringify(parsePan(input), null, 4);
+ }
+}
+
+export default ParsePAN;
diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs
index 5267ad43..fc770f59 100755
--- a/src/web/waiters/RecipeWaiter.mjs
+++ b/src/web/waiters/RecipeWaiter.mjs
@@ -225,6 +225,9 @@ class RecipeWaiter {
*/
ingChange(e) {
if (e && e?.target?.classList?.contains("no-state-change")) return;
+ if (e?.target?.classList?.contains("arg")) {
+ this.syncArgVisualState(e.target);
+ }
window.dispatchEvent(this.manager.statechange);
}
@@ -553,6 +556,8 @@ class RecipeWaiter {
} else {
ingEls[i].value = args[i];
}
+
+ this.syncArgVisualState(ingEls[i]);
}
this.triggerArgEvents(op);
@@ -753,6 +758,30 @@ class RecipeWaiter {
}
+ /**
+ * Keeps floating-label state in sync for programmatically populated args.
+ *
+ * @param {HTMLElement} el
+ */
+ syncArgVisualState(el) {
+ if (!el) return;
+
+ const group = el.closest(".form-group, .bmd-form-group");
+ if (!group) return;
+
+ let isFilled = false;
+ if (el.getAttribute("type") === "checkbox" || el.getAttribute("type") === "radio") {
+ isFilled = el.checked;
+ } else if (typeof el.value === "string") {
+ isFilled = el.value.trim().length > 0;
+ } else {
+ isFilled = Boolean(el.value);
+ }
+
+ group.classList.toggle("is-filled", isFilled);
+ }
+
+
/**
* Handler for operationadd events.
*
@@ -832,6 +861,7 @@ class RecipeWaiter {
if (text) {
targ.value = text;
+ this.syncArgVisualState(targ);
return;
}
@@ -840,7 +870,7 @@ class RecipeWaiter {
const self = this;
reader.onload = function (e) {
targ.value = e.target.result;
- // Trigger floating label move
+ self.syncArgVisualState(targ);
const changeEvent = new Event("change");
targ.dispatchEvent(changeEvent);
window.dispatchEvent(self.manager.statechange);
diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs
index 3edb6846..a4cfc0b8 100644
--- a/tests/operations/tests/Payment.mjs
+++ b/tests/operations/tests/Payment.mjs
@@ -209,6 +209,68 @@ TestRegister.addTests([
}
]
},
+ {
+ name: "Generate Test PAN: Visa curated sample",
+ input: "",
+ expectedOutput: JSON.stringify({
+ brand: "Visa",
+ mode: "Curated sample",
+ pan: "4024140000000131",
+ source: "Public Visa test PAN published in Mastercard AVS scenario documentation.",
+ network: "Visa",
+ majorIndustryIdentifier: "4",
+ issuerIdentificationNumber: "40241400",
+ length: 16,
+ luhnValid: true,
+ matchedRule: {
+ rangeStart: "4",
+ rangeEnd: "4",
+ lengths: [13, 16, 19],
+ description: "Visa cards begin with 4."
+ }
+ }, null, 4),
+ recipeConfig: [
+ {
+ op: "Generate Test PAN",
+ args: ["Visa", "Curated sample", 16, true]
+ }
+ ]
+ },
+ {
+ name: "Generate Test PAN: American Express generated sample",
+ input: "",
+ expectedOutput: "371234567890120",
+ recipeConfig: [
+ {
+ op: "Generate Test PAN",
+ args: ["American Express", "Generated valid PAN", 15, false]
+ }
+ ]
+ },
+ {
+ name: "Parse PAN: Discover sample",
+ input: "6011000991543426",
+ expectedOutput: JSON.stringify({
+ pan: "6011000991543426",
+ network: "Discover",
+ majorIndustryIdentifier: "6",
+ issuerIdentificationNumber: "60110009",
+ length: 16,
+ luhnValid: true,
+ matchedRule: {
+ rangeStart: "6011",
+ rangeEnd: "6011",
+ lengths: [16, 17, 18, 19],
+ description: "Discover range 6011."
+ }
+ }, null, 4),
+ recipeConfig: [
+ {
+ op: "Parse PAN",
+ args: []
+ }
+ ]
+ },
{
name: "Verify Card Validation Data: known CVV2 sample",
input: "0123456789ABCDEFFEDCBA9876543210",