diff --git a/cuenca_validations/types/__init__.py b/cuenca_validations/types/__init__.py index 045fc9b6..c59c9b9a 100644 --- a/cuenca_validations/types/__init__.py +++ b/cuenca_validations/types/__init__.py @@ -1,6 +1,9 @@ __all__ = [ 'AccountUseType', 'AccountQuery', + 'AccountRequest', + 'AccountUpdateRequest', + 'AccountValidationStatus', 'Address', 'AgentQuery', 'AgentRequest', @@ -57,11 +60,20 @@ 'KYCValidationRequest', 'KYCValidationSource', 'Language', + 'LegalPersonRequest', + 'LegalPersonUpdateRequest', 'LimitedWalletRequest', 'MonthlyMovementsType', 'MonthlySpendingType', - 'LegalPersonRequest', - 'LegalPersonUpdateRequest', + 'OperationalEventAction', + 'OperationalEventQuery', + 'OperatorLoginRequest', + 'OperatorLoginResponse', + 'OperatorQuery', + 'OperatorRequest', + 'OperatorRole', + 'OperatorStatus', + 'OperatorUpdateRequest', 'PartnerRequest', 'PartnerUpdateRequest', 'PasswordResetRequest', @@ -80,8 +92,10 @@ 'SavingRequest', 'SavingUpdateRequest', 'ServiceProviderCategory', + 'SessionMetadata', 'SessionQuery', 'SessionRequest', + 'SessionResponse', 'SessionType', 'State', 'StatementQuery', @@ -132,6 +146,7 @@ from .card import StrictPaymentCardNumber from .enums import ( AccountUseType, + AccountValidationStatus, AuthorizerTransaction, BankAccountStatus, CardErrorType, @@ -158,6 +173,9 @@ Language, MonthlyMovementsType, MonthlySpendingType, + OperationalEventAction, + OperatorRole, + OperatorStatus, PlatformType, PosCapability, Profession, @@ -216,6 +234,8 @@ EventQuery, FileQuery, IdentityQuery, + OperationalEventQuery, + OperatorQuery, PostalCodeQuery, QueryParams, SessionQuery, @@ -229,6 +249,8 @@ WalletTransactionQuery, ) from .requests import ( + AccountRequest, + AccountUpdateRequest, AgentRequest, ApiKeyUpdateRequest, BankAccountValidationRequest, @@ -244,6 +266,10 @@ LegalPersonRequest, LegalPersonUpdateRequest, LimitedWalletRequest, + OperatorLoginRequest, + OperatorLoginResponse, + OperatorRequest, + OperatorUpdateRequest, PartnerRequest, PartnerUpdateRequest, PasswordResetRequest, @@ -251,7 +277,9 @@ QuestionnairesRequest, SavingRequest, SavingUpdateRequest, + SessionMetadata, SessionRequest, + SessionResponse, StrictTransferRequest, TOSRequest, TransferRequest, diff --git a/cuenca_validations/types/enums.py b/cuenca_validations/types/enums.py index 6da7bc8b..098cb020 100644 --- a/cuenca_validations/types/enums.py +++ b/cuenca_validations/types/enums.py @@ -745,3 +745,25 @@ class RequiredAction(str, Enum): level_up_required = 'level_up_required' level_up_invitation = 'level_up_invitation' fix_documents = 'fix_documents' + + +class AccountValidationStatus(str, Enum): + verified = 'verified' + pending = 'pending' + + +class OperationalEventAction(str, Enum): + account_created = 'account_created' + account_updated = 'account_updated' + transfer_created = 'transfer_created' + statement_downloaded = 'statement_downloaded' + + +class OperatorRole(str, Enum): + operator = 'operator' + authorizer = 'authorizer' + + +class OperatorStatus(str, Enum): + active = 'active' + disabled = 'disabled' diff --git a/cuenca_validations/types/queries.py b/cuenca_validations/types/queries.py index 5981c269..c5e794f7 100644 --- a/cuenca_validations/types/queries.py +++ b/cuenca_validations/types/queries.py @@ -22,6 +22,7 @@ CardType, EventType, KYCFileType, + OperationalEventAction, SessionType, TermsOfService, TransferNetwork, @@ -153,6 +154,15 @@ class AccountQuery(QueryParams): account_number: Optional[str] = None +class OperationalEventQuery(QueryParams): + actor_id: Optional[str] = None + action: Optional[OperationalEventAction] = None + + +class OperatorQuery(QueryParams): + email: Optional[EmailStr] = None + + class BalanceEntryQuery(QueryParams): funding_instrument_uri: Optional[str] = None wallet_id: str = 'default' diff --git a/cuenca_validations/types/requests.py b/cuenca_validations/types/requests.py index ffbf867b..c0a5e721 100644 --- a/cuenca_validations/types/requests.py +++ b/cuenca_validations/types/requests.py @@ -18,6 +18,7 @@ from ..types.enums import ( AccountUseType, + AccountValidationStatus, AuthorizerTransaction, CardDesign, CardFundingType, @@ -36,6 +37,8 @@ KYCValidationSource, MonthlyMovementsType, MonthlySpendingType, + OperatorRole, + OperatorStatus, PlatformType, PosCapability, Profession, @@ -629,11 +632,29 @@ class UserLoginRequest(BaseRequest): ) +class SessionMetadata(BaseModel): + operator_id: Optional[str] = None + model_config = ConfigDict(extra='forbid') + + +class SessionResponse(BaseModel): + id: str + created_at: dt.datetime + user_id: str + platform_id: str + expires_at: dt.datetime + type: SessionType + success_url: Optional[SerializableAnyUrl] = None + failure_url: Optional[SerializableAnyUrl] = None + metadata: Optional[SessionMetadata] = None + + class SessionRequest(BaseRequest): user_id: str type: SessionType success_url: Optional[SerializableAnyUrl] = None failure_url: Optional[SerializableAnyUrl] = None + metadata: Optional[SessionMetadata] = None model_config = ConfigDict( json_schema_extra={ 'example': { @@ -735,6 +756,39 @@ class LimitedWalletRequest(BaseRequest): allowed_rfc: Optional[Rfc] = None +class AccountRequest(BaseRequest): + name: StrictStr + account_number: Clabe + alias: Optional[StrictStr] = None + curp: Optional[Curp] = None + rfc: Optional[Rfc] = None + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'name': 'Aceros del Norte SA de CV', + 'account_number': '072691004495711499', + 'alias': 'Proveedor acero', + } + }, + ) + + +class AccountUpdateRequest(BaseRequest): + name: Optional[StrictStr] = None + alias: Optional[StrictStr] = None + validation_status: Optional[AccountValidationStatus] = None + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'alias': 'Fletes', + 'validation_status': 'verified', + } + }, + ) + + class PlatformRequest(BaseModel): name: str rfc: Optional[str] = None @@ -891,6 +945,13 @@ class LegalPersonRequest(BaseRequest): }, ) + @field_validator('rfc') + @classmethod + def validate_legal_rfc(cls, rfc: Rfc) -> Rfc: + if len(rfc) != 12: + raise ValueError('RFC must be 12 characters for legal persons') + return rfc + class LegalPersonUpdateRequest(BaseRequest): legal_name: Optional[str] = None @@ -898,6 +959,97 @@ class LegalPersonUpdateRequest(BaseRequest): address: Optional[AddressRequest] = None legal_representatives: Optional[list[LegalRepresentative]] = None + @model_validator(mode="before") + @classmethod + def check_at_least_one_param(cls, values: DictStrAny) -> DictStrAny: + if not values: + raise ValueError('At least one parameter must be provided') + return values + + @field_validator('rfc') + @classmethod + def validate_legal_rfc(cls, rfc: Optional[Rfc]) -> Optional[Rfc]: + if rfc is not None and len(rfc) != 12: + raise ValueError('RFC must be 12 characters for legal persons') + return rfc + + +class OperatorRequest(BaseRequest): + name: str + email: EmailStr + phone: PhoneNumber + company_user_id: str + role: OperatorRole + status: OperatorStatus = OperatorStatus.active + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'name': 'Maria Lopez', + 'email': 'maria.lopez@aceros.com', + 'phone': '+525512345678', + 'company_user_id': 'USWqY5cvkISJOxHyEKjAKf8w', + 'role': 'operator', + } + }, + ) + + @field_validator('email', mode='before') + @classmethod + def validate_email(cls, email: str) -> str: + return normalize_email(email) + + +class OperatorUpdateRequest(BaseRequest): + name: Optional[str] = None + phone: Optional[PhoneNumber] = None + role: Optional[OperatorRole] = None + status: Optional[OperatorStatus] = None + + @model_validator(mode="before") + @classmethod + def check_at_least_one_param(cls, values: DictStrAny) -> DictStrAny: + if not values: + raise ValueError('At least one parameter must be provided') + return values + + +class OperatorLoginRequest(BaseRequest): + email: EmailStr + password: Annotated[Password, LogConfig(masked=True)] + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'email': 'maria.lopez@aceros.com', + 'password': 'supersecret', + } + }, + ) + + @field_validator('email', mode='before') + @classmethod + def validate_email(cls, email: str) -> str: + return normalize_email(email) + + +class OperatorLoginResponse(BaseModel): + session_token: str + operator_id: str + role: OperatorRole + company_user_id: str + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'session_token': 'SEWqY5cvkISJOxHyEKjAKf8w', + 'operator_id': 'OPWqY5cvkISJOxHyEKjAKf8w', + 'role': 'authorizer', + 'company_user_id': 'USWqY5cvkISJOxHyEKjAKf8w', + } + }, + ) + class PhoneVerificationAssociationRequest(BaseRequest): verification_id: str diff --git a/cuenca_validations/version.py b/cuenca_validations/version.py index 9f552eb9..d1c239a9 100644 --- a/cuenca_validations/version.py +++ b/cuenca_validations/version.py @@ -1 +1 @@ -__version__ = '2.1.42' +__version__ = '2.1.41.dev5' diff --git a/tests/test_requests.py b/tests/test_requests.py index d756be59..b081e7a4 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -2,9 +2,24 @@ from pydantic import ValidationError from pydantic_extra_types.phone_numbers import PhoneNumber -from cuenca_validations.types.enums import Country, VerificationType +from cuenca_validations.types.enums import ( + Country, + OperatorRole, + OperatorStatus, + SessionType, + VerificationType, +) +from cuenca_validations.types.queries import OperatorQuery from cuenca_validations.types.requests import ( + LegalPersonRequest, + LegalPersonUpdateRequest, + OperatorLoginRequest, + OperatorLoginResponse, + OperatorRequest, + OperatorUpdateRequest, PasswordResetRequest, + SessionMetadata, + SessionRequest, UpdateTransferRequest, UserTOSAgreementRequest, UserUpdateRequest, @@ -12,6 +27,188 @@ ) from cuenca_validations.typing import DictStrAny +LEGAL_REPRESENTATIVE: DictStrAny = { + 'names': 'Juan', + 'first_surname': 'Perez', + 'job': 'Director General', + 'phone_number': '+525512345678', + 'email_address': 'juan.perez@aceros.com', + 'address': { + 'street': 'Reforma', + 'ext_number': '265', + 'postal_code_id': 'PC2ygq9j2bS9-9tsuVawzErA', + }, +} + +LEGAL_PERSON_REQUEST: DictStrAny = { + 'legal_name': 'Aceros del Norte SA de CV', + 'rfc': 'ADN850101ABC', + 'address': { + 'street': 'Reforma', + 'ext_number': '265', + 'postal_code_id': 'PC2ygq9j2bS9-9tsuVawzErA', + }, + 'legal_representatives': [LEGAL_REPRESENTATIVE], +} + + +def test_legal_person_request_valid() -> None: + req = LegalPersonRequest.model_validate(LEGAL_PERSON_REQUEST) + assert req.legal_name == 'Aceros del Norte SA de CV' + assert req.rfc == 'ADN850101ABC' + + +def test_legal_person_request_rejects_physical_rfc() -> None: + with pytest.raises(ValidationError) as ex: + LegalPersonRequest.model_validate( + {**LEGAL_PERSON_REQUEST, 'rfc': 'GOCG650418TJ1'} + ) + assert 'RFC must be 12 characters for legal persons' in str(ex.value) + + +def test_legal_person_request_forbids_extra() -> None: + with pytest.raises(ValidationError) as ex: + LegalPersonRequest.model_validate( + {**LEGAL_PERSON_REQUEST, 'foo': 'bar'} + ) + assert 'Extra inputs are not permitted' in str(ex.value) + + +def test_legal_person_update_requires_at_least_one_param() -> None: + with pytest.raises(ValueError) as ex: + LegalPersonUpdateRequest() + assert 'At least one parameter must be provided' in str(ex.value) + + +def test_legal_person_update_valid() -> None: + req = LegalPersonUpdateRequest.model_validate({'legal_name': 'New name'}) + assert req.legal_name == 'New name' + + +def test_legal_person_update_accepts_legal_rfc() -> None: + req = LegalPersonUpdateRequest.model_validate({'rfc': 'ADN850101ABC'}) + assert req.rfc == 'ADN850101ABC' + + +def test_legal_person_update_rejects_physical_rfc() -> None: + with pytest.raises(ValidationError) as ex: + LegalPersonUpdateRequest.model_validate({'rfc': 'GOCG650418TJ1'}) + assert 'RFC must be 12 characters for legal persons' in str(ex.value) + + +def test_operator_request_valid() -> None: + req = OperatorRequest( + name='Maria Lopez', + email='Maria+Tag@Aceros.com', + phone=PhoneNumber('+525512345678'), + company_user_id='USWqY5cvkISJOxHyEKjAKf8w', + role=OperatorRole.operator, + ) + assert req.email == 'maria@aceros.com' + assert req.status == OperatorStatus.active + + +def test_operator_request_rejects_invalid_role() -> None: + with pytest.raises(ValidationError) as ex: + OperatorRequest.model_validate( + { + 'name': 'Maria Lopez', + 'email': 'maria@aceros.com', + 'phone': '+525512345678', + 'company_user_id': 'USWqY5cvkISJOxHyEKjAKf8w', + 'role': 'admin', + } + ) + assert 'role' in str(ex.value) + + +def test_operator_update_requires_at_least_one_param() -> None: + with pytest.raises(ValueError) as ex: + OperatorUpdateRequest() + assert 'At least one parameter must be provided' in str(ex.value) + + +def test_operator_update_valid() -> None: + req = OperatorUpdateRequest.model_validate({'name': 'New name'}) + assert req.name == 'New name' + + +def test_operator_login_request_valid() -> None: + req = OperatorLoginRequest.model_validate( + { + 'email': 'Operator+Tag@Aceros.com', + 'password': 'supersecret', + } + ) + assert req.email == 'operator@aceros.com' + + +def test_operator_login_request_rejects_short_password() -> None: + with pytest.raises(ValidationError) as ex: + OperatorLoginRequest.model_validate( + { + 'email': 'operator@aceros.com', + 'password': 'short', + } + ) + assert 'password' in str(ex.value) + + +def test_operator_login_request_forbids_extra() -> None: + with pytest.raises(ValidationError) as ex: + OperatorLoginRequest.model_validate( + { + 'email': 'operator@aceros.com', + 'password': 'supersecret', + 'foo': 'bar', + } + ) + assert 'Extra inputs are not permitted' in str(ex.value) + + +def test_operator_login_response_valid() -> None: + resp = OperatorLoginResponse( + session_token='SEWqY5cvkISJOxHyEKjAKf8w', + operator_id='OPWqY5cvkISJOxHyEKjAKf8w', + role=OperatorRole.authorizer, + company_user_id='USWqY5cvkISJOxHyEKjAKf8w', + ) + assert resp.session_token == 'SEWqY5cvkISJOxHyEKjAKf8w' + assert resp.operator_id == 'OPWqY5cvkISJOxHyEKjAKf8w' + assert resp.role == OperatorRole.authorizer + assert resp.company_user_id == 'USWqY5cvkISJOxHyEKjAKf8w' + + +def test_operator_query_valid() -> None: + query = OperatorQuery.model_validate({'email': 'maria.lopez@aceros.com'}) + assert str(query.email) == 'maria.lopez@aceros.com' + + +def test_operator_query_forbids_extra() -> None: + with pytest.raises(ValidationError) as ex: + OperatorQuery.model_validate( + {'email': 'maria@aceros.com', 'foo': 'bar'} + ) + assert 'Extra inputs are not permitted' in str(ex.value) + + +def test_session_request_with_operator_metadata() -> None: + req = SessionRequest( + user_id='USWqY5cvkISJOxHyEKjAKf8w', + type=SessionType.registration, + metadata=SessionMetadata(operator_id='OPWqY5cvkISJOxHyEKjAKf8w'), + ) + assert req.metadata is not None + assert req.metadata.operator_id == 'OPWqY5cvkISJOxHyEKjAKf8w' + + +def test_session_request_metadata_forbids_extra() -> None: + with pytest.raises(ValidationError) as ex: + SessionMetadata.model_validate( + {'operator_id': 'OPWqY5cvkISJOxHyEKjAKf8w', 'foo': 'bar'} + ) + assert 'Extra inputs are not permitted' in str(ex.value) + @pytest.mark.parametrize('environment', ['api.stage', 'api.sandbox', 'api']) def test_file_cuenca_url(environment: str) -> None: