From a517917da3dfdbc5e2ae70c41327b3626d4b671e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Cabrera?= Date: Thu, 27 Aug 2026 17:22:39 -0600 Subject: [PATCH 1/5] feat: add account and operational event types Types for oaxaca beneficiaries (#605) and audit log (#608). Co-authored-by: Cursor --- cuenca_validations/types/__init__.py | 10 ++++++++ cuenca_validations/types/enums.py | 12 ++++++++++ cuenca_validations/types/queries.py | 6 +++++ cuenca_validations/types/requests.py | 34 ++++++++++++++++++++++++++++ cuenca_validations/version.py | 2 +- 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cuenca_validations/types/__init__.py b/cuenca_validations/types/__init__.py index b36296b4..8e0194bf 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', @@ -60,6 +63,8 @@ 'LimitedWalletRequest', 'MonthlyMovementsType', 'MonthlySpendingType', + 'OperationalEventAction', + 'OperationalEventQuery', 'PartnerRequest', 'PartnerUpdateRequest', 'PasswordResetRequest', @@ -129,6 +134,7 @@ from .card import StrictPaymentCardNumber from .enums import ( AccountUseType, + AccountValidationStatus, AuthorizerTransaction, BankAccountStatus, CardErrorType, @@ -155,6 +161,7 @@ Language, MonthlyMovementsType, MonthlySpendingType, + OperationalEventAction, PlatformType, PosCapability, Profession, @@ -213,6 +220,7 @@ EventQuery, FileQuery, IdentityQuery, + OperationalEventQuery, PostalCodeQuery, QueryParams, SessionQuery, @@ -226,6 +234,8 @@ WalletTransactionQuery, ) from .requests import ( + AccountRequest, + AccountUpdateRequest, AgentRequest, ApiKeyUpdateRequest, BankAccountValidationRequest, diff --git a/cuenca_validations/types/enums.py b/cuenca_validations/types/enums.py index ea98eab8..07f9e950 100644 --- a/cuenca_validations/types/enums.py +++ b/cuenca_validations/types/enums.py @@ -744,3 +744,15 @@ 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' diff --git a/cuenca_validations/types/queries.py b/cuenca_validations/types/queries.py index 5981c269..d0c6b6e2 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,11 @@ class AccountQuery(QueryParams): account_number: Optional[str] = None +class OperationalEventQuery(QueryParams): + actor_id: Optional[str] = None + action: Optional[OperationalEventAction] = 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 00a19a0d..2de8f23d 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, @@ -735,6 +736,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 diff --git a/cuenca_validations/version.py b/cuenca_validations/version.py index e5bf0c19..c1e12ab0 100644 --- a/cuenca_validations/version.py +++ b/cuenca_validations/version.py @@ -1 +1 @@ -__version__ = '2.1.40' +__version__ = '2.1.41.dev0' From d30a655bdf7beccf154700690ae78b425b7ed3b1 Mon Sep 17 00:00:00 2001 From: Juliette Trejo Ceballos Date: Tue, 1 Sep 2026 21:59:23 +0100 Subject: [PATCH 2/5] feat: introduce operator and moral person request types --- cuenca_validations/types/__init__.py | 16 ++++ cuenca_validations/types/enums.py | 10 +++ cuenca_validations/types/requests.py | 127 ++++++++++++++++++++++++++ cuenca_validations/version.py | 2 +- tests/test_requests.py | 130 ++++++++++++++++++++++++++- 5 files changed, 283 insertions(+), 2 deletions(-) diff --git a/cuenca_validations/types/__init__.py b/cuenca_validations/types/__init__.py index 8e0194bf..a90ed0b4 100644 --- a/cuenca_validations/types/__init__.py +++ b/cuenca_validations/types/__init__.py @@ -63,8 +63,16 @@ 'LimitedWalletRequest', 'MonthlyMovementsType', 'MonthlySpendingType', + 'MoralPersonRequest', + 'MoralPersonUpdateRequest', 'OperationalEventAction', 'OperationalEventQuery', + 'OperatorLoginRequest', + 'OperatorLoginResponse', + 'OperatorRequest', + 'OperatorRole', + 'OperatorStatus', + 'OperatorUpdateRequest', 'PartnerRequest', 'PartnerUpdateRequest', 'PasswordResetRequest', @@ -162,6 +170,8 @@ MonthlyMovementsType, MonthlySpendingType, OperationalEventAction, + OperatorRole, + OperatorStatus, PlatformType, PosCapability, Profession, @@ -249,6 +259,12 @@ FraudFundsTransferRequest, KYCValidationRequest, LimitedWalletRequest, + MoralPersonRequest, + MoralPersonUpdateRequest, + OperatorLoginRequest, + OperatorLoginResponse, + OperatorRequest, + OperatorUpdateRequest, PartnerRequest, PartnerUpdateRequest, PasswordResetRequest, diff --git a/cuenca_validations/types/enums.py b/cuenca_validations/types/enums.py index 07f9e950..4fbd19cf 100644 --- a/cuenca_validations/types/enums.py +++ b/cuenca_validations/types/enums.py @@ -756,3 +756,13 @@ class OperationalEventAction(str, Enum): 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/requests.py b/cuenca_validations/types/requests.py index 2de8f23d..4c9d2168 100644 --- a/cuenca_validations/types/requests.py +++ b/cuenca_validations/types/requests.py @@ -37,6 +37,8 @@ KYCValidationSource, MonthlyMovementsType, MonthlySpendingType, + OperatorRole, + OperatorStatus, PlatformType, PosCapability, Profession, @@ -897,5 +899,130 @@ class PartnerUpdateRequest(BaseRequest): shareholders: Optional[list[Shareholder]] = None +class MoralPersonRequest(BaseRequest): + legal_name: str + rfc: Rfc + legal_representatives: list[LegalRepresentative] + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'legal_name': 'Aceros del Norte SA de CV', + 'rfc': 'ADN850101ABC', + 'legal_representatives': [ + { + 'names': 'Juan', + 'first_surname': 'Perez', + 'job': 'Director General', + 'phone_number': '+525512345678', + 'email_address': 'juan.perez@aceros.com', + 'address': AddressRequest.model_json_schema().get( + 'example' + ), + } + ], + } + }, + ) + + @field_validator('rfc') + @classmethod + def validate_moral_rfc(cls, rfc: Rfc) -> Rfc: + if len(rfc) != 12: + raise ValueError('RFC must be 12 characters for moral persons') + return rfc + + +class MoralPersonUpdateRequest(BaseRequest): + legal_name: Optional[str] = None + rfc: Optional[Rfc] = 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_moral_rfc(cls, rfc: Optional[Rfc]) -> Optional[Rfc]: + if rfc is not None and len(rfc) != 12: + raise ValueError('RFC must be 12 characters for moral 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 + + model_config = ConfigDict( + json_schema_extra={ + 'example': { + 'session_token': 'SEWqY5cvkISJOxHyEKjAKf8w', + } + }, + ) + + class PhoneVerificationAssociationRequest(BaseRequest): verification_id: str diff --git a/cuenca_validations/version.py b/cuenca_validations/version.py index c1e12ab0..7ea8ac45 100644 --- a/cuenca_validations/version.py +++ b/cuenca_validations/version.py @@ -1 +1 @@ -__version__ = '2.1.41.dev0' +__version__ = '2.1.42.dev0' diff --git a/tests/test_requests.py b/tests/test_requests.py index d756be59..fec37d2b 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -2,8 +2,19 @@ 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, + VerificationType, +) from cuenca_validations.types.requests import ( + MoralPersonRequest, + MoralPersonUpdateRequest, + OperatorLoginRequest, + OperatorLoginResponse, + OperatorRequest, + OperatorUpdateRequest, PasswordResetRequest, UpdateTransferRequest, UserTOSAgreementRequest, @@ -12,6 +23,123 @@ ) 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', + }, +} + +MORAL_PERSON_REQUEST: DictStrAny = { + 'legal_name': 'Aceros del Norte SA de CV', + 'rfc': 'ADN850101ABC', + 'legal_representatives': [LEGAL_REPRESENTATIVE], +} + + +def test_moral_person_request_valid() -> None: + req = MoralPersonRequest.model_validate(MORAL_PERSON_REQUEST) + assert req.legal_name == 'Aceros del Norte SA de CV' + assert req.rfc == 'ADN850101ABC' + + +def test_moral_person_request_rejects_physical_rfc() -> None: + with pytest.raises(ValidationError) as ex: + MoralPersonRequest.model_validate( + {**MORAL_PERSON_REQUEST, 'rfc': 'GOCG650418TJ1'} + ) + assert 'RFC must be 12 characters for moral persons' in str(ex.value) + + +def test_moral_person_request_forbids_extra() -> None: + with pytest.raises(ValidationError) as ex: + MoralPersonRequest.model_validate( + {**MORAL_PERSON_REQUEST, 'foo': 'bar'} + ) + assert 'Extra inputs are not permitted' in str(ex.value) + + +def test_moral_person_update_requires_at_least_one_param() -> None: + with pytest.raises(ValueError) as ex: + MoralPersonUpdateRequest() + assert 'At least one parameter must be provided' 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_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') + assert resp.session_token == 'SEWqY5cvkISJOxHyEKjAKf8w' + @pytest.mark.parametrize('environment', ['api.stage', 'api.sandbox', 'api']) def test_file_cuenca_url(environment: str) -> None: From 57dd7d5c33915c9e4c1175bc189b04e06f30faaf Mon Sep 17 00:00:00 2001 From: Juliette Trejo Ceballos Date: Tue, 1 Sep 2026 22:04:34 +0100 Subject: [PATCH 3/5] fix: update version to 2.1.41.dev1 --- cuenca_validations/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cuenca_validations/version.py b/cuenca_validations/version.py index 7ea8ac45..03a316dd 100644 --- a/cuenca_validations/version.py +++ b/cuenca_validations/version.py @@ -1 +1 @@ -__version__ = '2.1.42.dev0' +__version__ = '2.1.41.dev1' From 0807762f5007012ebc83a5722458880d0194d40e Mon Sep 17 00:00:00 2001 From: Juliette Trejo Ceballos Date: Tue, 1 Sep 2026 22:08:31 +0100 Subject: [PATCH 4/5] feat: add tests for moral and operator person update requests --- tests/test_requests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_requests.py b/tests/test_requests.py index fec37d2b..aa261013 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -71,6 +71,22 @@ def test_moral_person_update_requires_at_least_one_param() -> None: assert 'At least one parameter must be provided' in str(ex.value) +def test_moral_person_update_valid() -> None: + req = MoralPersonUpdateRequest.model_validate({'legal_name': 'New name'}) + assert req.legal_name == 'New name' + + +def test_moral_person_update_accepts_moral_rfc() -> None: + req = MoralPersonUpdateRequest.model_validate({'rfc': 'ADN850101ABC'}) + assert req.rfc == 'ADN850101ABC' + + +def test_moral_person_update_rejects_physical_rfc() -> None: + with pytest.raises(ValidationError) as ex: + MoralPersonUpdateRequest.model_validate({'rfc': 'GOCG650418TJ1'}) + assert 'RFC must be 12 characters for moral persons' in str(ex.value) + + def test_operator_request_valid() -> None: req = OperatorRequest( name='Maria Lopez', @@ -103,6 +119,11 @@ def test_operator_update_requires_at_least_one_param() -> None: 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( { From fb872e5c65cd4560aeffa3a72ab570d5689ffd10 Mon Sep 17 00:00:00 2001 From: Juliette Trejo Ceballos Date: Wed, 2 Sep 2026 00:39:52 +0100 Subject: [PATCH 5/5] feat: add OperatorQuery and SessionMetadata types with corresponding SessionResponse updates --- cuenca_validations/types/__init__.py | 6 +++++ cuenca_validations/types/queries.py | 4 ++++ cuenca_validations/types/requests.py | 18 ++++++++++++++ tests/test_requests.py | 35 ++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/cuenca_validations/types/__init__.py b/cuenca_validations/types/__init__.py index a90ed0b4..79119601 100644 --- a/cuenca_validations/types/__init__.py +++ b/cuenca_validations/types/__init__.py @@ -69,6 +69,7 @@ 'OperationalEventQuery', 'OperatorLoginRequest', 'OperatorLoginResponse', + 'OperatorQuery', 'OperatorRequest', 'OperatorRole', 'OperatorStatus', @@ -90,8 +91,10 @@ 'SavingRequest', 'SavingUpdateRequest', 'ServiceProviderCategory', + 'SessionMetadata', 'SessionQuery', 'SessionRequest', + 'SessionResponse', 'SessionType', 'State', 'StatementQuery', @@ -231,6 +234,7 @@ FileQuery, IdentityQuery, OperationalEventQuery, + OperatorQuery, PostalCodeQuery, QueryParams, SessionQuery, @@ -272,7 +276,9 @@ QuestionnairesRequest, SavingRequest, SavingUpdateRequest, + SessionMetadata, SessionRequest, + SessionResponse, StrictTransferRequest, TOSRequest, TransferRequest, diff --git a/cuenca_validations/types/queries.py b/cuenca_validations/types/queries.py index d0c6b6e2..c5e794f7 100644 --- a/cuenca_validations/types/queries.py +++ b/cuenca_validations/types/queries.py @@ -159,6 +159,10 @@ class OperationalEventQuery(QueryParams): 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 4c9d2168..73effe24 100644 --- a/cuenca_validations/types/requests.py +++ b/cuenca_validations/types/requests.py @@ -632,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': { diff --git a/tests/test_requests.py b/tests/test_requests.py index aa261013..95904da2 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -6,8 +6,10 @@ Country, OperatorRole, OperatorStatus, + SessionType, VerificationType, ) +from cuenca_validations.types.queries import OperatorQuery from cuenca_validations.types.requests import ( MoralPersonRequest, MoralPersonUpdateRequest, @@ -16,6 +18,8 @@ OperatorRequest, OperatorUpdateRequest, PasswordResetRequest, + SessionMetadata, + SessionRequest, UpdateTransferRequest, UserTOSAgreementRequest, UserUpdateRequest, @@ -162,6 +166,37 @@ def test_operator_login_response_valid() -> None: assert resp.session_token == 'SEWqY5cvkISJOxHyEKjAKf8w' +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: request_data: DictStrAny = dict(