From 79d9e5445899225fb9b6aae6d84dafcde9bed2f2 Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 17 Aug 2016 17:34:04 +0100 Subject: [PATCH] Basic port of Stripe JS credit card validation and formatting lib. --- .../coffee/directives/creditCards.coffee | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 services/web/public/coffee/directives/creditCards.coffee diff --git a/services/web/public/coffee/directives/creditCards.coffee b/services/web/public/coffee/directives/creditCards.coffee new file mode 100644 index 0000000000..ae9df139a9 --- /dev/null +++ b/services/web/public/coffee/directives/creditCards.coffee @@ -0,0 +1,395 @@ +define [ + "base" +], (App) -> + App.factory 'ccUtils', () -> + defaultFormat = /(\d{1,4})/g; + defaultInputFormat = /(?:^|\s)(\d{4})$/ + + cards = [ + # Credit cards + { + 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..19] + cvcLength: [3] + luhn: false + } + { + type: 'jcb' + patterns: [35] + format: defaultFormat + length: [16] + cvcLength: [3] + luhn: true + } + ] + + cardFromNumber = (num) -> + num = (num + '').replace(/\D/g, "") + for card in cards + for pattern in card.patterns + p = pattern + "" + return card if num.substr(0, p.length) == p + + cardFromType = (type) -> + return card for card in cards when card.type is type + + parseExpiry = (value = "") -> + [month, year] = value.split(/[\s\/]+/, 2) + + # Allow for year shortcut + if year?.length is 2 and /^\d+$/.test(year) + prefix = (new Date).getFullYear() + prefix = prefix.toString()[0..1] + year = prefix + year + + month = parseInt(month, 10) + year = parseInt(year, 10) + + month: month, year: year + + return { + fromNumber: cardFromNumber + fromType: cardFromType + defaultFormat: defaultFormat + defaultInputFormat: defaultInputFormat + parseExpiry: parseExpiry + } + + App.factory 'ccFormat', (ccUtils, $filter) -> + hasTextSelected = ($target) -> + # If some text is selected + return true if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt $target.prop('selectionEnd') + + # If some text is selected in IE + if document?.selection?.createRange? + return true if document.selection.createRange().text + + false + + # Replace Full-Width Chars + replaceFullWidthChars = (str = '') -> + fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19' + halfWidth = '0123456789' + + value = '' + chars = str.split('') + + # Avoid using reserved word `char` + for chr in chars + idx = fullWidth.indexOf(chr) + chr = halfWidth[idx] if idx > -1 + value += chr + + value + + # Format Numeric + reFormatNumeric = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = value.replace(/\D/g, '') + safeVal(value, $target) + + # Format Card Number + reFormatCardNumber = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = $.payment.formatCardNumber(value) + #safeVal(value, $target) + + formatCardNumber = (e) -> + # Only format if input is a number + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + value = $target.val() + card = ccUtils.cardFromNumber(value + digit) + length = (value.replace(/\D/g, '') + digit).length + + upperLength = 16 + upperLength = card.length[card.length.length - 1] if card + return if length >= upperLength + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + if card && card.type is 'amex' + # AMEX cards are formatted differently + re = /^(\d{4}|\d{4}\s\d{6})$/ + else + re = /(?:^|\s)(\d{4})$/ + + # If '4242' + 4 + if re.test(value) + e.preventDefault() + setTimeout -> $target.val(value + ' ' + digit) + + # If '424' + 2 + else if re.test(value + digit) + e.preventDefault() + setTimeout -> $target.val(value + digit + ' ') + + formatBackCardNumber = (e) -> + $target = $(e.currentTarget) + value = $target.val() + + # Return unless backspacing + return unless e.which is 8 + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + # Remove the digit + trailing space + if /\d\s$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d\s$/, '')) + # Remove digit if ends in space + digit + else if /\s\d?$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d$/, '')) + + # Format Expiry + reFormatExpiry = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = $.payment.formatExpiry(value) + safeVal(value, $target) + + formatExpiry = (e) -> + # Only format if input is a number + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + val = $target.val() + digit + + if /^\d$/.test(val) and val not in ['0', '1'] + e.preventDefault() + setTimeout -> $target.val("0#{val} / ") + + else if /^\d\d$/.test(val) + e.preventDefault() + setTimeout -> + # Split for months where we have the second digit > 2 (past 12) and turn + # that into (m1)(m2) => 0(m1) / (m2) + m1 = parseInt(val[0], 10) + m2 = parseInt(val[1], 10) + if m2 > 2 and m1 != 0 + $target.val("0#{m1} / #{m2}") + else + $target.val("#{val} / ") + + formatForwardExpiry = (e) -> + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + $target = $(e.currentTarget) + val = $target.val() + + if /^\d\d$/.test(val) + $target.val("#{val} / ") + + formatForwardSlash = (e) -> + which = String.fromCharCode(e.which) + return unless which is '/' or which is ' ' + + $target = $(e.currentTarget) + val = $target.val() + + if /^\d$/.test(val) and val isnt '0' + $target.val("0#{val} / ") + + formatBackExpiry = (e) -> + $target = $(e.currentTarget) + value = $target.val() + + # Return unless backspacing + return unless e.which is 8 + + # Return if focus isn't at the end of the text + return if $target.prop('selectionStart')? and + $target.prop('selectionStart') isnt value.length + + # Remove the trailing space + last digit + if /\d\s\/\s$/.test(value) + e.preventDefault() + setTimeout -> $target.val(value.replace(/\d\s\/\s$/, '')) + + parseExpiry = (value) -> + if value? + dateAsObj = ccUtils.parseExpiry(value); + expiry = new Date dateAsObj.year, dateAsObj.month - 1 + return $filter('date')(expiry, 'MM/yyyy') + + # Format CVC + reFormatCVC = (e) -> + $target = $(e.currentTarget) + setTimeout -> + value = $target.val() + value = replaceFullWidthChars(value) + value = value.replace(/\D/g, '')[0...4] + safeVal(value, $target) + + # Restrictions + restrictNumeric = (e) -> + # Key event is for a browser shortcut + return true if e.metaKey or e.ctrlKey + + # If keycode is a space + return false if e.which is 32 + + # If keycode is a special char (WebKit) + return true if e.which is 0 + + # If char is a special char (Firefox) + return true if e.which < 33 + + input = String.fromCharCode(e.which) + + # Char is a number or a space + !!/[\d\s]/.test(input) + + restrictCardNumber = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + # Restrict number of digits + value = ($target.val() + digit).replace(/\D/g, '') + card = cardFromNumber(value) + + if card + value.length <= card.length[card.length.length - 1] + else + # All other cards are 16 digits long + value.length <= 16 + + restrictExpiry = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + value = $target.val() + digit + value = value.replace(/\D/g, '') + + return false if value.length > 6 + + restrictCVC = (e) -> + $target = $(e.currentTarget) + digit = String.fromCharCode(e.which) + return unless /^\d+$/.test(digit) + + return if hasTextSelected($target) + + val = $target.val() + digit + val.length <= 4 + + setCardType = (e) -> + $target = $(e.currentTarget) + val = $target.val() + cardType = $.payment.cardType(val) or 'unknown' + + unless $target.hasClass(cardType) + allTypes = (card.type for card in cards) + + $target.removeClass('unknown') + $target.removeClass(allTypes.join(' ')) + + $target.addClass(cardType) + $target.toggleClass('identified', cardType isnt 'unknown') + $target.trigger('payment.cardType', cardType) + + return { + hasTextSelected + replaceFullWidthChars + reFormatNumeric + reFormatCardNumber + formatCardNumber + formatBackCardNumber + reFormatExpiry + formatExpiry + formatForwardExpiry + formatForwardSlash + formatBackExpiry + parseExpiry + reFormatCVC + restrictNumeric + restrictCardNumber + restrictExpiry + restrictCVC + setCardType + } + + App.directive 'ccFormatExpiry', (ccFormat) -> + restrict: 'A' + require: 'ngModel' + link: (scope, el, attrs, ngModel) -> + el.on 'keypress', ccFormat.restrictExpiry + el.on 'keypress', ccFormat.formatExpiry + el.on 'keypress', ccFormat.formatForwardSlash + el.on 'keypress', ccFormat.formatForwardExpiry + el.on 'keydown', ccFormat.formatBackExpiry + + ngModel.$parsers.push ccFormat.parseExpiry + ngModel.$formatters.push ccFormat.parseExpiry + + + \ No newline at end of file