From fc5c11511c7421fd3537144c024acb48aea7520f Mon Sep 17 00:00:00 2001 From: Yasel Couce Date: Wed, 3 Mar 2021 21:23:08 -0300 Subject: [PATCH 1/3] rtc.data_models_aec: add validation for 'cedente_rut' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'cedente_rut' should be the DTE's 'emisor_rut' or the last 'cesionario_rut' when the sequence is greater than 2 Condition sourced from document "Instructivo Técnico Registro Público Electrónico de Transferencia de Crédito" Source: (https://github.com/cl-sii-extraoficial/archivos-oficiales/blob/master/src/docs/rtc/2013-02-11-instructivo-tecnico.pdf) --- cl_sii/rtc/data_models_aec.py | 22 ++++++++++ tests/test_rtc_data_models_aec.py | 71 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/cl_sii/rtc/data_models_aec.py b/cl_sii/rtc/data_models_aec.py index 62b6d4a6..70a983f9 100644 --- a/cl_sii/rtc/data_models_aec.py +++ b/cl_sii/rtc/data_models_aec.py @@ -776,6 +776,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], diff --git a/tests/test_rtc_data_models_aec.py b/tests/test_rtc_data_models_aec.py index d469371d..4d25f65b 100644 --- a/tests/test_rtc_data_models_aec.py +++ b/tests/test_rtc_data_models_aec.py @@ -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) From 8f2d3f13a12f97539ba20e0d3e52f694e580fff7 Mon Sep 17 00:00:00 2001 From: Yasel Couce Date: Fri, 5 Mar 2021 17:19:58 -0300 Subject: [PATCH 2/3] rtc.data_models: validates 'fecha_cesion_dt' is before or equal to the current day --- cl_sii/rtc/data_models.py | 47 ++++++++++++++++++++++++++++------- cl_sii/rtc/data_models_aec.py | 4 +-- tests/test_rtc_data_models.py | 36 +++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/cl_sii/rtc/data_models.py b/cl_sii/rtc/data_models.py index bb5241b2..951d9548 100644 --- a/cl_sii/rtc/data_models.py +++ b/cl_sii/rtc/data_models.py @@ -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) + + 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". @@ -245,9 +264,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) + validate_cesion_fecha(v, cls.DATETIME_FIELDS_TZ) return v @pydantic.validator('fecha_cesion_dt') @@ -321,13 +340,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]: @@ -392,9 +418,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 @@ -676,10 +702,13 @@ def as_dte_data_l2(self) -> dte_data_models.DteDataL2: # TODO: Validate value of 'fecha_ultimo_vencimiento' in relation to the DTE data. - @pydantic.validator( - 'fecha_cesion_dt', - 'fecha_firma_dt', - ) + @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_firma_dt') def validate_datetime_tz(cls, v: object) -> object: if isinstance(v, datetime): tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ) diff --git a/cl_sii/rtc/data_models_aec.py b/cl_sii/rtc/data_models_aec.py index 70a983f9..13a32493 100644 --- a/cl_sii/rtc/data_models_aec.py +++ b/cl_sii/rtc/data_models_aec.py @@ -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) diff --git a/tests/test_rtc_data_models.py b/tests/test_rtc_data_models.py index f5e36807..5d7e47ce 100644 --- a/tests/test_rtc_data_models.py +++ b/tests/test_rtc_data_models.py @@ -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 @@ -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 @@ -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() From b1ca3c7782d17e98ab3812e1da79e5fbe983eef0 Mon Sep 17 00:00:00 2001 From: Yasel Couce Date: Fri, 5 Mar 2021 21:21:06 -0300 Subject: [PATCH 3/3] rtc.data_models: validate 'fecha_ultimo_vencimiento' is consistent with dte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate 'fecha_ultimo_vencimiento' of the "cesión" is after or equal to 'fecha_emision' of the DTE. Source: (https://github.com/cl-sii-extraoficial/archivos-oficiales/blob/master/src/docs/rtc/2013-02-11-instructivo-tecnico.pdf) --- cl_sii/rtc/data_models.py | 49 +++++++++++++++++++++++++++++++++-- cl_sii/rtc/data_models_aec.py | 4 ++- tests/test_rtc_data_models.py | 26 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/cl_sii/rtc/data_models.py b/cl_sii/rtc/data_models.py index 951d9548..cc178c36 100644 --- a/cl_sii/rtc/data_models.py +++ b/cl_sii/rtc/data_models.py @@ -99,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 + 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( @@ -537,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, @@ -700,8 +733,6 @@ 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): @@ -745,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 diff --git a/cl_sii/rtc/data_models_aec.py b/cl_sii/rtc/data_models_aec.py index 13a32493..eca091bd 100644 --- a/cl_sii/rtc/data_models_aec.py +++ b/cl_sii/rtc/data_models_aec.py @@ -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 diff --git a/tests/test_rtc_data_models.py b/tests/test_rtc_data_models.py index 5d7e47ce..baf91417 100644 --- a/tests/test_rtc_data_models.py +++ b/tests/test_rtc_data_models.py @@ -730,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): """