Last active
June 28, 2023 08:05
-
-
Save cawa87/95c3a1be9a5c0303eaff35c82707759e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace App\Domain\Service\Kyc; | |
abstract class AbstractServiceResult | |
{ | |
/** @psalm-suppress PropertyNotSetInConstructor */ | |
private string $failureMessage; | |
/** @psalm-suppress PropertyNotSetInConstructor */ | |
private int $failureCode; | |
/** @var string[] */ | |
private array $apiHeaders = []; | |
/** @psalm-suppress PropertyNotSetInConstructor */ | |
private \stdClass|string|null $apiBody; | |
/** @psalm-suppress PropertyNotSetInConstructor */ | |
private ?\Throwable $cause; | |
final protected function __construct( | |
private readonly bool $success | |
) { | |
} | |
public static function failure( | |
string $message, | |
int $code, | |
array $apiHeaders, | |
\stdClass|string|null $apiBody, | |
\Throwable $cause = null | |
): static { | |
$result = new static(false); | |
$result->failureMessage = $message; | |
$result->failureCode = $code; | |
$result->apiHeaders = $apiHeaders; | |
$result->apiBody = $apiBody; | |
$result->cause = $cause; | |
return $result; | |
} | |
public function isSuccess(): bool | |
{ | |
return $this->success; | |
} | |
public function getFailureMessage(): string | |
{ | |
return $this->failureMessage; | |
} | |
public function getFailureCode(): int | |
{ | |
return $this->failureCode; | |
} | |
/** | |
* @return array<string, string> | |
*/ | |
public function getApiHeaders(): array | |
{ | |
return $this->apiHeaders; | |
} | |
public function getApiBody(): string|\stdClass|null | |
{ | |
return $this->apiBody; | |
} | |
public function getCause(): ?\Throwable | |
{ | |
return $this->cause; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection AnnotationMissingUseInspection */ | |
declare(strict_types=1); | |
namespace App\Infrastructure\Delivery\Http\Controller; | |
use App\Application\Command\User\CreateCheck; | |
use App\Infrastructure\Delivery\Http\Validation\Constraints\CreateCheckConstraint; | |
use Emi\Common\Bus\CommandBusInterface; | |
use Emi\Common\Bus\QueryBusInterface; | |
use Emi\Common\Http\ConstantsHeaderInterface; | |
use Emi\Common\Http\Validation\ValidatorInterface; | |
use Emi\Common\Response\EmptyResponse; | |
use Emi\Common\Response\JsonResponse; | |
use Emi\Common\Response\PayloadFactory; | |
use Psr\Http\Message\ResponseInterface as Response; | |
use Psr\Http\Message\ServerRequestInterface as Request; | |
final class CreateCheckController | |
{ | |
use EmptyResponse; | |
use JsonResponse; | |
public function __construct( | |
private readonly CreateCheckConstraint $constraint, | |
private readonly ValidatorInterface $validator, | |
private readonly CommandBusInterface $commandBus, | |
private readonly QueryBusInterface $queryBus, | |
private readonly PayloadFactory $payloadFactory | |
) { | |
} | |
/** | |
* @OA\Post( | |
* path="/v1/check", | |
* summary="Start KYC checking", | |
* tags={"KYC"}, | |
* | |
* @OA\Parameter( | |
* name="X-Auth-User-Id", | |
* in="header", | |
* required=true, | |
* | |
* @OA\Schema(type="string", format="uuid"), | |
* ), | |
* | |
* @OA\Parameter( | |
* name="Cf-Connecting-Ip", | |
* in="header", | |
* required=true, | |
* | |
* @OA\Schema(type="string", format="ipv4"), | |
* ), | |
* | |
* @OA\Parameter( | |
* name="Cf-Ipcountry", | |
* in="header", | |
* required=true, | |
* | |
* @OA\Schema(type="string"), | |
* ), | |
* | |
* @OA\RequestBody( | |
* | |
* @OA\JsonContent( | |
* type="object", | |
* required={"citizenship"}, | |
* | |
* @OA\Property( | |
* property="document_ids", | |
* type="array", | |
* items=@OA\Items(type="string", format="uuid"), | |
* ), | |
* @OA\Property( | |
* property="documents", | |
* type="array", | |
* items=@OA\Items( | |
* type="object", | |
* required={"id_front", "issuing_country", "document_type"}, | |
* @OA\Property(property="id_front",type="string",format="uuid"), | |
* @OA\Property(property="id_back",type="string",format="uuid"), | |
* @OA\Property(property="issuing_country",type="string",example="Belarus"), | |
* @OA\Property(property="document_type",type="string",enum={ | |
* "passport", | |
* "driving_licence", | |
* "national_identity_card", | |
* "residence_permit", | |
* "visa", | |
* "work_permit", | |
* "live_photo", | |
* "live_video" | |
* }) | |
* ), | |
* ), | |
* @OA\Property( | |
* property="citizenship", | |
* type="string", | |
* example="Belarus" | |
* ), | |
* ), | |
* ), | |
* | |
* @OA\Response( | |
* response=200, | |
* description="OK", | |
* | |
* @OA\JsonContent( | |
* type="object", | |
* | |
* @OA\Property(property="error_code", type="string", default="ok"), | |
* @OA\Property(property="error_message", type="string"), | |
* @OA\Property(property="payload", type="object") | |
* ), | |
* ), | |
* | |
* @OA\Response( | |
* response="404", | |
* description="User not found", | |
* ), | |
* @OA\Response( | |
* response="422", | |
* description="Parameters validation error", | |
* | |
* @OA\JsonContent(ref="#/components/schemas/ValidationErrorResponse"), | |
* ), | |
* | |
* @OA\Response( | |
* response="500", | |
* description="Unexpected case. Something went wrong.", | |
* | |
* @OA\JsonContent(ref="#/components/schemas/InternalServerErrorResponse"), | |
* ), | |
* ) | |
*/ | |
public function __invoke(Request $request, Response $response): Response | |
{ | |
$params = []; | |
$userId = null; | |
$documents = []; | |
$citizenship = null; | |
if ($request->hasHeader(ConstantsHeaderInterface::HEADER_USER_ID)) { | |
$userId = $request->getHeaderLine(ConstantsHeaderInterface::HEADER_USER_ID); | |
$params[CreateCheckConstraint::HEADER_USER_ID] = $userId; | |
} | |
if (isset(($requestBody = $request->getParsedBody())[CreateCheckConstraint::PARAM_DOCUMENT_IDS])) { | |
$documentIds = $requestBody[CreateCheckConstraint::PARAM_DOCUMENT_IDS]; | |
$params[CreateCheckConstraint::PARAM_DOCUMENT_IDS] = $documentIds; | |
} | |
if (isset($requestBody[CreateCheckConstraint::PARAM_DOCUMENTS])) { | |
$requestDocuments = $requestBody[CreateCheckConstraint::PARAM_DOCUMENTS]; | |
$params[CreateCheckConstraint::PARAM_DOCUMENTS] = $requestDocuments; | |
} | |
if (isset($requestBody[CreateCheckConstraint::PARAM_CITIZENSHIP])) { | |
$citizenship = $requestBody[CreateCheckConstraint::PARAM_CITIZENSHIP]; | |
$params[CreateCheckConstraint::PARAM_CITIZENSHIP] = $citizenship; | |
} | |
$this->validator->validate($params, $this->constraint->constraints()); | |
if (isset($requestDocuments)) { | |
array_walk( | |
$requestDocuments, | |
static function (array $item) use (&$documents): void { | |
$documents[] = new CreateCheck\DTO\Document( | |
idFront: $item[CreateCheckConstraint::DOCUMENT_ID_FRONT], | |
documentType: $item[CreateCheckConstraint::DOCUMENT_TYPE], | |
issuingCountry: $item[CreateCheckConstraint::DOCUMENT_ISSUING_COUNTRY], | |
idBack: $item[CreateCheckConstraint::DOCUMENT_ID_BACK] ?? null, | |
); | |
} | |
); | |
} elseif (isset($documentIds)) { | |
array_walk( | |
$documentIds, | |
static function (string $item) use (&$documents): void { | |
$documents[] = new CreateCheck\DTO\Document( | |
idFront: $item, | |
); | |
} | |
); | |
} | |
/** | |
* @psalm-suppress PossiblyNullArgument | |
*/ | |
$this->commandBus->handle(new CreateCheck\Command( | |
userId: $userId, | |
citizenship: $citizenship, | |
documents: $documents, | |
clientIpAddr: $request->getHeaderLine(ConstantsHeaderInterface::HEADER_CLIENT_IP), | |
clientCountry: $request->getHeaderLine(ConstantsHeaderInterface::HEADER_CLIENT_COUNTRY), | |
)); | |
return $this->respondWithData($response, $this->payloadFactory->ok([])); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace App\Infrastructure\Delivery\Http\Middleware; | |
use Emi\Common\Exception\EntityNotFoundException; | |
use Emi\Common\Response\JsonResponse; | |
use Emi\Common\Response\PayloadFactory; | |
use Psr\Http\Message\ResponseFactoryInterface; | |
use Psr\Http\Message\ResponseInterface; | |
use Psr\Http\Message\ServerRequestInterface; | |
use Psr\Http\Server\MiddlewareInterface; | |
use Psr\Http\Server\RequestHandlerInterface; | |
use Psr\Log\LoggerInterface; | |
class DomainExceptionMiddleware implements MiddlewareInterface | |
{ | |
use JsonResponse; | |
public function __construct( | |
private readonly ResponseFactoryInterface $responseFactory, | |
private readonly LoggerInterface $logger, | |
private readonly PayloadFactory $payloadFactory | |
) { | |
} | |
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface | |
{ | |
try { | |
return $handler->handle($request); | |
} catch (EntityNotFoundException $entityNotFoundException) { | |
return $this->respondWithData( | |
$this->responseFactory->createResponse(), | |
$this->payloadFactory->notFound($entityNotFoundException->getMessage()) | |
); | |
} catch (\DomainException $domainException) { | |
$this->logger->error($domainException->getMessage(), [ | |
'exception' => $domainException, | |
'url' => (string) $request->getUri(), | |
]); | |
return $this->respondWithData( | |
$this->responseFactory->createResponse(), | |
$this->payloadFactory->internalServerError($domainException->getMessage()) | |
); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding; | |
use App\Domain\Model\RuleSet\Rule\Payload; | |
use App\Domain\Model\RuleSet\Rule\RuleStatus; | |
use App\Domain\Model\RuleSet\RuleCheck; | |
use App\Domain\Model\RuleSet\RuleSetId; | |
use App\Domain\Model\RuleSet\RuleWatchKey; | |
use App\Domain\Service\RuleSet\RuleCheckerInterface; | |
final class FaceSimilarityBellowLimitRuleChecker implements RuleCheckerInterface | |
{ | |
/** | |
* @var float | |
*/ | |
private const SIMILARITY_LIMIT = 0.85; | |
/** | |
* @var string | |
*/ | |
public const IDENTITY_PLATFORM_FACE_SIMILARITY_BELOW_LIMIT = 'IDENTITY_PLATFORM_FACE_SIMILARITY_BELOW_LIMIT'; | |
public function checkFromPayload(RuleSetId $ruleSetId, Payload $payload): ?RuleCheck | |
{ | |
if (isset($payload->value[RuleWatchKey::KycFaceSimilarityScore->value])) { | |
if (RuleWatchKey::Undefined->value === $payload->value[RuleWatchKey::KycFaceSimilarityScore->value]) { | |
return RuleCheck::create( | |
name: $this->getRuleName(), | |
status: RuleStatus::NotTriggered, | |
); | |
} | |
return RuleCheck::create( | |
name: $this->getRuleName(), | |
status: $payload->value[RuleWatchKey::KycFaceSimilarityScore->value] < self::SIMILARITY_LIMIT ? RuleStatus::Triggered : RuleStatus::NotTriggered, | |
); | |
} | |
return null; | |
} | |
public function getRuleName(): string | |
{ | |
return self::IDENTITY_PLATFORM_FACE_SIMILARITY_BELOW_LIMIT; | |
} | |
public function getDescription(): string | |
{ | |
return 'Facial similarity below 0.85'; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace App\Infrastructure\ReadModel\Onfido; | |
use App\Infrastructure\ReadModel\Onfido\DTO\FetchAllResult; | |
use App\Infrastructure\ReadModel\Onfido\DTO\Filter; | |
use App\Infrastructure\ReadModel\Onfido\DTO\SimilarDocument; | |
use Cycle\Database\DatabaseProviderInterface; | |
use Cycle\Database\Query\SelectQuery; | |
final class FetcherService implements FetcherServiceInterface | |
{ | |
public function __construct( | |
private readonly DatabaseProviderInterface $dbal | |
) { | |
} | |
/** | |
* @throws \Exception | |
*/ | |
public function findAll( | |
Filter $filter = new Filter(), | |
string $sort = null, | |
?string $sortDir = null, | |
): FetchAllResult { | |
$select = self::applySort( | |
select: self::applyFilter(select: $this->buildSelect(), filter: $filter), | |
sort: $sort, | |
sortDir: $sortDir, | |
); | |
$total = $select->count(); | |
if ($total === 0) { | |
return new FetchAllResult($total, []); | |
} | |
$select = self::applyDefaultLimit(select: $select); | |
return new FetchAllResult(total: $total, documents: $this->fetch($select)); | |
} | |
/** | |
* @return \Generator<SimilarDocument> | |
* | |
* @throws \Exception | |
*/ | |
private function fetch(SelectQuery $select): \Generator | |
{ | |
$documentResult = $select->getIterator(); | |
while ($item = $documentResult->fetch()) { | |
yield new SimilarDocument( | |
type: $item['type'] ?? '', | |
number: $item['number'] ?? '', | |
userId: $item['user_id'] ?? '', | |
firstName: $item['first_name'] ?? '', | |
lastName: $item['last_name'] ?? '', | |
); | |
} | |
$documentResult->close(); | |
} | |
private function buildSelect(): SelectQuery | |
{ | |
return $this->dbal->database() | |
->select() | |
->from('onfido_document of_doc') | |
->leftJoin('onfido of')->on('of_doc.onfido_id', 'of.id') | |
->leftJoin('user usr')->on('of.user_id', 'usr.id') | |
->columns([ | |
'of_doc.type', 'of_doc.number', 'usr.first_name', 'usr.last_name', 'usr.id as user_id', | |
]); | |
} | |
private static function applyDefaultLimit(SelectQuery $select, int $limit = 10): SelectQuery | |
{ | |
$select = clone $select; | |
return $select->limit($limit); | |
} | |
/** | |
* @throws \Exception | |
*/ | |
private static function applyFilter(SelectQuery $select, Filter $filter): SelectQuery | |
{ | |
$select = clone $select; | |
foreach (self::buildConditions(filter: $filter) as $condition) { | |
$select->where(...$condition); | |
} | |
return $select; | |
} | |
private static function buildConditions(Filter $filter): array | |
{ | |
$where = []; | |
foreach ($filter->asArray() as $field => $value) { | |
$where[] = match ($field) { | |
'document_number' => [ | |
'of_doc.number', | |
'=', | |
$value, | |
], | |
'document_type' => [ | |
'of_doc.type', | |
'=', | |
$value, | |
], | |
'user_id' => [ | |
'usr.id', | |
'!=', | |
$value, | |
], | |
default => throw new \Exception('Unexpected filter field'), | |
}; | |
} | |
return array_filter($where); | |
} | |
private static function applySort(SelectQuery $select, ?string $sort = null, ?string $sortDir = null): SelectQuery | |
{ | |
$select = clone $select; | |
$sortDir ??= SelectQuery::SORT_ASC; | |
/** @psalm-suppress ArgumentTypeCoercion */ | |
return match ($sort) { | |
default => $select->orderBy( | |
[ | |
'of_doc.created_at' => 'desc', | |
] | |
), | |
}; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Tests\Integration\Infrastructure\Delivery\Http\Controller\Backoffice; | |
use App\Domain\Model\Data\DataId; | |
use App\Domain\Model\Data\DataRepositoryInterface; | |
use App\Domain\Model\RuleSet\Rule\Payload; | |
use App\Domain\Model\RuleSet\Rule\Rule; | |
use App\Domain\Model\RuleSet\Rule\RuleStatus; | |
use App\Domain\Model\RuleSet\RuleSetRepositoryInterface; | |
use App\Domain\Model\RuleSet\RuleWatchKey; | |
use App\Domain\VO\EventType; | |
use App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding\AgeBelow21AndAbove55RuleChecker; | |
use App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding\LogInsFromDifferentLocationsDuringOnboardingRuleChecker; | |
use App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding\NameOrSurnameSpellingDoesNotMatchTheDocumentSpellingRuleChecker; | |
use App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding\PhoneNumberPrefixDoesNotMatchWithTheCountryOfResidenceRuleChecker; | |
use App\Infrastructure\Domain\Service\RuleSet\Rule\Onboarding\SameIpAddressDuringOnboardingAsAnyOtherApplicantClientUsedDuringOnboardingRuleChecker; | |
use Emi\Auth\Event\User\UserCreated; | |
use Emi\Auth\Event\User\UserLoggedIn; | |
use Emi\Common\Core\Mock\Traits\JsonRequest; | |
use Emi\Common\Core\Mock\Traits\LoadFixture; | |
use Emi\Kyc\Event\User\CheckCompleted; | |
use Emi\User\Event\User\Updated; | |
use Fig\Http\Message\RequestMethodInterface; | |
use Fig\Http\Message\StatusCodeInterface; | |
use Tests\Selective\Builder\DataBuilder; | |
use Tests\Selective\Builder\RuleSetBuilder; | |
use Tests\Selective\WebTestCase; | |
final class GetRulesetControllerTest extends WebTestCase | |
{ | |
use JsonRequest; | |
use LoadFixture; | |
/** | |
* @var string | |
*/ | |
private const BASE_PATH = '/v1/backoffice/reports/ruleset/%s'; | |
private RuleSetRepositoryInterface $rulesetRepository; | |
private DataRepositoryInterface $dataRepository; | |
protected function setUp(): void | |
{ | |
$this->purge(); | |
$this->rulesetRepository = $this->container->get(RuleSetRepositoryInterface::class); | |
$this->dataRepository = $this->container->get(DataRepositoryInterface::class); | |
parent::setUp(); | |
} | |
public function testAgeRangeRuleSuccess(): void | |
{ | |
$ruleset = (new RuleSetBuilder()) | |
->withRule($this->buildAgeRangeRule()) | |
->build(); | |
$this->rulesetRepository->save($ruleset); | |
$response = $this->app()->handle( | |
$this->json( | |
RequestMethodInterface::METHOD_GET, | |
sprintf(self::BASE_PATH, $ruleset->getId()->getValue()), | |
), | |
); | |
$this->assertEquals(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); | |
$this->assertJson($body = (string) $response->getBody()); | |
$parsedBody = $this->decode($body); | |
$alert = $parsedBody['payload']['alerts'][0]; | |
$this->assertArrayHasKey('type', $alert); | |
$this->assertArrayHasKey('description', $alert); | |
$this->assertArrayHasKey('data', $alert); | |
$data = $alert['data'][0]; | |
$this->assertArrayHasKey('user_age', $data); | |
$this->assertArrayHasKey('birthdate', $data); | |
} | |
public function testNameSpellingRuleSuccess(): void | |
{ | |
$ruleset = (new RuleSetBuilder()) | |
->withRule($this->buildNameSpellingRule()) | |
->build(); | |
$this->rulesetRepository->save($ruleset); | |
$response = $this->app()->handle( | |
$this->json( | |
RequestMethodInterface::METHOD_GET, | |
sprintf(self::BASE_PATH, $ruleset->getId()->getValue()), | |
), | |
); | |
$this->assertEquals(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); | |
$this->assertJson($body = (string) $response->getBody()); | |
$parsedBody = $this->decode($body); | |
$alert = $parsedBody['payload']['alerts'][0]; | |
$this->assertArrayHasKey('type', $alert); | |
$this->assertArrayHasKey('description', $alert); | |
$this->assertArrayHasKey('data', $alert); | |
$data = $alert['data'][0]; | |
$this->assertEquals([ | |
'user_data' => [ | |
'first_name' => 'John', | |
'last_name' => 'Froom', | |
], | |
'identity_platform_data' => [ | |
'first_name' => 'John', | |
'last_name' => 'Frum', | |
], | |
], $data); | |
} | |
public function testLoginsFromDifferentLocationsRuleSucces(): void | |
{ | |
$ruleset = (new RuleSetBuilder()) | |
->withRule($this->buildLoginsFromDifferentLocationsRule()) | |
->build(); | |
$this->rulesetRepository->save($ruleset); | |
$response = $this->app()->handle( | |
$this->json( | |
RequestMethodInterface::METHOD_GET, | |
sprintf(self::BASE_PATH, $ruleset->getId()->getValue()), | |
), | |
); | |
$this->assertEquals(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); | |
$this->assertJson($body = (string) $response->getBody()); | |
$parsedBody = $this->decode($body); | |
$alert = $parsedBody['payload']['alerts'][0]; | |
$this->assertArrayHasKey('type', $alert); | |
$this->assertArrayHasKey('description', $alert); | |
$this->assertArrayHasKey('data', $alert); | |
$dataset = $alert['data']; | |
$this->assertEquals([ | |
0 => [ | |
'country' => 'Germany', | |
'occurred_on' => '2020-10-10 20:00:20', | |
], | |
1 => [ | |
'country' => 'Poland', | |
'occurred_on' => '2020-10-10 20:10:20', | |
], | |
2 => [ | |
'country' => 'Belarus', | |
'occurred_on' => '2020-10-10 20:20:20', | |
], | |
], $dataset); | |
} | |
public function testSameIpSignupRuleSuccess(): void | |
{ | |
$ruleset = (new RuleSetBuilder()) | |
->withRule($this->buildSameIpSignupRule()) | |
->build(); | |
$this->rulesetRepository->save($ruleset); | |
$response = $this->app()->handle( | |
$this->json( | |
RequestMethodInterface::METHOD_GET, | |
sprintf(self::BASE_PATH, $ruleset->getId()->getValue()), | |
), | |
); | |
$this->assertEquals(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); | |
$this->assertJson($body = (string) $response->getBody()); | |
$parsedBody = $this->decode($body); | |
$alert = $parsedBody['payload']['alerts'][0]; | |
$this->assertArrayHasKey('type', $alert); | |
$this->assertArrayHasKey('description', $alert); | |
$this->assertArrayHasKey('data', $alert); | |
$this->assertCount(2, $alert['data']); | |
$data = $alert['data'][0]; | |
$this->assertArrayHasKey('ip', $data); | |
$this->assertArrayHasKey('user_id', $data); | |
$this->assertArrayHasKey('first_name', $data); | |
$this->assertArrayHasKey('last_name', $data); | |
} | |
public function testPhonePrefixCountryRuleSuccess(): void | |
{ | |
$ruleset = (new RuleSetBuilder()) | |
->withRule($this->buildPhonePrefixCountryRule()) | |
->build(); | |
$this->rulesetRepository->save($ruleset); | |
$response = $this->app()->handle( | |
$this->json( | |
RequestMethodInterface::METHOD_GET, | |
sprintf(self::BASE_PATH, $ruleset->getId()->getValue()), | |
), | |
); | |
$this->assertEquals(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); | |
$this->assertJson($body = (string) $response->getBody()); | |
$parsedBody = $this->decode($body); | |
$alert = $parsedBody['payload']['alerts'][0]; | |
$this->assertArrayHasKey('type', $alert); | |
$this->assertArrayHasKey('description', $alert); | |
$this->assertArrayHasKey('data', $alert); | |
$this->assertCount(1, $alert['data']); | |
$data = $alert['data'][0]; | |
$this->assertEquals('(+358)123456', $data['phone']); | |
$this->assertEquals('Finland', $data['phone_matched_country']); | |
$this->assertEquals('Lebanon', $data['country_of_residence']); | |
} | |
private function buildAgeRangeRule(): Rule | |
{ | |
$dataBuilder = new DataBuilder(); | |
$userDOB = (new \DateTimeImmutable('-15 years'))->format('Y-m-d'); | |
$this->dataRepository->save( | |
$data1 = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([RuleWatchKey::KycUserBirthdate->value => $userDOB])) | |
->build() | |
); | |
return Rule::createFromPayload( | |
name: AgeBelow21AndAbove55RuleChecker::AGE_BELOW_21_AND_ABOVE_55, | |
payload: Payload::create( | |
dataId: $data1->getId()->getValue(), | |
payload: [], | |
), | |
status: RuleStatus::Triggered, | |
createdAt: new \DateTimeImmutable() | |
); | |
} | |
private function buildNameSpellingRule(): Rule | |
{ | |
$dataBuilder = new DataBuilder(); | |
$this->dataRepository->save( | |
$userCreatedData = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserCreated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserFirstName->value => 'Jon', | |
RuleWatchKey::UserLastName->value => 'Froom', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('-15 minutes')) | |
->build() | |
); | |
$this->dataRepository->save( | |
$userUpdatedData = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(Updated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserFirstName->value => 'John', | |
RuleWatchKey::UserLastName->value => 'Froom', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('-10 minutes')) | |
->build() | |
); | |
$this->dataRepository->save( | |
$userKycCheckCompletedData = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(CheckCompleted::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserFirstName->value => 'John', | |
RuleWatchKey::UserLastName->value => 'Frum', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable()) | |
->build() | |
); | |
$rule = Rule::createFromPayload( | |
name: NameOrSurnameSpellingDoesNotMatchTheDocumentSpellingRuleChecker::NAME_OR_SURNAME_SPELLING_DOES_NOT_MATCH_THE_DOCUMENT_SPELLING, | |
payload: Payload::create( | |
dataId: $userCreatedData->getId()->getValue(), | |
payload: [], | |
), | |
status: RuleStatus::Triggered, | |
createdAt: new \DateTimeImmutable() | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $userUpdatedData->getId()->getValue(), | |
payload: [], | |
) | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $userKycCheckCompletedData->getId()->getValue(), | |
payload: [], | |
) | |
); | |
// UserCreated => UserUpdated => KycCheckCompleted | |
return $rule; | |
} | |
private function buildLoginsFromDifferentLocationsRule(): Rule | |
{ | |
$dataBuilder = new DataBuilder(); | |
$this->dataRepository->save( | |
$login1 = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserLoggedIn::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserLoggedInClientCountry->value => 'Belarus', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('2020-10-10 20:20:20')) | |
->build() | |
); | |
$this->dataRepository->save( | |
$login2 = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserLoggedIn::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserLoggedInClientCountry->value => 'Poland', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('2020-10-10 20:10:20')) | |
->build() | |
); | |
$this->dataRepository->save( | |
$login3 = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserLoggedIn::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserLoggedInClientCountry->value => 'Germany', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('2020-10-10 20:00:20')) | |
->build() | |
); | |
$rule = Rule::createFromPayload( | |
name: LogInsFromDifferentLocationsDuringOnboardingRuleChecker::LOGINS_FROM_DIFFERENT_LOCATIONS_DURING_ONBOARDING, | |
payload: Payload::create( | |
dataId: $login1->getId()->getValue(), | |
payload: [], | |
), | |
status: RuleStatus::Triggered, | |
createdAt: new \DateTimeImmutable() | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $login2->getId()->getValue(), | |
payload: [], | |
) | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $login3->getId()->getValue(), | |
payload: [], | |
) | |
); | |
return $rule; | |
} | |
private function buildSameIpSignupRule(): Rule | |
{ | |
$mainUserId = $this->faker()->uuid(); | |
$userId2 = $this->faker()->uuid(); | |
$userId3 = $this->faker()->uuid(); | |
$dataBuilder = new DataBuilder(); | |
$this->dataRepository->save( | |
$signup = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserCreated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserId->value => $mainUserId, | |
RuleWatchKey::UserLoggedInClientIpAddr->value => '111.111.111.111', | |
RuleWatchKey::UserSignedUpClientIpAddr->value => '111.111.111.111', | |
])) | |
->build() | |
); | |
// second user data | |
$this->dataRepository->save( | |
$dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserCreated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserId->value => $userId2, | |
RuleWatchKey::UserLoggedInClientIpAddr->value => '111.111.111.111', | |
RuleWatchKey::UserSignedUpClientIpAddr->value => '111.111.111.111', | |
RuleWatchKey::UserFirstName->value => 'John', | |
RuleWatchKey::UserLastName->value => 'Frum', | |
])) | |
->build() | |
); | |
// third user data | |
$this->dataRepository->save( | |
$dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserCreated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserLoggedInClientIpAddr->value => '111.111.111.111', | |
RuleWatchKey::UserSignedUpClientIpAddr->value => '111.111.111.111', | |
RuleWatchKey::UserId->value => $userId3, | |
RuleWatchKey::UserFirstName->value => 'Carl', | |
RuleWatchKey::UserLastName->value => 'McCoy', | |
])) | |
->build() | |
); | |
return Rule::createFromPayload( | |
name: SameIpAddressDuringOnboardingAsAnyOtherApplicantClientUsedDuringOnboardingRuleChecker::SAME_IP_ADDRESS_DURING_ONBOARDING, | |
payload: Payload::create( | |
dataId: $signup->getId()->getValue(), | |
payload: [], | |
), | |
status: RuleStatus::Triggered, | |
createdAt: new \DateTimeImmutable() | |
); | |
} | |
private function buildPhonePrefixCountryRule(): Rule | |
{ | |
$dataBuilder = new DataBuilder(); | |
// creating user | |
$this->dataRepository->save( | |
$created = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(UserCreated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserId->value => $this->faker()->uuid(), | |
RuleWatchKey::UserPhoneNumber->value => '111111', | |
RuleWatchKey::UserPhoneNumberCode->value => '111', | |
RuleWatchKey::UserCountryOfResidence->value => 'Lebanon', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('-5 minutes')) | |
->build() | |
); | |
// updating phone number | |
$this->dataRepository->save( | |
$updated = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(Updated::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
RuleWatchKey::UserId->value => $this->faker()->uuid(), | |
RuleWatchKey::UserPhoneNumber->value => '123456', | |
RuleWatchKey::UserPhoneNumberCode->value => '358', | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('+5 minutes')) | |
->build() | |
); | |
// kyc check completed | |
$this->dataRepository->save( | |
$checkCompleted = $dataBuilder | |
->withId(new DataId($this->faker()->uuid())) | |
->withEventType(EventType::createFromEventClass(CheckCompleted::class)) | |
->withPayload(\App\Domain\Model\Data\Payload::createFromArray([ | |
// does not matter in this case | |
])) | |
->withEventOccurredOn(new \DateTimeImmutable('+15 minutes')) | |
->build() | |
); | |
$rule = Rule::createFromPayload( | |
name: PhoneNumberPrefixDoesNotMatchWithTheCountryOfResidenceRuleChecker::PHONE_NUMBER_PREFIX_DOES_NOT_MATCH_WITH_THE_COUNTRY_OF_RESIDENCE, | |
payload: Payload::create( | |
dataId: $created->getId()->getValue(), | |
payload: [], | |
), | |
status: RuleStatus::Triggered, | |
createdAt: new \DateTimeImmutable() | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $updated->getId()->getValue(), | |
payload: [], | |
) | |
); | |
$rule->updateDataIdsFromPayload( | |
Payload::create( | |
dataId: $checkCompleted->getId()->getValue(), | |
payload: [], | |
) | |
); | |
return $rule; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace Tests\Unit\Infrastructure\Domain\Service\Kyc\Onfido; | |
use App\Domain\Model\ProviderSpecific\Onfido\Check; | |
use App\Domain\Model\ProviderSpecific\Onfido\CheckGroup; | |
use App\Domain\Model\ProviderSpecific\Onfido\Detail; | |
use App\Domain\Model\ProviderSpecific\Onfido\Document; | |
use App\Domain\Service\Kyc\CheckGroupComparator\ServiceInterface as CheckGroupComparatorServiceInterface; | |
use App\Domain\Service\Kyc\FactoryResolverInterface; | |
use App\Domain\ValueObject\DocumentType; | |
use App\Domain\ValueObject\Gender; | |
use App\Domain\ValueObject\KycCheckResult; | |
use App\Domain\ValueObject\KycProvider; | |
use Emi\Common\Core\Mock\Traits\AppInitTrait; | |
use Emi\Common\ValueObject\Country; | |
use PHPUnit\Framework\TestCase; | |
use Tests\Selective\Builder\Onfido\CheckBuilder; | |
use Tests\Selective\Builder\Onfido\CheckGroupBuilder; | |
use Tests\Selective\Builder\Onfido\DetailBuilder; | |
use Tests\Selective\Builder\Onfido\DocumentBuilder; | |
use Tests\Selective\Builder\Onfido\ReportBuilder; | |
use Tests\Selective\Builder\Onfido\ReportDocumentBuilder; | |
final class CheckGroupComparatorServiceTest extends TestCase | |
{ | |
use AppInitTrait; | |
private CheckGroupComparatorServiceInterface $comparatorService; | |
/** | |
* @dataProvider getHaveSignificantDifferenceCases | |
*/ | |
public function testHaveSignificantDifference(array $detailsA, array $detailsB, bool $expect): void | |
{ | |
$checkGroupA = $this->prepareCheckGroup($detailsA); | |
$checkGroupB = $this->prepareCheckGroup($detailsB); | |
$res = $this->comparatorService->haveSignificantDifference(a: $checkGroupA, b: $checkGroupB); | |
$this->assertEquals($expect, $res); | |
} | |
/** | |
* @throws \Psr\Container\ContainerExceptionInterface | |
* @throws \Psr\Container\NotFoundExceptionInterface | |
*/ | |
protected function setUp(): void | |
{ | |
parent::setUp(); | |
/** @var FactoryResolverInterface $factoryResolver */ | |
$factoryResolver = $this->container()->get(FactoryResolverInterface::class); | |
$factory = $factoryResolver->resolve(KycProvider::Onfido); | |
$this->comparatorService = $factory->createCheckGroupComparatorService(); | |
} | |
/** | |
* @param array<string, string> $details | |
*/ | |
private function prepareCheckGroup(array $details): CheckGroup | |
{ | |
return (new CheckGroupBuilder()) | |
->withId($this->faker()->uuid()) | |
->withResult(KycCheckResult::Clear) | |
->withCompleteSent(true) | |
->withVerified(false) | |
->withChecks([ | |
$this->prepareCheck( | |
document: (new DocumentBuilder()) | |
->withSide(null) | |
->withType(DocumentType::Passport->value) | |
->build(), | |
details: $details | |
), | |
]) | |
->build(); | |
} | |
/** | |
* @param array<string, string> $details | |
*/ | |
private function prepareCheck(Document $document, array $details): Check | |
{ | |
return (new CheckBuilder()) | |
->withId($this->faker()->uuid()) | |
->withStatus('complete') | |
->withResult('clear') | |
->withReports([ | |
(new ReportBuilder()) | |
->withStatus('complete') | |
->withResult('clear') | |
->withName('document') | |
->withDocuments([ | |
(new ReportDocumentBuilder()) | |
->withDocumentId($document->getId()) | |
->build(), | |
]) | |
->withDetails(array_map( | |
static fn ($name, $value): Detail => (new DetailBuilder())->withName($name)->withValue($value)->build(), | |
array_keys($details), | |
array_values($details) | |
)) | |
->build(), | |
]) | |
->build(); | |
} | |
/** | |
* @return array<string, string> | |
*/ | |
private function generateDetails(array $copyFrom = []): array | |
{ | |
return [ | |
'gender' => $gender = $copyFrom['gender'] ?: $this->faker()->randomElement(Gender::cases())->value, | |
'date_of_birth' => $copyFrom['date_of_birth'] ?: $this->faker()->dateTimeBetween('-50 years', '-20 years')->format('Y-m-d'), | |
'first_name' => $copyFrom['first_name'] ?: $this->faker()->firstName($gender), | |
'last_name' => $copyFrom['last_name'] ?: $this->faker()->lastName(), | |
'nationality' => $copyFrom['nationality'] ?: $this->faker()->randomElement(Country::cases())->value, | |
'document_type' => $copyFrom['document_type'] ?: DocumentType::Passport->value, | |
'document_number' => $copyFrom['document_number'] ?: $this->faker()->numerify(str_repeat('#', 10)), | |
'date_of_expiry' => $copyFrom['date_of_expiry'] ?: $this->faker()->dateTimeBetween('now', '+10 years')->format('Y-m-d'), | |
'issuing_country' => $copyFrom['issuing_country'] ?: $this->faker()->randomElement(Country::cases())->getIso3(), | |
]; | |
} | |
private function getHaveSignificantDifferenceCases(): \Generator | |
{ | |
$detailsA = $this->generateDetails(); | |
yield 'Full match' => [ | |
'detailsA' => $detailsA, | |
'detailsB' => $detailsA, | |
'expect' => false, | |
]; | |
$significant = [ | |
'gender' => true, | |
'first_name' => true, | |
'last_name' => true, | |
'date_of_birth' => true, | |
'nationality' => true, | |
'issuing_country' => true, | |
]; | |
yield 'Partial match excluding significant' => [ | |
'detailsA' => $detailsA, | |
'detailsB' => $this->generateDetails(array_filter($detailsA, static fn (string $key): bool => isset($significant), \ARRAY_FILTER_USE_KEY)), | |
'expect' => false, | |
]; | |
foreach ($significant as $key => $value) { | |
if ($key === 'gender') { | |
$detailsB = $this->generateDetails($detailsA); | |
$detailsB['gender'] = match ($detailsB['gender']) { | |
Gender::Male->value => Gender::Female->value, | |
Gender::Female->value => Gender::Male->value, | |
}; | |
yield 'Partial match excluding gender' => [ | |
'detailsA' => $detailsA, | |
'detailsB' => $detailsB, | |
'expect' => true, | |
]; | |
continue; | |
} | |
yield sprintf('Partial match excluding %s', $key) => [ | |
'detailsA' => $detailsA, | |
'detailsB' => $this->generateDetails(array_filter($detailsA, static fn (string $_key): bool => $_key !== $key, \ARRAY_FILTER_USE_KEY)), | |
'expect' => true, | |
]; | |
} | |
yield 'Total mismatch' => [ | |
'detailsA' => $detailsA, | |
'detailsB' => $this->generateDetails(), | |
'expect' => true, | |
]; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Application\Command\ArchiveRuleSetsByUserId; | |
use App\Domain\Model\RuleSet\RuleSetRepositoryInterface; | |
use App\Domain\Model\RuleSet\RuleSetType; | |
use Emi\Common\Event\EventDispatcherInterface; | |
use Emi\Common\Service\Clock\ClockInterface; | |
use Emi\Common\ValueObject\UserId; | |
final class Handler | |
{ | |
public function __construct( | |
private readonly RuleSetRepositoryInterface $ruleSetRepo, | |
private readonly ClockInterface $clock, | |
private readonly EventDispatcherInterface $eventDispatcher, | |
) { | |
} | |
public function __invoke(Command $command): void | |
{ | |
$foundRuleSets = $this->ruleSetRepo->findAllByUserIdAndRuleSetType( | |
ruleSetType: RuleSetType::from($command->ruleSetType), | |
userId: new UserId($command->userId) | |
); | |
foreach ($foundRuleSets as $foundRuleSet) { | |
$foundRuleSet->archived($this->clock->currentTime()); | |
$this->ruleSetRepo->save($foundRuleSet); | |
$this->eventDispatcher->dispatch(...$foundRuleSet->releaseEvents()); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace App\Infrastructure\Delivery\Grpc; | |
use App\Application\Command\FaceAuth\Create\Command as CreateFaceAuthCommand; | |
use App\Application\Query\RetrieveCustomerChecks; | |
use App\Application\Query\RetrieveLastCompletedCheck; | |
use App\Application\Query\RetrieveSimilarDocuments; | |
use App\Application\Query\RetrieveSimilarDocuments\Result as RetrieveSimilarDocumentsResult; | |
use App\Domain\Model\FaceAuth\Exception\PhotoValidationFailedException; | |
use App\Domain\Model\User\Exception\KycNotInitializedException; | |
use Emi\Common\Bus\CommandBusInterface; | |
use Emi\Common\Bus\QueryBusInterface; | |
use Emi\Common\Exception\EntityNotFoundException; | |
use Emi\Common\Telemetry\Tracer; | |
use Emi\Common\Telemetry\TracingWrapper; | |
use Emi\CommonApi\Service\Grpc\Code; | |
use Emi\CommonApi\Service\Grpc\Result; | |
use Emi\Kyc\Service\Check; | |
use Emi\Kyc\Service\CheckListResponse; | |
use Emi\Kyc\Service\CreateFaceAuthRequest; | |
use Emi\Kyc\Service\CreateFaceAuthResponse; | |
use Emi\Kyc\Service\KycServiceInterface; | |
use Emi\Kyc\Service\RetrieveCheckListRequest; | |
use Emi\Kyc\Service\RetrieveLastCompletedCheckRequest; | |
use Emi\Kyc\Service\RetrieveLastCompletedCheckResponse; | |
use Emi\Kyc\Service\RetrieveSimilarDocumentsRequest; | |
use Emi\Kyc\Service\SimilarDocument; | |
use Emi\Kyc\Service\SimilarDocumentsResponse; | |
use OpenTelemetry\API\Trace\TracerProviderInterface; | |
use Spiral\RoadRunner\GRPC; | |
class KycService implements KycServiceInterface | |
{ | |
public function __construct( | |
private readonly CommandBusInterface $commandBus, | |
private readonly QueryBusInterface $queryBus, | |
private readonly TracerProviderInterface $tracerProvider, | |
) { | |
} | |
public function CreateFaceAuth( | |
GRPC\ContextInterface $ctx, | |
CreateFaceAuthRequest $in | |
): CreateFaceAuthResponse { | |
try { | |
TracingWrapper::wrap( | |
tracer: $this->tracerProvider->getTracer(Tracer::NAME), | |
spanName: 'GRPC KYC - createFaceAuth', | |
action: function () use ($in): void { | |
$this->commandBus->handle( | |
new CreateFaceAuthCommand( | |
faceAuthId: $in->getFaceAuthId(), | |
userId: $in->getUserId(), | |
providerPhotoData: $in->getPhotoData(), | |
) | |
); | |
}, | |
); | |
return new CreateFaceAuthResponse(); | |
} catch (\Throwable $throwable) { | |
$status = match ($throwable::class) { | |
PhotoValidationFailedException::class => Code::RESOURCE_EXHAUSTED, | |
EntityNotFoundException::class => Code::NOT_FOUND, | |
default => Code::UNKNOWN, | |
}; | |
return (new CreateFaceAuthResponse()) | |
->setResult( | |
(new Result()) | |
->setStatus($status) | |
->setErrorCode((string) $throwable->getCode()) | |
->setErrorDescription($throwable->getMessage()) | |
); | |
} | |
} | |
public function RetrieveSimilarDocuments(GRPC\ContextInterface $ctx, RetrieveSimilarDocumentsRequest $in): SimilarDocumentsResponse | |
{ | |
try { | |
return TracingWrapper::wrap( | |
tracer: $this->tracerProvider->getTracer(Tracer::NAME), | |
spanName: 'GRPC KYC - retrieveSimilarDocuments', | |
action: function () use ($in): SimilarDocumentsResponse { | |
/** | |
* @var RetrieveSimilarDocumentsResult $result | |
*/ | |
$result = $this->queryBus->query( | |
new RetrieveSimilarDocuments\Query( | |
userId: $in->getUserId(), | |
documentNumber: $in->getDocumentNumber(), | |
documentType: $in->getDocumentType(), | |
) | |
); | |
return (new SimilarDocumentsResponse()) | |
->setDocuments((static function () use ($result): array { | |
/** | |
* @var array<SimilarDocument> $out | |
*/ | |
$out = []; | |
foreach ($result->documents as $doc) { | |
$out[] = (new SimilarDocument()) | |
->setDocumentNumber($doc->number) | |
->setDocumentType($doc->type) | |
->setUserId($doc->userId) | |
->setFirstName($doc->firstName) | |
->setLastName($doc->lastName); | |
} | |
return $out; | |
})()); | |
}, | |
); | |
} catch (\Throwable $throwable) { | |
$status = match ($throwable::class) { | |
KycNotInitializedException::class => Code::UNAVAILABLE, | |
EntityNotFoundException::class => Code::NOT_FOUND, | |
default => Code::UNKNOWN, | |
}; | |
return (new SimilarDocumentsResponse()) | |
->setResult( | |
(new Result()) | |
->setStatus($status) | |
->setErrorCode((string) $throwable->getCode()) | |
->setErrorDescription($throwable->getMessage()) | |
); | |
} | |
} | |
public function RetrieveCheckList( | |
GRPC\ContextInterface $ctx, | |
RetrieveCheckListRequest $in | |
): CheckListResponse { | |
try { | |
$result = TracingWrapper::wrap( | |
tracer: $this->tracerProvider->getTracer(Tracer::NAME), | |
spanName: 'GRPC KYC - retrieveCheckList', | |
action: function () use ($in): RetrieveCustomerChecks\Result { | |
return $this->queryBus->query( | |
new RetrieveCustomerChecks\Query( | |
id: $in->getUserId(), | |
) | |
); | |
}, | |
); | |
return (new CheckListResponse()) | |
->setChecks(array_map( | |
static fn (RetrieveCustomerChecks\DTO\CheckGroup $checkGroup): Check => (new Check()) | |
->setId($checkGroup->id) | |
->setCreatedAt($checkGroup->createdAt->format('Y-m-d H:i:s')) | |
->setResult($checkGroup->result->value), | |
$result->checks | |
)); | |
} catch (\Throwable $throwable) { | |
$status = match ($throwable::class) { | |
KycNotInitializedException::class => Code::UNAVAILABLE, | |
EntityNotFoundException::class => Code::NOT_FOUND, | |
default => Code::UNKNOWN, | |
}; | |
return (new CheckListResponse()) | |
->setResult( | |
(new Result()) | |
->setStatus($status) | |
->setErrorCode((string) $throwable->getCode()) | |
->setErrorDescription($throwable->getMessage()) | |
); | |
} | |
} | |
public function RetrieveUserLastCompletedCheck(GRPC\ContextInterface $ctx, RetrieveLastCompletedCheckRequest $in): RetrieveLastCompletedCheckResponse | |
{ | |
try { | |
return TracingWrapper::wrap( | |
tracer: $this->tracerProvider->getTracer(Tracer::NAME), | |
spanName: 'GRPC KYC - retrieveUserLastCompletedCheck', | |
action: function () use ($in): RetrieveLastCompletedCheckResponse { | |
/** | |
* @var RetrieveLastCompletedCheck\Result $result | |
*/ | |
$result = $this->queryBus->query( | |
new RetrieveLastCompletedCheck\Query( | |
userId: $in->getUserId(), | |
) | |
); | |
return (new RetrieveLastCompletedCheckResponse()) | |
->setData($result->lastCompletedCheckIdData) | |
->setOccurredOn($result->occurredOn); | |
}, | |
); | |
} catch (\Throwable $throwable) { | |
$status = match ($throwable::class) { | |
KycNotInitializedException::class => Code::UNAVAILABLE, | |
EntityNotFoundException::class => Code::NOT_FOUND, | |
default => Code::UNKNOWN, | |
}; | |
return (new RetrieveLastCompletedCheckResponse()) | |
->setResult( | |
(new Result()) | |
->setStatus($status) | |
->setErrorCode((string) $throwable->getCode()) | |
->setErrorDescription($throwable->getMessage()) | |
); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace App\Infrastructure\Delivery\Console; | |
use Cycle\Database\DatabaseProviderInterface; | |
use Emi\Common\Bus\CommandBusInterface; | |
use Emi\Common\Contract\Id; | |
use Emi\Common\Service\User\UserOriginFetcherInterface; | |
use Symfony\Component\Console\Command\Command; | |
use Symfony\Component\Console\Input\InputInterface; | |
use Symfony\Component\Console\Output\OutputInterface; | |
class MigrateVerifiedUsersCheckGroups extends Command | |
{ | |
/** | |
* @var string | |
*/ | |
final public const COMMAND_NAME = 'onfido:check-groups:migrate-verified'; | |
public function __construct( | |
private readonly CommandBusInterface $commandBus, | |
private readonly DatabaseProviderInterface $dbal, | |
private readonly UserOriginFetcherInterface $userOriginFetcher, | |
string $name = null | |
) { | |
parent::__construct($name); | |
} | |
protected function configure(): void | |
{ | |
$this | |
->setName(self::COMMAND_NAME) | |
->setDescription("Synchronize users' verified status with existing check groups"); | |
} | |
/** | |
* @throws \Exception | |
*/ | |
protected function execute(InputInterface $input, OutputInterface $output): int | |
{ | |
$output->writeln("Starting to synchronize check groups' verified status with user service"); | |
foreach ($this->iterateUsersWithCheckGroups() as $userId) { | |
$output->writeln(sprintf('Synchronizing user %s', $userId)); | |
try { | |
$user = $this->userOriginFetcher->getUserById(new Id($userId)); | |
$this->commandBus->handle(new \App\Application\Command\User\VerifyCheck\Command( | |
userId: $user->getUserId(), | |
verifiedAt: new \DateTimeImmutable($user->getVerifiedAt()) | |
)); | |
} catch (\Throwable $throwable) { | |
$output->writeln(sprintf('Error: %s', $throwable->getMessage())); | |
} | |
} | |
$output->writeln('Done.'); | |
return 0; | |
} | |
/** | |
* @return \Generator<string> | |
* | |
* @throws \Exception | |
*/ | |
private function iterateUsersWithCheckGroups(): \Generator | |
{ | |
$iterator = $this->dbal->database() | |
->select('u.id as u_id') | |
->distinct(true) | |
->from('user as u') | |
->innerJoin('onfido', 'o') | |
->on('o.user_id', 'u.id') | |
->innerJoin('onfido_check_group', 'ocg') | |
->on('ocg.onfido_id', 'o.id') | |
->where(['ocg.is_verified' => false]) | |
->getIterator(); | |
foreach ($iterator as ['u_id' => $userId]) { | |
yield $userId; | |
} | |
$iterator->close(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** @noinspection PhpFullyQualifiedNameUsageInspection */ | |
declare(strict_types=1); | |
namespace Tests\Integration\Infrastructure\Domain\Service\Kyc\Onfido\ReportDataCollector; | |
use App\Domain\Model\ProviderSpecific\RepositoryInterface; | |
use App\Domain\Model\User\UserId; | |
use App\Domain\Model\User\UserRepositoryInterface; | |
use App\Domain\Service\Kyc\FactoryInterface; | |
use App\Domain\Service\Kyc\FactoryResolverInterface; | |
use App\Domain\Service\Kyc\ReportDataCollector\ServiceInterface; | |
use App\Domain\ValueObject\Gender; | |
use App\Domain\ValueObject\KycProvider; | |
use App\Domain\ValueObject\KycStatus; | |
use App\Infrastructure\Domain\Service\Kyc\Onfido\ParseReport\Document\ReportBreakdowns as DocumentReportBreakdowns; | |
use App\Infrastructure\Domain\Service\Kyc\Onfido\ParseReport\FacialSimilarity\ReportBreakdowns as FacialSimilarityReportBreakdowns; | |
use Emi\Common\Core\Mock\Traits\LoadFixture; | |
use Emi\Common\ValueObject\Country; | |
use Emi\Common\ValueObject\PersonName; | |
use PHPUnit\Framework\TestCase; | |
use Tests\Selective\Builder\Onfido\AggregateBuilder; | |
use Tests\Selective\Builder\Onfido\CheckBuilder; | |
use Tests\Selective\Builder\Onfido\CheckGroupBuilder; | |
use Tests\Selective\Builder\Onfido\DetailBuilder; | |
use Tests\Selective\Builder\Onfido\DocumentBuilder; | |
use Tests\Selective\Builder\Onfido\ReportBuilder; | |
use Tests\Selective\Builder\Onfido\ReportDocumentBuilder; | |
use Tests\Selective\Builder\UserBuilder; | |
class ServiceTest extends TestCase | |
{ | |
use LoadFixture; | |
private RepositoryInterface $repo; | |
private UserRepositoryInterface $userRepo; | |
private ServiceInterface $dataCollector; | |
/** | |
* @throws \Safe\Exceptions\JsonException | |
*/ | |
public function testCollect(): void | |
{ | |
$aKnownFacesDetails = []; | |
$aFacialSimilarityDetails = []; | |
$aWatchlistAmlDetails = []; | |
$aDocDetails = []; | |
$docDetails = iterator_to_array($this->buildDocumentDetails($aDocDetails)); | |
$user = (new UserBuilder()) | |
->withId(new UserId($this->faker()->uuid())) | |
->withKycProvider(KycProvider::Onfido) | |
->withGender(Gender::from($aDocDetails['gender'])) | |
->withName(new PersonName( | |
$aDocDetails['first_name'], | |
$aDocDetails['last_name'], | |
)) | |
->withBirthdate($this->faker()->dateTimeBetween()) | |
->withResidence($residence = $this->faker()->country()) | |
->withCitizenship($residence) | |
->withKycStatus(KycStatus::Provided) | |
->build(); | |
$this->userRepo->save($user); | |
$onfido = (new AggregateBuilder($this->faker())) | |
->withId($this->faker()->uuid()) | |
->withUserId($user->getId()) | |
->withHref($this->faker()->url()) | |
->withToken($this->faker()->asciify(str_repeat('*', 300))) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withUpdatedAt($this->faker()->dateTimeBetween('-1 days')) | |
->withDocuments([ | |
$doc = (new DocumentBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withType($aDocDetails['document_type']) | |
->withSide(null) | |
->withNumber($aDocDetails['document_number']) | |
->withIssuingCountry($aDocDetails['issuing_country']) | |
->withExpiresOn($this->faker()->dateTimeBetween('now', '+3 years')) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withDeclaredType('passport') | |
->withDeclaredIssuingCountry(null) | |
->build(), | |
]) | |
->withCheckGroups([ | |
(new CheckGroupBuilder()) | |
->withId($checkGroupId = $this->faker()->uuid()) | |
->withCompleteSent(true) | |
->withVerified(true) | |
->withChecks([ | |
(new CheckBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withStatus('complete') | |
->withResult('consider') | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withReports([ | |
(new ReportBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withName('document') | |
->withStatus('complete') | |
->withResult($this->faker()->randomElement(['clear', 'consider'])) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withDocuments([ | |
(new ReportDocumentBuilder()) | |
->withId($this->faker()->uuid()) | |
->withDocumentId($doc->getId()) | |
->build(), | |
]) | |
->withDetails($docDetails) | |
->build(), | |
(new ReportBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withName('facial_similarity_motion') | |
->withStatus('complete') | |
->withResult($this->faker()->randomElement(['clear', 'consider'])) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withDocuments([ | |
(new ReportDocumentBuilder()) | |
->withId($this->faker()->uuid()) | |
->withDocumentId($doc->getId()) | |
->build(), | |
]) | |
->withDetails(iterator_to_array($this->buildFacialSimilarityDetails($aFacialSimilarityDetails))) | |
->build(), | |
]) | |
->build(), | |
(new CheckBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withStatus('complete') | |
->withResult('consider') | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withReports([ | |
(new ReportBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withName('known_faces') | |
->withStatus('complete') | |
->withResult($this->faker()->randomElement(['clear', 'consider'])) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withDetails(iterator_to_array($this->buildKnownFacesDetails($aKnownFacesDetails))) | |
->build(), | |
(new ReportBuilder()) | |
->withId($this->faker()->uuid()) | |
->withHref($this->faker()->url()) | |
->withName('watchlist_aml') | |
->withStatus('complete') | |
->withResult($this->faker()->randomElement(['clear', 'consider'])) | |
->withCreatedAt($this->faker()->dateTimeBetween('-2 days', '-1 days')) | |
->withDetails(iterator_to_array($this->buildWatchlistAmlDetails($aWatchlistAmlDetails))) | |
->build(), | |
]) | |
->build(), | |
]) | |
->build(), | |
]) | |
->build(); | |
$this->repo->save($onfido); | |
$data = $this->dataCollector->collect($onfido, $checkGroupId); | |
$allDetails = [ | |
...$aDocDetails, | |
...$aFacialSimilarityDetails, | |
...$aWatchlistAmlDetails, | |
...$aKnownFacesDetails, | |
]; | |
foreach ($allDetails as $detail => $expectedVal) { | |
$key = $this->mapDetail((string) $detail); | |
if (null !== $key) { | |
$expectedVal = self::mapValue($key, $expectedVal); | |
$this->assertEquals($expectedVal, $data[$key] ?? null); | |
} | |
} | |
$this->assertEquals($checkGroupId, $data['kyc_check_id'] ?? ''); | |
$this->assertEquals(0, $data['known_documents'] ?? -1); | |
$this->assertEquals(\count($aKnownFacesDetails), $data['known_faces'] ?? -1); | |
$this->assertCount(30, $data); | |
} | |
/** | |
* @throws \Psr\Container\ContainerExceptionInterface | |
* @throws \Psr\Container\NotFoundExceptionInterface | |
*/ | |
protected function setUp(): void | |
{ | |
$this->purge(); | |
/** @var FactoryInterface $factory */ | |
$factory = $this->container()->get(FactoryResolverInterface::class)->resolve(KycProvider::Onfido); | |
$this->repo = $factory->createRepository(); | |
$this->userRepo = $this->container()->get(UserRepositoryInterface::class); | |
$this->dataCollector = $factory->createReportDataCollector(); | |
parent::setUp(); | |
} | |
private function buildDocumentDetails(array &$details = []): \Generator | |
{ | |
$details = [ | |
'gender' => $gender = $this->faker()->randomElement(['male', 'female']), | |
'document_type' => $this->faker()->randomElement(['passport', 'driving_license']), | |
'document_number' => $this->faker()->numerify('AA#######'), | |
'issuing_country' => $this->faker()->randomElement(Country::cases())->getIso3(), | |
'date_of_birth' => $this->faker()->dateTimeBetween()->format('Y-m-d'), | |
'first_name' => $this->faker()->firstName($gender), | |
'last_name' => $this->faker()->lastName(), | |
// breakdowns | |
DocumentReportBreakdowns::VisualAuthenticityFonts->value => $this->faker()->randomElement(['clear', 'caution']), | |
DocumentReportBreakdowns::VisualAuthenticityPictureFaceIntegrity->value => $this->faker()->randomElement(['clear', 'caution']), | |
DocumentReportBreakdowns::VisualAuthenticitySecurityFeatures->value => $this->faker()->randomElement(['clear', 'caution']), | |
DocumentReportBreakdowns::VisualAuthenticityTemplate->value => $this->faker()->randomElement(['clear', 'caution']), | |
DocumentReportBreakdowns::VisualAuthenticityOriginalDocumentPresent->value => $this->faker()->randomElement(['clear', 'caution']), | |
DocumentReportBreakdowns::ImageIntegritySupportedDocument->value => $this->faker()->randomElement(['clear', 'unidentified']), | |
DocumentReportBreakdowns::VisualAuthenticityDigitalTampering->value => $this->faker()->randomElement(['clear', 'suspected']), | |
DocumentReportBreakdowns::DataValidationExpiryDateFormat->value => $this->faker()->randomElement(['clear', 'suspected']), | |
DocumentReportBreakdowns::DataValidationDobDateFormat->value => $this->faker()->randomElement(['clear', 'suspected']), | |
DocumentReportBreakdowns::DataValidationMrzFormat->value => $this->faker()->randomElement(['clear', 'suspected']), | |
DocumentReportBreakdowns::ImageIntegrityConclusiveDocumentQualityAbnormalDocumentFeatures->value => $this->faker()->randomElement(['clear', 'suspected']), | |
]; | |
foreach ($details as $detail => $value) { | |
yield (new DetailBuilder()) | |
->withId($this->faker()->uuid()) | |
->withName($detail) | |
->withValue($value) | |
->build(); | |
} | |
} | |
private function buildFacialSimilarityDetails(array &$details = []): \Generator | |
{ | |
$details = [ | |
FacialSimilarityReportBreakdowns::FaceComparisonFaceMatch->value => $this->faker()->randomFloat(4, .0, 1.0), | |
FacialSimilarityReportBreakdowns::ImageIntegritySourceIntegrity->value => $this->faker()->randomElement(['clear', 'consider']), | |
FacialSimilarityReportBreakdowns::VisualAuthenticityLivenessDetected->value => $this->faker()->randomElement(['clear', 'consider']), | |
FacialSimilarityReportBreakdowns::VisualAuthenticitySpoofingDetection->value => $this->faker()->randomElement(['clear', 'consider']), | |
]; | |
foreach ($details as $detail => $value) { | |
yield (new DetailBuilder()) | |
->withId($this->faker()->uuid()) | |
->withName($detail) | |
->withValue((string) $value) | |
->build(); | |
} | |
} | |
private function buildWatchlistAmlDetails(array &$details = []): \Generator | |
{ | |
$details = [ | |
'politically_exposed_person' => $this->faker()->randomElement(['clear', 'consider']), | |
'sanction' => $this->faker()->randomElement(['clear', 'consider']), | |
'adverse_media' => $this->faker()->randomElement(['clear', 'consider']), | |
'legal_and_regulatory_warnings' => $this->faker()->randomElement(['clear', 'consider']), | |
]; | |
foreach ($details as $detail => $value) { | |
yield (new DetailBuilder()) | |
->withId($this->faker()->uuid()) | |
->withName($detail) | |
->withValue($value) | |
->build(); | |
} | |
} | |
/** | |
* @throws \Safe\Exceptions\JsonException | |
*/ | |
private function buildKnownFacesDetails(array &$details = []): \Generator | |
{ | |
$details = []; | |
for ($i = 0; $i < 10; ++$i) { | |
$details[sprintf('match_%d', $i)] = \Safe\json_encode([ | |
'applicant_id' => $this->faker()->uuid(), | |
'score' => $this->faker()->randomFloat(4, .7, .96), | |
'media_id' => $this->faker()->uuid(), | |
'media_type' => 'motion', | |
'suspected' => $this->faker()->boolean(), | |
]); | |
} | |
foreach ($details as $detail => $value) { | |
yield (new DetailBuilder()) | |
->withId($this->faker()->uuid()) | |
->withName($detail) | |
->withValue($value) | |
->build(); | |
} | |
} | |
private function mapDetail(string $detail): ?string | |
{ | |
return match ($detail) { | |
// watchlist aml | |
'politically_exposed_person' => 'watchlist_aml.politically_exposed', | |
'sanction' => 'watchlist_aml.legal_warnings', | |
'adverse_media' => 'watchlist_aml.adverse_media', | |
'legal_and_regulatory_warnings' => 'watchlist_aml.sanction', | |
// facial similarity | |
FacialSimilarityReportBreakdowns::FaceComparisonFaceMatch->value => 'face_similarity.match_score', | |
FacialSimilarityReportBreakdowns::ImageIntegritySourceIntegrity->value => 'face_similarity.integrity', | |
FacialSimilarityReportBreakdowns::VisualAuthenticityLivenessDetected->value => 'face_similarity.liveness', | |
FacialSimilarityReportBreakdowns::VisualAuthenticitySpoofingDetection->value => 'face_similarity.spoofing', | |
// document | |
// properties | |
'document_type' => 'document.type', | |
'document_number' => 'document.number', | |
'issuing_country' => 'document.issuing_country', | |
'date_of_birth' => 'document.dob', | |
'first_name' => 'document.first_name', | |
'last_name' => 'document.last_name', | |
// breakdowns | |
DocumentReportBreakdowns::VisualAuthenticityFonts->value => 'document.fonts', | |
DocumentReportBreakdowns::VisualAuthenticityPictureFaceIntegrity->value => 'document.face_integrity', | |
DocumentReportBreakdowns::VisualAuthenticitySecurityFeatures->value => 'document.security_features', | |
DocumentReportBreakdowns::VisualAuthenticityTemplate->value => 'document.template', | |
DocumentReportBreakdowns::VisualAuthenticityOriginalDocumentPresent->value => 'document.original_document', | |
DocumentReportBreakdowns::ImageIntegritySupportedDocument->value => 'document.supported', | |
DocumentReportBreakdowns::VisualAuthenticityDigitalTampering->value => 'document.digital_tampering', | |
DocumentReportBreakdowns::DataValidationExpiryDateFormat->value => 'document.expiry_date_format', | |
DocumentReportBreakdowns::DataValidationDobDateFormat->value => 'document.dob_date_format', | |
DocumentReportBreakdowns::DataValidationMrzFormat->value => 'document.mrz_format', | |
default => null, | |
}; | |
} | |
private static function mapValue(string $key, mixed $expectedVal): mixed | |
{ | |
return match ($key) { | |
'document.issuing_country' => Country::fromIso3Code($expectedVal)->value, | |
default => $expectedVal | |
}; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment