From 75af5c7a8bacbd49a369e49ed246e51ed08474a6 Mon Sep 17 00:00:00 2001 From: Charlie Andrews-Jubelt Date: Tue, 5 Mar 2024 14:39:22 -0800 Subject: [PATCH] feat: kyc allowed values (#38) --- scripts/generateWidgetUrl.ts | 36 +++++-- src/components/KYCInfoFieldSection.test.tsx | 94 +++++++++++++++++++ src/components/KYCInfoFieldSection.tsx | 61 +++++++++++- src/components/KYCInfoScreen.tsx | 1 + src/components/PaymentInfoScreen.tsx | 4 +- .../PersonalDataAndDocumentsDetailed.tsx | 4 +- src/schema.test.ts | 29 ++++++ src/schema.ts | 8 +- 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 src/components/KYCInfoFieldSection.test.tsx diff --git a/scripts/generateWidgetUrl.ts b/scripts/generateWidgetUrl.ts index 470d06f..4d18b25 100644 --- a/scripts/generateWidgetUrl.ts +++ b/scripts/generateWidgetUrl.ts @@ -1,7 +1,11 @@ /* eslint-disable no-console */ import yargs from 'yargs' import { ProviderIds } from '../src/types' -import { CryptoType, FiatType } from '@fiatconnect/fiatconnect-types' +import { + CryptoType, + FiatType, + quoteResponseSchema, +} from '@fiatconnect/fiatconnect-types' function loadConfig() { return yargs @@ -80,10 +84,10 @@ export async function generateWidgetUrl() { }), }, ) - const quoteJson = await response.json() if (!response.ok) { - throw new Error(`quote response error json: ${JSON.stringify(quoteJson)}`) + throw new Error(`quote response error status: ${response.status}`) } + const quoteJson = quoteResponseSchema.parse(await response.json()) const { quote: { fiatAmount, quoteId, transferType }, kyc: { kycRequired, kycSchemas }, @@ -102,25 +106,41 @@ export async function generateWidgetUrl() { `"eId=${quoteId}` + `&country=${config.country}` if (kycRequired) { - widgetUrl += `&kycSchema=${kycSchemas[0]}` + const kycSchema = kycSchemas[0].kycSchema + widgetUrl += `&kycSchema=${kycSchema}` + const kycAllowedValues = kycSchemas[0].allowedValues + if (kycAllowedValues && Object.keys(kycAllowedValues).length > 0) { + widgetUrl += `&kycAllowedValues=${JSON.stringify(kycAllowedValues)}` + } } - const fiatAccountType = Object.keys(fiatAccountJson)[0] + const fiatAccountType = Object.keys( + fiatAccountJson, + )[0] as keyof typeof fiatAccountJson if (!fiatAccountType) { throw new Error('fiat account type not found in quote response') } widgetUrl += `&fiatAccountType=${fiatAccountType}` const fiatAccountSchema = - fiatAccountJson[fiatAccountType].fiatAccountSchemas[0].fiatAccountSchema + fiatAccountJson[fiatAccountType]?.fiatAccountSchemas[0]?.fiatAccountSchema if (!fiatAccountSchema) { throw new Error('fiat account schema not found in quote response') } widgetUrl += `&fiatAccountSchema=${fiatAccountSchema}` const userActionType = - fiatAccountJson[fiatAccountType].fiatAccountSchemas[0].userActionType + fiatAccountJson[fiatAccountType]?.fiatAccountSchemas[0].userActionType if (userActionType) { widgetUrl += `&userActionDetailsSchema=${userActionType}` } - + const fiatAccountAllowedValues = + fiatAccountJson[fiatAccountType]?.fiatAccountSchemas[0].allowedValues + if ( + fiatAccountAllowedValues && + Object.keys(fiatAccountAllowedValues).length > 0 + ) { + widgetUrl += `&fiatAccountAllowedValues=${JSON.stringify( + fiatAccountAllowedValues, + )}` + } console.log(widgetUrl) } diff --git a/src/components/KYCInfoFieldSection.test.tsx b/src/components/KYCInfoFieldSection.test.tsx new file mode 100644 index 0000000..5456437 --- /dev/null +++ b/src/components/KYCInfoFieldSection.test.tsx @@ -0,0 +1,94 @@ +import { getDropdownValues } from './KYCInfoFieldSection' +import { idTypeReverseFormatter } from './kycInfo/PersonalDataAndDocumentsDetailed' + +describe('KYCInfoFieldSection', () => { + describe('getDropdownValues', () => { + it('returns allowedValues if choices is undefined', () => { + const allowedValues: [string, ...string[]] = ['a', 'b'] + const choices = undefined + const reverseFormatter = undefined + const result = getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }) + expect(result).toEqual(allowedValues) + }) + it('returns choices if allowedValues is undefined', () => { + const allowedValues = undefined + const choices: [string, ...string[]] = ['a', 'b'] + const reverseFormatter = undefined + const result = getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }) + expect(result).toEqual(choices) + }) + it('returns choices that are in allowedValues', () => { + const allowedValues: [string, ...string[]] = ['a', 'b'] + const choices: [string, ...string[]] = ['a', 'b', 'c'] + const reverseFormatter = undefined + const result = getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }) + expect(result).toEqual(allowedValues) + }) + it('returns allowedValues if no choices are in allowedValues', () => { + const allowedValues: [string, ...string[]] = ['a', 'b'] + const choices: [string, ...string[]] = ['c', 'd'] + const reverseFormatter = undefined + const result = getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }) + expect(result).toEqual(allowedValues) + }) + it('returns human-readable values', () => { + const optionAid = 'a' + const optionAhumanReadable = 'Option A' + const optionBid = 'b' + const optionBhumanReadable = 'Option B' + const allowedValues: [string, ...string[]] = [optionAid, optionBid] + const choices: [string, ...string[]] = [ + optionAhumanReadable, + optionBhumanReadable, + 'Option C', + ] + const reverseFormatter = (optionId: string) => + `Option ${optionId.toUpperCase()}` + expect( + getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }), + ).toEqual(['Option A', 'Option B']) + expect( + getDropdownValues({ + allowedValues, + choices: undefined, + reverseFormatter, + }), + ).toEqual(['Option A', 'Option B']) + }) + it('works for ID type field', () => { + const allowedValues: [string, ...string[]] = ['IDC', 'PAS'] + const choices: [string, ...string[]] = [ + 'State-issued ID', + 'Passport', + "Driver's License", + ] + const reverseFormatter = idTypeReverseFormatter + const result = getDropdownValues({ + allowedValues, + choices, + reverseFormatter, + }) + expect(result).toEqual(['State-issued ID', 'Passport']) + }) + }) +}) diff --git a/src/components/KYCInfoFieldSection.tsx b/src/components/KYCInfoFieldSection.tsx index 8ed0569..41f676f 100644 --- a/src/components/KYCInfoFieldSection.tsx +++ b/src/components/KYCInfoFieldSection.tsx @@ -10,6 +10,60 @@ interface Props { ) => void setSubmitDisabled: (disabled: boolean) => void kycSchemaMetadata: Record + allowedValues?: Record +} + +function isNonemptyStringArray( + strings?: string[], +): strings is [string, ...string[]] { + return !!strings && strings.length > 0 +} + +function identity(x: T): T { + return x +} + +/** + * Merge 'allowedValues' and 'choices' into a single array and formats them for presentation to the end-user. + * + * @param allowedValues: a concept from the FiatConnect spec that allows the server to constraint the space of allowed values + * for some fields. This is useful for fields like "country" or "state" where the server would reject a request if the + * user submitted values from an unsupported region. + * @param choices: a FiatConnect Widget-specific concept that allows us to provide a dropdown when the spec already has a limited + * space of possible inputs for something like "identity card type" + * @param reverseFormatter: a function that returns a user-friendly representation of some value + */ +export function getDropdownValues({ + allowedValues, + choices, + reverseFormatter, +}: { + allowedValues: [string, ...string[]] | undefined + choices: [string, ...string[]] | undefined + reverseFormatter?: (x: string) => string +}): [string, ...string[]] | undefined { + if (!allowedValues) { + return choices + } + const humanReadableAllowedValues = allowedValues.map( + reverseFormatter ?? identity, + ) + if (!choices) { + return isNonemptyStringArray(humanReadableAllowedValues) + ? humanReadableAllowedValues + : undefined + } + const output = choices.filter((choice) => + humanReadableAllowedValues.includes(choice), + ) + if (!isNonemptyStringArray(output)) { + // eslint-disable-next-line no-console + console.warn( + `No valid choices for given allowedValues ${allowedValues}. Just showing allowedValues directly`, + ) + return humanReadableAllowedValues as [string, ...string[]] + } + return output } function KYCInfoFieldSection({ @@ -17,6 +71,7 @@ function KYCInfoFieldSection({ setKycInfo, setSubmitDisabled, kycSchemaMetadata, + allowedValues, }: Props) { const [errorMessage, setErrorMessage] = useState( undefined, @@ -132,7 +187,11 @@ function KYCInfoFieldSection({ ? fieldMetadata.reverseFormatter(getFieldValue(field)) : getFieldValue(field)) ?? '' } - allowedValues={fieldMetadata.choices ?? undefined} + allowedValues={getDropdownValues({ + allowedValues: allowedValues?.[field], + choices: fieldMetadata.choices, + reverseFormatter: fieldMetadata.reverseFormatter, + })} /> ) })} diff --git a/src/components/KYCInfoScreen.tsx b/src/components/KYCInfoScreen.tsx index 408453b..0283aaf 100644 --- a/src/components/KYCInfoScreen.tsx +++ b/src/components/KYCInfoScreen.tsx @@ -124,6 +124,7 @@ export function KYCInfoScreen({ setKycInfo={setKycInfoWrapper} setSubmitDisabled={setSubmitDisabled} kycSchemaMetadata={kycSchemaToMetadata[params.kycSchema]} + allowedValues={params.kycAllowedValues} />
diff --git a/src/components/PaymentInfoScreen.tsx b/src/components/PaymentInfoScreen.tsx index 59a52b2..4646174 100644 --- a/src/components/PaymentInfoScreen.tsx +++ b/src/components/PaymentInfoScreen.tsx @@ -91,7 +91,7 @@ export function PaymentInfoScreen({ fiatAccountDetails={fiatAccountDetails} setFiatAccountDetails={setFiatAccountDetailsWrapper} setSubmitDisabled={setSubmitDisabled} - allowedValues={params.allowedValues} + allowedValues={params.fiatAccountAllowedValues} fiatAccountType={FiatAccountType.BankAccount} fiatAccountSchemaMetadata={accountNumberSchemaMetadata} /> @@ -104,7 +104,7 @@ export function PaymentInfoScreen({ fiatAccountDetails={fiatAccountDetails} setFiatAccountDetails={setFiatAccountDetailsWrapper} setSubmitDisabled={setSubmitDisabled} - allowedValues={params.allowedValues} + allowedValues={params.fiatAccountAllowedValues} fiatAccountType={FiatAccountType.MobileMoney} fiatAccountSchemaMetadata={mobileMoneySchemaMetadata} /> diff --git a/src/components/kycInfo/PersonalDataAndDocumentsDetailed.tsx b/src/components/kycInfo/PersonalDataAndDocumentsDetailed.tsx index 71b852e..2ea0b5b 100644 --- a/src/components/kycInfo/PersonalDataAndDocumentsDetailed.tsx +++ b/src/components/kycInfo/PersonalDataAndDocumentsDetailed.tsx @@ -3,7 +3,7 @@ import { IdentificationDocumentType } from '@fiatconnect/fiatconnect-types' import { Buffer } from 'buffer' enum IdTypeFriendlyName { - IDC = 'Statue-issued ID', + IDC = 'State-issued ID', PAS = 'Passport', DL = `Driver's License`, } @@ -24,7 +24,7 @@ function idTypeFormatter(friendlyName: string): string { } } -function idTypeReverseFormatter(idType: string | undefined): string { +export function idTypeReverseFormatter(idType: string | undefined): string { switch (idType) { case IdentificationDocumentType.IDC: { return IdTypeFriendlyName.IDC diff --git a/src/schema.test.ts b/src/schema.test.ts index 545d520..7911f08 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -15,6 +15,13 @@ describe('schema', () => { fiatAccountSchema: 'AccountNumber', userActionDetailsSchema: 'AccountNumberUserAction', country: 'NG', + fiatAccountAllowedValues: JSON.stringify({ + institutionName: ['Access Bank', 'Covenant Microfinance Bank'], + country: ['NG'], + }), + kycAllowedValues: JSON.stringify({ + isoCountryCode: ['NG', 'GH'], + }), } it('accepts valid input', () => { expect(queryParamsSchema.safeParse(validQueryParams).success).toEqual( @@ -28,6 +35,20 @@ describe('schema', () => { fiatAccountSchema: 'MobileMoney', }).success, ).toEqual(true) + + expect( + queryParamsSchema.safeParse({ + ...validQueryParams, + kycAllowedValues: undefined, + }).success, + ).toEqual(true) + + expect( + queryParamsSchema.safeParse({ + ...validQueryParams, + fiatAccountAllowedValues: undefined, + }).success, + ).toEqual(true) }) it('rejects invalid input', () => { @@ -54,6 +75,14 @@ describe('schema', () => { quoteId: undefined, }).success, ).toEqual(false) + + // invalid JSON for allowedValues + expect( + queryParamsSchema.safeParse({ + ...validQueryParams, + fiatAccountAllowedValues: 'invalid-json', + }).success, + ).toEqual(false) }) }) }) diff --git a/src/schema.ts b/src/schema.ts index 1d33c29..94e6194 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -22,6 +22,9 @@ const stringToJSONSchema = z }) const stringifiedNumberSchema = z.string().regex(/^(\d+|\d*\.\d+)$/) +const allowedValuesSchema = stringToJSONSchema + .pipe(z.record(z.array(z.string()).nonempty())) + .optional() export const queryParamsSchema = z.object({ providerId: z.nativeEnum(ProviderIds), apiKey: z.string().optional(), @@ -50,9 +53,8 @@ export const queryParamsSchema = z.object({ userActionDetailsSchema: z .enum([TransferInUserActionDetails.AccountNumberUserAction]) .optional(), - allowedValues: stringToJSONSchema - .pipe(z.record(z.array(z.string()).nonempty())) - .optional(), + fiatAccountAllowedValues: allowedValuesSchema, + kycAllowedValues: allowedValuesSchema, country: z.string(), }) export type QueryParams = z.infer