From 727d3ce6f062784250b905336bcc44d27a4c6028 Mon Sep 17 00:00:00 2001 From: Javier Toledo Date: Tue, 18 Apr 2023 18:11:25 +0100 Subject: [PATCH] [Booster] Milestone 3: Address Verification --- .../commands/process-address-verification.ts | 50 +++++++++++++++++++ kyc-booster/src/common/state-validation.ts | 23 ++++++--- kyc-booster/src/common/types.ts | 2 +- kyc-booster/src/entities/profile.ts | 45 +++++++++++++++-- .../events/address-verification-rejected.ts | 11 ++++ .../events/address-verification-success.ts | 11 ++++ .../src/read-models/ProfileReadModel.ts | 30 +++++++++-- 7 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 kyc-booster/src/commands/process-address-verification.ts create mode 100644 kyc-booster/src/events/address-verification-rejected.ts create mode 100644 kyc-booster/src/events/address-verification-success.ts diff --git a/kyc-booster/src/commands/process-address-verification.ts b/kyc-booster/src/commands/process-address-verification.ts new file mode 100644 index 0000000..f8788ad --- /dev/null +++ b/kyc-booster/src/commands/process-address-verification.ts @@ -0,0 +1,50 @@ +import { Booster, Command } from '@boostercloud/framework-core' +import { Register, UUID } from '@boostercloud/framework-types' +import { AddressVerificationSuccess } from '../events/address-verification-success' +import { AddressVerificationRejected } from '../events/address-verification-rejected' +import { isValidTransition } from '../common/state-validation' +import { Profile } from '../entities/profile' + +@Command({ + authorize: 'all', +}) +export class ProcessAddressVerification { + public constructor( + readonly userId: UUID, + readonly verificationId: UUID, + readonly result: 'success' | 'rejected', + readonly timestamp: string + ) {} + + public static async handle(command: ProcessAddressVerification, register: Register): Promise { + const profile = await Booster.entity(Profile, command.userId) + + // TODO: Ideally, the command payload should include some kind of signature to ensure that it comes + // from the address verification service. This is not implemented in this example, but it's a good + // practice to avoid malicious users sending fake verification results. + + // Reject verification confirmations for unknown profiles + if (!profile) { + throw new Error(`Profile with ID ${command.userId} not found`) + } + + // 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')) { + register.events(new AddressVerificationSuccess(command.userId, command.verificationId, command.timestamp)) + } else if (command.result === 'rejected' && isValidTransition(profile.kycStatus, 'KYCAddressRejected')) { + register.events(new AddressVerificationRejected(command.userId, command.verificationId, command.timestamp)) + } else { + // Handle invalid state transitions + throw new Error( + `Invalid transition from ${profile.kycStatus} to ${ + command.result === 'success' ? 'KYCAddressVerified' : 'KYCAddressRejected' + }` + ) + } + } +} diff --git a/kyc-booster/src/common/state-validation.ts b/kyc-booster/src/common/state-validation.ts index 445dd9b..3da21be 100644 --- a/kyc-booster/src/common/state-validation.ts +++ b/kyc-booster/src/common/state-validation.ts @@ -4,11 +4,20 @@ export function isValidTransition( currentState: KYCStatus, newState: KYCStatus, ): boolean { - const allowedTransitions: Record> = { - KYCPending: ['KYCIDVerified', 'KYCIDRejected'], - KYCIDVerified: [], - KYCIDRejected: [], - }; - - return allowedTransitions[currentState]?.includes(newState) + return allowedTransitions(currentState).includes(newState); } + +function allowedTransitions(currentState: KYCStatus): KYCStatus[] { + switch (currentState) { + case 'KYCPending': + return ['KYCIDVerified', 'KYCIDRejected']; + case 'KYCIDVerified': + return ['KYCAddressVerified', 'KYCAddressRejected']; + case 'KYCIDRejected': + return []; + case 'KYCAddressVerified': + return []; + case 'KYCAddressRejected': + return []; + } +} \ No newline at end of file diff --git a/kyc-booster/src/common/types.ts b/kyc-booster/src/common/types.ts index 35f32ee..2584760 100644 --- a/kyc-booster/src/common/types.ts +++ b/kyc-booster/src/common/types.ts @@ -1 +1 @@ -export type KYCStatus = 'KYCPending' | 'KYCIDVerified' | 'KYCIDRejected' \ No newline at end of file +export type KYCStatus = 'KYCPending' | 'KYCIDVerified' | 'KYCIDRejected' | 'KYCAddressVerified' | 'KYCAddressRejected' diff --git a/kyc-booster/src/entities/profile.ts b/kyc-booster/src/entities/profile.ts index c23a953..11fed5c 100644 --- a/kyc-booster/src/entities/profile.ts +++ b/kyc-booster/src/entities/profile.ts @@ -4,6 +4,8 @@ import { ProfileCreated } from '../events/profile-created' import { KYCStatus } from '../common/types' import { IDVerificationSuccess } from '../events/id-verification-success' import { IDVerificationRejected } from '../events/id-verification-rejected' +import { AddressVerificationSuccess } from '../events/address-verification-success' +import { AddressVerificationRejected } from '../events/address-verification-rejected' @Entity export class Profile { @@ -21,9 +23,12 @@ export class Profile { readonly kycStatus: KYCStatus, readonly ssn?: string, readonly tin?: string, - readonly verificationId?: UUID, + readonly idVerificationId?: UUID, readonly idVerifiedAt?: string, - readonly idRejectedAt?: string + readonly idRejectedAt?: string, + readonly addressVerificationId?: UUID, + readonly addressVerifiedAt?: string, + readonly addressRejectedAt?: string ) {} @Reduces(ProfileCreated) @@ -33,7 +38,7 @@ export class Profile { @Reduces(IDVerificationSuccess) public static onIDVerificationSuccess(event: IDVerificationSuccess, currentProfile?: Profile): Profile { - return Profile.nextProfile({ kycStatus: 'KYCIDVerified', verificationId: event.verificationId, idVerifiedAt: event.timestamp }, currentProfile) + return Profile.nextProfile({ kycStatus: 'KYCIDVerified', idVerificationId: event.verificationId, idVerifiedAt: event.timestamp }, currentProfile) } @Reduces(IDVerificationRejected) @@ -41,10 +46,40 @@ export class Profile { return Profile.nextProfile({ kycStatus: 'KYCIDRejected', idRejectedAt: event.timestamp }, currentProfile) } + @Reduces(AddressVerificationSuccess) + public static onAddressVerificationSuccess(event: AddressVerificationSuccess, currentProfile?: Profile): Profile { + return Profile.nextProfile({ kycStatus: 'KYCAddressVerified', addressVerificationId: event.verificationId, addressVerifiedAt: event.timestamp }, currentProfile) + } + + @Reduces(AddressVerificationRejected) + public static onAddressVerificationRejected(event: AddressVerificationRejected, currentProfile?: Profile): Profile { + return Profile.nextProfile({ kycStatus: 'KYCAddressRejected', addressRejectedAt: event.timestamp }, currentProfile) + } + private static nextProfile(fields: Partial, currentProfile?: Profile): Profile { if (!currentProfile) { - throw new Error('Cannot reduce an event over an non-existing profile') + throw new Error('Cannot reduce an event over a non-existing profile') } - return new Profile(currentProfile.id, fields.firstName || currentProfile.firstName, fields.lastName || currentProfile.lastName, fields.address || currentProfile.address, fields.city || currentProfile.city, fields.state || currentProfile.state, fields.zipCode || currentProfile.zipCode, fields.dateOfBirth || currentProfile.dateOfBirth, fields.phoneNumber || currentProfile.phoneNumber, fields.email || currentProfile.email, fields.kycStatus || currentProfile.kycStatus, fields.ssn || currentProfile.ssn, fields.tin || currentProfile.tin) + return new Profile( + currentProfile.id, + fields.firstName || currentProfile.firstName, + fields.lastName || currentProfile.lastName, + fields.address || currentProfile.address, + fields.city || currentProfile.city, + fields.state || currentProfile.state, + fields.zipCode || currentProfile.zipCode, + fields.dateOfBirth || currentProfile.dateOfBirth, + fields.phoneNumber || currentProfile.phoneNumber, + fields.email || currentProfile.email, + fields.kycStatus || currentProfile.kycStatus, + fields.ssn || currentProfile.ssn, + fields.tin || currentProfile.tin, + fields.idVerificationId || currentProfile.idVerificationId, + fields.idVerifiedAt || currentProfile.idVerifiedAt, + fields.idRejectedAt || currentProfile.idRejectedAt, + fields.addressVerificationId || currentProfile.addressVerificationId, + fields.addressVerifiedAt || currentProfile.addressVerifiedAt, + fields.addressRejectedAt || currentProfile.addressRejectedAt + ) } } diff --git a/kyc-booster/src/events/address-verification-rejected.ts b/kyc-booster/src/events/address-verification-rejected.ts new file mode 100644 index 0000000..fce7dba --- /dev/null +++ b/kyc-booster/src/events/address-verification-rejected.ts @@ -0,0 +1,11 @@ +import { Event } from '@boostercloud/framework-core' +import { UUID } from '@boostercloud/framework-types' + +@Event +export class AddressVerificationRejected { + public constructor(readonly profileId: UUID, readonly verificationId: UUID, readonly timestamp: string) {} + + public entityID(): UUID { + return this.profileId + } +} diff --git a/kyc-booster/src/events/address-verification-success.ts b/kyc-booster/src/events/address-verification-success.ts new file mode 100644 index 0000000..d8a9430 --- /dev/null +++ b/kyc-booster/src/events/address-verification-success.ts @@ -0,0 +1,11 @@ +import { Event } from '@boostercloud/framework-core' +import { UUID } from '@boostercloud/framework-types' + +@Event +export class AddressVerificationSuccess { + public constructor(readonly profileId: UUID, readonly verificationId: UUID, readonly timestamp: string) {} + + public entityID(): UUID { + return this.profileId + } +} diff --git a/kyc-booster/src/read-models/ProfileReadModel.ts b/kyc-booster/src/read-models/ProfileReadModel.ts index e069245..63cde31 100644 --- a/kyc-booster/src/read-models/ProfileReadModel.ts +++ b/kyc-booster/src/read-models/ProfileReadModel.ts @@ -21,12 +21,36 @@ export class ProfileReadModel { readonly kycStatus: KYCStatus, readonly ssn?: string, readonly tin?: string, - readonly verificationId?: UUID, - readonly verifiedAt?: string, + readonly idVerificationId?: UUID, + readonly idVerifiedAt?: string, + readonly idRejectedAt?: string, + readonly addressVerificationId?: UUID, + readonly addressVerifiedAt?: string, + readonly addressRejectedAt?: string ) {} @Projects(Profile, 'id') public static projectProfile(entity: Profile): ProjectionResult { - return new ProfileReadModel(entity.id, entity.firstName, entity.lastName, entity.address, entity.city, entity.state, entity.zipCode, entity.dateOfBirth, entity.phoneNumber, entity.email, entity.kycStatus, entity.ssn, entity.tin, entity.verificationId, entity.idVerifiedAt) + return new ProfileReadModel( + entity.id, + entity.firstName, + entity.lastName, + entity.address, + entity.city, + entity.state, + entity.zipCode, + entity.dateOfBirth, + entity.phoneNumber, + entity.email, + entity.kycStatus, + entity.ssn, + entity.tin, + entity.idVerificationId, + entity.idVerifiedAt, + entity.idRejectedAt, + entity.addressVerificationId, + entity.addressVerifiedAt, + entity.addressRejectedAt + ) } }