Merge pull request #193 from houdiniproject/payment_code

Move in credit card validation code from stripe/jquery.payment
This commit is contained in:
Eric Schultz 2019-05-20 10:11:25 -05:00 committed by GitHub
commit 4b272a1489
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 581 additions and 0 deletions

View file

@ -0,0 +1,343 @@
// License: LGPL-3.0-or-later
// based on https://github.com/stripe/jquery.payment/blob/master/test/specs.coffee
import { CreditCardTypeManager, defaultFormat } from './credit_card'
import 'jest';
import _ = require('lodash');
describe('CreditCardTypeManager', () => {
let cc: CreditCardTypeManager
beforeEach(() => {
cc = new CreditCardTypeManager()
})
describe('Validating a card number', () => {
it('should fail if empty', () => {
const topic = cc.validateCardNumber('')
expect(topic).toBeFalsy()
})
it('should fail if is a bunch of spaces', () => {
const topic = cc.validateCardNumber(' ')
expect(topic).toBeFalsy()
})
it('should success if is valid', () => {
const topic = cc.validateCardNumber('4242424242424242')
expect(topic).toBeTruthy()
})
it('that has dashes in it but is valid', () => {
const topic = cc.validateCardNumber('4242-4242-4242-4242')
expect(topic).toBeTruthy()
})
it('should succeed if it has spaces in it but is valid', () => {
const topic = cc.validateCardNumber('4242 4242 4242 4242')
expect(topic).toBeTruthy()
})
it('that does not pass the luhn checker', () => {
const topic = cc.validateCardNumber('4242424242424241')
expect(topic).toBeFalsy()
})
it('should fail if is more than 16 digits', () => {
const topic = cc.validateCardNumber('42424242424242424')
expect(topic).toBeFalsy()
})
it('should fail if is less than 10 digits', () => {
const topic = cc.validateCardNumber('424242424')
expect(topic).toBeFalsy()
})
it('should fail with non-digits', () => {
const topic = cc.validateCardNumber('4242424e42424241')
expect(topic).toBeFalsy()
})
it('should validate for all card types', () => {
expect(cc.validateCardNumber('6759649826438453')).toBe('maestro')
expect(cc.validateCardNumber('6007220000000004')).toBe('forbrugsforeningen')
expect(cc.validateCardNumber('5019717010103742')).toBe('dankort')
expect(cc.validateCardNumber('4111111111111111')).toBe('visa')
expect(cc.validateCardNumber('4012888888881881')).toBe('visa')
expect(cc.validateCardNumber('4222222222222')).toBe('visa')
expect(cc.validateCardNumber('4462030000000000')).toBe('visa')
expect(cc.validateCardNumber('4484070000000000')).toBe('visa')
expect(cc.validateCardNumber('5555555555554444')).toBe('mastercard')
expect(cc.validateCardNumber('5454545454545454')).toBe('mastercard')
expect(cc.validateCardNumber('2221000002222221')).toBe('mastercard')
expect(cc.validateCardNumber('378282246310005')).toBe('amex')
expect(cc.validateCardNumber('371449635398431')).toBe('amex')
expect(cc.validateCardNumber('378734493671000')).toBe('amex')
expect(cc.validateCardNumber('30569309025904')).toBe('dinersclub')
expect(cc.validateCardNumber('38520000023237')).toBe('dinersclub')
expect(cc.validateCardNumber('36700102000000')).toBe('dinersclub')
expect(cc.validateCardNumber('36148900647913')).toBe('dinersclub')
expect(cc.validateCardNumber('6011111111111117')).toBe('discover')
expect(cc.validateCardNumber('6011000990139424')).toBe('discover')
expect(cc.validateCardNumber('6271136264806203568')).toBe('unionpay')
expect(cc.validateCardNumber('6236265930072952775')).toBe('unionpay')
expect(cc.validateCardNumber('6204679475679144515')).toBe('unionpay')
expect(cc.validateCardNumber('6216657720782466507')).toBe('unionpay')
expect(cc.validateCardNumber('3530111333300000')).toBe('jcb')
expect(cc.validateCardNumber('3566002020360505')).toBe('jcb')
})
})
describe('Validating a CVC', () => {
it('should fail if is empty', () => {
const topic = cc.validateCardCVC('')
expect(topic).toBeFalsy()
})
it('should pass if is valid', () => {
const topic = cc.validateCardCVC('123')
expect(topic).toBeTruthy()
})
it('should fail with non-digits', () => {
const topic = cc.validateCardNumber('12e')
expect(topic).toBeFalsy()
})
it('should fail with less than 3 digits', () => {
const topic = cc.validateCardNumber('12')
expect(topic).toBeFalsy()
})
it('should fail with more than 4 digits', () => {
const topic = cc.validateCardNumber('12345')
expect(topic).toBeFalsy()
})
})
describe('Validating an expiration date', () => {
it('should fail expires is before the current year', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1, currentTime.getFullYear() - 1)
expect(topic).toBeFalsy()
})
it('that expires in the current year but before current month', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth(), currentTime.getFullYear())
expect(topic).toBeFalsy()
})
it('that has an invalid month', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(13, currentTime.getFullYear())
expect(topic).toBeFalsy()
})
it('that is this year and month', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1, currentTime.getFullYear())
expect(topic).toBeTruthy()
})
it('that is just after this month', () => {
// Remember - months start with 0 in JavaScript!
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1, currentTime.getFullYear())
expect(topic).toBeTruthy()
})
it('that is after this year', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1, currentTime.getFullYear() + 1)
expect(topic).toBeTruthy()
})
it('that is a two-digit year', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1,
('' + currentTime.getFullYear()).substr(0, 2))
expect(topic).toBeTruthy()
})
it('that is a two-digit year in the past (i.e. 1990s)', () => {
const currentTime = new Date()
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1, 99)
expect(topic).toBeFalsy()
})
it('that has string numbers', () => {
const currentTime = new Date()
currentTime.setFullYear(currentTime.getFullYear() + 1, currentTime.getMonth() + 2)
const topic = cc.validateCardExpiry(currentTime.getMonth() + 1 + '', currentTime.getFullYear() + '')
expect(topic).toBeTruthy()
})
it('that has non-numbers', () => {
const topic = cc.validateCardExpiry('h12', '3300')
expect(topic).toBeFalsy()
})
it('should fail if year or month is NaN', () => {
const topic = cc.validateCardExpiry('12', NaN)
expect(topic).toBeFalsy()
})
it('should support year shorthand', () => {
expect(cc.validateCardExpiry('05', '20')).toBeTruthy()
})
})
describe('Validating a CVC number', () => {
it('should validate a three digit number with no card type', () => {
const topic = cc.validateCardCVC('123')
expect(topic).toBeTruthy()
})
it('should validate a three digit number with card type amex', () => {
const topic = cc.validateCardCVC('123', 'amex')
expect(topic).toBeTruthy()
})
it('should validate a three digit number with card type other than amex', () => {
const topic = cc.validateCardCVC('123', 'visa')
expect(topic).toBeTruthy()
})
it('should not validate a four digit number with a card type other than amex', () => {
const topic = cc.validateCardCVC('1234', 'visa')
expect(topic).toBeFalsy()
})
it('should validate a four digit number with card type amex', () => {
const topic = cc.validateCardCVC('1234', 'amex')
expect(topic).toBeTruthy()
})
it('should not validate a number larger than 4 digits', () => {
const topic = cc.validateCardCVC('12344')
expect(topic).toBeFalsy()
})
})
describe('Parsing an expiry value', () => {
it('should parse string expiry', () => {
const topic = cc.cardExpiryVal('03 / 2025')
expect(topic).toEqual({ month: 3, year: 2025 })
})
it('should support shorthand year', () => {
const topic = cc.cardExpiryVal('05/04')
expect(topic).toEqual({ month: 5, year: 2004 })
})
it('should return NaN when it cannot parse', () => {
const topic = cc.cardExpiryVal('05/dd')
expect(isNaN(topic.year)).toBeTruthy()
})
})
describe('Getting a card type', () => {
it('should return Visa that begins with 40', () => {
const topic = cc.cardType('4012121212121212')
expect(topic).toBe('visa')
})
it('that begins with 2 should return MasterCard', () => {
const topic = cc.cardType('2221000002222221')
expect(topic).toBe('mastercard')
})
it('that begins with 5 should return MasterCard', () => {
const topic = cc.cardType('5555555555554444')
expect(topic).toBe('mastercard')
})
it('that begins with 34 should return American Express', () => {
const topic = cc.cardType('3412121212121212')
expect(topic).toBe('amex')
})
it('that is not numbers should return null', () => {
const topic = cc.cardType('aoeu')
expect(topic).toBe(null)
})
it('that has unrecognized beginning numbers should return null', () => {
const topic = cc.cardType('aoeu')
expect(topic).toBe(null)
})
it('should return correct type for all test numbers', () => {
expect(cc.cardType('6759649826438453')).toBe('maestro')
expect(cc.cardType('6220180012340012345')).toBe('maestro')
expect(cc.cardType('6007220000000004')).toBe('forbrugsforeningen')
expect(cc.cardType('5019717010103742')).toBe('dankort')
expect(cc.cardType('4111111111111111')).toBe('visa')
expect(cc.cardType('4012888888881881')).toBe('visa')
expect(cc.cardType('4222222222222')).toBe('visa')
expect(cc.cardType('4462030000000000')).toBe('visa')
expect(cc.cardType('4484070000000000')).toBe('visa')
expect(cc.cardType('5555555555554444')).toBe('mastercard')
expect(cc.cardType('5454545454545454')).toBe('mastercard')
expect(cc.cardType('2221000002222221')).toBe('mastercard')
expect(cc.cardType('378282246310005')).toBe('amex')
expect(cc.cardType('371449635398431')).toBe('amex')
expect(cc.cardType('378734493671000')).toBe('amex')
expect(cc.cardType('30569309025904')).toBe('dinersclub')
expect(cc.cardType('38520000023237')).toBe('dinersclub')
expect(cc.cardType('36700102000000')).toBe('dinersclub')
expect(cc.cardType('36148900647913')).toBe('dinersclub')
expect(cc.cardType('6011111111111117')).toBe('discover')
expect(cc.cardType('6011000990139424')).toBe('discover')
expect(cc.cardType('6271136264806203568')).toBe('unionpay')
expect(cc.cardType('6236265930072952775')).toBe('unionpay')
expect(cc.cardType('6204679475679144515')).toBe('unionpay')
expect(cc.cardType('6216657720782466507')).toBe('unionpay')
expect(cc.cardType('3530111333300000')).toBe('jcb')
expect(cc.cardType('3566002020360505')).toBe('jcb')
})
})
describe('Extending the card collection', () => {
it('should expose an array of standard card types', () => {
const cards = cc.cards
expect(Array.isArray(cards))
const visa = _.find(cards, (card) => card.type === 'visa')
expect(visa).toBeTruthy()
})
it('should support new card types', () => {
const wing = {
type: 'wing',
patterns: [501818],
length: [16],
luhn: false,
format: defaultFormat,
cvcLength: [2]
}
cc.cards.unshift(wing)
const wingCard = '5018 1818 1818 1818'
expect(cc.cardType(wingCard)).toBe('wing')
expect(cc.validateCardNumber(wingCard)).toBeTruthy()
})
})
})

View file

@ -0,0 +1,238 @@
// License: LGPL-3.0-or-later
// based on: https://github.com/stripe/jquery.payment/blob/master/lib/jquery.payment.js
import _ = require("lodash");
export const defaultFormat = /(\d{1,4})/g;
export function luhnCheck(num: string | number):boolean {
var digit, digits, odd, sum, _i, _len;
odd = true;
sum = 0;
digits = (num + '').split('').reverse();
for (_i = 0, _len = digits.length; _i < _len; _i++) {
digit = digits[_i];
digit = parseInt(digit, 10);
if ((odd = !odd)) {
digit *= 2;
}
if (digit > 9) {
digit -= 9;
}
sum += digit;
}
return sum % 10 === 0;
}
export function cardExpiryVal(value:any): {month:number, year: number} {
var [month, year] = value.split(/[\s\/]+/, 2)
// Allow for year shortcut
if (year && year.length == 2 && /^\d+$/.test(year)){
const prefix = (new Date()).getFullYear()
const prefixStr = prefix.toString().substr(0,2)
year = prefixStr + year
}
month = parseInt(month, 10)
year = parseInt(year, 10)
return {month: month, year: year}
}
export const validateCardExpiry = (month:any, year:any):boolean => {
if (typeof month === 'object' && 'month' in month ){
var {month, year} = month
}
if (!(month && year)) {
return false
}
month = _.trim(month)
year = _.trim(year)
if (!/^\d+$/.test(month))
return false
if(!/^\d+$/.test(year))
return false;
if (!(1 <= month && month <= 12))
return false;
if (year.length == 2)
if (year < 70)
year = `20${year}`
else
year = `19${year}`
if (!(year.length == 4))
return false
var expiry = new Date(year, month)
var currentTime = new Date()
// Months start from 0 in JavaScript
expiry.setMonth(expiry.getMonth() - 1)
// # The cc expires at the end of the month,
// # so we need to make the expiry the first day
// # of the month after
expiry.setMonth(expiry.getMonth() + 1, 1)
return expiry > currentTime
}
interface Card {
type: string,
patterns: number[],
format: RegExp,
length: number[],
cvcLength: number[],
luhn: boolean
}
interface ValidateCardExpiry {
(date: { month: string | number, year: string | number }): boolean
(month: string | number, year: string | number): boolean
}
export class CreditCardTypeManager {
cards: Card[] = [
{
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
}
];
cardFromType(type: string) {
return _.find(this.cards, (card) => card.type === type)
}
cardFromNumber(num: string):Card {
num = (num + '').replace(/\D/g, '')
return _.find(this.cards, (card:Card) => {
return card.patterns.some((pattern) => {
const p = pattern + ''
return num.substr(0, p.length) == p
})
})
}
validateCardNumber(num: any) {
num = (num + '').replace(/\s+|-/g, '')
if (!/^\d+$/.test(num)){
return false
}
const card = this.cardFromNumber(num)
if (!card)
return false
if (card.length.some(i => i === num.length) && (card.luhn == false || luhnCheck(num))) {
return card.type
}
}
validateCardCVC(cvc: string, type?: string) {
cvc = _.trim(cvc)
if (!/^\d+$/.test(cvc))
return false
var card = this.cardFromType(type)
if (card) {
//check against specific card
return card.cvcLength.some((i) => i === cvc.length)
}
else {
// Check against all types
return cvc.length >= 3 && cvc.length <= 4
}
}
validateCardExpiry:ValidateCardExpiry = validateCardExpiry as any
cardExpiryVal = cardExpiryVal
cardType(num: string) {
if (!num)
return null;
const card = this.cardFromNumber(num)
if (card && card.type)
return card.type
else
return null
}
}