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

rtc: Update AEC XML document validations #199

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
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
94 changes: 84 additions & 10 deletions cl_sii/rtc/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ def validate_cesion_seq(value: int) -> None:
raise ValueError("Value is out of the valid range.", value)


def validate_cesion_fecha(value: datetime, tz: tz_utils.PytzTimezone) -> None:
"""
Validate value of date and time when the "cesión" happened.

:raises ValueError:
"""

tz_utils.validate_dt_tz(value, tz)

current_date_in_tz = tz_utils.get_now_tz_aware().astimezone(tz)
Copy link
Contributor

Choose a reason for hiding this comment

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

AFAIK it is not necessary to .astimezone(tz) when comparing TZ-aware datetime values, because the "real" underlying datetime is not affected by the TZ used for representation. Am I right @jtrh ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess I was wrong (see my latest review)


if not (value.date() <= current_date_in_tz.date()):
raise ValueError(
'Value of "fecha_cesion_dt" must be before or equal to the current day.',
value,
current_date_in_tz
)


def validate_cesion_monto(value: int) -> None:
"""
Validate amount of the "cesión".
Expand Down Expand Up @@ -80,6 +99,25 @@ def validate_cesion_and_dte_montos(cesion_value: int, dte_value: int) -> None:
raise ValueError('Value of "cesión" must be <= value of DTE.', cesion_value, dte_value)


def validate_cesion_fecha_ultimo_vencimiento_is_consistent_with_dte(
cesion_value: date, dte_value: date
) -> None:
"""
Validate 'fecha_ultimo_vencimiento' of the "cesión" is after or equal
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any documentation for this rule?

to 'fecha_emision' of the DTE.

> Que la fecha del último vencimiento sea mayor o igual a la fecha
> consignada en el documento.
Source: https://github.com/cl-sii-extraoficial/archivos-oficiales/blob/master/src/docs/rtc/2013-02-11-instructivo-tecnico.pdf



:raises ValueError:
""" # noqa: E501
if not (cesion_value >= dte_value):
raise ValueError('Value of "cesión" must be >= value of DTE.', cesion_value, dte_value)


@pydantic.dataclasses.dataclass(
frozen=True,
config=type('Config', (), dict(
Expand Down Expand Up @@ -245,9 +283,9 @@ def validate_dte_tipo_dte(cls, v: object) -> object:
return v

@pydantic.validator('fecha_cesion_dt')
def validate_datetime_tz(cls, v: object) -> object:
def validate_fecha_cesion_dt(cls, v: object) -> object:
if isinstance(v, datetime):
tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ)
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense to nesting tz_utils.validate_dt_tz() into validate_cesion_fecha()?

validate_cesion_fecha(v, cls.DATETIME_FIELDS_TZ)
Copy link
Contributor

Choose a reason for hiding this comment

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

inconsistent naming

Copy link
Author

@yaselc yaselc Mar 25, 2021

Choose a reason for hiding this comment

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

return v

@pydantic.validator('fecha_cesion_dt')
Expand Down Expand Up @@ -321,13 +359,20 @@ class CesionL0:
- Same timestamp as the "Registro AoR DTE" event ``DTE Cedido``.
- The above statements were empirically verified for
``CesionNaturalKey(dte_key=DteNaturalKey(Rut('99***140-4'), 33, 3105), seq=2)``.
- When receiving an XML AEC document, the SII validates this date is before or
equal to the current day.
From the section "Modelo de Operación", where the validations to the annotation
requests in the "Registro Público de Transferencia de Créditos" are listed:
> La validez de la fecha de transferencia, debiendo ser anterior o igual al día
> actual
Source: https://github.com/cl-sii-extraoficial/archivos-oficiales/blob/master/src/docs/rtc/2013-02-11-instructivo-tecnico.pdf

.. warning:: The timestamp is generated by the signer of the AEC so it
cannot be fully trusted. It is not clear how much validation is
performed by the SII. A more trustworthy value is the RPETC email's
``Fecha de Recepcion``, which is generated by the SII, but most of the
time only the "fecha cesión" will be available.
"""
""" # noqa: E501

@property
def natural_key(self) -> Optional[CesionNaturalKey]:
Expand Down Expand Up @@ -392,9 +437,9 @@ def validate_seq(cls, v: object) -> object:
return v

@pydantic.validator('fecha_cesion_dt')
def validate_datetime_tz(cls, v: object) -> object:
def validate_fecha_cesion_dt(cls, v: object) -> object:
if isinstance(v, datetime):
tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ)
validate_cesion_fecha(v, cls.DATETIME_FIELDS_TZ)
return v


Expand Down Expand Up @@ -511,6 +556,20 @@ def validate_monto_cedido_does_not_exceed_dte_monto_total(

return values

@pydantic.root_validator(skip_on_failure=True)
def validate_fecha_ultimo_vencimiento_is_consistent_with_dte(
cls, values: Mapping[str, object],
) -> Mapping[str, object]:
fecha_ultimo_vencimiento = values['fecha_ultimo_vencimiento']
dte_fecha_emision = values['dte_fecha_emision']

if isinstance(fecha_ultimo_vencimiento, date) and isinstance(dte_fecha_emision, date):
validate_cesion_fecha_ultimo_vencimiento_is_consistent_with_dte(
cesion_value=fecha_ultimo_vencimiento, dte_value=dte_fecha_emision
)

return values


@pydantic.dataclasses.dataclass(
frozen=True,
Expand Down Expand Up @@ -674,12 +733,13 @@ def as_dte_data_l2(self) -> dte_data_models.DteDataL2:

# TODO: Validate value of 'fecha_firma_dt' in relation to the DTE data.

# TODO: Validate value of 'fecha_ultimo_vencimiento' in relation to the DTE data.
@pydantic.validator('fecha_cesion_dt')
def validate_fecha_cesion_dt(cls, v: object) -> object:
if isinstance(v, datetime):
validate_cesion_fecha(v, cls.DATETIME_FIELDS_TZ)
return v

@pydantic.validator(
'fecha_cesion_dt',
'fecha_firma_dt',
)
@pydantic.validator('fecha_firma_dt')
def validate_datetime_tz(cls, v: object) -> object:
if isinstance(v, datetime):
tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ)
Expand Down Expand Up @@ -716,3 +776,17 @@ def validate_dte_data_l2(cls, values: Mapping[str, Any]) -> Mapping[str, object]
raise

return values

@pydantic.root_validator(skip_on_failure=True)
def validate_fecha_ultimo_vencimiento_is_consistent_with_dte(
cls, values: Mapping[str, object],
) -> Mapping[str, object]:
fecha_ultimo_vencimiento = values['fecha_ultimo_vencimiento']
dte_fecha_emision = values['dte_fecha_emision']

if isinstance(fecha_ultimo_vencimiento, date) and isinstance(dte_fecha_emision, date):
validate_cesion_fecha_ultimo_vencimiento_is_consistent_with_dte(
cesion_value=fecha_ultimo_vencimiento, dte_value=dte_fecha_emision
)

return values
30 changes: 27 additions & 3 deletions cl_sii/rtc/data_models_aec.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,9 @@ def validate_contribuyente_razon_social(cls, v: object) -> object:
return v

@pydantic.validator('fecha_cesion_dt')
def validate_datetime_tz(cls, v: object) -> object:
def validate_fecha_cesion_dt(cls, v: object) -> object:
if isinstance(v, datetime):
tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ)
data_models.validate_cesion_fecha(v, cls.DATETIME_FIELDS_TZ)
return v

@pydantic.root_validator(skip_on_failure=True)
Expand Down Expand Up @@ -367,7 +367,9 @@ def validate_fecha_ultimo_vencimiento_is_consistent_with_dte(
isinstance(fecha_ultimo_vencimiento, date)
and isinstance(dte, dte_data_models.DteDataL1)
):
pass # TODO: Validate value of 'fecha_ultimo_vencimiento' in relation to the DTE data.
data_models.validate_cesion_fecha_ultimo_vencimiento_is_consistent_with_dte(
cesion_value=fecha_ultimo_vencimiento, dte_value=dte.fecha_emision_date
)

return values

Expand Down Expand Up @@ -776,6 +778,28 @@ def validate_last_cesion_matches_some_fields(

return values

@pydantic.root_validator(skip_on_failure=True)
def validate_cesiones_rut_cedente_match_previous_rut_cesionario_or_dte_emisor(
cls, values: Mapping[str, object],
) -> Mapping[str, object]:
dte = values['dte']
cesiones = values['cesiones']

if isinstance(dte, dte_data_models.DteXmlData) and isinstance(cesiones, Sequence):
dte_l1 = dte.as_dte_data_l1()
valid_cedente_rut = dte_l1.emisor_rut
for cesion in cesiones:
if cesion.cedente_rut != valid_cedente_rut:
raise ValueError(
f"'cedente_rut' of 'cesion' must match previous 'cesionario_rut'"
f" or DTE\'s 'emisor_rut' if there is no previuos 'cesion'"
f" {cesion.cedente_rut!r} != {valid_cedente_rut!r}.",
)

valid_cedente_rut = cesion.cesionario_rut

return values

# @pydantic.root_validator
# def validate_signature_value_and_signature_x509_cert_der_may_only_be_none_together(
# cls, values: Mapping[str, object],
Expand Down
62 changes: 60 additions & 2 deletions tests/test_rtc_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import dataclasses
import unittest
from datetime import date, datetime
from datetime import date, datetime, timedelta
from unittest.mock import patch

import pydantic

Expand Down Expand Up @@ -223,7 +224,7 @@ def test_validate_dte_tipo_dte(self) -> None:
validation_errors = assert_raises_cm.exception.errors()
self.assertIn(expected_validation_error, validation_errors)

def test_validate_datetime_tz(self) -> None:
def test_validate_fecha_cesion_dt(self) -> None:
self._set_obj_1()

obj = self.obj_1
Expand Down Expand Up @@ -269,6 +270,37 @@ def test_validate_datetime_tz(self) -> None:
validation_errors = assert_raises_cm.exception.errors()
self.assertIn(expected_validation_error, validation_errors)

# Test value constraints:

today_tz_aware = tz_utils.get_now_tz_aware().astimezone(
CesionAltNaturalKey.DATETIME_FIELDS_TZ
).replace(microsecond=0)

tomorrow_tz_aware = today_tz_aware + timedelta(days=1)

expected_validation_error = {
'loc': ('fecha_cesion_dt',),
'msg':
'('
'''\'Value of "fecha_cesion_dt" must be before or equal to the current day.\','''
f' {repr(tomorrow_tz_aware)},'
f' {repr(today_tz_aware)}'
')',
'type': 'value_error',
}

with patch('cl_sii.libs.tz_utils.get_now_tz_aware') as mock_get_now_tz_aware:
with self.assertRaises(pydantic.ValidationError) as assert_raises_cm:
mock_get_now_tz_aware.return_value = today_tz_aware
dataclasses.replace(
obj,
fecha_cesion_dt=tomorrow_tz_aware,
)
mock_get_now_tz_aware.get_now_tz_aware.assert_called_once()

validation_errors = assert_raises_cm.exception.errors()
self.assertIn(expected_validation_error, validation_errors)

def test_truncate_fecha_cesion_dt_to_minutes(self) -> None:
self._set_obj_1()

Expand Down Expand Up @@ -698,6 +730,32 @@ def test_validate_monto_cedido_does_not_exceed_dte_monto_total(self) -> None:
for expected_validation_error in expected_validation_errors:
self.assertIn(expected_validation_error, validation_errors)

def test_validate_fecha_ultimo_vencimiento_is_not_before_dte_fecha_emision(self) -> None:
self._set_obj_1()

obj = self.obj_1
expected_validation_errors = [
{
'loc': ('__root__',),
'msg':
"""('Value of "cesión" must be >= value of DTE.',"""
" datetime.date(2019, 5, 1), datetime.date(2019, 5, 2))",
'type': 'value_error',
},
]

with self.assertRaises(pydantic.ValidationError) as assert_raises_cm:
dataclasses.replace(
obj,
fecha_ultimo_vencimiento=date(2019, 5, 1),
dte_fecha_emision=date(2019, 5, 2),
)

validation_errors = assert_raises_cm.exception.errors()
self.assertEqual(len(validation_errors), len(expected_validation_errors))
for expected_validation_error in expected_validation_errors:
self.assertIn(expected_validation_error, validation_errors)


class CesionL2Test(CesionL1Test):
"""
Expand Down
71 changes: 71 additions & 0 deletions tests/test_rtc_data_models_aec.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,3 +678,74 @@ def test_validate_last_cesion_matches_some_fields(self) -> None:
self.assertEqual(len(validation_errors), len(expected_validation_errors))
for expected_validation_error in expected_validation_errors:
self.assertIn(expected_validation_error, validation_errors)

def test_validate_cedente_rut_matches_emisor_rut_in_cesion_seq_1(self) -> None:
self._set_obj_1()

obj = self.obj_1

expected_validation_errors = [
{
'loc': ('__root__',),
'msg':
"'cedente_rut' of 'cesion' must match previous 'cesionario_rut'"
" or DTE\'s 'emisor_rut' if there is no previuos 'cesion'"
" Rut('76389992-6')"
" !="
" Rut('76354771-K').", # DTE's emisor RUT
'type': 'value_error',
},
]

with self.assertRaises(pydantic.ValidationError) as assert_raises_cm:
dataclasses.replace(
obj,
cesiones=[
dataclasses.replace(
obj.cesiones[0],
cedente_rut=obj.cesiones[0].cesionario_rut,
),
obj.cesiones[1],
],
)

validation_errors = assert_raises_cm.exception.errors()
self.assertEqual(len(validation_errors), len(expected_validation_errors))
for expected_validation_error in expected_validation_errors:
self.assertIn(expected_validation_error, validation_errors)

def test_validate_cedente_rut_matches_cesionario_rut_in_cesion_seq_2(self) -> None:
self._set_obj_1()

obj = self.obj_1

expected_validation_errors = [
{
'loc': ('__root__',),
'msg':
"'cedente_rut' of 'cesion' must match previous 'cesionario_rut'"
" or DTE\'s 'emisor_rut' if there is no previuos 'cesion'"
" Rut('76598556-0')"
" !="
" Rut('76389992-6').", # RUT of the 'cesionario' of the 'cesion' sequence 1
'type': 'value_error',
},
]

with self.assertRaises(pydantic.ValidationError) as assert_raises_cm:
dataclasses.replace(
obj,
cedente_rut=obj.cesiones[1].cesionario_rut, # To skip previous validation
cesiones=[
obj.cesiones[0],
dataclasses.replace(
obj.cesiones[1],
cedente_rut=obj.cesiones[1].cesionario_rut,
),
],
)

validation_errors = assert_raises_cm.exception.errors()
self.assertEqual(len(validation_errors), len(expected_validation_errors))
for expected_validation_error in expected_validation_errors:
self.assertIn(expected_validation_error, validation_errors)