overleaf/services/web/public/coffee/directives/creditCards.coffee
2016-08-18 17:56:53 +01:00

539 lines
No EOL
14 KiB
CoffeeScript

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
cardType = (num) ->
return null unless num
cardFromNumber(num)?.type or null
formatCardNumber = (num) ->
num = num.replace(/\D/g, '')
card = cardFromNumber(num)
return num unless card
upperLength = card.length[card.length.length - 1]
num = num[0...upperLength]
if card.format.global
num.match(card.format)?.join(' ')
else
groups = card.format.exec(num)
return unless groups?
groups.shift()
groups = $.grep(groups, (n) -> n) # Filter empty groups
groups.join(' ')
formatExpiry = (expiry) ->
parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/)
return '' unless parts
mon = parts[1] || ''
sep = parts[2] || ''
year = parts[3] || ''
if year.length > 0
sep = ' / '
else if sep is ' /'
mon = mon.substring(0, 1)
sep = ''
else if mon.length == 2 or sep.length > 0
sep = ' / '
else if mon.length == 1 and mon not in ['0', '1']
mon = "0#{mon}"
sep = ' / '
return mon + sep + year
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)
return unless !isNaN(month) and !isNaN(year)
month: month, year: year
return {
fromNumber: cardFromNumber
fromType: cardFromType
cardType: cardType
formatExpiry: formatExpiry
formatCardNumber: formatCardNumber
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
safeVal = (value, $target) ->
try
cursor = $target.prop('selectionStart')
catch error
cursor = null
last = $target.val()
$target.val(value)
if cursor != null && $target.is(":focus")
cursor = value.length if cursor is last.length
# 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".
if last != value
prevPair = last[cursor-1..cursor]
currPair = value[cursor-1..cursor]
digit = value[cursor]
cursor = cursor + 1 if /\d/.test(digit) and
prevPair == "#{digit} " and currPair == " #{digit}"
$target.prop('selectionStart', cursor)
$target.prop('selectionEnd', cursor)
# 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 = ccUtils.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.fromNumber(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$/, ''))
getFormattedCardNumber = (num) ->
num = num.replace(/\D/g, '')
card = ccUtils.fromNumber(num)
return num unless card
upperLength = card.length[card.length.length - 1]
num = num[0...upperLength]
if card.format.global
num.match(card.format)?.join(' ')
else
groups = card.format.exec(num)
return unless groups?
groups.shift()
groups = $.grep(groups, (n) -> n) # Filter empty groups
groups.join(' ')
parseCardNumber = (value) ->
if value? then value.replace(/\s/g, '') else value
# Format Expiry
reFormatExpiry = (e) ->
$target = $(e.currentTarget)
setTimeout ->
value = $target.val()
value = replaceFullWidthChars(value)
value = ccUtils.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)
return unless dateAsObj?
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 = ccUtils.fromNumber(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 = ccUtils.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
getFormattedCardNumber
parseCardNumber
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.restrictNumeric
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
el.on "change", ccFormat.reFormatExpiry
el.on "input", ccFormat.reFormatExpiry
el.on "paste", ccFormat.reFormatExpiry
ngModel.$parsers.push ccFormat.parseExpiry
ngModel.$formatters.push ccFormat.parseExpiry
App.directive "ccFormatCardNumber", (ccFormat) ->
restrict: "A"
require: "ngModel"
link: (scope, el, attrs, ngModel) ->
el.on "keypress", ccFormat.restrictNumeric
el.on "keypress", ccFormat.restrictCardNumber
el.on "keypress", ccFormat.formatCardNumber
el.on "keydown", ccFormat.formatBackCardNumber
el.on "paste", ccFormat.reFormatCardNumber
ngModel.$parsers.push ccFormat.parseCardNumber
ngModel.$formatters.push ccFormat.getFormattedCardNumber
App.directive "ccFormatSecCode", (ccFormat) ->
restrict: "A"
require: "ngModel"
link: (scope, el, attrs, ngModel) ->
el.on "keypress", ccFormat.restrictNumeric
el.on "keypress", ccFormat.restrictCVC
el.on "paste", ccFormat.reFormatCVC
el.on "change", ccFormat.reFormatCVC
el.on "input", ccFormat.reFormatCVC