Skip to content

Commit

Permalink
[Booster] 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 fd7995e commit 444facc
Show file tree
Hide file tree
Showing 20 changed files with 382 additions and 116 deletions.
2 changes: 2 additions & 0 deletions kyc-booster/.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"
200 changes: 114 additions & 86 deletions kyc-booster/package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions kyc-booster/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
"version": "0.1.0",
"author": "Javier Toledo",
"dependencies": {
"@boostercloud/framework-common-helpers": "^1.7.0",
"@boostercloud/framework-core": "^1.7.0",
"@boostercloud/framework-provider-aws": "^1.7.0",
"@boostercloud/framework-types": "^1.7.0",
"@boostercloud/framework-common-helpers": "^1.10.0",
"@boostercloud/framework-core": "^1.10.0",
"@boostercloud/framework-provider-aws": "^1.10.0",
"@boostercloud/framework-types": "^1.10.0",
"tslib": "^2.4.0"
},
"devDependencies": {
"@boostercloud/framework-provider-aws-infrastructure": "^1.7.0",
"@boostercloud/framework-provider-local": "^1.7.0",
"@boostercloud/framework-provider-local-infrastructure": "^1.7.0",
"@boostercloud/metadata-booster": "^1.7.0",
"@boostercloud/framework-provider-aws-infrastructure": "^1.10.0",
"@boostercloud/framework-provider-local": "^1.10.0",
"@boostercloud/framework-provider-local-infrastructure": "^1.10.0",
"@boostercloud/metadata-booster": "^1.10.0",
"@types/jsonwebtoken": "^8.5.1",
"@types/mocha": "8.2.2",
"@types/node": "16.11.7",
Expand Down
9 changes: 7 additions & 2 deletions kyc-booster/src/commands/process-address-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ export class ProcessAddressVerification {
throw new Error(`Profile with ID ${command.userId} not found`)
}

// Reject verification confirmations for profiles that don't need address verification
if (profile.skipsAddressVerification()) {
throw new Error('Address verification not supported for people living in invisible countries.')
}

// Ensure that the verification result is valid
if (command.result !== 'success' && command.result !== 'rejected') {
throw new Error(`Invalid address verification result: ${command.result}`)
}

// Emit the corresponding event depending on the result, making sure that the transition is valid
if (command.result === 'success' && isValidTransition(profile.kycStatus, 'KYCAddressVerified')) {
if (command.result === 'success' && isValidTransition(profile, 'KYCAddressVerified')) {
register.events(new AddressVerificationSuccess(command.userId, command.verificationId, command.timestamp))
} else if (command.result === 'rejected' && isValidTransition(profile.kycStatus, 'KYCAddressRejected')) {
} else if (command.result === 'rejected' && isValidTransition(profile, 'KYCAddressRejected')) {
register.events(new AddressVerificationRejected(command.userId, command.verificationId, command.timestamp))
} else {
// Handle invalid state transitions
Expand Down
4 changes: 2 additions & 2 deletions kyc-booster/src/commands/process-id-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export class ProcessIDVerification {
}

// Emit the corresponding event depending on the result, making sure that the transition is valid
if (command.result === 'success' && isValidTransition(profile.kycStatus, 'KYCIDVerified')) {
if (command.result === 'success' && isValidTransition(profile, 'KYCIDVerified')) {
register.events(new IDVerificationSuccess(command.userId, command.verificationId, command.timestamp))
} else if (command.result === 'rejected' && isValidTransition(profile.kycStatus, 'KYCIDRejected')) {
} else if (command.result === 'rejected' && isValidTransition(profile, 'KYCIDRejected')) {
register.events(new IDVerificationRejected(command.userId, command.verificationId, command.timestamp))
} else {
// Handle invalid state transitions
Expand Down
4 changes: 2 additions & 2 deletions kyc-booster/src/commands/submit-manual-background-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export class SubmitManualBackgroundCheck {
}

// Emit the corresponding event depending on the resolution, making sure that the transition is valid
if (command.resolution === 'passed' && isValidTransition(profile.kycStatus, 'KYCBackgroundCheckPassed')) {
if (command.resolution === 'passed' && isValidTransition(profile, 'KYCBackgroundCheckPassed')) {
register.events(new BackgroundCheckPassed(command.userId, command.validatorId, command.timestamp))
} else if (command.resolution === 'rejected' && isValidTransition(profile.kycStatus, 'KYCBackgroundCheckRejected')) {
} else if (command.resolution === 'rejected' && isValidTransition(profile, 'KYCBackgroundCheckRejected')) {
register.events(new BackgroundCheckRejected(command.userId, command.validatorId, command.timestamp))
} else {
// Handle invalid state transitions
Expand Down
31 changes: 21 additions & 10 deletions kyc-booster/src/common/state-validation.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
import { KYCStatus } from "./types"
import { Profile } from '../entities/profile';

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

function allowedTransitions(currentState: KYCStatus): KYCStatus[] {
switch (currentState) {
function 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
3 changes: 2 additions & 1 deletion kyc-booster/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type KYCStatus =
| 'KYCAddressRejected'
| 'KYCBackgroundCheckPassed'
| 'KYCBackgroundCheckRequiresManualReview'
| 'KYCBackgroundCheckRejected';
| 'KYCBackgroundCheckRejected'
| 'KYCCompleted'

export type IncomeSource =
| 'Salary'
Expand Down
34 changes: 34 additions & 0 deletions kyc-booster/src/entities/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { BackgroundCheckPassed } from '../events/background-check-passed';
import { BackgroundCheckRejected } from '../events/background-check-rejected'
import { BackgroundCheckManualReviewRequired } from '../events/background-check-manual-review-required';
import { ProfileOcupationDataAdded } from '../events/profile-ocupation-data-added'
import { WelcomeEmailDeliveryFailed } from '../events/welcome-email-delivery-failed'
import { WelcomeEmailDelivered } from '../events/welcome-email-delivered'
import { KYCCompleted } from '../events/kyc-completed';

const countriesWithNoAddressVerifications = ['Wakanda'];

@Entity
export class Profile {
Expand Down Expand Up @@ -42,6 +47,8 @@ export class Profile {
readonly occupation?: string,
readonly employer?: string,
readonly sourceOfIncome?: IncomeSource,
readonly welcomeEmailDeliveredAt?: string,
readonly welcomeEmailDeliveryFailedAt?: string,
) {}

@Reduces(ProfileCreated)
Expand Down Expand Up @@ -120,6 +127,27 @@ export class Profile {
}, currentProfile)
}

@Reduces(WelcomeEmailDelivered)
public static onWelcomeEmailDelivered(event: WelcomeEmailDelivered, currentProfile?: Profile): Profile {
return Profile.nextProfile({
welcomeEmailDeliveredAt: event.timestamp,
}, currentProfile)
}

@Reduces(WelcomeEmailDeliveryFailed)
public static onWelcomeEmailDeliveryFailed(event: WelcomeEmailDeliveryFailed, currentProfile?: Profile): Profile {
return Profile.nextProfile({
welcomeEmailDeliveryFailedAt: event.timestamp,
}, currentProfile)
}

@Reduces(KYCCompleted)
public static onKYCCompleted(event: KYCCompleted, currentProfile?: Profile): Profile {
return Profile.nextProfile({
kycStatus: 'KYCCompleted',
}, currentProfile)
}

private static nextProfile(fields: Partial<Profile>, currentProfile?: Profile): Profile {
if (!currentProfile) {
throw new Error('Cannot reduce an event over a non-existing profile')
Expand Down Expand Up @@ -153,6 +181,12 @@ export class Profile {
fields.occupation || currentProfile.occupation,
fields.employer || currentProfile.employer,
fields.sourceOfIncome || currentProfile.sourceOfIncome,
fields.welcomeEmailDeliveredAt || currentProfile.welcomeEmailDeliveredAt,
fields.welcomeEmailDeliveryFailedAt || currentProfile.welcomeEmailDeliveryFailedAt,
)
}

public skipsAddressVerification(): boolean {
return countriesWithNoAddressVerifications.includes(this.country);
}
}
17 changes: 17 additions & 0 deletions kyc-booster/src/entities/promo-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Entity, Reduces } from '@boostercloud/framework-core'
import { UUID } from '@boostercloud/framework-types'
import { PromoCodeCreated } from '../events/promo-code-created'

@Entity
export class PromoCode {
public constructor(
public id: UUID,
readonly profileId: UUID,
) {}

@Reduces(PromoCodeCreated)
public static reducePromoCodeCreated(event: PromoCodeCreated, currentPromoCode?: PromoCode): PromoCode {
return new PromoCode(event.promoCodeId, event.profileId)
}

}
25 changes: 25 additions & 0 deletions kyc-booster/src/event-handlers/complete-kyc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { WelcomeEmailDelivered } from '../events/welcome-email-delivered'
import { Booster, EventHandler } from '@boostercloud/framework-core'
import { Register } from '@boostercloud/framework-types'
import { WelcomeEmailDeliveryFailed } from '../events/welcome-email-delivery-failed'
import { KYCCompleted } from '../events/kyc-completed'
import { isValidTransition } from '../common/state-validation'
import { Profile } from '../entities/profile'

@EventHandler(WelcomeEmailDelivered)
@EventHandler(WelcomeEmailDeliveryFailed)
export class CompleteKYC {
public static async handle(event: WelcomeEmailDelivered, register: Register): Promise<void> {
const profile = await Booster.entity(Profile, event.profileId)

if (!profile) {
throw new Error(`Profile ${event.profileId} not found`)
}

if (isValidTransition(profile, 'KYCCompleted')) {
register.events(new KYCCompleted(event.profileId))
} else {
throw new Error(`Invalid state transition for profile ${event.profileId} while completing KYC. Current state: ${profile.kycStatus}`)
}
}
}
59 changes: 59 additions & 0 deletions kyc-booster/src/event-handlers/send-welcome-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { BackgroundCheckPassed } from '../events/background-check-passed'
import { Booster, EventHandler } from '@boostercloud/framework-core'
import { Register, UUID } from '@boostercloud/framework-types'
import { Profile } from '../entities/profile'
import { PromoCodeCreated } from '../events/promo-code-created'
import { WelcomeEmailDelivered } from '../events/welcome-email-delivered'
import { WelcomeEmailDeliveryFailed } from '../events/welcome-email-delivery-failed'

@EventHandler(BackgroundCheckPassed)
export class SendWelcomeEmail {
public static async handle(event: BackgroundCheckPassed, register: Register): Promise<void> {
const mailServiceURLStr = process.env.MAIL_SERVICE_URL
const mailServiceAPIKey = process.env.MAIL_SERVICE_API_KEY

const profile = await Booster.entity(Profile, event.profileId);

if (!profile) {
throw new Error(`Profile ${event.profileId} not found`);
}

const { id, firstName, lastName, email } = profile;
const profileData = {
id,
firstName,
lastName,
email,
};

let templateId: string
if (profile.country === 'Wakanda') {
register.events(new PromoCodeCreated(UUID.generate(), profile.id))
templateId = 'WakandianSpecialKYCWelcomeEmailTemplate'
} else {
templateId = 'KYCWelcomeEmailTemplate'
}

if (!mailServiceURLStr) {
throw new Error(`MAIL_SERVICE_URL not set: ${mailServiceURLStr}`);
}
const mailServiceURL = new URL(mailServiceURLStr)

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

const response = await mailServiceResponse.json();
if (response.result === 'delivered') {
register.events(new WelcomeEmailDelivered(profile.id, new Date().toISOString()))
} else {
register.events(new WelcomeEmailDeliveryFailed(profile.id, new Date().toISOString()))
}
}
}
14 changes: 13 additions & 1 deletion kyc-booster/src/event-handlers/trigger-background-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@ import { Register } from '@boostercloud/framework-types'
import { Profile } from '../entities/profile';
import { BackgroundCheckManualReviewRequired } from '../events/background-check-manual-review-required';
import { BackgroundCheckPassed } from '../events/background-check-passed';
import { IDVerificationSuccess } from '../events/id-verification-success';

@EventHandler(IDVerificationSuccess)
@EventHandler(AddressVerificationSuccess)
export class TriggerBackgroundCheck {
public static async handle(event: AddressVerificationSuccess, register: Register): Promise<void> {
public static async handle(event: IDVerificationSuccess | AddressVerificationSuccess, register: Register): Promise<void> {
const profile = await Booster.entity(Profile, event.profileId);

if (!profile) {
throw new Error(`Profile ${event.profileId} not found`);
}

// If the profile is set to skip address verification, we should never receive an AddressVerificationSuccess event
if (event instanceof AddressVerificationSuccess && profile.skipsAddressVerification()) {
throw new Error(`AddressVerificationSuccess should have never happened for profile ${event.profileId}, because ${profile.country} is invisible.`);
}

// If the profile is not set to skip address verification, we should never receive an IDVerificationSuccess event
if (event instanceof IDVerificationSuccess && !profile.skipsAddressVerification()) {
throw new Error(`IDVerificationSuccess should have never happened for profile ${event.profileId}, because ${profile.country} is not an visible country.`);
}

const passedOFACTest = await this.checkOFACListInclusion(profile);
const passedPEPTest = await this.checkPEPListInclusion(profile);

Expand Down
4 changes: 2 additions & 2 deletions kyc-booster/src/events/id-verification-success.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { UUID } from '@boostercloud/framework-types'
@Event
export class IDVerificationSuccess {
public constructor(
readonly userId: UUID,
readonly profileId: UUID,
readonly verificationId: UUID,
readonly timestamp: string,
) {}

public entityID(): UUID {
return this.userId
return this.profileId
}
}
13 changes: 13 additions & 0 deletions kyc-booster/src/events/kyc-completed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Event } from '@boostercloud/framework-core'
import { UUID } from '@boostercloud/framework-types'

@Event
export class KYCCompleted {
public constructor(
readonly profileId: UUID,
) {}

public entityID(): UUID {
return this.profileId
}
}
14 changes: 14 additions & 0 deletions kyc-booster/src/events/promo-code-created.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Event } from '@boostercloud/framework-core'
import { UUID } from '@boostercloud/framework-types'

@Event
export class PromoCodeCreated {
public constructor(
readonly promoCodeId: UUID,
readonly profileId: UUID,
) {}

public entityID(): UUID {
return this.promoCodeId
}
}
14 changes: 14 additions & 0 deletions kyc-booster/src/events/welcome-email-delivered.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Event } from '@boostercloud/framework-core'
import { UUID } from '@boostercloud/framework-types'

@Event
export class WelcomeEmailDelivered {
public constructor(
readonly profileId: UUID,
readonly timestamp: string,
) {}

public entityID(): UUID {
return this.profileId
}
}
Loading

0 comments on commit 444facc

Please sign in to comment.