Skip to content

Commit

Permalink
process kyb applications, creating CDD is address is specified in the…
Browse files Browse the repository at this point in the history
… original request
  • Loading branch information
polymath-eric committed May 9, 2024
1 parent d25ee97 commit 24a1e33
Show file tree
Hide file tree
Showing 20 changed files with 473 additions and 73 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Environment
.env
.nx

# compiled output
dist
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,33 @@ If you haven't used Redis, `npx redis-commander` should bring up a simple [web b
### Understand this workspace

Run `nx graph` to see a diagram of the dependencies of the projects.

## General Flow

Users can choose one of 3 providers: Fractal, Jumio and Netki and provide an address to onboard as their primary key.

### Fractal

If the user selects Fractal they will be directed to Fractal's platform who will handle the onboarding process in its entirety. In this case the CDD service is only providing a link and has no more involvement.

### Jumio

If the user selects Jumio a UUID will be generated and templated into a URL for the user to complete their identity verification at. After the user uploads their documents and are verified, Jumio will dispatch a callback to our server, which will in turn be processed by our worker to create the user's CDD claim.

The user will be redirected to our frontend page, and the link will contain their address so we can provide accurate information about their application status.

### Netki

If the user selects Netki an access code will be associated to their provided address. This access code is templated into a URL to direct the user to verify their identity via mobile device. When the documents are verified Netki will dispatch a callback to our server, which will use the included access code

Netki does not allow a client ID to be specified so an access code to address lookup must be maintained. If the user restarts the flow they will be issued a new access code and our server will receive a callback to update the lookup.

The user will be redirect to our frontend page, but the redirect link will no contain the access code nor the address. This makes it difficult to determine the application status after their redirect, especially in the case when they started on desktop and were redirected to mobile for verification.

#### Business

Netki also offers support for KYB. For this flow an access code is allocated manually via API. The business submitter will need to verify their identity personally in addition to uploading business documents.

When the individual verifies their identity a callback will be issued containing the access code an business id. Our worker will create an association from this business ID to the address if it was provided in the access code generation.

When the business is verified Netki will issue another webhook where the business ID to address lookup will be used to create the CDD claim.
13 changes: 11 additions & 2 deletions apps/cdd-backend/src/app-redis/app-redis.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,24 @@ describe('AppRedisService', () => {
const exampleCode = { code: 'someCode' } as unknown as NetkiAccessLinkModel;

it('should add codes', async () => {
redis.scard.mockResolvedValue(1);
redis.sadd.mockResolvedValue(1);

const result = await service.pushNetkiCodes([exampleCode]);

expect(redis.sadd).toHaveBeenCalledWith(netkiAvailableCodesPrefix, [
JSON.stringify(exampleCode),
]);
expect(result).toEqual({ added: 1, total: 1 });
expect(result).toEqual(1);
});
});

describe('getAccessCodeCount', () => {
it('should return code count', async () => {
redis.scard.mockResolvedValue(3);

const result = await service.getAccessCodeCount();

expect(result).toEqual(3);
});
});

Expand Down
36 changes: 27 additions & 9 deletions apps/cdd-backend/src/app-redis/app-redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
netkiAddressPrefixer,
netkiBusinessAppPrefixer,
netkiBusinessAppPrefix,
netkiBusinessToAddressPrefixer,
} from './utils';

@Injectable()
Expand Down Expand Up @@ -81,23 +82,22 @@ export class AppRedisService {
await this.redis.del(netkiAccessCodeKey);
}

async pushNetkiCodes(
newCodes: NetkiAccessLinkModel[]
): Promise<{ added: number; total: number }> {
async pushNetkiCodes(newCodes: NetkiAccessLinkModel[]): Promise<number> {
const added = await this.redis.sadd(
netkiAvailableCodesPrefix,
newCodes.map((link) => JSON.stringify(link))
);

const total = await this.redis.scard(netkiAvailableCodesPrefix);

this.logger.info('added new netki codes', {
attemptedToAdd: newCodes.length,
added,
total,
});

return { added, total };
return added;
}

async getAccessCodeCount(): Promise<number> {
return this.redis.scard(netkiAvailableCodesPrefix);
}

async popNetkiAccessLink(): Promise<NetkiAccessLinkModel | null> {
Expand All @@ -117,8 +117,6 @@ export class AppRedisService {
this.redis.keys(`${netkiBusinessAppPrefix}*`),
]);

console.log({ allocatedIndividualCodes, allocatedBusinessCodes });

return new Set(
[...allocatedIndividualCodes, ...allocatedBusinessCodes].map((code) =>
code
Expand All @@ -128,6 +126,26 @@ export class AppRedisService {
);
}

async setBusinessIdToAddress(
businessId: string,
address: string
): Promise<void> {
const prefixedKey = netkiBusinessToAddressPrefixer(businessId);

this.logger.debug('associating netki business ID to address', {
businessId,
address,
});

await this.redis.set(prefixedKey, address);
}

async getNetkiBusinessAddress(businessId: string): Promise<string | null> {
const businessKey = netkiBusinessToAddressPrefixer(businessId);

return this.redis.get(businessKey);
}

async availableNetkiCodeCount(): Promise<number> {
return await this.redis.scard(netkiAvailableCodesPrefix);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ export interface CddApplicationModel {
provider: CddProvider;
timestamp: string;
externalId: string;
isBusiness?: boolean;
}
4 changes: 4 additions & 0 deletions apps/cdd-backend/src/app-redis/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export const netkiAvailableCodesPrefix = 'netki-codes' as const;
export const netkiAllocatedCodePrefix = 'netki-allocated-codes:' as const;
export const netkiBusinessAppPrefix = 'netki-business-codes:' as const;
export const netkiBusinessToAddressPrefix = 'netki-business-address:' as const;

export const netkiAddressPrefixer = (id: string) =>
`${netkiAllocatedCodePrefix}${id}`;

export const netkiBusinessAppPrefixer = (id: string) =>
`${netkiBusinessAppPrefix}${id}`;

export const netkiBusinessToAddressPrefixer = (address: string) =>
`${netkiBusinessToAddressPrefix}${address}`;
86 changes: 85 additions & 1 deletion apps/cdd-backend/src/cdd-worker/cdd.processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Polymesh } from '@polymeshassociation/polymesh-sdk';
import { Job } from 'bull';
import { MockPolymesh } from '../test-utils/mocks';
import { CddProcessor } from './cdd.processor';
import { JumioCddJob, MockCddJob, NetkiCddJob } from './types';
import {
JumioCddJob,
MockCddJob,
NetkiBusinessJob,
NetkiCddJob,
} from './types';
import { JumioCallbackDto } from '../jumio/types';

import jumioVerifiedData from '../test-utils/jumio-http/webhook-approved-verified.json';
Expand Down Expand Up @@ -193,6 +198,85 @@ describe('cddProcessor', () => {
processor.generateCdd(mockNetkiCompletedJob)
).rejects.toThrowError();
});

it('should assign the address to business ID in case of a business application', async () => {
mockNetkiCompletedJob.data.value.identity.transaction_identity.identity_access_code.business =
'netkiBusinessId';

mockRedis.getNetkiAddress.mockResolvedValue(null);
mockRedis.getNetkiBusinessApplication.mockResolvedValue({
id: 'someId',
accessCode: 'someCode',
link: 'someLink',
address: 'someAddress',
timestamp: new Date().toISOString(),
});

await processor.generateCdd(mockNetkiCompletedJob);

expect(mockRedis.setBusinessIdToAddress).toHaveBeenCalledWith(
'netkiBusinessId',
'someAddress'
);
});

it('should throw an error if there is no business in the callback', async () => {
mockRedis.getNetkiAddress.mockResolvedValue(null);
mockRedis.getNetkiBusinessApplication.mockResolvedValue({
id: 'someId',
accessCode: 'someCode',
link: 'someLink',
address: 'someAddress',
timestamp: new Date().toISOString(),
});

await expect(
processor.generateCdd(mockNetkiCompletedJob)
).rejects.toThrow();
});
});

describe('netki business job', () => {
let mockNetkiCompletedJob: Job<NetkiBusinessJob>;

beforeEach(() => {
mockNetkiCompletedJob = {
...createMock<Job>(),
data: {
type: 'netki-kyb',
value: {
parent_business: 'someBusinessId',
status: 'accepted',
},
},
};
});

it('should create a cdd claim', async () => {
mockRedis.getNetkiBusinessAddress.mockResolvedValue('someAddress');

await processor.generateCdd(mockNetkiCompletedJob);

expect(mockPolymesh.identities.registerIdentity).toHaveBeenCalledWith(
{
targetAccount: 'someAddress',
createCdd: true,
},
{ signingAccount: 'netkiSignerAddress' }
);
});

it('should not create a cdd claim if status is not accepted', async () => {
mockRedis.getNetkiBusinessAddress.mockResolvedValue('someAddress');

mockNetkiCompletedJob.data.value.status = 'rejected';

await processor.generateCdd(mockNetkiCompletedJob);

expect(
mockPolymesh.identities.registerIdentity
).not.toHaveBeenCalled();
});
});

describe('restarted job', () => {
Expand Down
80 changes: 68 additions & 12 deletions apps/cdd-backend/src/cdd-worker/cdd.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
JobIdentifier,
JumioCddJob,
NetkiCddJob,
NetkiBusinessJob,
} from './types';
import { Identity } from '@polymeshassociation/polymesh-sdk/types';
import { NetkiBusinessApplicationModel } from '../app-redis/models/netki-business-application.model';
Expand All @@ -36,17 +37,53 @@ export class CddProcessor {
await this.handleNetki(job.data);
} else if (job.data.type === 'mock') {
await this.handleMockJob(job.data);
} else if (job.data.type === 'netki-kyb') {
await this.handleNetkiBusiness(job.data);
} else {
throw new Error('unknown CDD job type encountered');
}

this.logger.info('finished processing CDD job', { jobId: job.id });
}

private async handleNetkiBusiness({
value: { parent_business: businessId, status },
}: NetkiBusinessJob): Promise<void> {
if (status !== 'accepted') {
this.logger.info(
'netki business callback did not have accepted status. No action will be taken'
);

return;
}

const address = await this.redis.getNetkiBusinessAddress(businessId);

if (!address) {
this.logger.info(
'there was no address associated to the business ID - no action will be taken',
{
businessId,
}
);

return;
}

await this.createCddClaim(
{ id: businessId, provider: 'netki' },
address,
'netki'
);
}

private async handleNetki({ value: netki }: NetkiCddJob): Promise<void> {
const {
identity: {
transaction_identity: { client_guid: guid },
transaction_identity: {
client_guid: guid,
identity_access_code: { business },
},
state,
},
} = netki;
Expand All @@ -55,21 +92,25 @@ export class CddProcessor {

this.logger.info('starting netki job', { jobId, state });

const [address, businessApplication] = await Promise.all([
const [individualAddress, businessApplication] = await Promise.all([
this.redis.getNetkiAddress(id),
this.redis.getNetkiBusinessApplication(id),
]);

if (!address && !businessApplication) {
if (!individualAddress && !businessApplication) {
throw new Error('no information associated to netki code');
}

this.logger.info('netki job info retrieved', { jobId, address, state });
this.logger.info('netki job info retrieved', {
jobId,
address: individualAddress,
state,
});

if (state === 'restarted') {
this.logger.debug('handling netki restart', { jobId });
if (address) {
await this.handleNetkiRestart(jobId, address, netki);
if (individualAddress) {
await this.handleNetkiRestart(jobId, individualAddress, netki);
} else if (businessApplication) {
await this.handleNetkiRestartForBusiness(
jobId,
Expand All @@ -79,20 +120,35 @@ export class CddProcessor {
}
} else if (state === 'completed') {
this.logger.debug('handling netki completed', { jobId });
if (address) {
await this.createCddClaim(jobId, address, 'netki');
await this.clearAddressApplications(jobId, address);
if (individualAddress) {
await this.createCddClaim(jobId, individualAddress, 'netki');
await this.clearAddressApplications(jobId, individualAddress);
} else if (businessApplication?.address) {
await this.createCddClaim(jobId, businessApplication.address, 'netki'); // should this get its own key?
if (!business) {
this.logger.error(
'no business ID was in callback for business application',
{ jobId, address: businessApplication.address }
);

throw new Error(
'No business ID was in callback, but it was expected'
);
}

await this.redis.setBusinessIdToAddress(
business,
businessApplication.address
);
} else {
this.logger.info(
'no address was associated to netki application, no CDD claim is being made'
'no address was associated to netki business application, no business ID association will be made'
);
}
} else {
this.logger.info('netki state did not have a handler - no action taken', {
jobId,
address,
address: individualAddress,
businessApplicationId: businessApplication?.id,
state,
});
}
Expand Down
Loading

0 comments on commit 24a1e33

Please sign in to comment.