Skip to content

Instantly share code, notes, and snippets.

@mdchaney
Created July 28, 2022 02:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mdchaney/c653694703bfceb19bce5dbd5b8e29c6 to your computer and use it in GitHub Desktop.
Save mdchaney/c653694703bfceb19bce5dbd5b8e29c6 to your computer and use it in GitHub Desktop.
Modernization of Stripe's jquery.payment - almost drop-in replacement with no jQuery
// Based on Stripe's jquery.payment, offers a simple set of utilities
// for handling credit card numbers, expirations, and CVC/CVV2 codes
//
// cc_format_card_number(field)
// cc_format_card_expiry(field)
// cc_format_card_cvc(field)
//
// "field" may be either the actual DOM element reference or a string
// with the ID of the DOM element. This will add event handlers to
// the field to handle input.
//
// cc_card_type(card_number)
//
// Returns card type as a string given the card number
//
// cc_card_expiry_val(expiry)
//
// Returns an object with "month" and "year" as integer properties
//
// cc_validate_card_number(card_number)
//
// Checks validity (luhn) of card number, returns true or false
//
// cc_validate_card_expiry(expiry)
//
// Returns true if and only if expiry is valid month/year pair and
// either this month or in the future.
//
// cc_validate_card_cvc(cvc, card_type)
//
// Returns true if cvc is valid for the given card type
//
// cc_blurred_card_number(card_number)
//
// Returns the card number with all but the last four digits replaced
// with •
const defaultFormat = /(\d{1,4})/g;
const cards = [
{
type: 'maestro',
patterns: [5018, 502, 503, 506, 56, 58, 639, 6220, 67],
format: defaultFormat,
length: [12, 13, 14, 15, 16, 17, 18, 19],
cvcLength: [3],
luhn: true
}, {
type: 'forbrugsforeningen',
patterns: [600],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'dankort',
patterns: [5019],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'visa',
patterns: [4],
format: defaultFormat,
length: [13, 16],
cvcLength: [3],
luhn: true
}, {
type: 'mastercard',
patterns: [51, 52, 53, 54, 55, 22, 23, 24, 25, 26, 27],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'amex',
patterns: [34, 37],
format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/,
length: [15],
cvcLength: [3, 4],
luhn: true
}, {
type: 'dinersclub',
patterns: [30, 36, 38, 39],
format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/,
length: [14],
cvcLength: [3],
luhn: true
}, {
type: 'discover',
patterns: [60, 64, 65, 622],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}, {
type: 'unionpay',
patterns: [62, 88],
format: defaultFormat,
length: [16, 17, 18, 19],
cvcLength: [3],
luhn: false
}, {
type: 'jcb',
patterns: [35],
format: defaultFormat,
length: [16],
cvcLength: [3],
luhn: true
}
];
function card_from_number(num) {
num = num.toString().replace(/\D/g, '');
return cards.find(card => {
return card.patterns.find(pattern => {
pattern = pattern.toString();
return (num.substr(0, pattern.length) === pattern);
});
});
};
function card_from_type(type) {
return cards.find(card => card.type === type);
};
export const cc_luhn_check = function(num) {
let odd = false;
let sum = 0;
const digits = (num + '').split('').reverse();
for (var digit of digits) {
digit = parseInt(digit, 10);
if (odd) digit *= 2;
if (digit > 9) digit -= 9;
sum += digit;
odd = !odd;
}
return sum % 10 === 0;
};
function has_text_selected(el) {
if (el.selectionStart != null && el.selectionStart !== el.selectionEnd) {
return true;
}
}
// This hack looks for scenarios where we are changing an input's value such
// that "X| " is replaced with " |X" (where "|" is the cursor). In those
// scenarios, we want " X|".
//
// For example:
// 1. Input field has value "4444| "
// 2. User types "1"
// 3. Input field has value "44441| "
// 4. Reformatter changes it to "4444 |1"
// 5. By incrementing the cursor, we make it "4444 1|"
//
// This is awful, and ideally doesn't go here, but given the current design
// of the system there does not appear to be a better solution.
//
// Note that we can't just detect when the cursor-1 is " ", because that
// would incorrectly increment the cursor when backspacing, e.g. pressing
// backspace in this scenario: "4444 1|234 5".
function safe_val(value, $target) {
var currPair, cursor, digit, error, last, prevPair;
try {
cursor = $target.selectionStart;
} catch (_error) {
error = _error;
cursor = null;
}
last = $target.value;
$target.value = value;
if (cursor !== null && document.activeElement == $target) {
if (cursor === last.length) {
cursor = value.length;
}
if (last !== value) {
prevPair = last.slice(cursor - 1, +cursor + 1 || 9e9);
currPair = value.slice(cursor - 1, +cursor + 1 || 9e9);
digit = value[cursor];
if (/\d/.test(digit) && prevPair === ("" + digit + " ") && currPair === (" " + digit)) {
cursor = cursor + 1;
}
}
$target.selectionStart = cursor;
$target.selectionEnd = cursor;
}
};
function replace_full_width_chars(str) {
str = str ? str : '';
let fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19';
let halfWidth = '0123456789';
return str.split('').map(chr => {
const idx = fullWidth.indexOf(chr);
return (idx > -1 ? halfWidth[idx] : chr);
}).join('');
};
function reformat_numeric_evh(ev) {
const $target = ev.target;
return setTimeout(function() {
let value = $target.value;
value = replace_full_width_chars(value);
value = value.replace(/\D/g, '');
safe_val(value, $target);
});
};
function reformat_card_number_evh(ev) {
var $target = ev.target;
return setTimeout(function() {
let value = $target.value;
value = replace_full_width_chars(value);
value = payment_format_card_number(value);
safe_val(value, $target);
});
};
// Handles digit added
function format_card_number_evh(ev) {
// Only format if input is a digit
let digit = ev.key;
if (!/^\d+$/.test(digit)) return;
const $target = ev.target;
let value = $target.value;
let card = card_from_number(value + digit);
let length = (value.replace(/\D/g, '') + digit).length;
let upperLength = 16;
if (card) upperLength = card.length[card.length.length - 1];
if (length >= upperLength) return;
// Return if focus isn't at the end of the text
if ($target.selectionStart != null && $target.selectionStart !== value.length)
return;
let re;
if (card && card.type === 'amex') {
re = /^(\d{4}|\d{4}\s\d{6})$/;
} else {
re = /(?:^|\s)(\d{4})$/;
}
if (re.test(value)) {
ev.preventDefault();
setTimeout(function() {
$target.value = value + ' ' + digit;
});
} else if (re.test(value + digit)) {
ev.preventDefault();
setTimeout(function() {
$target.value = value + digit + ' ';
});
}
};
// Handles backspace key
function format_back_card_number_evh(ev) {
// return unless backspacing
if (ev.key != 'Backspace') return;
let $target = ev.target;
let value = $target.value;
// Return if focus isn't at the end of the text
if ($target.selectionStart != null && $target.selectionStart !== value.length)
return;
// Remove digit + trailing space
if (/\d\s$/.test(value)) {
ev.preventDefault();
setTimeout(function() {
$target.value = value.replace(/\d\s$/, '');
});
// Remove digit if ends in space + digit
} else if (/\s\d?$/.test(value)) {
ev.preventDefault();
setTimeout(function() {
$target.value = value.replace(/\d$/, '');
});
}
};
function reformat_expiry_evh(ev) {
var $target = ev.target;
setTimeout(function() {
let value = $target.value;
value = replace_full_width_chars(value);
value = payment_format_expiry(value);
safe_val(value, $target);
});
};
// Handles digit input in expiry
function format_expiry_evh(ev) {
let digit = ev.key;
if (!/^\d+$/.test(digit)) return;
let $target = ev.target;
let val = $target.value + digit;
if (/^\d$/.test(val) && (val !== '0' && val !== '1')) {
ev.preventDefault();
setTimeout(function() {
$target.value = "0" + val + " / ";
});
} else if (/^\d\d$/.test(val)) {
ev.preventDefault();
setTimeout(function() {
// Split for months where we have the second digit > 2 (past 12) and turn
// that into (m1)(m2) => 0(m1) / (m2)
var m1, m2;
m1 = parseInt(val[0], 10);
m2 = parseInt(val[1], 10);
if (m2 > 2 && m1 !== 0) {
$target.value = "0" + m1 + " / " + m2;
} else {
$target.value = "" + val + " / ";
}
});
}
};
// add slash to expiry if there are exactly two digits
function format_forward_expiry_evh(ev) {
const digit = ev.key;
if (!/^\d+$/.test(digit)) return;
let $target = ev.target;
let val = $target.value;
if (/^\d\d$/.test(val)) {
$target.value = "" + val + " / ";
}
};
// handle slash or space in expiry
function format_forward_slash_and_space_evh(ev) {
const key = ev.key;
if (key !== '/' && key !== ' ') return;
let $target = ev.target;
const val = $target.value;
if (/^\d$/.test(val) && val !== '0') {
$target.value = "0" + val + " / ";
}
};
function format_back_expiry_evh(ev) {
// return unless backspacing
if (ev.key != 'Backspace') return;
let $target = ev.target;
let value = $target.value;
// Return if focus isn't at the end of the text
if ($target.selectionStart != null && $target.selectionStart !== value.length)
return;
if (/\d\s\/\s$/.test(value)) {
ev.preventDefault();
setTimeout(function() {
$target.value = value.replace(/\d\s\/\s$/, '');
});
}
};
function reformat_cvc_evh(ev) {
let $target = ev.target;
setTimeout(function() {
let value = $target.value;
value = replace_full_width_chars(value);
value = value.replace(/\D/g, '').slice(0, 4);
safe_val(value, $target);
});
};
// This function is strange due to handling of space
function restrict_numeric_evh(ev) {
const input = ev.key;
if (input == 'Meta' || input == 'Control') return true;
if (input == ' ') return false;
return !!/[\d\s]/.test(input);
};
function restrict_card_number_evh(ev) {
const digit = ev.key;
if (!/^\d+$/.test(digit)) return;
const $target = ev.target;
if (has_text_selected($target)) return;
const value = ($target.value + digit).replace(/\D/g, '');
const card = card_from_number(value);
if (card) {
return value.length <= card.length[card.length.length - 1];
} else {
// Assume max length is 16 if the card type is unidentified
return value.length <= 16;
}
};
function restrict_expiry_evh(ev) {
const digit = ev.key;
if (!/^\d+$/.test(digit)) return;
const $target = ev.target;
if (has_text_selected($target)) return;
let value = ($target.value + digit).replace(/\D/g, '');
return (value.length <= 6);
};
function restrict_cvc_evh(ev) {
const digit = ev.key;
if (!/^\d+$/.test(digit)) return;
const $target = ev.target;
if (has_text_selected($target)) return;
let value = ($target.value + digit).replace(/\D/g, '');
return (value.length <= 4);
};
function set_card_type_evh(ev) {
const $target = ev.target;
const value = $target.value;
const card_type = cc_card_type(value) || 'unknown';
if (card_type !== $target.dataset.cardType) {
$target.dataset.cardType = card_type;
$target.dataset.cardIdentified = card_type !== 'unknown';
const change_event = new Event('payment.cardType', { card_type: card_type });
$target.dispatchEvent(change_event);
}
};
// This accepts either a string as a DOM element ID or a DOM
// element. It will return the element.
function payment_normalize_el(el) {
if (typeof(el) == 'string') {
return document.getElementById(el);
} else {
return el;
}
}
export const cc_format_card_cvc = function(el) {
el = payment_normalize_el(el);
el.addEventListener('keypress', restrict_numeric_evh);
el.addEventListener('keypress', restrict_cvc_evh);
el.addEventListener('paste', reformat_cvc_evh);
el.addEventListener('change', reformat_cvc_evh);
el.addEventListener('input', reformat_cvc_evh);
};
export const cc_format_card_expiry = function(el) {
el = payment_normalize_el(el);
el.addEventListener('keypress', restrict_numeric_evh);
el.addEventListener('keypress', restrict_expiry_evh);
el.addEventListener('keypress', format_expiry_evh);
el.addEventListener('keypress', format_forward_slash_and_space_evh);
el.addEventListener('keypress', format_forward_expiry_evh);
el.addEventListener('keydown', format_back_expiry_evh);
el.addEventListener('change', reformat_expiry_evh);
el.addEventListener('input', reformat_expiry_evh);
};
export const cc_format_card_number = function(el) {
el = payment_normalize_el(el);
el.addEventListener('keypress', restrict_numeric_evh);
el.addEventListener('keypress', restrict_card_number_evh);
el.addEventListener('keypress', format_card_number_evh);
el.addEventListener('keydown', format_back_card_number_evh);
el.addEventListener('keyup', set_card_type_evh);
el.addEventListener('paste', reformat_card_number_evh);
el.addEventListener('change', reformat_card_number_evh);
el.addEventListener('input', reformat_card_number_evh);
el.addEventListener('input', set_card_type_evh);
}
export const cc_restrict_numeric = function(el) {
el = payment_normalize_el(el);
el.addEventListener('keypress', restrict_numeric_evh);
el.addEventListener('paste', reformat_numeric_evh);
el.addEventListener('change', reformat_numeric_evh);
el.addEventListener('input', reformat_numeric_evh);
}
function payment_default_century() {
return Math.floor((new Date).getFullYear() / 100) * 100;
}
export const cc_card_expiry_val = function(value) {
var md = value.match(/(\d+)\D+(\d+)/);
if (md) {
let month = parseInt(md[1], 10);
let year = parseInt(md[2], 10);
if (year < 100) {
year += payment_default_century();
}
return { month: month, year: year };
} else {
return null;
}
}
export const cc_validate_card_number = function(num) {
num = num.toString().replace(/\D+/g, '');
if (!/^\d+$/.test(num)) return false;
const card = card_from_number(num);
if (!card) return false;
// The length has to be one of the possible card lengths, and
// the luhn has to be correct if the card type includes it.
if (!card.length.find(len => len == num.length)) return false;
if (!card.luhn) return true;
return cc_luhn_check(num);
};
export const cc_validate_card_expiry = function(month, year) {
// Allow passing the object with month & year properties
if (typeof month === 'object' && 'month' in month) {
const _ref = month;
month = _ref.month;
year = _ref.year;
}
month = parseInt(month);
year = parseInt(year);
if (month < 1 || month > 12) return false;
if (year < 100) year += payment_default_century();
// Note that months are zero-based in JavaScript, so this is the first
// day of the next month. It handles "12" just fine and will go to
// the next year.
let expiry = new Date(year, month, 1);
const currentTime = new Date;
return (expiry > currentTime);
};
export const cc_validate_card_cvc = function(cvc, card_type) {
let card = card_from_type(card_type);
cvc = cvc.toString().replace(/\D+/g, '');
if (!/^\d+$/.test(cvc)) return false;
if (card) {
return card.cvcLength.find(x => x == cvc.length);
} else {
return cvc.length >= 3 && cvc.length <= 4;
}
};
export const cc_card_type = function(num) {
const card = card_from_number(num);
return card ? card.type : null;
}
export const cc_blurred_card_number = function(num) {
let cnt = 0;
return num.toString().split('').reverse().map((s,i) => { if (s!=' ') cnt++; return (cnt<5 || s==' ' ? s : '•'); }).reverse().join('')
}
function payment_format_card_number(num) {
num = num.replace(/\D/g, '');
const card = card_from_number(num);
if (!card) return num;
// chop off at maximum size
const upperLength = card.length[card.length.length - 1];
num = num.slice(0, upperLength);
if (card.format.global) {
return num.match(card.format).join(' ')
} else {
let groups = card.format.exec(num);
if (groups == null) return null;
groups.shift();
// Remove empty groups, join with space
return groups.filter(x => x).join(' ');
}
};
function payment_format_expiry(expiry) {
const parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/);
if (!parts) return '';
let mon = parts[1] || '';
let sep = parts[2] || '';
let year = parts[3] || '';
if (year.length > 0) {
sep = ' / ';
} else if (sep === ' /') {
mon = mon.substring(0, 1);
sep = '';
} else if (mon.length === 2 || sep.length > 0) {
sep = ' / ';
} else if (mon.length === 1 && (mon !== '0' && mon !== '1')) {
mon = "0" + mon;
sep = ' / ';
}
return mon + sep + year;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment