Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cuenca_validations/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
'FileBatchUploadRequest',
'FileRequest',
'FileUploadRequest',
'FraudFundsTransferAcceptedResponse',
'FraudFundsTransferQuery',
'FraudFundsTransferRequest',
'FraudFundsTransferResultEvent',
'Gender',
'IncomeType',
'IssuerNetwork',
Expand Down Expand Up @@ -207,6 +211,7 @@
DepositQuery,
EventQuery,
FileQuery,
FraudFundsTransferQuery,
IdentityQuery,
PostalCodeQuery,
QueryParams,
Expand All @@ -230,6 +235,9 @@
FileBatchUploadRequest,
FileRequest,
FileUploadRequest,
FraudFundsTransferAcceptedResponse,
FraudFundsTransferRequest,
FraudFundsTransferResultEvent,
KYCValidationRequest,
LimitedWalletRequest,
PartnerRequest,
Expand Down
6 changes: 6 additions & 0 deletions cuenca_validations/types/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
KYCFileType,
SessionType,
TermsOfService,
TransactionStatus,
TransferNetwork,
UserStatus,
)
Expand Down Expand Up @@ -153,6 +154,11 @@ class WalletTransactionQuery(QueryParams):
wallet_uri: Optional[str] = None


class FraudFundsTransferQuery(QueryParams):
request_id: Optional[str] = None
status: Optional[TransactionStatus] = None


class UserQuery(QueryParams):
phone_number: Optional[str] = None
email_address: Optional[EmailStr] = None
Expand Down
57 changes: 56 additions & 1 deletion cuenca_validations/types/requests.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import datetime as dt
from typing import Annotated, Any, Optional, Union
from typing import Annotated, Any, Literal, Optional, Union

from clabe import Clabe
from pydantic import (
Expand Down Expand Up @@ -309,6 +309,61 @@ class WalletTransactionRequest(BaseRequest):
amount: StrictPositiveInt


class FraudFundsTransferRequest(BaseRequest):
user_id: NonEmptyStr
clabe: Clabe
concepto: NonEmptyStr
amount: Optional[StrictPositiveInt] = None
reason: NonEmptyStr
request_id: NonEmptyStr


class FraudFundsTransferAcceptedResponse(BaseRequest):
request_id: NonEmptyStr
status: Literal['queued']


class FraudFundsTransferResultEvent(BaseRequest):
schema_version: NonEmptyStr
event_type: Literal[
'fraud_funds_transfer.succeeded',
'fraud_funds_transfer.failed',
]
request_id: NonEmptyStr
user_id: NonEmptyStr
transaction_id: Optional[NonEmptyStr] = None
amount: Optional[StrictPositiveInt] = None
clave_rastreo: Optional[NonEmptyStr] = None
reason_code: Optional[NonEmptyStr] = None
message: Optional[NonEmptyStr] = None
completed_at: dt.datetime

@model_validator(mode='after')
def validate_payload(self) -> 'FraudFundsTransferResultEvent':
if self.event_type == 'fraud_funds_transfer.succeeded':
required = {
'transaction_id': self.transaction_id,
'amount': self.amount,
}
missing = [
field for field, value in required.items() if value is None
]
if missing:
raise ValueError(
f'{", ".join(missing)} required for succeeded event'
)
return self

required = {
'reason_code': self.reason_code,
'message': self.message,
}
missing = [field for field, value in required.items() if value is None]
if missing:
raise ValueError(f'{", ".join(missing)} required for failed event')
return self
Comment on lines +341 to +364
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In Pydantic v2.10.5, can Field(discriminator="event_type")withLiteral-based union variants enforce event-specific required fields without a custom @model_validator?

💡 Result:

Yes, in Pydantic v2.10.5, using Field(discriminator="event_type") with Literal-based union variants automatically enforces event-specific required fields without needing a custom @model_validator [1][2]. When you define a discriminated union, Pydantic uses the discriminator field to identify which specific model in the Union should be used for validation [1]. Once the correct variant is selected, Pydantic validates the input data against that specific model's schema, which includes enforcing all required fields defined within that variant [1][2]. If the input data contains the correct discriminator tag but is missing fields required by the selected model, Pydantic will raise a ValidationError identifying the missing fields for that specific variant [1][2]. Example: from typing import Literal, Union from pydantic import BaseModel, Field class EventA(BaseModel): event_type: Literal["A"] required_a: str class EventB(BaseModel): event_type: Literal["B"] required_b: int class Container(BaseModel): event: Union[EventA, EventB] = Field(discriminator="event_type") # This will raise a ValidationError because 'required_a' is missing for EventA Container.model_validate({"event": {"event_type": "A"}}) # This will succeed Container.model_validate({"event": {"event_type": "A", "required_a": "data"}})

Citations:


🏁 Script executed:

cd cuenca_validations && cat -n types/requests.py | sed -n '300,400p'

Repository: cuenca-mx/cuenca-validations

Length of output: 3850


Replace manual event payload validation with a discriminated union model.

validate_payload implements variant-required fields that Pydantic v2.10.5 already supports via Literal + discriminator-based unions. Pydantic's Field(discriminator="event_type") with separate event model variants automatically enforces event-specific required fields without custom validators, improving schema validation and error clarity.

Per the mandatory coding guideline "Use built-in Pydantic validators," replace this custom @model_validator with discriminated union variants (e.g., separate SucceededEvent and FailedEvent models) that define required fields as non-optional per event type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cuenca_validations/types/requests.py` around lines 342 - 365, Remove the
custom `@model_validator` validate_payload on FraudFundsTransferResultEvent and
instead define two variant models (e.g., FraudFundsTransferSucceeded and
FraudFundsTransferFailed) that set event_type =
Literal['fraud_funds_transfer.succeeded'] or
Literal['fraud_funds_transfer.failed'] and declare transaction_id and amount
(for succeeded) and reason_code and message (for failed) as required
(non-Optional) fields; then change the payload type to a
Union[FraudFundsTransferSucceeded, FraudFundsTransferFailed] and use Field(...,
discriminator="event_type") so Pydantic enforces event-specific required fields
automatically.



class FraudValidationRequest(BaseModel):
amount: StrictPositiveInt
merchant_name: str
Expand Down
2 changes: 1 addition & 1 deletion cuenca_validations/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2.1.32'
__version__ = '2.1.33.dev0'
84 changes: 84 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

from cuenca_validations.types import (
CardQuery,
FraudFundsTransferAcceptedResponse,
FraudFundsTransferRequest,
FraudFundsTransferResultEvent,
JSONEncoder,
QueryParams,
SantizedDict,
Expand Down Expand Up @@ -636,6 +639,87 @@ def test_bank_account_validation_clabe_request():
assert BankAccountValidationRequest(account_number='646180157098510917')


def test_fraud_funds_transfer_models():
request = FraudFundsTransferRequest(
user_id='US123',
clabe='646180157098510917',
concepto=' fondos fraude ',
amount=100,
reason='fraud_report',
request_id='REQ123',
)

assert request.concepto == 'fondos fraude'
assert request.model_dump() == {
'user_id': 'US123',
'clabe': '646180157098510917',
'concepto': 'fondos fraude',
'amount': 100,
'reason': 'fraud_report',
'request_id': 'REQ123',
}

response = FraudFundsTransferAcceptedResponse(
request_id='REQ123',
status='queued',
)

assert response.status == 'queued'

succeeded_event = FraudFundsTransferResultEvent(
schema_version='1.0',
event_type='fraud_funds_transfer.succeeded',
request_id='REQ123',
user_id='US123',
transaction_id='TR123',
amount=100,
clave_rastreo='RASTREO123',
completed_at=now,
)

assert succeeded_event.transaction_id == 'TR123'

failed_event = FraudFundsTransferResultEvent(
schema_version='1.0',
event_type='fraud_funds_transfer.failed',
request_id='REQ123',
user_id='US123',
reason_code='insufficient_funds',
message='Insufficient funds',
completed_at=now,
)

assert failed_event.reason_code == 'insufficient_funds'


@pytest.mark.parametrize(
'event_type,expected_error',
[
(
'fraud_funds_transfer.succeeded',
'transaction_id, amount required for succeeded event',
),
(
'fraud_funds_transfer.failed',
'reason_code, message required for failed event',
),
],
)
def test_fraud_funds_transfer_result_event_requires_payload(
event_type, expected_error
):
with pytest.raises(ValidationError) as ex:
FraudFundsTransferResultEvent(
schema_version='1.0',
event_type=event_type,
request_id='REQ123',
user_id='US123',
completed_at=now,
)

assert expected_error in str(ex.value)


@pytest.mark.parametrize(
'input_data',
[
Expand Down
Loading