Created
July 28, 2022 02:31
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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