Skip to content

Commit

Permalink
[NestJS] Milestone 6: Plot twist! Special treat to Wakandians.
Browse files Browse the repository at this point in the history
  • Loading branch information
javiertoledo committed May 11, 2023
1 parent bc563b0 commit fd7995e
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 21 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,13 @@ This milestone introduced other two use cases: add the occupation data, and add

### Milestone 6: Plot twist!

In this milestone, we will need to change our business logic to skip wakandians from passing address verification, and we will automatically send a welcome email as the final step of the KYC process, including a special promo code to buy vibranium for wakandians.
In this milestone, we will need to change our business logic to skip wakandians from passing address verification, and we will automatically send a welcome email as the final step of the KYC process, including a special promo code to buy vibranium for wakandians. So we're going to apply the following changes:

1. Reject any address verification webhook call for wakandians.
2. Allow `KYCStatus` to transition from `KYCIDVerified` to `KYCBackgroundCheckPassed` or `KYCBackgroundCheckRequiresManualReview` for wakandians.
3. Trigger the automated background check after a successful ID verification for wakandians.
4. Trigger the welcome email after the background check has been passed. For wakandians, generate and include promo codes to buy vibranium.
5. When the email is sent, no matter if it succeeds or not, the result is registered and the profile is moved to the final `KYCCompleted` state.

TBD!

Expand Down
2 changes: 2 additions & 0 deletions kyc-nest/.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ OFAC_PROXY_URL="http://localhost:8000"
OFAC_PROXY_API_KEY="1234567890"
PEP_PROXY_URL="http://localhost:8000"
PEP_PROXY_API_KEY="1234567890"
MAIL_SERVICE_URL="http://localhost:8000"
MAIL_SERVICE_API_KEY="1234567890"
5 changes: 3 additions & 2 deletions kyc-nest/src/kyc/kyc.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { KYCService } from './kyc.service';
import { KYCController } from './kyc.controller';
import { ProfileModule } from 'src/profile/profile.module';
import { ConfigModule } from '@nestjs/config';
import { ProfileModule } from 'src/profile/profile.module';
import { PromoCodeModule } from 'src/promo-code/promo-code-module';

@Module({
imports: [ProfileModule, ConfigModule.forRoot()],
imports: [ProfileModule, PromoCodeModule, ConfigModule.forRoot()],
providers: [KYCService],
controllers: [KYCController],
})
Expand Down
65 changes: 65 additions & 0 deletions kyc-nest/src/kyc/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
import { ProfileService } from '../profile/profile.service';
import { Profile } from 'src/profile/profile.entity';
import { ConfigService } from '@nestjs/config';
import { PromoCodeService } from '../promo-code/promo-code.service';

@Injectable()
export class KYCService {
constructor(
private readonly profileService: ProfileService,
private readonly promoCodeService: PromoCodeService,
private configService: ConfigService,
) {}

Expand All @@ -29,6 +31,12 @@ export class KYCService {
idVerificationId: message.verificationId,
idVerifiedAt: message.timestamp,
});

// Trigger the automated background check for profiles that skip the address verification
const profile = await this.profileService.findById(userId);
if (profile.skipsAddressVerification()) {
this.performBackgroundCheck(userId);
}
} else if (message.result === 'rejected') {
await this.profileService.update(userId, {
kycStatus: 'KYCIDRejected',
Expand All @@ -48,6 +56,12 @@ export class KYCService {

console.log('Received address verification webhook message:', message);

// Wakandians cannot legally share their address or location
const profile = await this.profileService.findById(userId);
if (profile.skipsAddressVerification()) {
throw new Error('Invalid address verification trial');
}

if (message.result === 'success') {
await this.profileService.update(userId, {
kycStatus: 'KYCAddressVerified',
Expand Down Expand Up @@ -77,6 +91,8 @@ export class KYCService {
backgroundCheckManualValidatorId: validatorId,
backgroundCheckPassedAt: timestamp,
});

await this.sendWelcomeEmail(userId);
} else {
await this.profileService.update(userId, {
kycStatus: 'KYCBackgroundCheckRejected',
Expand All @@ -97,6 +113,8 @@ export class KYCService {
kycStatus: 'KYCBackgroundCheckPassed',
backgroundCheckPassedAt: new Date().toISOString(),
});

await this.sendWelcomeEmail(profile.id);
} else {
await this.profileService.update(profileId, {
kycStatus: 'KYCBackgroundCheckRequiresManualReview',
Expand Down Expand Up @@ -152,4 +170,51 @@ export class KYCService {
const pepData = await pepResponse.json();
return pepData.result === 'clear';
}

private async sendWelcomeEmail(userId: string): Promise<void> {
const mailServiceURL = this.configService.get<string>('MAIL_SERVICE_URL');
const mailServiceAPIKey = this.configService.get<string>(
'MAIL_SERVICE_API_KEY',
);

const profile = await this.profileService.findById(userId);
const { id, firstName, lastName, email } = profile;
const profileData = {
id,
firstName,
lastName,
email,
};

const extraData = {};
if (profile.country === 'Wakanda') {
const promoCode = await this.promoCodeService.create(profile);
extraData['templateId'] = 'WakandianSpecialKYCWelcomeEmailTemplate';
extraData['promoCode'] = promoCode.code;
} else {
extraData['templateId'] = 'KYCWelcomeEmailTemplate';
}

const mailServiceResponse = await fetch(mailServiceURL, {
method: 'POST',
headers: { Authorization: `Bearer ${mailServiceAPIKey}` },
body: JSON.stringify({
origin: 'kycService',
...extraData,
...profileData,
}),
});

const response = await mailServiceResponse.json();
const resultStatus = {};
if (response.result === 'delivered') {
resultStatus['welcomeEmailDeliveredAt'] = new Date().toISOString();
} else {
resultStatus['welcomeEmailDeliveryFailedAt'] = new Date().toISOString();
}
this.profileService.update(userId, {
kycStatus: 'KYCCompleted',
...resultStatus,
});
}
}
23 changes: 21 additions & 2 deletions kyc-nest/src/profile/profile.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, OneToOne } from 'typeorm';
import { Relative } from '../relative/relative.entity';
import { PromoCode } from '../promo-code/promo-code.entity';

export type KYCStatus =
| 'KYCPending'
Expand All @@ -9,7 +10,8 @@ export type KYCStatus =
| 'KYCAddressRejected'
| 'KYCBackgroundCheckPassed'
| 'KYCBackgroundCheckRequiresManualReview'
| 'KYCBackgroundCheckRejected';
| 'KYCBackgroundCheckRejected'
| 'KYCCompleted';

export enum IncomeSource {
Salary = 'Salary',
Expand All @@ -23,6 +25,8 @@ export enum IncomeSource {
SocialSecurity = 'Social Security',
}

const countriesWithNoAddressVerifications = ['Wakanda'];

@Entity()
export class Profile {
@PrimaryGeneratedColumn()
Expand Down Expand Up @@ -109,6 +113,21 @@ export class Profile {
@Column({ nullable: true })
backgroundCheckRejectedAt?: string;

@Column({ nullable: true })
welcomeEmailDeliveredAt?: string;

@Column({ nullable: true })
welcomeEmailDeliveryFailedAt?: string;

@OneToMany(() => Relative, (relative) => relative.profile, { cascade: true })
relatives: Relative[];

@OneToOne(() => PromoCode, (promoCode) => promoCode.profile, {
cascade: true,
})
promoCode: PromoCode;

public skipsAddressVerification(): boolean {
return countriesWithNoAddressVerifications.includes(this.country);
}
}
37 changes: 22 additions & 15 deletions kyc-nest/src/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class ProfileService {

if (
profileData.kycStatus &&
!this.isValidTransition(profile.kycStatus, profileData.kycStatus)
!this.isValidTransition(profile, profileData.kycStatus)
) {
throw new BadRequestException(
`Invalid status transition from '${profile.kycStatus}' to '${profileData.kycStatus}'`,
Expand All @@ -42,7 +42,7 @@ export class ProfileService {
async findById(id: string): Promise<Profile> {
const options: FindOneOptions<Profile> = {
where: { id },
relations: ['relatives'],
relations: ['relatives', 'promoCode'],
};

const profile = await this.profileRepository.findOne(options);
Expand All @@ -52,34 +52,41 @@ export class ProfileService {
return profile;
}

private isValidTransition(
currentState: KYCStatus,
newState: KYCStatus,
): boolean {
return this.allowedTransitions(currentState).includes(newState);
private isValidTransition(profile: Profile, newState: KYCStatus): boolean {
return this.allowedTransitions(profile).includes(newState);
}

private allowedTransitions(currentState: KYCStatus): KYCStatus[] {
switch (currentState) {
private allowedTransitions(profile: Profile): KYCStatus[] {
const AddressVerificationTargetStates: KYCStatus[] = [
'KYCAddressVerified',
'KYCAddressRejected',
];
const AutomatedBackgroundCheckTargetStates: KYCStatus[] = [
'KYCBackgroundCheckPassed',
'KYCBackgroundCheckRequiresManualReview',
];
switch (profile.kycStatus) {
// Initial state
case 'KYCPending':
return ['KYCIDVerified', 'KYCIDRejected'];
// Step 1: ID Verified, waiting for address verification
case 'KYCIDVerified':
return ['KYCAddressVerified', 'KYCAddressRejected'];
if (profile.skipsAddressVerification()) {
return AutomatedBackgroundCheckTargetStates;
} else {
return AddressVerificationTargetStates;
}
// Step 2: Address verified, waiting for background check
case 'KYCAddressVerified':
return [
'KYCBackgroundCheckPassed',
'KYCBackgroundCheckRequiresManualReview',
];
return AutomatedBackgroundCheckTargetStates;
// Step 3: Background check suspicious, waiting for manual review
case 'KYCBackgroundCheckRequiresManualReview':
return ['KYCBackgroundCheckPassed', 'KYCBackgroundCheckRejected'];
// Step 4: Background check passed, waiting for risk assessment
case 'KYCBackgroundCheckPassed':
return [];
return ['KYCCompleted'];
// Final states
case 'KYCCompleted':
case 'KYCIDRejected':
case 'KYCAddressRejected':
case 'KYCBackgroundCheckRejected':
Expand Down
12 changes: 12 additions & 0 deletions kyc-nest/src/promo-code/promo-code-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProfileModule } from '../profile/profile.module';
import { PromoCode } from './promo-code.entity';
import { PromoCodeService } from './promo-code.service';

@Module({
imports: [TypeOrmModule.forFeature([PromoCode]), ProfileModule],
providers: [PromoCodeService],
exports: [PromoCodeService],
})
export class PromoCodeModule {}
16 changes: 16 additions & 0 deletions kyc-nest/src/promo-code/promo-code.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import { Profile } from '../profile/profile.entity';

@Entity()
export class PromoCode {
@PrimaryGeneratedColumn()
id: string;

@Column()
code: string;

@ManyToOne(() => Profile, (profile) => profile.relatives, {
onDelete: 'CASCADE',
})
profile: Profile;
}
24 changes: 24 additions & 0 deletions kyc-nest/src/promo-code/promo-code.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProfileService } from '../profile/profile.service';
import { PromoCode } from './promo-code.entity';
import { Profile } from 'src/profile/profile.entity';
import * as crypto from 'crypto';

@Injectable()
export class PromoCodeService {
constructor(
private readonly profileService: ProfileService,
@InjectRepository(PromoCode)
private relativeRepository: Repository<PromoCode>,
) {}

async create(profile: Profile): Promise<PromoCode> {
const code = crypto.randomBytes(20).toString('hex');
const promoCode = this.relativeRepository.create({ code });
promoCode.profile = profile;

return this.relativeRepository.save(promoCode);
}
}
3 changes: 2 additions & 1 deletion mock-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const server = http.createServer((req, res) => {
console.log(chunk.toString());
});
let response = {
result: 'clear'
//result: 'clear'
result: 'delivered'
};
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
Expand Down

0 comments on commit fd7995e

Please sign in to comment.