Merge pull request #193 from houdiniproject/payment_code
Move in credit card validation code from stripe/jquery.payment
This commit is contained in:
commit
4b272a1489
2 changed files with 581 additions and 0 deletions
343
javascripts/src/lib/payments/credit_card.spec.ts
Normal file
343
javascripts/src/lib/payments/credit_card.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
238
javascripts/src/lib/payments/credit_card.ts
Normal file
238
javascripts/src/lib/payments/credit_card.ts
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue