diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 6f5e8f8c72..f66739982a 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -173,6 +173,7 @@ import { getCurrentPrivateUser } from './get-current-private-user' import { updatePrivateUser } from './update-private-user' import { setPushToken } from './push-token' import { updateNotifSettings } from './update-notif-settings' +import { getVerificationDocuments } from 'api/gidx/get-verification-documents' const allowCorsUnrestricted: RequestHandler = cors({}) @@ -345,6 +346,7 @@ const handlers: { [k in APIPath]: APIHandler } = { 'get-verification-status-gidx': getVerificationStatus, 'upload-document-gidx': uploadDocument, 'callback-gidx': callbackGIDX, + 'get-verification-documents-gidx': getVerificationDocuments, } Object.entries(handlers).forEach(([path, handler]) => { diff --git a/backend/api/src/gidx/get-verification-documents.ts b/backend/api/src/gidx/get-verification-documents.ts new file mode 100644 index 0000000000..b0ab2a1234 --- /dev/null +++ b/backend/api/src/gidx/get-verification-documents.ts @@ -0,0 +1,90 @@ +import { APIError, APIHandler } from 'api/helpers/endpoint' +import { log } from 'shared/utils' +import { getGIDXStandardParams } from 'shared/gidx/helpers' +import { GIDX_REGISTATION_ENABLED, GIDXDocument } from 'common/gidx/gidx' + +export const getVerificationDocuments: APIHandler< + 'get-verification-documents-gidx' +> = async (_, auth) => { + if (!GIDX_REGISTATION_ENABLED) + throw new APIError(400, 'GIDX registration is disabled') + const { + documents, + unrejectedUtilityDocuments, + unrejectedIdDocuments, + rejectedDocuments, + } = await getIdentityVerificationDocuments(auth.uid) + + return { + status: 'success', + documents, + utilityDocuments: unrejectedUtilityDocuments, + idDocuments: unrejectedIdDocuments, + rejectedDocuments, + } +} + +export const getIdentityVerificationDocuments = async (userId: string) => { + const ENDPOINT = + 'https://api.gidx-service.in/v3.0/api/DocumentLibrary/CustomerDocuments' + const body = { + MerchantCustomerID: userId, + ...getGIDXStandardParams(), + } as Record + const queryParams = new URLSearchParams(body).toString() + const urlWithParams = `${ENDPOINT}?${queryParams}` + + const res = await fetch(urlWithParams) + if (!res.ok) { + throw new APIError(400, 'GIDX verification session failed') + } + + const data = (await res.json()) as DocumentCheck + log( + 'Registration response:', + data.ResponseMessage, + 'docs', + data.DocumentCount, + 'userId', + data.MerchantCustomerID + ) + const { Documents: documents } = data + + const isRejected = (doc: GIDXDocument) => + doc.DocumentStatus === 3 && + doc.DocumentNotes.length > 0 && + !doc.DocumentNotes.some((n) => n.NoteText == acceptDocText) + + const acceptedDocuments = documents.filter( + (doc) => + doc.DocumentStatus === 3 && + doc.DocumentNotes.length > 0 && + doc.DocumentNotes.some((n) => n.NoteText == acceptDocText) + ) + const rejectedDocuments = documents.filter(isRejected) + const unrejectedUtilityDocuments = documents.filter( + (doc) => + (doc.CategoryType === 7 || doc.CategoryType === 1) && !isRejected(doc) + ) + const unrejectedIdDocuments = documents.filter( + (doc) => doc.CategoryType != 7 && doc.CategoryType != 1 && !isRejected(doc) + ) + + return { + documents, + rejectedDocuments, + acceptedDocuments, + unrejectedUtilityDocuments, + unrejectedIdDocuments, + } +} + +type DocumentCheck = { + ResponseCode: number + ResponseMessage: string + MerchantCustomerID: string + DocumentCount: number + Documents: GIDXDocument[] +} + +const acceptDocText = 'Review Complete - Customer Identity Verified' diff --git a/backend/api/src/gidx/get-verification-status.ts b/backend/api/src/gidx/get-verification-status.ts index d0150fd730..1e529d3ad2 100644 --- a/backend/api/src/gidx/get-verification-status.ts +++ b/backend/api/src/gidx/get-verification-status.ts @@ -1,21 +1,10 @@ import { APIError, APIHandler } from 'api/helpers/endpoint' -import { getPrivateUserSupabase, log } from 'shared/utils' +import { getPrivateUserSupabase } from 'shared/utils' import { getPhoneNumber } from 'shared/helpers/get-phone-number' -import { - getGIDXCustomerProfile, - getGIDXStandardParams, -} from 'shared/gidx/helpers' -import { - GIDX_DOCUMENTS_REQUIRED, - GIDX_REGISTATION_ENABLED, - GIDXCustomerProfile, - GIDXDocument, -} from 'common/gidx/gidx' -import { updateUser } from 'shared/supabase/users' -import { createSupabaseDirectClient } from 'shared/supabase/init' +import { getGIDXCustomerProfile } from 'shared/gidx/helpers' +import { GIDX_REGISTATION_ENABLED, GIDXCustomerProfile } from 'common/gidx/gidx' import { processUserReasonCodes } from 'api/gidx/register' -const ENDPOINT = - 'https://api.gidx-service.in/v3.0/api/DocumentLibrary/CustomerDocuments' +import { getIdentityVerificationDocuments } from 'api/gidx/get-verification-documents' export const getVerificationStatus: APIHandler< 'get-verification-status-gidx' @@ -43,11 +32,13 @@ export const getVerificationStatusInternal = async ( } const { ReasonCodes, FraudConfidenceScore, IdentityConfidenceScore } = customerProfile + const { status, message } = await processUserReasonCodes( userId, ReasonCodes, FraudConfidenceScore, - IdentityConfidenceScore + IdentityConfidenceScore, + true ) if (status === 'error') { return { @@ -55,59 +46,10 @@ export const getVerificationStatusInternal = async ( message, } } - const body = { - MerchantCustomerID: userId, - ...getGIDXStandardParams(), - } as Record - const queryParams = new URLSearchParams(body).toString() - const urlWithParams = `${ENDPOINT}?${queryParams}` - - const res = await fetch(urlWithParams) - if (!res.ok) { - throw new APIError(400, 'GIDX verification session failed') - } - - const data = (await res.json()) as DocumentCheck - log( - 'Registration response:', - data.ResponseMessage, - 'docs', - data.DocumentCount, - 'userId', - data.MerchantCustomerID - ) - const { Documents: documents } = data - - const pg = createSupabaseDirectClient() - - if ( - documents.filter( - (doc) => doc.DocumentStatus === 3 && doc.DocumentNotes.length === 0 - ).length >= GIDX_DOCUMENTS_REQUIRED - ) { - await updateUser(pg, userId, { - kycStatus: 'verified', - }) - } else if ( - documents.filter( - (doc) => doc.DocumentStatus === 3 && doc.DocumentNotes.length > 0 - ).length > 0 - ) { - await updateUser(pg, userId, { - kycStatus: 'await-more-documents', - }) - } + const { documents } = await getIdentityVerificationDocuments(userId) return { status: 'success', documents, } } - -type DocumentCheck = { - ResponseCode: number - ResponseMessage: string - MerchantCustomerID: string - DocumentCount: number - Documents: GIDXDocument[] -} diff --git a/backend/api/src/gidx/register.ts b/backend/api/src/gidx/register.ts index eb7a605eb4..f9c4dedc2c 100644 --- a/backend/api/src/gidx/register.ts +++ b/backend/api/src/gidx/register.ts @@ -17,9 +17,11 @@ import { import { intersection } from 'lodash' import { getGIDXStandardParams } from 'shared/gidx/helpers' import { + GIDX_DOCUMENTS_REQUIRED, GIDX_REGISTATION_ENABLED, GIDXRegistrationResponse, } from 'common/gidx/gidx' +import { getIdentityVerificationDocuments } from 'api/gidx/get-verification-documents' const ENDPOINT = 'https://api.gidx-service.in/v3.0/api/CustomerIdentity/CustomerRegistration' @@ -70,7 +72,8 @@ export const register: APIHandler<'register-gidx'> = async ( auth.uid, ReasonCodes, FraudConfidenceScore, - IdentityConfidenceScore + IdentityConfidenceScore, + false ) return { status, @@ -82,7 +85,8 @@ export const processUserReasonCodes = async ( userId: string, ReasonCodes: string[], FraudConfidenceScore: number, - IdentityConfidenceScore: number + IdentityConfidenceScore: number, + queryForDocuments: boolean ): Promise => { const pg = createSupabaseDirectClient() @@ -185,9 +189,52 @@ export const processUserReasonCodes = async ( // User is not blocked and ID is verified if (ReasonCodes.includes('ID-VERIFIED')) { log('Registration passed with allowed codes:', ReasonCodes) - await updateUser(pg, userId, { - kycStatus: 'await-documents', - }) + // New user, no documents yet + if (!queryForDocuments) { + await updateUser(pg, userId, { + kycStatus: 'await-documents', + }) + return { status: 'success' } + } + + const { + documents, + unrejectedUtilityDocuments, + unrejectedIdDocuments, + acceptedDocuments, + rejectedDocuments, + } = await getIdentityVerificationDocuments(userId) + const acceptedUtilityDocuments = unrejectedUtilityDocuments.filter( + (doc) => doc.DocumentStatus === 3 + ) + const acceptedIdDocuments = unrejectedIdDocuments.filter( + (doc) => doc.DocumentStatus === 3 + ) + const pendingDocuments = documents.filter((doc) => doc.DocumentStatus !== 3) + if ( + acceptedDocuments.length >= GIDX_DOCUMENTS_REQUIRED && + acceptedUtilityDocuments.length > 0 && + acceptedIdDocuments.length > 0 + ) { + // They passed the reason codes and have the required documents + await updateUser(pg, userId, { + kycStatus: 'verified', + }) + } else if ( + acceptedDocuments.length < GIDX_DOCUMENTS_REQUIRED && + pendingDocuments.length > 0 + ) { + await updateUser(pg, userId, { + kycStatus: 'pending', + }) + } else if ( + rejectedDocuments.length > 0 || + acceptedDocuments.length < GIDX_DOCUMENTS_REQUIRED + ) { + await updateUser(pg, userId, { + kycStatus: 'await-documents', + }) + } return { status: 'success' } } diff --git a/backend/api/src/gidx/upload-document.ts b/backend/api/src/gidx/upload-document.ts index 8e4770ce5e..c74bd4830d 100644 --- a/backend/api/src/gidx/upload-document.ts +++ b/backend/api/src/gidx/upload-document.ts @@ -4,12 +4,14 @@ import { isProd, log } from 'shared/utils' import * as admin from 'firebase-admin' import { PROD_CONFIG } from 'common/envs/prod' import { DEV_CONFIG } from 'common/envs/dev' -import { updateUser } from 'shared/supabase/users' -import { createSupabaseDirectClient } from 'shared/supabase/init' import { DocumentRegistrationResponse, + GIDX_DOCUMENTS_REQUIRED, GIDX_REGISTATION_ENABLED, } from 'common/gidx/gidx' +import { getIdentityVerificationDocuments } from 'api/gidx/get-verification-documents' +import { createSupabaseDirectClient } from 'shared/supabase/init' +import { updateUser } from 'shared/supabase/users' const ENDPOINT = 'https://api.gidx-service.in/v3.0/api/DocumentLibrary/DocumentRegistration' @@ -54,8 +56,20 @@ export const uploadDocument: APIHandler<'upload-document-gidx'> = async ( Document.FileName ) await deleteFileFromFirebase(fileUrl) + const { documents, unrejectedIdDocuments, unrejectedUtilityDocuments } = + await getIdentityVerificationDocuments(auth.uid) + const pg = createSupabaseDirectClient() - await updateUser(pg, auth.uid, { kycStatus: 'pending' }) + if ( + documents.length >= GIDX_DOCUMENTS_REQUIRED && + unrejectedUtilityDocuments.length > 0 && + unrejectedIdDocuments.length > 0 + ) { + // They passed the reason codes and have the required documents + await updateUser(pg, auth.uid, { + kycStatus: 'pending', + }) + } return { status: 'success' } } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 6e450d6190..0139245e56 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1307,6 +1307,19 @@ export const API = (_apiTypeCheck = { }, props: z.object({}), }, + 'get-verification-documents-gidx': { + method: 'POST', + visibility: 'undocumented', + authed: true, + returns: {} as { + status: string + documents: GIDXDocument[] + utilityDocuments: GIDXDocument[] + idDocuments: GIDXDocument[] + rejectedDocuments: GIDXDocument[] + }, + props: z.object({}), + }, 'upload-document-gidx': { method: 'POST', visibility: 'undocumented', diff --git a/common/src/gidx/gidx.ts b/common/src/gidx/gidx.ts index 4cb4c55130..c3e4a00dac 100644 --- a/common/src/gidx/gidx.ts +++ b/common/src/gidx/gidx.ts @@ -44,11 +44,13 @@ export type GIDXDocument = { FileName: string FileSize: number DateTime: string - DocumentNotes: { - AuthorName: string - NoteText: string - DateTime: string - }[] + DocumentNotes: DocumentNote[] +} + +export type DocumentNote = { + AuthorName: string + NoteText: string + DateTime: string } export type GPSData = { diff --git a/common/src/user.ts b/common/src/user.ts index 2c269fcc4d..1705d55726 100644 --- a/common/src/user.ts +++ b/common/src/user.ts @@ -74,7 +74,6 @@ export type User = { | 'temporary-block' | 'verified' | 'pending' - | 'await-more-documents' | 'await-documents' } diff --git a/web/components/gidx/register-user-form.tsx b/web/components/gidx/register-user-form.tsx index f417bd0795..04f11b5e8b 100644 --- a/web/components/gidx/register-user-form.tsx +++ b/web/components/gidx/register-user-form.tsx @@ -28,7 +28,7 @@ import { useNativeMessages } from 'web/hooks/use-native-messages' const body = { ...exampleCustomers[2], - MerchantCustomerID: '11', + // MerchantCustomerID: '11', } // { // EmailAddress: 'gochanman@yahoo.com', @@ -68,7 +68,7 @@ export const RegisterUserForm = (props: { user: User }) => { const router = useRouter() // TODO: After development, if user is verified, redirect to the final page const [page, setPage] = useState( - user.kycStatus === 'verified' + user.kycStatus === 'verified' || user.kycStatus === 'pending' ? 1000 : user.kycStatus === 'await-documents' ? 4 @@ -394,6 +394,21 @@ export const RegisterUserForm = (props: { user: User }) => { ) } + if (user.kycStatus === 'await-documents') { + return ( + + Document errors + + There was an error with one or more of your documents. Please upload + new documents. + + + + + + ) + } + return ( diff --git a/web/components/gidx/upload-document.tsx b/web/components/gidx/upload-document.tsx index 6856579144..afa94197c9 100644 --- a/web/components/gidx/upload-document.tsx +++ b/web/components/gidx/upload-document.tsx @@ -1,5 +1,5 @@ import { useUser } from 'web/hooks/use-user' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Row } from 'web/components/layout/row' import { Button, buttonClass } from 'web/components/buttons/button' import { z } from 'zod' @@ -11,7 +11,11 @@ import { uploadPrivateImage } from 'web/lib/firebase/storage' import { last } from 'lodash' import { Select } from 'web/components/widgets/select' import clsx from 'clsx' -import { idNameToCategoryType } from 'common/gidx/gidx' +import { GIDXDocument, idNameToCategoryType } from 'common/gidx/gidx' +import { + MdOutlineCheckBox, + MdOutlineCheckBoxOutlineBlank, +} from 'react-icons/md' export const UploadDocuments = (props: { back: () => void @@ -19,7 +23,12 @@ export const UploadDocuments = (props: { }) => { const { back, next } = props const user = useUser() - + const [docs, setDocs] = useState<{ + documents: GIDXDocument[] + utilityDocuments: GIDXDocument[] + rejectedDocuments: GIDXDocument[] + idDocuments: GIDXDocument[] + } | null>(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [file, setFile] = useState(null) @@ -45,83 +54,190 @@ export const UploadDocuments = (props: { return } const ext = last(file.name.split('.')) - const fileName = 'id-document.' + ext + const fileName = `id-document-${ + getKeyFromValue(idNameToCategoryType, CategoryType) ?? '' + }.${ext}` + const fileUrl = await uploadPrivateImage(user.id, file, fileName) setLoading(true) setError(null) - const response = await api('upload-document-gidx', { + const { status } = await api('upload-document-gidx', { fileUrl, fileName, - CategoryType: 2, + CategoryType, + }).catch((e) => { + console.error(e) + if (e instanceof APIError) { + setError(e.message) + } + return { status: 'error' } }) - .catch((e) => { - if (e instanceof APIError) { - setError(e.message) - } - return null - }) - .finally(() => { - setLoading(false) - }) - console.log(response) - if (response) next() + if (status !== 'success') { + setLoading(false) + return + } + await getAndSetDocuments() } + const getAndSetDocuments = async () => { + setLoading(true) + const { documents, utilityDocuments, rejectedDocuments, idDocuments } = + await api('get-verification-documents-gidx', {}) + .catch((e) => { + console.error(e) + if (e instanceof APIError) { + setError(e.message) + } + return { + documents: null, + utilityDocuments: null, + idDocuments: null, + rejectedDocuments: null, + } + }) + .finally(() => setLoading(false)) + if (!documents) return + console.log(documents) + setDocs({ + documents, + rejectedDocuments, + utilityDocuments, + idDocuments, + }) + setFile(null) + } + + useEffect(() => { + getAndSetDocuments() + }, []) + + const hasIdDoc = (docs?.idDocuments ?? []).length > 0 + const hasUtilityDoc = (docs?.utilityDocuments ?? []).length > 0 + const hasRejectedUtilityDoc = (docs?.rejectedDocuments ?? []).some( + (doc) => doc.CategoryType === 7 || doc.CategoryType === 1 + ) + const hasRejectedIdDoc = (docs?.rejectedDocuments ?? []).some( + (doc) => doc.CategoryType !== 7 && doc.CategoryType !== 1 + ) return ( Identity Verification + + Please upload both: +
    +
  • + {hasIdDoc ? ( + + + Identity document such as passport or driver's license + + ) : ( + + + + Identity document such as passport or driver's license + + {hasRejectedIdDoc && ( + + Your previous id document was rejected, please try again. + + )} + + )} +
  • +
  • + {hasUtilityDoc ? ( + + + A utility bill or similar showing your name and address + + ) : ( + + + + A utility bill or similar showing your name and address + + {hasRejectedUtilityDoc && ( + + Your previous utility document was rejected, please try + again. + + )} + + )} +
  • +
+ {error && {error}} - - Document type - - Document to upload - {file && ( - {'Document'} - )} - {file ? {file.name} : null} - - setFile(files[0])} - className={clsx( - !file - ? buttonClass('md', 'indigo') - : buttonClass('md', 'indigo-outline') - )} + {(!hasIdDoc || !hasUtilityDoc) && ( + + Document type + + {file && ( + {'Document'} + )} + {file ? {file.name} : null} + + setFile(files[0])} + className={clsx( + !file + ? buttonClass('md', 'indigo') + : buttonClass('md', 'indigo-outline') + )} + > + Select a {file ? 'different ' : ''}file + + + + )} - + {!hasIdDoc || !hasUtilityDoc ? ( + + ) : ( + + )} ) } + +const getKeyFromValue = (obj: Record, value: number) => + Object.keys(obj).find((key) => obj[key] === value) diff --git a/web/components/gidx/verify-me.tsx b/web/components/gidx/verify-me.tsx index d4a8f0121c..b3fde58774 100644 --- a/web/components/gidx/verify-me.tsx +++ b/web/components/gidx/verify-me.tsx @@ -17,8 +17,16 @@ import { useState } from 'react' import { Row } from 'web/components/layout/row' import { toast } from 'react-hot-toast' -export const VerifyMe = (props: { user: User | null | undefined }) => { - const user = useWebsocketUser(props.user?.id) +export const VerifyMe = (props: { user: User }) => { + const user = useWebsocketUser(props.user?.id) || props.user + + const [show, setShow] = useState( + GIDX_REGISTATION_ENABLED && + user.kycStatus !== 'verified' && + user.kycStatus !== 'block' && + user.kycStatus !== 'temporary-block' + ) + const [documents, setDocuments] = useState(null) const [loading, setLoading] = useState(false) const getStatus = async () => { @@ -31,14 +39,7 @@ export const VerifyMe = (props: { user: User | null | undefined }) => { // TODO: if they need to re-register, show them a link to do so if (message) toast.error(message) } - if ( - !GIDX_REGISTATION_ENABLED || - !user || - user.kycStatus === 'verified' || - user.kycStatus === 'block' || - user.kycStatus === 'temporary-block' - ) - return null + if (!show || !user) return null if (user.kycStatus === 'pending') { return ( { ) } + if (user.kycStatus === 'verified') { + return ( + + + + 🎉 Congrats, you've been + verified! 🎉 + + + + + + ) + } + return ( , content: ( <> - + {currentUser && }