Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

KYC Service Layer #35774

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
9 changes: 0 additions & 9 deletions corehq/apps/integration/kyc/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,3 @@ def __init__(self, *args, **kwargs):
}),
)
)

def clean(self):
user_data_store = self.cleaned_data['user_data_store']
other_case_type = self.cleaned_data['other_case_type']
if user_data_store == UserDataStore.OTHER_CASE_TYPE and not other_case_type:
self.add_error('other_case_type', _('Please specify a value'))
elif user_data_store != UserDataStore.OTHER_CASE_TYPE:
self.cleaned_data['other_case_type'] = None
return self.cleaned_data
92 changes: 90 additions & 2 deletions corehq/apps/integration/kyc/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext as _

import jsonfield

from corehq.apps.es.case_search import CaseSearchES
from corehq.apps.users.models import CommCareUser
from corehq.form_processor.models import CommCareCase
from corehq.motech.const import OAUTH2_CLIENT
from corehq.motech.models import ConnectionSettings


Expand All @@ -18,6 +24,9 @@ class UserDataStore(object):


class KycProviders(models.TextChoices):
# When adding a new provider:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to have a short documentation for steps to add a new provider.
Could be done in the follow up work along with TODOs.

# 1. Add connection settings to `settings.py` if necessary
# 2. Add it to `KycConfig.get_connections_settings()`
MTN_KYC = 'mtn_kyc', _('MTN KYC')


Expand All @@ -26,10 +35,89 @@ class KycConfig(models.Model):
user_data_store = models.CharField(max_length=25, choices=UserDataStore.CHOICES)
other_case_type = models.CharField(max_length=126, null=True)
api_field_to_user_data_map = jsonfield.JSONField(default=list)
connection_settings = models.ForeignKey(ConnectionSettings, on_delete=models.PROTECT)
provider = models.CharField(max_length=25, choices=KycProviders.choices, default=KycProviders.MTN_KYC)
provider = models.CharField(
max_length=25,
choices=KycProviders.choices,
default=KycProviders.MTN_KYC,
)
connection_settings = models.ForeignKey(
ConnectionSettings,
on_delete=models.PROTECT,
# Assumes we can determine connection settings for provider
null=True,
blank=True,
)

class Meta:
constraints = [
models.UniqueConstraint(fields=['domain', 'provider'], name='unique_domain_provider'),
]

def clean(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool 👍

super().clean()
if (
self.user_data_store == UserDataStore.OTHER_CASE_TYPE
and not self.other_case_type
):
raise ValidationError({
'other_case_type': _(
'This field is required when "User Data Store" is set to '
'"Other Case Type".'
)
})
elif self.user_data_store != UserDataStore.OTHER_CASE_TYPE:
self.other_case_type = None

def get_connection_settings(self):
if not self.connection_settings_id:
if self.provider == KycProviders.MTN_KYC:
kyc_settings = settings.MTN_KYC_CONNECTION_SETTINGS
self.connection_settings = ConnectionSettings.objects.create(
domain=self.domain,
name=KycProviders.MTN_KYC.label,
url=kyc_settings['url'],
auth_type=OAUTH2_CLIENT,
client_id=kyc_settings['client_id'],
client_secret=kyc_settings['client_secret'],
token_url=kyc_settings['token_url'],
)
self.save()
# elif self.provider == KycProviders.NEW_PROVIDER_HERE: ...
else:
raise ValueError(f'Unable to determine connection settings for KYC provider {self.provider!r}.')
return self.connection_settings

def get_user_objects(self):
"""
Returns all CommCareUser or CommCareCase instances based on the
user data store.
"""
if self.user_data_store in (
UserDataStore.CUSTOM_USER_DATA,
UserDataStore.USER_CASE,
):
return CommCareUser.by_domain(self.domain)
elif self.user_data_store == UserDataStore.OTHER_CASE_TYPE:
assert self.other_case_type
case_ids = (
CaseSearchES()
.domain(self.domain)
.case_type(self.other_case_type)
).get_ids()
if not case_ids:
return []
return CommCareCase.objects.get_cases(case_ids, self.domain)

def get_user_objects_by_ids(self, obj_ids):
"""
Returns all CommCareUser or CommCareCase instances based on the
user data store and user IDs.
"""
if self.user_data_store in (
UserDataStore.CUSTOM_USER_DATA,
UserDataStore.USER_CASE,
):
return [CommCareUser.get_by_user_id(id_) for id_ in obj_ids]
elif self.user_data_store == UserDataStore.OTHER_CASE_TYPE:
assert self.other_case_type
return CommCareCase.objects.get_cases(obj_ids, self.domain)
44 changes: 44 additions & 0 deletions corehq/apps/integration/kyc/schemas/kyc-verify-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"firstName",
"lastName",
"phoneNumber",
"emailAddress",
"nationalIdNumber",
"streetAddress",
"city",
"postCode",
"country"
],
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"phoneNumber": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"nationalIdNumber": {
"type": "string"
},
"streetAddress": {
"type": "string"
},
"city": {
"type": "string"
},
"postCode": {
"type": "string"
},
"country": {
"type": "string"
}
}
}
153 changes: 150 additions & 3 deletions corehq/apps/integration/kyc/services.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,129 @@
import json
import os
import re
from datetime import datetime

from django.conf import settings
from django.utils.text import camel_case_to_spaces

import jsonschema

from corehq.apps.hqcase.utils import update_case
from corehq.apps.integration.kyc.models import UserDataStore


class UserCaseNotFound(Exception):
pass


def verify_users(user_objs, config):
# TODO: An endpoint to verify a group of users does not seem to be
# available using Chenosis. If we have to do this with
# multiple calls, consider using Celery gevent workers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

results = []
for user_obj in user_objs:
is_verified = verify_user(user_obj, config)
save_result(user_obj, config, is_verified)
results.append(is_verified)
Copy link
Contributor

@ajeety4 ajeety4 Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like having just verification results is not very helpful to the caller. Consider adding an identifier (e.g. user_id) along with the verification result.

results.append(
    {
        'user_id': 'abc',
        'is_verified': True,
    }
)

return results


def verify_user(user_obj, config):
"""
Verify a user using the Chenosis MTN KYC API.

Returns True if all user data is accurate above its required
threshold, otherwise False.
"""

# Documentation: https://apiportal.chenosis.io/en/apis/kyc-verify#endpoints
#
# Example request:
#
# POST https://dev.api.chenosis.io/kycVerify/v1/customers/{customerId}
# {
# "firstName": "John",
# "lastName": "Doe",
# "phoneNumber": "27650600900",
# "emailAddress": "[email protected]",
# "nationalIdNumber": "123456789",
# "streetAddress": "1st Park Avenue, Mzansi, Johannesburg",
# "city": "Johannesburg",
# "postCode": "20200",
# "country": "South Africa"
# }
#
# Example 200 response:
#
# {
# "statusCode": "0000",
# "statusMessage": "Success.",
# "customerId": "27650600900",
# "transactionId": "232TXYZ-212",
# "data": {
# "firstName": 100,
# "lastName": 100,
# "phoneNumber": 100,
# "emailAddress": 100,
# "nationalIdNumber": 100,
# "streetAddress": 100,
# "city": 100,
# "postCode": 100,
# "country": 100
# }
# }

# TODO: Determine what thresholds we want
required_thresholds = {
'firstName': 100,
Copy link
Contributor

@Charl1996 Charl1996 Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kaapstorm How do these scores function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like a percentage of how close the value that was submitted is to the value that MTN has on record. We haven't been able to test this yet, because we don't (yet) have credentials we can use for testing.

'lastName': 100,
'phoneNumber': 100,
'emailAddress': 100,
'nationalIdNumber': 100,
'streetAddress': 80, # Allow streetAddress to be less accurate
'city': 100,
'postCode': 100,
'country': 0, # e.g "Ivory Coast" vs. "Republic of Côte d'Ivoire" vs. "CIV"?
}

user_data = get_user_data_for_api(user_obj, config)
_validate_schema('kycVerify/v1', user_data)
requests = config.get_connection_settings().get_requests()
response = requests.post(
f'/kycVerify/v1/customers/{user_data["phoneNumber"]}',
json=user_data,
)
response.raise_for_status()
field_scores = response.json().get('data', {})
return all(v >= required_thresholds[k] for k, v in field_scores.items())


def save_result(user_obj, config, is_verified):
update = {
'kyc_provider': config.provider.value, # TODO: Or config.provider.label?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.provider.value does not work as provider is the actual string value.

Suggested change
'kyc_provider': config.provider.value, # TODO: Or config.provider.label?
'kyc_provider': config.provider, # TODO: Or config.provider.get_provider_display()?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am bit inclined towards storing the value here in case we want to make a conditional check on this later.

'kyc_last_verified_at': datetime.utcnow(), # TODO: UTC or project timezone?
'kyc_is_verified': is_verified,
}
if config.user_data_store == UserDataStore.CUSTOM_USER_DATA:
user_data = user_obj.get_user_data(config.domain)
user_data.update(update)
elif config.user_data_store == UserDataStore.USER_CASE:
case = user_obj.get_usercase()
update_case(
config.domain,
case.case_id,
case_properties=update,
device_id='corehq.apps.integration.kyc.services.save_result',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider using device_id=__name__ + '.save_result'

)
else: # UserDataStore.OTHER_CASE_TYPE
update_case(
config.domain,
user_obj.case_id,
case_properties=update,
device_id='corehq.apps.integration.kyc.services.save_result',
)


def get_user_data_for_api(source, config):
"""
Returns a dictionary of user data for the API.
Expand All @@ -26,11 +145,13 @@ def get_user_data_for_api(source, config):

def _get_source_data(source, config):
"""
Returns a dictionary of source data.
``source`` is a CommCareUser or a CommCareCase.
Returns a dictionary of source data.

:param source: A CommCareUser or a CommCareCase.
:param config: A KycConfig instance.
"""
if config.user_data_store == UserDataStore.CUSTOM_USER_DATA:
source_data = source.get_user_data(config.domain)
source_data = source.get_user_data(config.domain).to_dict()
elif config.user_data_store == UserDataStore.USER_CASE:
custom_user_case = source.get_usercase()
if not custom_user_case:
Expand All @@ -39,3 +160,29 @@ def _get_source_data(source, config):
else:
source_data = source.case_json
return source_data


def _validate_schema(endpoint, data):
"""
Validate the data against the schema for the given endpoint
"""
schema_filename = f'{_kebab_case(endpoint)}.json'
schema_path = os.path.join(
settings.BASE_DIR, 'corehq', 'apps', 'integration', 'kyc', 'schemas',
schema_filename,
)
with open(schema_path) as f:
schema = json.load(f)
jsonschema.validate(data, schema)


def _kebab_case(value):
"""
Convert a string to kebab-case

>>> _kebab_case('Hello, World!')
'hello-world'
"""
value = camel_case_to_spaces(value)
value = re.sub(r"[^\w\s-]", "-", value.lower())
return re.sub(r"[-\s]+", "-", value).strip("-_")
Loading