Skip to content

Instantly share code, notes, and snippets.

@cawa87
Last active June 28, 2023 08:05
Show Gist options
  • Save cawa87/95c3a1be9a5c0303eaff35c82707759e to your computer and use it in GitHub Desktop.
Save cawa87/95c3a1be9a5c0303eaff35c82707759e to your computer and use it in GitHub Desktop.
<?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;
}
}
<?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([]));
}
}
<?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())
);
}
}
}
<?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';
}
}
<?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',
]
),
};
}
}
<?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;
}
}
<?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,
];
}
}
<?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());
}
}
}
<?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())
);
}
}
}
<?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();
}
}
<?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