@danports/cardswipe
Version:
Capture & parse data from magnetic stripe card readers.
531 lines (436 loc) • 19.2 kB
JavaScript
const cardSwipe = {
cardSwipe: this,
settings: {},
init: function (options) {
return cardSwipe.methods.init(options);
},
// Built-in parsers. These include simplistic credit card parsers that
// recognize various card issuers based on patterns of the account number.
// There is no guarantee these are correct or complete; they are based
// on information from Wikipedia.
// Account numbers are validated by the Luhn checksum algorithm.
builtinParsers: {
// Generic parser. Separates raw data into up to three lines.
generic: function (rawData) {
let pattern = new RegExp("^(%[^%;\\?]+\\?)?(;[0-9\\:<>\\=]+\\?)?([+;][0-9\\:<>\\=]+\\?)?");
let match = pattern.exec(rawData);
if (!match) return null;
// Extract the three lines
let cardData = {
type: "generic",
line1: match[1] ? match[1].slice(1, -1) : "",
line2: match[2] ? match[2].slice(1, -1) : "",
line3: match[3] ? match[3].slice(1, -1) : ""
};
return cardData;
},
// Visa card parser.
visa: function (rawData) {
// Visa issuer number begins with 4 and may vary from 13 to 19 total digits. 16 digits is most common.
let pattern = new RegExp("^%B(4[0-9]{12,18})\\^([A-Z ]+)/([A-Z ]+)(\\.[A-Z ]+)?\\^([0-9]{2})([0-9]{2})");
let match = pattern.exec(rawData);
if (!match) return null;
let account = match[1];
if (!cardSwipe.luhnChecksum(account))
return null;
let cardData = {
type: "visa",
account: account,
lastName: match[2].trim(),
firstName: match[3].trim(),
honorific: match[4] ? match[4].trim().slice(1) : "",
expYear: match[5],
expMonth: match[6]
};
return cardData;
},
// MasterCard parser.
mastercard: function (rawData) {
// MasterCard starts with 51-55, and is 16 digits long.
let pattern = new RegExp("^%B(5[1-5][0-9]{14})\\^([A-Z ]+)/([A-Z ]+)(\\.[A-Z ]+)?\\^([0-9]{2})([0-9]{2})");
let match = pattern.exec(rawData);
if (!match) return null;
let account = match[1];
if (!cardSwipe.luhnChecksum(account))
return null;
let cardData = {
type: "mastercard",
account: account,
lastName: match[2],
firstName: match[3],
honorific: match[4] ? match[4].trim().slice(1) : "",
expYear: match[5],
expMonth: match[6]
};
return cardData;
},
// Discover parser.
discover: function (rawData) {
// discover starts with 6, and is 16 digits long.
let pattern = new RegExp("^%B(6[0-9]{15})\\^([A-Z ]+)/([A-Z ]+)(\\.[A-Z ]+)?\\^([0-9]{2})([0-9]{2})");
let match = pattern.exec(rawData);
if (!match) return null;
let account = match[1];
if (!cardSwipe.luhnChecksum(account))
return null;
let cardData = {
type: "discover",
account: account,
lastName: match[2],
firstName: match[3],
honorific: match[4] ? match[4].trim().slice(1) : "",
expYear: match[5],
expMonth: match[6]
};
return cardData;
},
// American Express parser
amex: function (rawData) {
// American Express starts with 34 or 37, and is 15 digits long.
let pattern = new RegExp("^%B(3[4|7][0-9]{13})\\^([A-Z ]+)/([A-Z ]+)(\\.[A-Z ]+)?\\^([0-9]{2})([0-9]{2})");
let match = pattern.exec(rawData);
if (!match) return null;
let account = match[1];
if (!cardSwipe.luhnChecksum(account))
return null;
let cardData = {
type: "amex",
account: account,
lastName: match[2],
firstName: match[3],
honorific: match[4] ? match[4].trim().slice(1) : "",
expYear: match[5],
expMonth: match[6]
};
return cardData;
}
},
// State definitions:
states: { IDLE: 0, PENDING1: 1, PENDING2: 2, READING: 3, DISCARD: 4, PREFIX: 5 },
// State names used when debugging.
stateNames: { 0: 'IDLE', 1: 'PENDING1', 2: 'PENDING2', 3: 'READING', 4: 'DISCARD', 5: 'PREFIX' },
// Holds current state. Update only through state function.
currentState: 0,
// Gets or sets the current state.
state: function () {
if (arguments.length === 0) {
return cardSwipe.currentState;
}
// Set new state.
let newState = arguments[0];
if (newState == cardSwipe.state)
return;
if (cardSwipe.settings.debug) { console.log("%s -> %s", cardSwipe.stateNames[cardSwipe.currentState], cardSwipe.stateNames[newState]); }
// Raise events when entering and leaving the READING state
if (newState == cardSwipe.states.READING) {
let event = new CustomEvent('scanstart.cardswipe');
document.dispatchEvent(event);
};
if (cardSwipe.currentState == cardSwipe.states.READING) {
let event = new CustomEvent('scanend.cardswipe');
document.dispatchEvent(event);
};
cardSwipe.currentState = newState;
},
// Array holding scanned characters
scanbuffer: [],
// Interdigit timer
timerHandle: 0,
// Keypress listener
listener: function (e) {
if (cardSwipe.settings.debug) { console.log(e.which + ': ' + String.fromCharCode(e.which)); }
switch (cardSwipe.state()) {
// IDLE: Look for prfix characters or line 1 or line 2 start
// characters, and jump to PENDING1 or PENDING2.
case cardSwipe.states.IDLE:
// Look for prefix characters, and jump to PREFIX.
if (cardSwipe.isInPrefixCodes(e.which)) {
cardSwipe.state(cardSwipe.states.PREFIX);
e.preventDefault();
e.stopPropagation();
cardSwipe.startTimer();
}
// Cards with (and readers reading) line 1:
// look for '%', and jump to PENDING1.
if (e.which == 37) {
cardSwipe.state(cardSwipe.states.PENDING1);
cardSwipe.scanbuffer = [];
cardSwipe.processCode(e.which);
e.preventDefault();
e.stopPropagation();
cardSwipe.startTimer();
}
// Cards without (or readers ignoring) line 1:
// look for ';', and jump to PENDING_LINE
if (e.which == 59) {
cardSwipe.state(cardSwipe.states.PENDING2);
cardSwipe.scanbuffer = [];
cardSwipe.processCode(e.which);
e.preventDefault();
e.stopPropagation();
cardSwipe.startTimer();
}
break;
// PENDING1: Look for A-Z then jump to READING.
// Otherwise, pass the keypress through, reset and jump to IDLE.
case cardSwipe.states.PENDING1:
// Look for format code character, A-Z. Almost always B for cards
// used by the general public. Some reader / OS combinations
// will issue lowercase characters when the caps lock key is on.
if ((e.which >= 65 && e.which <= 90) || (e.which >= 97 && e.which <= 122)) {
cardSwipe.state(cardSwipe.states.READING);
// Leaving focus on a form element wreaks browser-dependent
// havoc because of keyup and keydown events. This is a
// cross-browser way to prevent trouble.
let el = document.querySelector(':focus');
if (el) el.blur();
cardSwipe.processCode(e.which);
e.preventDefault();
e.stopPropagation();
cardSwipe.startTimer();
}
else {
cardSwipe.clearTimer();
cardSwipe.scanbuffer = null;
cardSwipe.state(cardSwipe.states.IDLE);
}
break;
// PENDING_LINE2: look for 0-9, then jump to READING.
// Otherwise, pass the keypress through, reset and jump to IDLE.
case cardSwipe.states.PENDING2:
// Look for digit.
if ((e.which >= 48 && e.which <= 57)) {
cardSwipe.state(cardSwipe.states.READING);
let el = document.querySelector(':focus');
if (el) el.blur();
cardSwipe.processCode(e.which);
e.preventDefault();
e.stopPropagation();
cardSwipe.startTimer();
}
else {
cardSwipe.clearTimer();
cardSwipe.scanbuffer = null;
cardSwipe.state(cardSwipe.states.IDLE);
}
break;
// READING: Copy characters to buffer until newline, then process the scanned characters
case cardSwipe.states.READING:
cardSwipe.processCode(e.which);
cardSwipe.startTimer();
e.preventDefault();
e.stopPropagation();
// Carriage return indicates end of scan
if (e.which == 13) {
cardSwipe.clearTimer();
cardSwipe.state(cardSwipe.states.IDLE);
cardSwipe.processScan();
}
if (cardSwipe.settings.firstLineOnly && e.which == 63) {
// End of line 1. Return early, and eat remaining characters.
cardSwipe.state(cardSwipe.states.DISCARD);
cardSwipe.processScan();
}
break;
// DISCARD: Eat up characters until newline, then jump to IDLE
case cardSwipe.states.DISCARD:
e.preventDefault();
e.stopPropagation();
if (e.which == 13) {
cardSwipe.clearTimer();
cardSwipe.state(cardSwipe.states.IDLE);
return;
}
cardSwipe.startTimer();
break;
// PREFIX: Eat up characters until % is seen, then jump to PENDING1
case cardSwipe.states.PREFIX:
// If prefix character again, pass it through and return to IDLE state.
if (cardSwipe.isInPrefixCodes(e.which)) {
cardSwipe.state(states.IDLE);
return;
}
// Eat character.
e.preventDefault();
e.stopPropagation();
// Look for '%'
if (e.which == 37) {
cardSwipe.state(states.PENDING1);
cardSwipe.scanbuffer = [];
cardSwipe.processCode(e.which);
}
// Look for ';'
if (e.which == 59) {
cardSwipe.state(states.PENDING2);
cardSwipe.scanbuffer = [];
cardSwipe.processCode(e.which);
}
cardSwipe.startTimer();
}
},
// Converts a scancode to a character and appends it to the buffer.
processCode: function (code) {
cardSwipe.scanbuffer.push(String.fromCharCode(code));
},
startTimer: function () {
clearTimeout(cardSwipe.timerHandle);
cardSwipe.timerHandle = setTimeout(cardSwipe.onTimeout, cardSwipe.settings.interdigitTimeout);
},
clearTimer: function () {
clearTimeout(cardSwipe.timerHandle);
cardSwipe.timerHandle = 0;
},
// Invoked when the timer lapses.
onTimeout: function () {
if (cardSwipe.settings.debug) { console.log('Timeout!'); }
if (cardSwipe.state() == cardSwipe.states.READING) {
cardSwipe.processScan();
}
cardSwipe.scanbuffer = null;
cardSwipe.state(states.IDLE);
},
// Processes the scanned card
processScan: function () {
if (cardSwipe.settings.debug) {
console.log(cardSwipe.scanbuffer);
}
let rawData = cardSwipe.scanbuffer.join('');
// Invoke rawData callback if defined, a testing hook.
if (cardSwipe.settings.rawDataCallback) { cardSwipe.settings.rawDataCallback.call(this, rawData); }
let result = cardSwipe.parseData(rawData);
if (result) {
// Scan complete. Invoke callback
if (cardSwipe.settings.success) { cardSwipe.settings.success.call(this, result, rawData); }
document.dispatchEvent(new CustomEvent("success.cardswipe", { detail: { rawData, result } }));
}
else {
// All parsers failed.
if (cardSwipe.settings.failure) { cardSwipe.settings.failure.call(this, rawData); }
document.dispatchEvent(new CustomEvent("failure.cardswipe", { detail: { rawData } }));
}
},
// Invokes parsers until one succeeds, and returns the parsed result,
// or null if none succeed.
parseData: function (rawData) {
for (let i = 0; i < cardSwipe.settings.parsers.length; i++) {
let ref = cardSwipe.settings.parsers[i];
let parser;
// ref is a function or the name of a builtin parser
if (typeof (ref) === "function") {
parser = ref;
}
else if (typeof (ref) === "string") {
parser = cardSwipe.builtinParsers[ref];
}
if (parser != null) {
let parsedData = parser.call(this, rawData);
if (parsedData == null)
continue;
return parsedData;
}
}
// All parsers failed.
return null;
},
bindOn: function (elm, evtName, handler) {
evtName.split('.').reduce(function (evtPart, evt) {
evt = evt ? evt + '.' + evtPart : evtPart;
elm.addEventListener(evt, handler, true);
return evt;
}, '');
},
bindOff: function (elm, evtName, handler) {
evtName.split('.').reduce(function (evtPart, evt) {
evt = evt ? evt + '.' + evtPart : evtPart;
elm.removeEventListener(evt, handler, true);
return evt;
}, '');
},
// Binds the event listener
bindListener: function () {
document.addEventListener("keypress", cardSwipe.listener);
},
// Unbinds the event listener
unbindListener: function () {
document.removeEventListener("keypress", cardSwipe.listener);
},
// Default callback used if no other specified. Works with default parser.
defaultSuccessCallback: function (cardData) {
let text = ['Line 1: ', cardData.line1, '\nLine 2: ', cardData.line2, '\nLine 3: ', cardData.line3].join('');
alert(text);
},
isInPrefixCodes: function (arg) {
if (!cardSwipe.settings.prefixCodes) {
return false;
}
return (cardSwipe.settings.prefixCodes.indexOf(arg) !== -1);
//return $.inArray(arg, cardSwipe.settings.prefixCodes) != -1;
},
// Apply the Luhn checksum test. Returns true on a valid account number.
// The input is assumed to be a string containing only digits.
luhnChecksum: function (digits) {
let map = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9];
let sum = 0;
// Proceed right to left. Even and odd digit positions are handled differently.
let n = digits.length;
let odd = true;
while (n--) {
let d = parseInt(digits.charAt(n), 10);
if (odd) {
// Odd digits used as is
sum += d;
}
else {
// Even digits mapped
sum += map[d];
}
odd = !odd;
}
return sum % 10 === 0 && sum > 0;
},
// Callable plugin methods
methods: {
init: function (options) {
let defaults = {
enabled: true,
interdigitTimeout: 250,
success: cardSwipe.defaultSuccessCallback,
failure: null,
parsers: ["visa", "mastercard", "amex", "discover", "generic"],
firstLineOnly: false,
prefixCharacter: null,
debug: false
};
cardSwipe.settings = Object.assign(defaults, options);
// Is a prefix character defined?
if (cardSwipe.settings.prefixCharacter) {
// Check if prefix character is an array, if its not, convert
let isPrefixCharacterArray = Object.prototype.toString.call(cardSwipe.settings.prefixCharacter) === '[object Array]';
if (!isPrefixCharacterArray) {
cardSwipe.settings.prefixCharacter = [cardSwipe.settings.prefixCharacter];
}
cardSwipe.settings.prefixCodes = [];
for (let prefix of cardSwipe.settings.prefixCharacter) {
if (prefix.length != 1) {
throw 'prefixCharacter must be a single character';
}
cardSwipe.settings.prefixCodes.push(prefix.charCodeAt(0));
}
}
// Reset state
cardSwipe.clearTimer();
cardSwipe.state(cardSwipe.states.IDLE);
cardSwipe.scanbuffer = null;
cardSwipe.unbindListener();
if (cardSwipe.settings.enabled)
cardSwipe.methods.enable();
},
disable: function () {
cardSwipe.unbindListener();
},
enable: function () {
cardSwipe.bindListener();
}
}
}
export default cardSwipe;