Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds custom bin validation #36

Merged
merged 6 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/no-shadow': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test": "jest"
},
"dependencies": {
"@basis-theory/basis-theory-js": "^2.3.2",
"@basis-theory/basis-theory-js": "^2.7.0",
"@react-navigation/native-stack": "^6.9.17",
"@react-navigation/native": "^6.1.9",
"card-validator": "^8.1.1",
Expand Down
6 changes: 6 additions & 0 deletions src/components/CardNumberElement.hook.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CreditCardType } from '@basis-theory/basis-theory-js/types/elements';
import type { ForwardedRef } from 'react';
import { useRef, useState } from 'react';
import type { TextInput } from 'react-native';
Expand All @@ -11,22 +12,27 @@ import { useBtRef } from './shared/useBtRef';
import { useBtRefUnmount } from './shared/useBtRefUnmount';
import { useMask } from './shared/useMask';
import { useUserEventHandlers } from './shared/useUserEventHandlers';
import { useCustomBin } from './useCustomBin.hook';

type UseCardNumberElementProps = {
btRef?: ForwardedRef<BTRef>;
onChange?: EventConsumer;
cardTypes?: CreditCardType[];
};

const id = uuid.v4().toString();

export const useCardNumberElement = ({
btRef,
onChange,
cardTypes,
}: UseCardNumberElementProps) => {
const type = ElementType.CARD_NUMBER;
const elementRef = useRef<TextInput>(null);
const [elementValue, setElementValue] = useState<string>('');

useCustomBin(cardTypes);

useBtRefUnmount({ btRef });

const mask = useMask({
Expand Down
9 changes: 4 additions & 5 deletions src/components/CardNumberElement.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import React from 'react';
import type { StyleProp, TextStyle } from 'react-native';
import type {
UseCardNumberElementProps} from './CardNumberElement.hook';
import {
useCardNumberElement
} from './CardNumberElement.hook';
import MaskInput from 'react-native-mask-input';
import type { UseCardNumberElementProps } from './CardNumberElement.hook';
import { useCardNumberElement } from './CardNumberElement.hook';

type CardNumberProps = UseCardNumberElementProps & {
style?: StyleProp<TextStyle>;
Expand All @@ -21,10 +18,12 @@ export const CardNumberElement = ({
placeholder,
placeholderTextColor,
onChange,
cardTypes,
}: CardNumberProps) => {
const { elementRef, _onChange, elementValue, mask } = useCardNumberElement({
btRef,
onChange,
cardTypes,
});

return (
Expand Down
64 changes: 64 additions & 0 deletions src/components/useCustomBin.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { CreditCardType } from '@basis-theory/basis-theory-js/types/elements';
import { creditCardType } from 'card-validator';
import { always, compose, groupBy, ifElse, isEmpty } from 'ramda';
import { useEffect, useState } from 'react';

const throwIfDuppedCardConfig = ifElse(
(arr: (CreditCardType[] | undefined)[]) =>
arr.some((el) => el && el.length > 1),
() => {
const msg = `Detected multiple cardType objects with the same type in the element configuration.`;

// telemetryLogger.logger.info(msg);
throw new Error(msg);
},
always(false)
);

const groupByCreditCardType = groupBy(
(obj: CreditCardType) => obj['type'] as string
);

const checkDuppedCards = compose(
throwIfDuppedCardConfig,
Object.values,
groupByCreditCardType
);

export const useCustomBin = (cardTypes?: CreditCardType[]) => {
// keep the count in internal state to prevent creditCardType from blowing up when
// there are multiple renders
const [cardTypesCount, setCardTypeCount] = useState(0);

useEffect(() => {
if (
cardTypes &&
cardTypesCount !== cardTypes.length &&
!isEmpty(cardTypes) &&
!checkDuppedCards(cardTypes)
) {
setCardTypeCount(cardTypes.length);

// removes existing cardTypes from creditCardType
[
'visa',
'mastercard',
'american-express',
'diners-club',
'discover',
'jcb',
'unionpay',
'maestro',
'elo',
'mir',
'hiper',
'hipercard',
].forEach(creditCardType.removeCard);

// we append new cardTypes to creditCardType
// if creditCardType.type already exists, it gets overwritten
// @ts-expect-error types mismatch between our types and creditCardType's
cardTypes.forEach(creditCardType.addCard);
}
}, [cardTypes]);
};
9 changes: 6 additions & 3 deletions src/utils/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ const isBtDateRef = (val: unknown): val is InputBTRefWithDatepart =>

const isPrimitive = anyPass([isString, isBoolean, isNumber, isNil]);

const removeMax = (list: number[]) =>
reject(equals(reduce(max, Number.NEGATIVE_INFINITY, list)), list);
/**
* Removes all occurrences of the maximum value from an array of numbers.
*/
const filterOutMaxOccurrences = (numbers: number[]) =>
numbers.filter((num) => num !== Math.max(...numbers));

export {
extractDigits,
filterOutMaxOccurrences,
isBoolean,
isBtDateRef,
isBtRef,
Expand All @@ -52,5 +56,4 @@ export {
isRegExp,
isString,
isToken,
removeMax,
};
40 changes: 27 additions & 13 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { cvv, expirationDate, number } from 'card-validator';
import { T, always, cond, equals, flip, isEmpty, partial, split } from 'ramda';
import { flip, isEmpty, partial, split } from 'ramda';
import type { Mask, ValidationResult } from '../BaseElementTypes';
import { ElementType } from '../BaseElementTypes';
import { extractDigits, isRegExp, isString, removeMax } from './shared';
import {
extractDigits,
filterOutMaxOccurrences,
isRegExp,
isString,
} from './shared';

type ValidatorResult = {
isValid: boolean;
Expand Down Expand Up @@ -34,7 +39,7 @@ const _cardNumberValidator = (value: string) => {
// that could be valid
const expectedLengths =
card && brandsWithMultipleCardLenghts.includes(card.type)
? removeMax(card.lengths)
? filterOutMaxOccurrences(card.lengths)
: [];

if (
Expand All @@ -58,7 +63,16 @@ const _expirationDateValidator = expirationDate;

// eslint-disable-next-line @typescript-eslint/default-param-last
const _maskValidator = (mask: Mask = [], value: string) => {
const customZip = <T, U, V>(fn: (a: T, b: U) => V, a: T[], b: U[]): V[] =>
/**
* Combines two arrays element-wise using a specified function.
*
* @param {function(T, U): V} fn - A function that combines elements from the first and second arrays.
* @param {T[]} a - The first array.
* @param {U[]} b - The second array.
*
* @returns {V[]} An array containing the result of applying the function to corresponding elements of the input arrays.
*/
const zipWith = <T, U, V>(fn: (a: T, b: U) => V, a: T[], b: U[]): V[] =>
a.map((x, i) => fn(x, b[i]));

const matchMaskCharAtIndex = (
Expand All @@ -69,11 +83,11 @@ const _maskValidator = (mask: Mask = [], value: string) => {
? maskChar.test(valChar)
: isString(maskChar) && maskChar === valChar;

const isValid = customZip(matchMaskCharAtIndex, mask, value.split('')).every(
const isValid = zipWith(matchMaskCharAtIndex, mask, value.split('')).every(
Boolean
);

const isPotentiallyValid = customZip(
const isPotentiallyValid = zipWith(
matchMaskCharAtIndex,
mask.slice(0, value.length),
split('', value)
Expand Down Expand Up @@ -120,10 +134,10 @@ const cardVerificationCodeValidator = (cvc: string): ValidationResult =>
const textMaskValidator = (value: string, mask?: Mask): ValidationResult =>
runValidator(value, partial(_maskValidator, [mask]));

export const _getValidationStrategy = cond([
[equals(ElementType.CVC), always(cardVerificationCodeValidator)],
[equals(ElementType.EXPIRATION_DATE), always(cardExpirationDateValidator)],
[equals(ElementType.CARD_NUMBER), always(cardNumberValidator)],
[equals(ElementType.TEXT), always(textMaskValidator)],
[T, always(() => undefined)],
]);
export const _getValidationStrategy = (elementType: ElementType) =>
({
[ElementType.CVC]: cardVerificationCodeValidator,
[ElementType.EXPIRATION_DATE]: cardExpirationDateValidator,
[ElementType.CARD_NUMBER]: cardNumberValidator,
[ElementType.TEXT]: textMaskValidator,
}[elementType]);
72 changes: 72 additions & 0 deletions tests/components/CardNumberElement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
* @format
*/

import { VISA, MASTERCARD } from '@basis-theory/basis-theory-js/types/elements';
import 'react-native';
import React from 'react';

import { render, fireEvent, screen } from '@testing-library/react-native';
import { CardNumberElement } from '../../src';
import cardValidator from 'card-validator';

describe('CardNumberElement', () => {
beforeEach(() => {
cardValidator.creditCardType.resetModifications();
});

const mockedRef = {
current: {
id: '123',
Expand Down Expand Up @@ -38,6 +44,72 @@ describe('CardNumberElement', () => {
});
});

describe('CustomBin', () => {
test('validates custom bin', async () => {
const doStuff = jest.fn();

const { getByPlaceholderText } = render(
<CardNumberElement
btRef={mockedRef}
cardTypes={[
{
...VISA,
patterns: [...VISA.patterns, 8405], // add custom bin to VISA from tabapay
},
MASTERCARD,
]}
onChange={doStuff}
placeholder="Card Number"
style={{}}
/>
);

const el = getByPlaceholderText('Card Number');

fireEvent.changeText(el, '8405840704999997', {});

expect(doStuff).toHaveBeenLastCalledWith({
empty: false,
errors: undefined,
valid: true,
maskSatisfied: true,
complete: true,
cvcLength: 3,
cardBin: '84058407',
cardLast4: '9997',
brand: 'visa',
});
});

test('returns unknown for valid bin and unsuported card brand', async () => {
const doStuff = jest.fn();

const { getByPlaceholderText } = render(
<CardNumberElement
btRef={mockedRef}
cardTypes={[VISA]}
onChange={doStuff}
placeholder="Card Number"
style={{}}
/>
);

const el = getByPlaceholderText('Card Number');

fireEvent.changeText(el, '5555555555554444', {});

expect(doStuff).toHaveBeenLastCalledWith({
empty: false,
errors: [{ targetId: 'cardNumber', type: 'invalid' }],
valid: false,
maskSatisfied: true,
complete: false,
cvcLength: undefined,
brand: 'unknown',
});
});
});

describe('Dynamic card lengths', () => {
test('input masks card number correctly and emits the correct events', () => {
const onChange = jest.fn();
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1223,10 +1223,10 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"

"@basis-theory/basis-theory-js@^2.3.2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@basis-theory/basis-theory-js/-/basis-theory-js-2.3.2.tgz#104311f83882685ba3759be44397b5ccc9176cb0"
integrity sha512-LfifQbF/dY5XXZl+roYnrSpKA1jTZ1VyrRQ2oqE6TsfaH4J45b6vrFYmKkGr4Mv8H5ZZIbrBm7YPVJUKaz5Lbg==
"@basis-theory/basis-theory-js@^2.7.0":
version "2.7.0"
resolved "https://registry.yarnpkg.com/@basis-theory/basis-theory-js/-/basis-theory-js-2.7.0.tgz#0a1b439fdcfd4144ff71b52467a8a21da9559cae"
integrity sha512-x726u3SRgyMtofA/tn3WS7Bma1M4JOGI+f1sseESVUu5xrk8gftGqU0fxIKYNUZlAP39WXsmo8fpRzNZ9peO3Q==
dependencies:
axios "^1.6.0"
camelcase-keys "^6.2.2"
Expand Down
Loading