Skip to content

Commit

Permalink
(flag disabled) KYC registration (#2678)
Browse files Browse the repository at this point in the history
* Basic layout for gidx registration integration

* Add registration phone verification section

* Add first pass at reason code handling

* Remove unused

* Get verification session script string

* Get verification session script string

* Upoad identity documents to gidx

* Refactor

* Get verification session script string

* Fix import

* Fix import

* Tweak user flow

* Comment

* Request location - builds on ios, but not on android yet

* Yarn clean - builds on ios and android

* Fix 2fa login problem on android

* Allow android to pull from top to refresh

* Set end refresh on load end

* Enable cache on webview

* Fix urls

* Add gidx registration enabled flag

* Get last known location if known
  • Loading branch information
IanPhilips authored Jun 21, 2024
1 parent 83b39a0 commit c31de72
Show file tree
Hide file tree
Showing 74 changed files with 4,004 additions and 349 deletions.
2 changes: 2 additions & 0 deletions backend/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@
"expo-server-sdk": "3.6.0",
"express": "4.18.1",
"firebase-admin": "11.11.1",
"form-data": "4.0.0",
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"libphonenumber-js": "1.11.3",
"link-preview-js": "3.0.4",
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
Expand Down
12 changes: 10 additions & 2 deletions backend/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ import { getUserPortfolio } from './get-user-portfolio'
import { createuser } from 'api/create-user'
import { verifyPhoneNumber } from 'api/verify-phone-number'
import { requestOTP } from 'api/request-phone-otp'
import { getPhoneNumber } from 'api/get-phone-number'
import { multiSell } from 'api/multi-sell'
import { convertSpiceToMana } from './convert-sp-to-mana'
import { donate } from './donate'
Expand All @@ -167,6 +166,11 @@ import { blockGroup, unblockGroup } from './block-group'
import { blockMarket, unblockMarket } from './block-market'
import { getTxnSummaryStats } from 'api/get-txn-summary-stats'
import { getManaSummaryStats } from 'api/get-mana-summary-stats'
import { register } from 'api/gidx/register'
import { getVerificationSession } from 'api/gidx/get-verification-session'
import { uploadDocument } from 'api/gidx/upload-document'
import { callbackGIDX } from 'api/gidx/callback'
import { getVerificationStatus } from 'api/gidx/get-verification-status'
import { getCurrentPrivateUser } from './get-current-private-user'
import { updatePrivateUser } from './update-private-user'
import { setPushToken } from './push-token'
Expand Down Expand Up @@ -331,7 +335,6 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
createuser: createuser,
'verify-phone-number': verifyPhoneNumber,
'request-otp': requestOTP,
'phone-number': getPhoneNumber,
'multi-sell': multiSell,
'get-feed': getFeed,
'get-mana-supply': getManaSupply,
Expand All @@ -340,6 +343,11 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'search-contract-positions': searchContractPositions,
'get-txn-summary-stats': getTxnSummaryStats,
'get-mana-summary-stats': getManaSummaryStats,
'register-gidx': register,
'get-verification-session-gidx': getVerificationSession,
'get-verification-status-gidx': getVerificationStatus,
'upload-document-gidx': uploadDocument,
'callback-gidx': callbackGIDX,
}

Object.entries(handlers).forEach(([path, handler]) => {
Expand Down
20 changes: 0 additions & 20 deletions backend/api/src/get-phone-number.ts

This file was deleted.

13 changes: 13 additions & 0 deletions backend/api/src/gidx/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { APIHandler } from 'api/helpers/endpoint'
import { log } from 'shared/utils'

export const callbackGIDX: APIHandler<'callback-gidx'> = async (props) => {
log('callback-gidx', props)
return { success: true }
}

const documentStatus = {

Check warning on line 9 in backend/api/src/gidx/callback.ts

View workflow job for this annotation

GitHub Actions / test

'documentStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
1: 'Not Reviewed',
2: 'Under Review',
3: 'Review Complete',
}
54 changes: 54 additions & 0 deletions backend/api/src/gidx/get-verification-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { getPrivateUserSupabase, log } from 'shared/utils'
import { getPhoneNumber } from 'shared/helpers/get-phone-number'
import { getGIDXStandardParams } from 'shared/gidx/standard-params'
import {
GIDX_REGISTATION_ENABLED,
GIDXVerificationResponse,
} from 'common/gidx/gidx'
const ENDPOINT = 'https://api.gidx-service.in/v3.0/api/WebReg/CreateSession'
export const getVerificationSession: APIHandler<
'get-verification-session-gidx'
> = async (props, auth) => {
if (!GIDX_REGISTATION_ENABLED)
throw new APIError(400, 'GIDX registration is disabled')
const user = await getPrivateUserSupabase(auth.uid)
if (!user) {
throw new APIError(404, 'Private user not found')
}
if (!user.email) {
throw new APIError(400, 'User must have an email address')
}
const phoneNumberWithCode = await getPhoneNumber(auth.uid)
if (!phoneNumberWithCode) {
throw new APIError(400, 'User must have a phone number')
}
const body = {
// TODO: add back in prod
// MerchantCustomerID: auth.uid,,
// EmailAddress: user.email,
// MobilePhoneNumber: parsePhoneNumber(phoneNumberWithCode)?.nationalNumber ?? phoneNumberWithCode,
// DeviceIpAddress: getIp(req),
CustomerIpAddress: props.DeviceIpAddress,
// CallbackURL: 'https://api.manifold.markets/v0/gidx/verification-callback',
CallbackURL:
'https://enabled-bream-sharply.ngrok-free.app/v0/callback-gidx',
...getGIDXStandardParams(),
...props,
}
log('Registration request:', body)
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
throw new APIError(400, 'GIDX verification session failed')
}

const data = (await res.json()) as GIDXVerificationResponse
log('Registration response:', data)
return data
}
58 changes: 58 additions & 0 deletions backend/api/src/gidx/get-verification-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { getPrivateUserSupabase, log } from 'shared/utils'
import { getPhoneNumber } from 'shared/helpers/get-phone-number'
import { getGIDXStandardParams } from 'shared/gidx/standard-params'
import { GIDX_REGISTATION_ENABLED, GIDXDocument } from 'common/gidx/gidx'
// TODO this endpoint returns a 404, the endpoint doesn't exist...
const ENDPOINT =
'https://api.gidx-service.in/v3.0/api/DocumentLibrary/CustomerDocument'

export const getVerificationStatus: APIHandler<
'get-verification-status-gidx'
> = async (_, auth) => {
if (!GIDX_REGISTATION_ENABLED)
throw new APIError(400, 'GIDX registration is disabled')
const user = await getPrivateUserSupabase(auth.uid)
if (!user) {
throw new APIError(404, 'Private user not found')
}
if (!user.email) {
throw new APIError(400, 'User must have an email address')
}
const phoneNumberWithCode = await getPhoneNumber(auth.uid)
if (!phoneNumberWithCode) {
throw new APIError(400, 'User must have a phone number')
}
// TODO: Handle more than just check on their document upload. Let them know if they've failed, blocked, not yet started, etc.

const body = {
MerchantCustomerID: auth.uid,
...getGIDXStandardParams(),
}
const queryParams = new URLSearchParams(body as any).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 {
ResponseCode: number
ResponseMessage: string
MerchantCustomerID: string
DocumentCount: number
Documents: GIDXDocument[]
}
log(
'Registration response:',
data.ResponseMessage,
'docs',
data.DocumentCount,
'userId',
data.MerchantCustomerID
)
return {
status: 'success',
}
}
177 changes: 177 additions & 0 deletions backend/api/src/gidx/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { getPrivateUserSupabase, log } from 'shared/utils'
import { getPhoneNumber } from 'shared/helpers/get-phone-number'
import { updateUser } from 'shared/supabase/users'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {
otherErrorCodes,
hasIdentityError,
blockedCodes,
allowedFlaggedCodes,
locationTemporarilyBlockedCodes,
} from 'common/reason-codes'
import { intersection } from 'lodash'
import { getGIDXStandardParams } from 'shared/gidx/standard-params'
import { GIDX_REGISTATION_ENABLED } from 'common/gidx/gidx'
const ENDPOINT =
'https://api.gidx-service.in/v3.0/api/CustomerIdentity/CustomerRegistration'

export const register: APIHandler<'register-gidx'> = async (
props,
auth,
req

Check warning on line 22 in backend/api/src/gidx/register.ts

View workflow job for this annotation

GitHub Actions / test

'req' is defined but never used. Allowed unused args must match /^_/u
) => {
if (!GIDX_REGISTATION_ENABLED)
throw new APIError(400, 'GIDX registration is disabled')
const pg = createSupabaseDirectClient()
const user = await getPrivateUserSupabase(auth.uid)
if (!user) {
throw new APIError(404, 'Private user not found')
}
if (!user.email) {
throw new APIError(400, 'User must have an email address')
}
const phoneNumberWithCode = await getPhoneNumber(auth.uid)
if (!phoneNumberWithCode) {
throw new APIError(400, 'User must have a phone number')
}
const body = {
// TODO: add back in prod
// MerchantCustomerID: auth.uid,,
// EmailAddress: user.email,
// MobilePhoneNumber: parsePhoneNumber(phoneNumberWithCode)?.nationalNumber ?? phoneNumberWithCode,
// DeviceIpAddress: getIp(req),
...getGIDXStandardParams(),
...props,
}
log('Registration request:', body)
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
throw new APIError(400, 'GIDX registration failed')
}

const data = (await res.json()) as GIDXRegistrationResponse
log('Registration response:', data)
const { ReasonCodes, FraudConfidenceScore, IdentityConfidenceScore } = data

// Timeouts or input errors
const errorCodes = intersection(otherErrorCodes, ReasonCodes)
if (errorCodes.length > 0) {
log('Registration failed, resulted in error codes:', errorCodes)
await updateUser(pg, auth.uid, { kycStatus: 'failed' })
return { status: 'error', ReasonCodes }
}

// User identity match is low confidence or attempt may be fraud
if (FraudConfidenceScore < 50 || IdentityConfidenceScore < 50) {
log(
'Registration failed, resulted in low confidence scores:',
FraudConfidenceScore,
IdentityConfidenceScore
)
await updateUser(pg, auth.uid, { kycStatus: 'failed' })
return {
status: 'error',
ReasonCodes,
FraudConfidenceScore,
IdentityConfidenceScore,
}
}

// User identity not found/verified
if (hasIdentityError(ReasonCodes)) {
log('Registration failed, resulted in identity errors:', ReasonCodes)
await updateUser(pg, auth.uid, { kycStatus: 'failed' })
return { status: 'error', ReasonCodes }
} else await updateUser(pg, auth.uid, { kycMatch: true })

// User is flagged for unknown address, vpn, unclear DOB, distance between attempts
const allowedFlags = intersection(allowedFlaggedCodes, ReasonCodes)
if (allowedFlags.length > 0) {
await updateUser(pg, auth.uid, { kycFlags: allowedFlags })
}

// User is in disallowed location, but they may move
if (intersection(locationTemporarilyBlockedCodes, ReasonCodes).length > 0) {
log(
'Registration failed, resulted in temporary blocked location codes:',
ReasonCodes
)
await updateUser(pg, auth.uid, { kycStatus: 'failed' })
return { status: 'error', ReasonCodes }
}

// User is blocked for any number of reasons
const blockedReasonCodes = intersection(blockedCodes, ReasonCodes)
if (blockedReasonCodes.length > 0) {
log('Registration failed, resulted in blocked codes:', blockedReasonCodes)
await updateUser(pg, auth.uid, { kycStatus: 'blocked' })
return { status: 'error', ReasonCodes }
}

// User is not blocked and ID is verified
if (ReasonCodes.includes('ID-VERIFIED')) {
log('Registration passed with allowed codes:', ReasonCodes)
await updateUser(pg, auth.uid, { kycStatus: 'verified' })
return { status: 'success', ReasonCodes }
}

log.error(
`Registration failed with unknown reason codes: ${ReasonCodes.join(', ')}`
)
return { status: 'error', ReasonCodes }
}

type GIDXRegistrationResponse = {
MerchantCustomerID: string
ReasonCodes: string[]
WatchChecks: WatchCheckType[]
ProfileMatch: ProfileMatchType
IdentityConfidenceScore: number
FraudConfidenceScore: number
CustomerRegistrationLink: string
LocationDetail: LocationDetailType
ResponseCode: number
ResponseMessage: string
ProfileMatches: ProfileMatchType[]
}

type WatchCheckType = {
SourceCode: string
SourceScore: number
MatchResult: boolean
MatchScore: number
}

type ProfileMatchType = {
NameMatch: boolean
AddressMatch: boolean
EmailMatch: boolean
IdDocumentMatch: boolean
PhoneMatch: boolean
MobilePhoneMatch: boolean
DateOfBirthMatch: boolean
CitizenshipMatch: boolean
}

type LocationDetailType = {
Latitude: number
Longitude: number
Radius: number
Altitude: number
Speed: number
LocationDateTime: string
LocationStatus: number
LocationServiceLevel: string
ReasonCodes: string[]
ComplianceLocationStatus: boolean
ComplianceLocationServiceStatus: string
IdentifierType: string
IdentifierUsed: string
}
Loading

0 comments on commit c31de72

Please sign in to comment.