Skip to content

Commit

Permalink
(PC-32147)[API] feat: support ubble v2 workflow update
Browse files Browse the repository at this point in the history
  • Loading branch information
dnguyen1-pass committed Nov 12, 2024
1 parent b1f40f2 commit b9a3ec9
Show file tree
Hide file tree
Showing 7 changed files with 578 additions and 118 deletions.
29 changes: 24 additions & 5 deletions api/src/pcapi/connectors/beneficiaries/ubble.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from contextlib import suppress
import re
import functools
import logging
import typing
Expand All @@ -25,6 +26,8 @@
"reference-data-checks": ubble_serializers.UbbleIdentificationReferenceDataChecks,
}

V2_IDENTIFICATION_RE = r"^idv_\w+"


def start_identification(
user_id: int, first_name: str, last_name: str, webhook_url: str, redirect_url: str
Expand All @@ -34,8 +37,16 @@ def start_identification(


def get_content(identification_id: str) -> fraud_models.UbbleContent:
ubble_backend = _get_ubble_backend()
return ubble_backend.get_content(identification_id)
if is_v2_identification(identification_id):
return UbbleV2Backend().get_content(identification_id)
return UbbleV1Backend().get_content(identification_id)


def is_v2_identification(identification_id: str | None) -> bool:
if not identification_id:
return False
v2_match = re.match(V2_IDENTIFICATION_RE, identification_id)
return bool(v2_match)


def download_ubble_picture(http_url: pydantic_networks.HttpUrl) -> tuple[str | None, typing.Any]:
Expand Down Expand Up @@ -86,7 +97,7 @@ def get_content(self, identification_id: str) -> fraud_models.UbbleContent:
P = typing.ParamSpec("P")


def log_and_handle_response_status(
def log_and_handle_ubble_response(
request_type: str,
) -> typing.Callable[[typing.Callable[P, fraud_models.UbbleContent]], typing.Callable[P, fraud_models.UbbleContent]]:
def log_response_status_and_reraise_if_needed(
Expand Down Expand Up @@ -152,7 +163,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> fraud_models.UbbleContent:


class UbbleV2Backend(UbbleBackend):
@log_and_handle_response_status("create-and-start-idv")
@log_and_handle_ubble_response("create-and-start-idv")
def start_identification( # pylint: disable=too-many-positional-arguments
self,
user_id: int,
Expand Down Expand Up @@ -182,8 +193,16 @@ def start_identification( # pylint: disable=too-many-positional-arguments

return ubble_content

@log_and_handle_ubble_response("identity-verifications")
def get_content(self, identification_id: str) -> fraud_models.UbbleContent:
raise NotImplementedError()
response = requests.get(
build_url(f"/v2/identity-verifications/{identification_id}"),
cert=(settings.UBBLE_CLIENT_CERTIFICATE_PATH, settings.UBBLE_CLIENT_KEY_PATH),
)
response.raise_for_status()

ubble_identification = parse_obj_as(ubble_serializers.UbbleV2IdentificationResponse, response.json())
return ubble_serializers.convert_identification_to_ubble_content(ubble_identification)


class UbbleV1Backend(UbbleBackend):
Expand Down
27 changes: 23 additions & 4 deletions api/src/pcapi/connectors/serialization/ubble_serializers.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import contextlib
import datetime
import enum
import logging

import pydantic.v1 as pydantic_v1

from pcapi.core.fraud import models as fraud_models
from pcapi.core.users import models as users_models


logger = logging.getLogger(__name__)


class UbbleIdentificationStatus(enum.Enum):
# ubble v2
PENDING = "pending"
Expand All @@ -16,7 +20,8 @@ class UbbleIdentificationStatus(enum.Enum):
APPROVED = "approved"
DECLINED = "declined"
RETRY_REQUIRED = "retry_required"
REFUSED = "refused"
INCONCLUSIVE = "inconclusive" # id verification is anonymized
REFUSED = "refused" # user did not consent to the verification
# ubble v1
UNINITIATED = "uninitiated" # Identification has only been created (user has not started the verification flow)
INITIATED = "initiated" # User has started the verification flow
Expand All @@ -43,6 +48,8 @@ class UbbleLinks(pydantic_v1.BaseModel):

class UbbleDocument(pydantic_v1.BaseModel):
full_name: str
first_names: str | None
last_name: str | None
birth_date: datetime.date | None
document_type: str
document_number: str | None
Expand All @@ -60,7 +67,7 @@ def parse_gender(cls, gender: str | None) -> users_models.GenderEnum | None:


class UbbleResponseCode(pydantic_v1.BaseModel):
response_code: int
code: int


class UbbleV2IdentificationResponse(pydantic_v1.BaseModel):
Expand All @@ -86,7 +93,7 @@ def document(self) -> UbbleDocument | None:
def fraud_reason_codes(self) -> list["fraud_models.FraudReasonCode"]:
return [
fraud_models.UBBLE_REASON_CODE_MAPPING.get(
response_code.response_code, fraud_models.FraudReasonCode.ID_CHECK_BLOCKED_OTHER
response_code.code, fraud_models.FraudReasonCode.ID_CHECK_BLOCKED_OTHER
)
for response_code in self.response_codes
]
Expand All @@ -102,7 +109,7 @@ def convert_identification_to_ubble_content(
if not document:
first_name, last_name = None, None
else:
first_name, last_name = document.full_name.split(" ", maxsplit=1)
first_name, last_name = _get_first_and_last_name(document)

content = fraud_models.UbbleContent(
birth_date=getattr(document, "birth_date", None),
Expand Down Expand Up @@ -131,6 +138,18 @@ def convert_identification_to_ubble_content(
return content


def _get_first_and_last_name(document: UbbleDocument) -> tuple[str, str]:
if document.first_names and document.last_name:
return document.first_names.split(", ")[0], document.last_name

logger.error(
"Name not composed of first names and last name: %s, defaulting to naive first name detection",
document.full_name,
)
first_name, last_name = document.full_name.split(" ", maxsplit=1)
return first_name, last_name


# DEPRECATED Ubble V1


Expand Down
27 changes: 23 additions & 4 deletions api/src/pcapi/core/fraud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,23 @@ def get_registration_datetime(self) -> datetime.datetime | None:
return self.registration_datetime


# https://ubbleai.github.io/developer-documentation/#reason-codes
UBBLE_REASON_CODE_MAPPING = {
# Ubble V2 https://docs.ubble.ai/#section/Handle-verification-results/Response-codes
61201: FraudReasonCode.NETWORK_CONNECTION_ISSUE, # applicant did not have a sufficient connection
61301: FraudReasonCode.BLURRY_DOCUMENT_VIDEO, # applicant’s document video is too blurry
61302: FraudReasonCode.LACK_OF_LUMINOSITY, # applicant performed their id verification under poor lighting conditions
61312: FraudReasonCode.LACK_OF_LUMINOSITY, # applicant hides part of the document
61313: FraudReasonCode.LACK_OF_LUMINOSITY, # applicant did not present a dynamic view of the document
61901: FraudReasonCode.UBBLE_INTERNAL_ERROR, # ubble messed up
62101: FraudReasonCode.ID_CHECK_EXPIRED, # applicant presented an expired document
62102: FraudReasonCode.ID_CHECK_NOT_SUPPORTED, # applicant presented a document which is not accepted
62103: FraudReasonCode.DOCUMENT_DAMAGED, # applicant has submitted a damaged document
62201: FraudReasonCode.ID_CHECK_NOT_AUTHENTIC, # applicant presented a photocopy of the document
62202: FraudReasonCode.ID_CHECK_NOT_AUTHENTIC, # applicant presented the document on a screen
62301: FraudReasonCode.ID_CHECK_NOT_AUTHENTIC, # applicant has submitted a counterfeit or falsification
62304: FraudReasonCode.NOT_DOCUMENT_OWNER, # applicant does not match the photograph of the document
62401: FraudReasonCode.ID_CHECK_DATA_MATCH, # applicant’s identity does not match with the expected one
# Ubble V1 https://ubbleai.github.io/developer-documentation/#reason-codes
1201: FraudReasonCode.NETWORK_CONNECTION_ISSUE, # applicant did not have a sufficient connection
1301: FraudReasonCode.BLURRY_DOCUMENT_VIDEO, # applicant’s document video is too blurry
1304: FraudReasonCode.LACK_OF_LUMINOSITY, # applicant hides part of the document
Expand Down Expand Up @@ -371,9 +386,13 @@ class UbbleContent(common_models.IdentityCheckContent):
signed_image_front_url: pydantic_v1.HttpUrl | None
signed_image_back_url: pydantic_v1.HttpUrl | None

_parse_birth_date = pydantic_v1.validator("birth_date", pre=True, allow_reuse=True)(
lambda d: datetime.datetime.strptime(d, "%Y-%m-%d").date() if d is not None else None
)
@pydantic_v1.validator("birth_date", pre=True)
def parse_birth_date(cls, birth_date: datetime.date | str | None) -> datetime.date | None:
if isinstance(birth_date, datetime.date):
return birth_date
if isinstance(birth_date, str):
return datetime.datetime.strptime(birth_date, "%Y-%m-%d").date()
return None

def get_birth_date(self) -> datetime.date | None:
return self.birth_date
Expand Down
69 changes: 58 additions & 11 deletions api/src/pcapi/core/fraud/ubble/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import re

from pcapi import settings
from pcapi.connectors.beneficiaries import ubble
from pcapi.connectors.serialization import ubble_serializers
from pcapi.core.fraud import api as fraud_api
from pcapi.core.fraud import models as fraud_models
Expand Down Expand Up @@ -30,6 +31,42 @@ def _ubble_message_from_code(code: fraud_models.FraudReasonCode) -> str:


def _ubble_result_fraud_item(user: users_models.User, content: fraud_models.UbbleContent) -> fraud_models.FraudItem:
if ubble.is_v2_identification(content.identification_id):
return _ubble_result_fraud_item_using_status(user, content)
return _ubble_result_fraud_item_using_score(user, content)


def _ubble_result_fraud_item_using_status(
user: users_models.User, content: fraud_models.UbbleContent
) -> fraud_models.FraudItem:
detail = f"Ubble {content.status.name if content.status else ''}"
match content.status:
case ubble_serializers.UbbleIdentificationStatus.APPROVED:
id_provider_detected_eligibility = subscription_api.get_id_provider_detected_eligibility(user, content)
if id_provider_detected_eligibility:
return fraud_models.FraudItem(status=fraud_models.FraudStatus.OK, detail=detail, reason_codes=[])
return _ubble_not_eligible_fraud_item(user, content)
case (
ubble_serializers.UbbleIdentificationStatus.RETRY_REQUIRED
| ubble_serializers.UbbleIdentificationStatus.DECLINED
):
status = fraud_models.FraudStatus.SUSPICIOUS
case _:
raise ValueError(f"unhandled Ubble status {content.status} for identification {content.identification_id}")

reason_codes = content.reason_codes or []
ubble_error_messages = [ubble_errors.UBBLE_CODE_ERROR_MAPPING.get(reason_code) for reason_code in reason_codes]
reason_code_details = [
ubble_error.detail_message for ubble_error in ubble_error_messages if ubble_error is not None
]
if reason_code_details:
detail += ": " + " | ".join(reason_code_details)
return fraud_models.FraudItem(status=status, detail=detail, reason_codes=reason_codes)


def _ubble_result_fraud_item_using_score(
user: users_models.User, content: fraud_models.UbbleContent
) -> fraud_models.FraudItem:
status = None
reason_codes = set(content.reason_codes or [])
detail = f"Ubble score {_ubble_readable_score(content.score)}: {content.comment}"
Expand All @@ -47,17 +84,7 @@ def _ubble_result_fraud_item(user: users_models.User, content: fraud_models.Ubbl
if id_provider_detected_eligibility:
status = fraud_models.FraudStatus.OK
else:
status = fraud_models.FraudStatus.KO
birth_date = content.get_birth_date()
registration_datetime = content.get_registration_datetime()
assert birth_date and registration_datetime # helps mypy next line
age = users_utils.get_age_at_date(birth_date, registration_datetime)
if age < min(users_constants.ELIGIBILITY_UNDERAGE_RANGE):
reason_codes.add(fraud_models.FraudReasonCode.AGE_TOO_YOUNG)
detail = _ubble_message_from_code(fraud_models.FraudReasonCode.AGE_TOO_YOUNG).format(age=age)
elif age > users_constants.ELIGIBILITY_AGE_18:
reason_codes.add(fraud_models.FraudReasonCode.AGE_TOO_OLD)
detail = _ubble_message_from_code(fraud_models.FraudReasonCode.AGE_TOO_OLD).format(age=age)
return _ubble_not_eligible_fraud_item(user, content)
elif content.score == ubble_serializers.UbbleScore.INVALID.value:
for score, reason_code in [
(content.reference_data_check_score, fraud_models.FraudReasonCode.ID_CHECK_DATA_MATCH),
Expand Down Expand Up @@ -93,6 +120,26 @@ def _ubble_result_fraud_item(user: users_models.User, content: fraud_models.Ubbl
return fraud_models.FraudItem(status=status, detail=detail, reason_codes=list(reason_codes))


def _ubble_not_eligible_fraud_item(
user: users_models.User, content: fraud_models.UbbleContent
) -> fraud_models.FraudItem:
detail = ""
reason_codes = []

birth_date = content.get_birth_date()
registration_datetime = content.get_registration_datetime()
assert birth_date and registration_datetime # helps mypy
age = users_utils.get_age_at_date(birth_date, registration_datetime)
if age < min(users_constants.ELIGIBILITY_UNDERAGE_RANGE):
reason_codes.append(fraud_models.FraudReasonCode.AGE_TOO_YOUNG)
detail = _ubble_message_from_code(fraud_models.FraudReasonCode.AGE_TOO_YOUNG).format(age=age)
elif age > users_constants.ELIGIBILITY_AGE_18:
reason_codes.append(fraud_models.FraudReasonCode.AGE_TOO_OLD)
detail = _ubble_message_from_code(fraud_models.FraudReasonCode.AGE_TOO_OLD).format(age=age)

return fraud_models.FraudItem(status=fraud_models.FraudStatus.KO, detail=detail, reason_codes=reason_codes)


def ubble_fraud_checks(user: users_models.User, content: fraud_models.UbbleContent) -> list[fraud_models.FraudItem]:
ubble_fraud_models_item = _ubble_result_fraud_item(user, content)
fraud_items = [ubble_fraud_models_item]
Expand Down
16 changes: 13 additions & 3 deletions api/src/pcapi/core/subscription/ubble/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@

def update_ubble_workflow(fraud_check: fraud_models.BeneficiaryFraudCheck) -> None:
content = ubble.get_content(fraud_check.thirdPartyId)
status = content.status

if settings.ENABLE_UBBLE_TEST_EMAIL and ubble_fraud_api.does_match_ubble_test_email(fraud_check.user.email):
content.birth_date = fraud_check.user.birth_date
Expand All @@ -48,11 +47,20 @@ def update_ubble_workflow(fraud_check: fraud_models.BeneficiaryFraudCheck) -> No

user: users_models.User = fraud_check.user

if status == ubble_serializers.UbbleIdentificationStatus.PROCESSING:
status = content.status
if status in (
ubble_serializers.UbbleIdentificationStatus.PROCESSING,
ubble_serializers.UbbleIdentificationStatus.CHECKS_IN_PROGRESS,
):
fraud_check.status = fraud_models.FraudCheckStatus.PENDING
pcapi_repository.repository.save(user, fraud_check)

elif status == ubble_serializers.UbbleIdentificationStatus.PROCESSED:
elif status in [
ubble_serializers.UbbleIdentificationStatus.APPROVED,
ubble_serializers.UbbleIdentificationStatus.RETRY_REQUIRED,
ubble_serializers.UbbleIdentificationStatus.DECLINED,
ubble_serializers.UbbleIdentificationStatus.PROCESSED,
]:
fraud_check = subscription_api.handle_eligibility_difference_between_declaration_and_identity_provider(
user, fraud_check
)
Expand Down Expand Up @@ -83,6 +91,8 @@ def update_ubble_workflow(fraud_check: fraud_models.BeneficiaryFraudCheck) -> No
external_attributes_api.update_external_user(user)

elif status in (
ubble_serializers.UbbleIdentificationStatus.INCONCLUSIVE,
ubble_serializers.UbbleIdentificationStatus.REFUSED,
ubble_serializers.UbbleIdentificationStatus.ABORTED,
ubble_serializers.UbbleIdentificationStatus.EXPIRED,
):
Expand Down
35 changes: 35 additions & 0 deletions api/tests/connectors/beneficiaries/ubble_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
from pcapi.connectors.serialization import ubble_serializers


UBBLE_IDENTIFICATION_V2_RESPONSE = {
"id": "idv_01jbcfv575hfh62b6t89304769",
"user_journey_id": "usj_02h13smebsb2y1tyyrzx1sgma7",
"applicant_id": "aplt_02jbcfv54zmsbrrzf4g1f7qfh7",
"webhook_url": "https://webhook.example.com",
"redirect_url": "https://redirect.example.com",
"declared_data": {"name": "Oriane Bertone"},
"created_on": "2024-10-29T15:55:50.391708Z",
"modified_on": "2024-10-30T17:01:23.856294Z",
"status": "approved",
"face": {"image_signed_url": "https://api.ubble.example.com/idv_01jbcfv575hfh62b6t89304769"},
"response_codes": [{"code": 10000, "summary": "approved"}],
"documents": [
{
"birth_date": "2005-03-10",
"document_expiry_date": "2030-01-01",
"document_issue_date": "2020-01-02",
"document_issuing_country": "FRA",
"document_mrz": "MRZ",
"document_number": "12AB12345",
"document_type": "Passport",
"first_names": "ORIANE, MAX",
"front_image_signed_url": "https://api.ubble.example.com/idv_01jbcfv575hfh62b6t89304769",
"full_name": "ORIANE MAX BERTONE",
"gender": "F",
"last_name": "BERTONE",
}
],
"_links": {
"self": {"href": "https://api.ubble.example.com/v2/identity-verifications/idv_01jbcfv575hfh62b6t89304769"},
"applicant": {"href": "https://api.ubble.example.com/v2/applicants/aplt_01jbcfv54zmsbrrzf4g1f7qfh7"},
"verification_url": {"href": "https://verification.ubble.example.com/"},
},
}

UBBLE_IDENTIFICATION_RESPONSE = {
"data": {
"type": "identifications",
Expand Down
Loading

0 comments on commit b9a3ec9

Please sign in to comment.