diff --git a/lib/const/credit-card-types.json b/lib/const/credit-card-types.json index b9d645624..66b34dfa0 100644 --- a/lib/const/credit-card-types.json +++ b/lib/const/credit-card-types.json @@ -2,10 +2,16 @@ "master": [ { "lengths": [16], - "ranges": [ + "ranges": [ [2221, 2720], [51, 55] - ] + ], + "co_brands": { + "cartes_bancaires": [ + [55555525, 55555525], + [527429, 527429] + ] + } } ], "diners_club": [ @@ -55,7 +61,75 @@ "lengths": [13, 16], "ranges": [ [4, 4] - ] + ], + "co_brands": { + "cartes_bancaires": [ + [40000025, 40000025], + [405687, 405687], + [411890, 411890], + [413512, 413512], + [413517, 413517], + [413529, 413531], + [413533, 413533], + [413537, 413539], + [413543, 413543], + [413849, 413853], + [415062, 415062], + [420114, 420115], + [436000, 436000], + [439826, 439827], + [439829, 439829], + [439837, 439837], + [439840, 439842], + [439844, 439844], + [439846, 439846], + [449983, 449989], + [456242, 456242], + [456245619, 456245619], + [497000, 497000], + [497005, 497005], + [497036, 497036], + [497079, 497079], + [497102, 497102], + [49712020, 497120], + [4971615, 4971616], + [497175, 49717512], + [49717549, 49717549], + [497299, 497299], + [497302, 497302], + [497308, 497308], + [497310, 497311], + [497409, 497409], + [497460, 497460], + [497463, 497463], + [497469, 497469], + [497510, 497510], + [497515501, 497515502], + [4975165, 4975165], + [4975365, 4975365], + [4975565, 4975565], + [4975585, 4975585], + [4975765, 4975765], + [4975795, 4975795], + [4975815, 4975815], + [4975855, 4975855], + [4975857, 4975857], + [4975955, 4975955], + [4975965, 4975965], + [497717, 497718], + [497723, 497723], + [497726, 497727], + [497729, 497729], + [497740, 497742], + [497749, 497752], + [497758, 497760], + [497763, 497763], + [497771, 497772], + [497774, 497775], + [497779, 497779], + [497782, 497782] + ] + } } ], "elo": [ @@ -106,7 +180,12 @@ [6282, 6289], [6291, 6292], [8100, 8171] - ] + ], + "co_brands": { + "cartes_bancaires": [ + [627244, 627244] + ] + } } ], "maestro": [ @@ -147,7 +226,189 @@ [650600, 650600], [650610, 650610], [66, 69] - ] + ], + "co_brands": { + "cartes_bancaires": [ + [501767, 501767], + [507589, 507589003], + [50758901, 507589013], + [50758902, 507589023], + [50758903, 507589033], + [50758904, 507589043], + [50758905, 507589053], + [50758906, 507589063], + [50758908, 507589083], + [50758909, 507589093], + [50759, 50759007], + [5075901, 50759017], + [507593, 50759411], + [507595, 507595], + [50759701, 50759701], + [50759703, 50759703], + [560408, 560408], + [56120201, 56120201], + [56120211, 56120211], + [56120214, 56120216], + [56120218, 5612021], + [56120221, 56120221], + [56120226, 56120228], + [5612023, 56120230], + [56120232, 56120232], + [56120234, 56120234], + [56120237, 56120238], + [56120241, 56120243], + [56120246, 56120246], + [56120262, 56120263], + [56120265, 56120265], + [5612027, 56120270], + [56120273, 56120277], + [5612028, 56120280], + [56120284, 56120284], + [56120286, 56120286], + [56120288, 56120290], + [56120292, 56120293], + [56120296, 56120297], + [56120301, 56120301], + [56120311, 56120311], + [56120314, 56120316], + [56120318, 5612031], + [56120321, 56120321], + [56120326, 56120328], + [5612033, 56120330], + [56120332, 56120332], + [56120334, 56120334], + [56120337, 56120338], + [56120341, 56120343], + [56120346, 56120346], + [56120362, 56120363], + [56120365, 56120365], + [5612037, 56120370], + [56120373, 56120377], + [5612038, 56120380], + [56120384, 56120384], + [56120386, 56120386], + [56120388, 56120390], + [56120392, 56120393], + [56120396, 56120397], + [56120401, 56120401], + [56120411, 56120411], + [56120414, 56120416], + [56120418, 5612041], + [56120421, 56120421], + [56120426, 56120428], + [5612043, 56120430], + [56120432, 56120432], + [56120434, 56120434], + [56120437, 56120438], + [56120441, 56120443], + [56120446, 56120446], + [56120462, 56120463], + [56120465, 56120465], + [5612047, 56120470], + [56120473, 56120477], + [5612048, 56120480], + [56120484, 56120484], + [56120486, 56120486], + [56120488, 56120490], + [56120492, 56120493], + [56120496, 56120497], + [56120501, 56120501], + [56120511, 56120511], + [56120514, 56120516], + [56120518, 5612051], + [56120521, 56120521], + [56120526, 56120528], + [5612053, 56120530], + [56120532, 56120532], + [56120534, 56120534], + [56120537, 56120538], + [56120541, 56120543], + [56120546, 56120546], + [56120562, 56120563], + [56120565, 56120565], + [5612057, 56120570], + [56120573, 56120577], + [5612058, 56120580], + [56120584, 56120584], + [56120586, 56120586], + [56120588, 56120590], + [56120592, 56120593], + [56120596, 56120597], + [561206, 56120600], + [56124102, 56124102], + [5612411, 56124110], + [56124112, 56124113], + [56124117, 56124117], + [5612412, 56124120], + [56124122, 56124123], + [56124125, 56124125], + [56124129, 5612412], + [56124131, 56124131], + [56124133, 56124133], + [56124135, 56124136], + [56124139, 5612413], + [56124144, 56124145], + [56124147, 56124148], + [5612416, 56124161], + [56124166, 5612416], + [56124171, 56124172], + [56124178, 5612417], + [56124181, 56124183], + [56124187, 56124187], + [56124194, 56124195], + [56124198, 56124198], + [56125402, 56125402], + [5612541, 56125410], + [56125412, 56125413], + [56125417, 56125417], + [5612542, 56125420], + [56125422, 56125425], + [56125429, 5612542], + [56125431, 56125431], + [56125433, 56125433], + [56125435, 56125436], + [56125439, 56125440], + [56125444, 56125445], + [56125447, 56125448], + [5612546, 56125461], + [56125466, 5612546], + [56125471, 56125472], + [56125478, 5612547], + [56125481, 56125483], + [56125487, 56125487], + [56125491, 56125491], + [56125494, 56125495], + [56125498, 561254], + [5817, 581701], + [581703, 581706], + [581708, 581713], + [581719, 581720], + [581722, 581723], + [581725, 58172], + [581733, 581735], + [581752, 581754], + [581757, 581761], + [581763, 581763], + [581772, 581772], + [581774, 581777], + [581779, 581783], + [581785, 581785], + [581788, 58178], + [581792, 581793], + [581795, 581796], + [581798, 5817], + [585402, 585405], + [585501, 585505], + [585577, 585582], + [639014, 639024], + [639026, 639068], + [63907, 639092], + [639095, 639096], + [639098, 639098], + [6391, 63912], + [6751, 6756] + ] + } }, { "lengths": [17, 18, 19], diff --git a/lib/recurly.js b/lib/recurly.js index 2ca95dc85..d9e4e67bc 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -93,6 +93,11 @@ const DEFAULTS = { card: { selector: '[data-recurly=card]', style: {} + }, + brand: { + selector: '[data-recurly=brand]', + style: {}, + inputType: 'select', } } }; @@ -244,6 +249,8 @@ export class Recurly extends Emitter { this.config.currency = options.currency; } + this.config.coBrands = options.coBrands || this.config.coBrands || []; + if ('cors' in options) { this.config.cors = options.cors; } diff --git a/lib/recurly/element/card-brand.js b/lib/recurly/element/card-brand.js new file mode 100644 index 000000000..65d23c525 --- /dev/null +++ b/lib/recurly/element/card-brand.js @@ -0,0 +1,10 @@ +import Element from './element'; + +export function factory (options) { + return new CardBrandElement({ ...options, inputType: 'select', elements: this }); +} + +export class CardBrandElement extends Element { + static type = 'brand'; + static elementClassName = 'CardBrandElement'; +} diff --git a/lib/recurly/elements.js b/lib/recurly/elements.js index 579e120da..750f40ec5 100644 --- a/lib/recurly/elements.js +++ b/lib/recurly/elements.js @@ -4,6 +4,7 @@ import Emitter from 'component-emitter'; import errors from './errors'; import { factory as cardElementFactory, CardElement } from './element/card'; import { factory as cardNumberElementFactory, CardNumberElement } from './element/card-number'; +import { factory as cardBrandElementFactory, CardBrandElement } from './element/card-brand'; import { factory as cardMonthElementFactory, CardMonthElement } from './element/card-month'; import { factory as cardYearElementFactory, CardYearElement } from './element/card-year'; import { factory as cardCvvElementFactory, CardCvvElement } from './element/card-cvv'; @@ -26,13 +27,14 @@ export function factory (options) { export default class Elements extends Emitter { CardElement = cardElementFactory; CardNumberElement = cardNumberElementFactory; + CardBrandElement = cardBrandElementFactory; CardMonthElement = cardMonthElementFactory; CardYearElement = cardYearElementFactory; CardCvvElement = cardCvvElementFactory; static VALID_SETS = [ - [ CardElement ], - [ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ] + [ CardElement, CardBrandElement ], + [ CardNumberElement, CardBrandElement, CardMonthElement, CardYearElement, CardCvvElement ] ]; constructor ({ recurly }) { diff --git a/lib/recurly/hosted-fields.js b/lib/recurly/hosted-fields.js index b57d28aed..06d213dc3 100644 --- a/lib/recurly/hosted-fields.js +++ b/lib/recurly/hosted-fields.js @@ -6,7 +6,7 @@ import { HostedField } from './hosted-field'; const debug = require('debug')('recurly:hostedFields'); -export const FIELD_TYPES = ['number', 'month', 'year', 'cvv', 'card']; +export const FIELD_TYPES = ['number', 'month', 'year', 'cvv', 'card', 'brand']; /** * HostedFields diff --git a/lib/recurly/validate.js b/lib/recurly/validate.js index 3c35ac0fc..760abd544 100644 --- a/lib/recurly/validate.js +++ b/lib/recurly/validate.js @@ -61,7 +61,7 @@ const BECS_BANK_ACCOUNT_REQUIRED_FIELDS = [ 'bsb_code', ]; -export const publicMethods = { cardNumber, cardType, expiry, cvv }; +export const publicMethods = { cardNumber, cardType, cardCoBrand, expiry, cvv }; /** * Validates a credit card number via length check and luhn algorithm. @@ -117,34 +117,57 @@ function buildCompareValue (start, length, terminator) { */ export function cardType (number, partial = false) { - const cardNumber = parseCard(number); - const compareLength = Math.min(cardNumber.length, 6); + return findCardGroup(number, partial)?.type || 'unknown'; +} - const compareValue = buildCompareValue(cardNumber, compareLength, '0'); +function findCardGroup (number, partial) { + const cardNumber = parseCard(number); - const types = Object.keys(CREDIT_CARD_TYPES).filter((type) => { + const types = Object.keys(CREDIT_CARD_TYPES).map(function (type) { if (partial && type == 'maestro') { // Maestro has a wide range (6*) that overlaps with some other types, // which can be disambiguated only when the full lenght is given return; } - return find(CREDIT_CARD_TYPES[type], ((group) => { + const group = find(CREDIT_CARD_TYPES[type], ((group) => { if (!partial && group.lengths.indexOf(cardNumber.length) < 0) { return false; } - return find(group.ranges, ([rangeBegin, rangeEnd]) => { - const start = buildCompareValue(rangeBegin, compareLength, '0'); - const end = buildCompareValue(rangeEnd, compareLength, '9'); - - return compareValue >= start && compareValue <= end; - }); + return findInRange(group.ranges, cardNumber); })); + + return group ? { type, group } : false; + }).filter(function (typeWithGroup) { return !!typeWithGroup; }); + + return types.length == 1 && types[0]; +} + +function findInRange (ranges, cardNumber) { + const compareLength = Math.min(cardNumber.length, 8); + const compareValue = buildCompareValue(cardNumber, compareLength, '0'); + + return find(ranges, ([rangeBegin, rangeEnd]) => { + const start = buildCompareValue(rangeBegin, compareLength, '0'); + const end = buildCompareValue(rangeEnd, compareLength, '9'); + + return compareValue >= start && compareValue <= end; }); +} - // Ignoring multiple matches because partials can match multiple ranges - return types.length == 1 && types[0] || 'unknown'; +export function cardCoBrand (number) { + const cardNumber = parseCard(number); + const coBrands = findCardGroup(cardNumber, false)?.group?.co_brands; + + if (!coBrands) { + return; + } + + return find(Object.keys(coBrands), function (coBrand) { + const ranges = coBrands[coBrand]; + return findInRange(ranges, cardNumber); + }); } /** diff --git a/test/unit/configure.test.js b/test/unit/configure.test.js index f1fafdaa5..328c07a8a 100644 --- a/test/unit/configure.test.js +++ b/test/unit/configure.test.js @@ -99,6 +99,26 @@ describe('Recurly.configure', function () { }); }); + describe('when options.coBrands is given', function () { + it('sets Recurly.config.coBrands as an array', function () { + const { recurly } = this; + recurly.configure({ publicKey: 'foo', coBrands: ['cartes_bancaires'] }); + assert.strictEqual(Array.isArray(recurly.config.coBrands), true); + }); + + it('sets Recurly.config.coBrands to the given value', function () { + const { recurly } = this; + recurly.configure({ publicKey: 'foo', coBrands: ['cartes_bancaires'] }); + assert.deepEqual(recurly.config.coBrands, ['cartes_bancaires']); + }); + + it('sets Recurly.config.coBrands to empty array if no value provided', function () { + const { recurly } = this; + recurly.configure({ publicKey: 'foo' }); + assert.deepEqual(recurly.config.coBrands, []); + }); + }); + describe('as a string parameter', function () { it('sets the publicKey', function () { const { recurly } = this; @@ -131,6 +151,11 @@ describe('Recurly.configure', function () { content: 'Credit Card Number' } }, + brand: { + placeholder: { + content: 'Credit Card Brand' + } + }, month: { placeholder: { content: 'Month (mm)' @@ -161,6 +186,7 @@ describe('Recurly.configure', function () { const { recurly } = this; recurly.configure(example); assert.deepStrictEqual(recurly.config.fields.number.style, example.style.number); + assert.deepStrictEqual(recurly.config.fields.brand.style, example.style.brand); assert.deepStrictEqual(recurly.config.fields.month.style, example.style.month); assert.deepStrictEqual(recurly.config.fields.year.style, example.style.year); assert.deepStrictEqual(recurly.config.fields.cvv.style, example.style.cvv); @@ -211,6 +237,7 @@ describe('Recurly.configure', function () { recurly.configure(objectExample); const { fields: fieldsConfig } = recurly.config; assert.strictEqual(fieldsConfig.number.style.fontWeight, 'normal'); + assert.strictEqual(fieldsConfig.brand.style.placeholder.content, 'Credit Card Brand'); assert.strictEqual(fieldsConfig.month.style.placeholder.content, 'Month (mm)'); assert.strictEqual(fieldsConfig.year.style.placeholder.content, 'Year (yy)'); assert.strictEqual(fieldsConfig.year.style.color, 'persimmon'); @@ -283,6 +310,7 @@ describe('Recurly.configure', function () { initRecurly(recurly, { fields: { number: `#number-${index}`, + brand: `#brand-${index}`, month: `#month-${index}`, year: `#year-${index}`, cvv: `#cvv-${index}` diff --git a/test/unit/elements.test.js b/test/unit/elements.test.js index c58e6a662..7573a75f9 100644 --- a/test/unit/elements.test.js +++ b/test/unit/elements.test.js @@ -25,6 +25,7 @@ describe('Elements', function () { [ 'CardElement', 'CardNumberElement', + 'CardBrandElement', 'CardMonthElement', 'CardYearElement', 'CardCvvElement' @@ -90,7 +91,7 @@ describe('Elements', function () { ['CardElement', 'CardNumberElement'], ['CardElement', 'CardCvvElement'], ['CardNumberElement', 'CardElement'], - ['CardNumberElement', 'CardMonthElement', 'CardYearElement', 'CardCvvElement', 'CardElement'] + ['CardNumberElement', 'CardBrandElement', 'CardMonthElement', 'CardYearElement', 'CardCvvElement', 'CardElement'] ]; invalidSets.forEach(invalidSet => { const elements = new Elements({ recurly: recurly }); @@ -232,6 +233,7 @@ describe('Elements', function () { beforeEach(function () { const { elements } = this; this.number = elements.CardNumberElement(); + this.brand = elements.CardBrandElement(); this.month = elements.CardMonthElement(); this.year = elements.CardYearElement(); this.cvv = elements.CardCvvElement(); @@ -270,8 +272,8 @@ describe('Elements', function () { describe('when all elements have begun attachment', function () { beforeEach(function () { - const { number, month, year, cvv } = this; - [number, month, year, cvv].forEach(stubGetter('attaching', true)); + const { number, brand, month, year, cvv } = this; + [number, brand, month, year, cvv].forEach(stubGetter('attaching', true)); }); describe('when none of those elements are yet attached', function () { @@ -290,9 +292,9 @@ describe('Elements', function () { describe('when all of those elements are attached', function () { beforeEach(function () { - const { number, month, year, cvv } = this; - [number, month, year, cvv].forEach(stubGetter('attaching', false)); - [number, month, year, cvv].forEach(stubGetter('attached', true)); + const { number, brand, month, year, cvv } = this; + [number, brand, month, year, cvv].forEach(stubGetter('attaching', false)); + [number, brand, month, year, cvv].forEach(stubGetter('attached', true)); }); it(...sendsElementsMessages()); diff --git a/test/unit/recurly.test.js b/test/unit/recurly.test.js index ee7292fa2..56df288d0 100644 --- a/test/unit/recurly.test.js +++ b/test/unit/recurly.test.js @@ -89,6 +89,7 @@ describe('Recurly', function () { initRecurly(recurly, { fields: { number: { selector: '#number-1' }, + brand: { selector: '#brand-1' }, month: { selector: '#month-1' }, year: { selector: '#year-1' }, cvv: { selector: '#cvv-1' } @@ -99,6 +100,7 @@ describe('Recurly', function () { recurly.configure({ fields: { number: { selector: '#number-2' }, + brand: { selector: '#brand-2' }, month: { selector: '#month-2' }, year: { selector: '#year-2' }, cvv: { selector: '#cvv-2' } @@ -113,7 +115,7 @@ describe('Recurly', function () { assert.strictEqual(recurly.readyState, 2); assert(readyStub.calledOnce); assert.strictEqual(testBed().querySelectorAll('#test-form-1 iframe').length, 0); - assert.strictEqual(testBed().querySelectorAll('#test-form-2 iframe').length, 4); + assert.strictEqual(testBed().querySelectorAll('#test-form-2 iframe').length, 5); assert(recurly.off.calledWithExactly('hostedFields:ready')); assert(recurly.off.calledWithExactly('hostedFields:state:change')); assert(recurly.off.calledWithExactly('hostedField:submit')); diff --git a/test/unit/support/fixtures.js b/test/unit/support/fixtures.js index 820485cc5..67323d67c 100644 --- a/test/unit/support/fixtures.js +++ b/test/unit/support/fixtures.js @@ -31,6 +31,7 @@ const elements = opts => ` const minimal = opts => `
+
@@ -45,6 +46,7 @@ const minimal = opts => ` const all = opts => `
+
@@ -160,6 +162,7 @@ const checkoutPricing = opts => ` const multipleForms = () => `
+
@@ -168,6 +171,7 @@ const multipleForms = () => `
+
diff --git a/test/unit/token.test.js b/test/unit/token.test.js index 32affeffa..71bf3a591 100644 --- a/test/unit/token.test.js +++ b/test/unit/token.test.js @@ -15,6 +15,7 @@ apiTest(requestMethod => { const valid = { number: '4111111111111111', + brand: 'visa', month: '01', year: new Date().getFullYear() + 1, first_name: 'foo', @@ -24,6 +25,7 @@ apiTest(requestMethod => { const elementsMap = { card: 'CardElement', number: 'CardNumberElement', + brand: 'CardBrandElement', month: 'CardMonthElement', year: 'CardYearElement', cvv: 'CardCvvElement' diff --git a/test/unit/validate.test.js b/test/unit/validate.test.js index 65fd34955..21f5450a8 100644 --- a/test/unit/validate.test.js +++ b/test/unit/validate.test.js @@ -32,6 +32,30 @@ describe('Recurly.validate', function () { }); }); + describe('cardCoBrand', function() { + it('should parse visa cartes bancaires', function () { + assert.strictEqual(recurly.validate.cardType('4000-0025-0000-1001'), 'visa'); + assert.strictEqual(recurly.validate.cardCoBrand('4000-0025-0000-1001'), 'cartes_bancaires'); + + assert.strictEqual(recurly.validate.cardType('4135-3100-0000-0000'), 'visa'); + assert.strictEqual(recurly.validate.cardCoBrand('4135-3100-0000-0000'), 'cartes_bancaires'); + + assert.strictEqual(recurly.validate.cardType('4360 0000 0100 0005'), 'visa'); + assert.strictEqual(recurly.validate.cardCoBrand('4360 0000 0100 0005'), 'cartes_bancaires'); + }); + + it('should parse mastercard cartes bancaires', function () { + assert.strictEqual(recurly.validate.cardType('5555-5525-0000-1001'), 'master'); + assert.strictEqual(recurly.validate.cardCoBrand('5555-5525-0000-1001'), 'cartes_bancaires'); + }); + + it('should return null for other cards', function () { + assert.strictEqual(recurly.validate.cardCoBrand('4111-1111-1111-1111'), undefined); + assert.strictEqual(recurly.validate.cardCoBrand('5454-5454-5454-5454'), undefined); + assert.strictEqual(recurly.validate.cardCoBrand('6011-0000-9013-9424'), undefined); + }); + }); + describe('cardType', function () { it('should parse visa', function () { assert.strictEqual(recurly.validate.cardType('4111-1111-1111-1'), 'visa'); diff --git a/types/lib/configure.d.ts b/types/lib/configure.d.ts index af58ad7e2..f08f663ae 100644 --- a/types/lib/configure.d.ts +++ b/types/lib/configure.d.ts @@ -6,6 +6,7 @@ export type RecurlyOptions = { currency?: string; required?: string[]; timeout?: number; + coBrands?: string[]; fraud?: { kount?: { dataCollector?: boolean; @@ -33,6 +34,7 @@ export type RecurlyOptions = { fields?: { all?: IndividualElementOptions; number?: IndividualElementOptions & { selector?: string }; + brand?: IndividualElementOptions & { selector?: string }; month?: IndividualElementOptions & { selector?: string }; year?: IndividualElementOptions & { selector?: string }; cvv?: IndividualElementOptions & { selector?: string }; diff --git a/types/lib/elements.d.ts b/types/lib/elements.d.ts index b893c8779..dd1c41684 100644 --- a/types/lib/elements.d.ts +++ b/types/lib/elements.d.ts @@ -258,6 +258,7 @@ export interface ElementsInstance extends Emitter { CardElement: (cardElementOptions?: CardElementOptions) => CardElement; CardNumberElement: (cardNumberElementOptions?: IndividualElementOptions) => IndividualElement; + CardBrandElement: (cardBrandElementOptions?: IndividualElementOptions) => IndividualElement; CardMonthElement: (cardMonthElementOptions?: IndividualElementOptions) => IndividualElement; CardYearElement: (cardYearElementOptions?: IndividualElementOptions) => IndividualElement; CardCvvElement: (cardCvvElementOptions?: IndividualElementOptions) => IndividualElement;