From eb18550a3296ab89ff2724ada0199d1e250f5b74 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Tue, 12 Jan 2021 20:18:07 -0300 Subject: [PATCH 1/4] =?UTF-8?q?rtc.data=5Fmodels:=20Add=20"cesi=C3=B3n"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `CesionL0` class. - Add `CesionL1` class. - Add `CesionL2` class. - Add tests. --- cl_sii/rtc/data_models.py | 462 +++++++++++++++++++++++- tests/test_rtc_data_models.py | 662 +++++++++++++++++++++++++++++++++- 2 files changed, 1120 insertions(+), 4 deletions(-) diff --git a/cl_sii/rtc/data_models.py b/cl_sii/rtc/data_models.py index be7af2af..8c5e06b3 100644 --- a/cl_sii/rtc/data_models.py +++ b/cl_sii/rtc/data_models.py @@ -20,8 +20,8 @@ from __future__ import annotations import dataclasses -from datetime import datetime -from typing import ClassVar, Mapping +from datetime import date, datetime +from typing import Any, ClassVar, Mapping, Optional import pydantic @@ -47,6 +47,19 @@ def validate_cesion_seq(value: int) -> None: raise ValueError("Value is out of the valid range.", value) +def validate_cesion_monto(value: int) -> None: + """ + Validate amount of the "cesión". + + :raises ValueError: + """ + if ( + value < constants.CESION_MONTO_CEDIDO_FIELD_MIN_VALUE + or value > constants.CESION_MONTO_CEDIDO_FIELD_MAX_VALUE + ): + raise ValueError("Value is out of the valid range.", value) + + def validate_cesion_dte_tipo_dte(value: TipoDteEnum) -> None: """ Validate "tipo DTE" of the "cesión". @@ -57,6 +70,16 @@ def validate_cesion_dte_tipo_dte(value: TipoDteEnum) -> None: raise ValueError('Value is not "cedible".', value) +def validate_cesion_and_dte_montos(cesion_value: int, dte_value: int) -> None: + """ + Validate amounts of the "cesión" and its associated DTE. + + :raises ValueError: + """ + 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( @@ -235,3 +258,438 @@ def truncate_fecha_cesion_dt_to_minutes(cls, v: object) -> object: if v.microsecond != 0: v = v.replace(microsecond=0) return v + + +@pydantic.dataclasses.dataclass( + frozen=True, + config=type('Config', (), dict( + arbitrary_types_allowed=True, + )) +) +class CesionL0: + """ + Data of a "cesión" (level 0). + + Its fields are enough to uniquely identify a "cesión" but nothing more. + + The class instances are immutable. + """ + + ########################################################################### + # Constants + ########################################################################### + + DATETIME_FIELDS_TZ: ClassVar[tz_utils.PytzTimezone] = SII_OFFICIAL_TZ + + ########################################################################### + # Fields + ########################################################################### + + dte_key: dte_data_models.DteNaturalKey + """ + Natural key of the "cesión"'s DTE. + """ + + seq: Optional[int] + """ + Sequence number of the "cesión". Must be >= 1. + """ + + cedente_rut: Rut + """ + RUT of the "cedente". + """ + + cesionario_rut: Rut + """ + RUT of the "cesionario". + """ + + fecha_cesion_dt: datetime + """ + Date and time when the "cesión" happened. + + .. note:: + - This is the timestamp of when the "cesión"'s AEC was digitally signed + (AEC XML document XPath: ``/AEC/DocumentoAEC/Caratula/TmstFirmaEnvio``). + - Same timestamp as the last ``Cesion`` element of AEC XPath + ``/AEC/DocumentoAEC/Cesiones/Cesion/DocumentoCesion/TmstCesion``. + - Same timestamp as RPETC email's ``Cesion`` / ``Fecha de la Cesion``. + - NOT the same timestamp as RPETC email's ``Fecha de Recepcion``. + - Almost the same timestamp as "Cesiones Periodo"'s ``FCH_CESION``, + (AEC's has seconds, "Cesiones Periodo"'s is truncated to the minute). + - 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)``. + + .. 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. + """ + + @property + def natural_key(self) -> Optional[CesionNaturalKey]: + if self.seq is not None: + return CesionNaturalKey( + dte_key=self.dte_key, + seq=self.seq, + ) + else: + return None + + @property + def alt_natural_key(self) -> CesionAltNaturalKey: + return CesionAltNaturalKey( + dte_key=self.dte_key, + cedente_rut=self.cedente_rut, + cesionario_rut=self.cesionario_rut, + fecha_cesion_dt=self.fecha_cesion_dt, + ) + + @property + def slug(self) -> str: + """ + Return an slug representation (that preserves uniquess) of the instance. + """ + # Note: Based on 'cl_sii.dte.data_models.DteNaturalKey.slug'. + return self.alt_natural_key.slug + + @property + def dte_emisor_rut(self) -> Rut: + return self.dte_key.emisor_rut + + @property + def dte_tipo_dte(self) -> TipoDteEnum: + return self.dte_key.tipo_dte + + @property + def dte_folio(self) -> int: + return self.dte_key.folio + + ########################################################################### + # Custom Methods + ########################################################################### + + def as_dict(self) -> Mapping[str, object]: + return dataclasses.asdict(self) + + ########################################################################### + # Validators + ########################################################################### + + @pydantic.validator('dte_key') + def validate_dte_tipo_dte(cls, v: object) -> object: + if isinstance(v, dte_data_models.DteNaturalKey): + validate_cesion_dte_tipo_dte(v.tipo_dte) + return v + + @pydantic.validator('seq') + def validate_seq(cls, v: object) -> object: + if isinstance(v, int): + validate_cesion_seq(v) + return v + + @pydantic.validator('fecha_cesion_dt') + def validate_datetime_tz(cls, v: object) -> object: + if isinstance(v, datetime): + tz_utils.validate_dt_tz(v, cls.DATETIME_FIELDS_TZ) + return v + + +@pydantic.dataclasses.dataclass( + frozen=True, + config=type('Config', (), dict( + arbitrary_types_allowed=True, + )) +) +class CesionL1(CesionL0): + """ + Data of a "cesión" (level 1). + + It is the minimal set of "cesión" data fields that are useful. + TODO: Explain why these fields were chosen as "minimal". + + The class instances are immutable. + """ + + ########################################################################### + # Fields + ########################################################################### + + monto_cedido: int + """ + Amount of the "cesión". + """ + + fecha_ultimo_vencimiento: date + """ + Date of "Ultimo Vencimiento". + """ + + dte_fecha_emision: date + """ + Field 'fecha_emision' of the DTE. + + .. warning:: It may not match the **real date** on which the DTE was issued + or received/processed by SII. + """ + + dte_receptor_rut: Rut + """ + RUT of the "receptor" of the DTE. + """ + + dte_monto_total: int + """ + Total amount of the DTE. + """ + + @property + def dte_vendedor_rut(self) -> Rut: + """ + Return the RUT of the DTE's "vendedor". + + :raises ValueError: + """ + return self.as_dte_data_l1().vendedor_rut + + @property + def dte_deudor_rut(self) -> Rut: + """ + Return the RUT of the DTE's "deudor". + + :raises ValueError: + """ + return self.as_dte_data_l1().deudor_rut + + ########################################################################### + # Custom Methods + ########################################################################### + + def as_dte_data_l1(self) -> dte_data_models.DteDataL1: + return dte_data_models.DteDataL1( + emisor_rut=self.dte_key.emisor_rut, + tipo_dte=self.dte_key.tipo_dte, + folio=self.dte_key.folio, + fecha_emision_date=self.dte_fecha_emision, + receptor_rut=self.dte_receptor_rut, + monto_total=self.dte_monto_total, + ) + + ########################################################################### + # Validators + ########################################################################### + + # TODO: Validate value of 'fecha_cesion_dt' in relation to the DTE data. + + @pydantic.validator('monto_cedido') + def validate_monto_cedido(cls, v: object) -> object: + if isinstance(v, int): + validate_cesion_monto(v) + return v + + @pydantic.root_validator(skip_on_failure=True) + def validate_monto_cedido_does_not_exceed_dte_monto_total( + cls, values: Mapping[str, object], + ) -> Mapping[str, object]: + monto_cedido = values['monto_cedido'] + dte_monto_total = values['dte_monto_total'] + + if isinstance(monto_cedido, int) and isinstance(dte_monto_total, int): + validate_cesion_and_dte_montos(cesion_value=monto_cedido, dte_value=dte_monto_total) + + return values + + +@pydantic.dataclasses.dataclass( + frozen=True, + config=type('Config', (), dict( + anystr_strip_whitespace=True, + arbitrary_types_allowed=True, + min_anystr_length=1, + )) +) +class CesionL2(CesionL1): + """ + Data of a "cesión" (level 2). + + The class instances are immutable. + """ + + ########################################################################### + # Fields + ########################################################################### + + fecha_firma_dt: Optional[datetime] = None + """ + Datetime of 'Firma del Archivo de Transferencias' + + .. warning:: It is not equal to the datetime on which the SII received/processed the "cesión". + """ + + cedente_razon_social: Optional[str] = dataclasses.field(default=None, repr=False) + """ + "Razón social" (legal name) of the "cedente". + """ + + cesionario_razon_social: Optional[str] = dataclasses.field(default=None, repr=False) + """ + "Razón social" (legal name) of the "cesionario". + """ + + cedente_email: Optional[str] = dataclasses.field(default=None, repr=False) + """ + Email address of the "cedente". + + .. warning:: Value may be an invalid email address. + """ + + cesionario_email: Optional[str] = dataclasses.field(default=None, repr=False) + """ + Email address of the "cesionario". + + .. warning:: Value may be an invalid email address. + """ + + dte_emisor_razon_social: Optional[str] = dataclasses.field(default=None, repr=False) + """ + "Razón social" (legal name) of the "emisor" of the DTE. + """ + + # dte_emisor_email: str + # """ + # Email address of the "emisor" of the DTE. + # + # .. warning:: Value may be an invalid email address. + # """ + + dte_receptor_razon_social: Optional[str] = dataclasses.field(default=None, repr=False) + """ + "Razón social" (legal name) of the "receptor" of the DTE. + """ + + # dte_receptor_email: str + # """ + # Email address of the "receptor" of the DTE. + # + # .. warning:: Value may be an invalid email address. + # """ + + dte_deudor_email: Optional[str] = dataclasses.field(default=None, repr=False) + """ + Email address of the "deudor" of the DTE. + + .. warning:: Value may be an invalid email address. + """ + + cedente_declaracion_jurada: Optional[str] = dataclasses.field(default=None, repr=False) + """ + "Declaración Jurada" by the "cedente". + + > Declaracion Jurada de Disponibilidad de Documentacion No Electronica. + + .. note:: + The RUT and "razón social" of the "deudor" of the DTE are included + in the text. However, this field is optional. + + Example: + "Se declara bajo juramento que + COMERCIALIZADORA INNOVA MOBEL SPA, RUT 76399752-9 + ha puesto a disposición del cesionario + ST CAPITAL S.A., RUT 76389992-6, + el o los documentos donde constan los recibos de las mercaderías + entregadas o servicios prestados, entregados por parte del deudor + de la factura + EMPRESAS LA POLAR S.A., RUT 96874030-K, + deacuerdo a lo establecido en la Ley N°19.983." + """ + + dte_fecha_vencimiento: Optional[date] = None + """ + "Fecha de vencimiento (pago)" of the DTE. + """ + + contacto_nombre: Optional[str] = None + """ + Name of the contact person. + + > Persona de Contacto para aclarar dudas. + """ + + contacto_telefono: Optional[str] = dataclasses.field(default=None, repr=False) + """ + Phone number of the contact person. + """ + + contacto_email: Optional[str] = dataclasses.field(default=None, repr=False) + """ + Email address of the contact person. + """ + + ########################################################################### + # Custom Methods + ########################################################################### + + def as_dte_data_l2(self) -> dte_data_models.DteDataL2: + return dte_data_models.DteDataL2( + emisor_rut=self.dte_key.emisor_rut, + tipo_dte=self.dte_key.tipo_dte, + folio=self.dte_key.folio, + fecha_emision_date=self.dte_fecha_emision, + receptor_rut=self.dte_receptor_rut, + monto_total=self.dte_monto_total, + emisor_razon_social=self.dte_emisor_razon_social, + receptor_razon_social=self.dte_receptor_razon_social, + fecha_vencimiento_date=self.dte_fecha_vencimiento, + ) + + ########################################################################### + # Validators + ########################################################################### + + # 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', + '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) + return v + + @pydantic.validator( + 'cedente_razon_social', + 'cesionario_razon_social', + 'dte_emisor_razon_social', + 'dte_receptor_razon_social', + ) + def validate_contribuyente_razon_social(cls, v: object) -> object: + if isinstance(v, str): + dte_data_models.validate_contribuyente_razon_social(v) + return v + + @pydantic.root_validator(skip_on_failure=True) + def validate_dte_data_l2(cls, values: Mapping[str, Any]) -> Mapping[str, object]: + dte_key = values['dte_key'] + try: + # Note: Delegate some validation to 'dte_data_models.DteDataL2'. + _ = dte_data_models.DteDataL2( + emisor_rut=dte_key.emisor_rut if dte_key is not None else None, + tipo_dte=dte_key.tipo_dte if dte_key is not None else None, + folio=dte_key.folio if dte_key is not None else None, + fecha_emision_date=values['dte_fecha_emision'], # type: ignore[arg-type] + receptor_rut=values['dte_receptor_rut'], # type: ignore[arg-type] + monto_total=values['dte_monto_total'], # type: ignore[arg-type] + emisor_razon_social=values['dte_emisor_razon_social'], + receptor_razon_social=values['dte_receptor_razon_social'], + fecha_vencimiento_date=values['dte_fecha_vencimiento'], + ) + except (TypeError, ValueError): + raise + + return values diff --git a/tests/test_rtc_data_models.py b/tests/test_rtc_data_models.py index eab7ba72..db53903c 100644 --- a/tests/test_rtc_data_models.py +++ b/tests/test_rtc_data_models.py @@ -2,16 +2,19 @@ import dataclasses import unittest -from datetime import datetime +from datetime import date, datetime import pydantic -from cl_sii.dte.data_models import DteNaturalKey +from cl_sii.dte.data_models import DteNaturalKey, DteDataL1, DteDataL2 from cl_sii.dte.constants import TipoDteEnum from cl_sii.libs import tz_utils from cl_sii.rtc.data_models import ( CesionNaturalKey, CesionAltNaturalKey, + CesionL0, + CesionL1, + CesionL2, ) from cl_sii.rut import Rut @@ -291,3 +294,658 @@ def test_truncate_fecha_cesion_dt_to_minutes(self) -> None: self.assertEqual(obj_with_microseconds.fecha_cesion_dt, expected_fecha_cesion_dt) self.assertEqual(obj_with_datetime_truncated.fecha_cesion_dt, expected_fecha_cesion_dt) self.assertEqual(obj_with_microseconds, obj_with_datetime_truncated) + + +class CesionL0Test(unittest.TestCase): + """ + Tests for :class:`CesionL0`. + """ + + def _set_obj_1(self) -> None: + obj_dte_natural_key = DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ) + + obj = CesionL0( + dte_key=obj_dte_natural_key, + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=CesionL0.DATETIME_FIELDS_TZ, + ), + ) + self.assertIsInstance(obj, CesionL0) + + self.obj_1_dte_natural_key = obj_dte_natural_key + self.obj_1 = obj + + def test_create_new_empty_instance(self) -> None: + with self.assertRaises(TypeError): + CesionL0() + + def test_str_and_repr(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = ( + "CesionL0(" + "dte_key=DteNaturalKey(" + "emisor_rut=Rut('76354771-K')," + " tipo_dte=," + " folio=170" + ")," + " seq=32," + " cedente_rut=Rut('76389992-6')," + " cesionario_rut=Rut('76598556-0')," + " fecha_cesion_dt=datetime.datetime(" + "2019, 4, 5, 12, 57, 32," + " tzinfo=" + ")" + ")" + ) + self.assertEqual(str(obj), expected_output) + self.assertEqual(repr(obj), expected_output) + + def test_as_dict(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = dict( + dte_key=dict( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ), + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=datetime.fromisoformat('2019-04-05T12:57:32-03:00'), + ) + self.assertEqual(obj.as_dict(), expected_output) + + def test_slug(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = '76354771-K--33--170--76389992-6--76598556-0--2019-04-05T12:57-03:00' + self.assertEqual(obj.slug, expected_output) + + def test_natural_key(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = CesionNaturalKey( + dte_key=DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ), + seq=32, + ) + self.assertEqual(obj.natural_key, expected_output) + + obj_without_seq = dataclasses.replace( + obj, + seq=None, + ) + self.assertIsNone(obj_without_seq.natural_key) + + def test_alt_natural_key(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = CesionAltNaturalKey( + dte_key=DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ), + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57), + tz=CesionL0.DATETIME_FIELDS_TZ, + ), + ) + self.assertEqual(obj.alt_natural_key, expected_output) + + obj_without_seq = dataclasses.replace( + obj, + seq=None, + ) + self.assertEqual(obj_without_seq.alt_natural_key, expected_output) + + def test_validate_dte_tipo_dte(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_validation_errors = [ + { + 'loc': ('dte_key',), + 'msg': + """('Value is not "cedible".', )""", + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + dte_key=dataclasses.replace( + obj.dte_key, + tipo_dte=TipoDteEnum.NOTA_CREDITO_ELECTRONICA, + ), + ) + + 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_seq(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + test_values = [-1, 0, 41, 1000] + + for test_value in test_values: + expected_validation_errors = [ + { + 'loc': ('seq',), + 'msg': f"""('Value is out of the valid range.', {test_value})""", + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + seq=test_value, + ) + + 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_datetime_tz(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + + # Test TZ-awareness: + + expected_validation_errors = [ + { + 'loc': ('fecha_cesion_dt',), + 'msg': 'Value must be a timezone-aware datetime object.', + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + fecha_cesion_dt=datetime(2019, 4, 5, 12, 57, 32), + ) + + 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) + + # Test TZ-value: + + expected_validation_errors = [ + { + 'loc': ('fecha_cesion_dt',), + 'msg': + '(' + '''"Timezone of datetime value must be 'America/Santiago'.",''' + ' datetime.datetime(2019, 4, 5, 12, 57, 32, tzinfo=)' + ')', + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=tz_utils.TZ_UTC, + ), + ) + + 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 CesionL1Test(CesionL0Test): + """ + Tests for :class:`CesionL1`. + """ + + maxDiff = None # FIXME + + def _set_obj_1(self) -> None: + obj_dte_natural_key = DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ) + + obj = CesionL1( + dte_key=obj_dte_natural_key, + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=CesionL1.DATETIME_FIELDS_TZ, + ), + monto_cedido=2996301, + fecha_ultimo_vencimiento=date(2019, 5, 1), + dte_fecha_emision=date(2019, 4, 1), + dte_receptor_rut=Rut('96790240-3'), + dte_monto_total=2996301, + ) + self.assertIsInstance(obj, CesionL1) + + self.obj_1_dte_natural_key = obj_dte_natural_key + self.obj_1 = obj + + def test_create_new_empty_instance(self) -> None: + with self.assertRaises(TypeError): + CesionL1() + + def test_str_and_repr(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = ( + "CesionL1(" + "dte_key=DteNaturalKey(" + "emisor_rut=Rut('76354771-K')," + " tipo_dte=," + " folio=170" + ")," + " seq=32," + " cedente_rut=Rut('76389992-6')," + " cesionario_rut=Rut('76598556-0')," + " fecha_cesion_dt=datetime.datetime(" + "2019, 4, 5, 12, 57, 32," + " tzinfo=" + ")," + " monto_cedido=2996301," + " fecha_ultimo_vencimiento=datetime.date(2019, 5, 1)," + " dte_fecha_emision=datetime.date(2019, 4, 1)," + " dte_receptor_rut=Rut('96790240-3')," + " dte_monto_total=2996301" + ")" + ) + self.assertEqual(str(obj), expected_output) + self.assertEqual(repr(obj), expected_output) + + def test_as_dict(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = dict( + dte_key=dict( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ), + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=datetime.fromisoformat('2019-04-05T12:57:32-03:00'), + monto_cedido=2996301, + fecha_ultimo_vencimiento=date(2019, 5, 1), + dte_fecha_emision=date(2019, 4, 1), + dte_receptor_rut=Rut('96790240-3'), + dte_monto_total=2996301, + ) + self.assertEqual(obj.as_dict(), expected_output) + + def test_as_dte_data_l1(self): + self._set_obj_1() + + obj = self.obj_1 + expected_output = DteDataL1( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + fecha_emision_date=date(2019, 4, 1), + receptor_rut=Rut('96790240-3'), + monto_total=2996301, + ) + self.assertEqual(obj.as_dte_data_l1(), expected_output) + + def test_validate_monto_cedido(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + test_values = [-1, 10 ** 18 + 1] + + for test_value in test_values: + expected_validation_errors = [ + { + 'loc': ('monto_cedido',), + 'msg': f"""('Value is out of the valid range.', {test_value})""", + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + monto_cedido=test_value, + ) + + 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_monto_cedido_does_not_exceed_dte_monto_total(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.', 1000, 999)""", + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + monto_cedido=1000, + dte_monto_total=999, + ) + + 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): + """ + Tests for :class:`CesionL2`. + """ + + maxDiff = None # FIXME + + def _set_obj_1(self) -> None: + obj_dte_natural_key = DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ) + + obj = CesionL2( + dte_key=obj_dte_natural_key, + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=CesionL2.DATETIME_FIELDS_TZ, + ), + monto_cedido=2996301, + fecha_ultimo_vencimiento=date(2019, 5, 1), + dte_fecha_emision=date(2019, 4, 1), + dte_receptor_rut=Rut('96790240-3'), + dte_monto_total=2996301, + fecha_firma_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=CesionL2.DATETIME_FIELDS_TZ, + ), + cedente_razon_social='ST CAPITAL S.A.', + cesionario_razon_social='Fondo de Inversión Privado Deuda y Facturas', + cedente_email='APrat@Financiaenlinea.com', + cesionario_email='solicitudes@stcapital.cl', + dte_emisor_razon_social='INGENIERIA ENACON SPA', + dte_receptor_razon_social='MINERA LOS PELAMBRES', + dte_deudor_email=None, + cedente_declaracion_jurada=( + 'Se declara bajo juramento que ST CAPITAL S.A., RUT 76389992-6 ha puesto ' + 'a disposicion del cesionario Fondo de Inversión Privado Deuda y Facturas, ' + 'RUT 76598556-0, el documento validamente emitido al deudor MINERA LOS ' + 'PELAMBRES, RUT 96790240-3.' + ), + dte_fecha_vencimiento=None, + contacto_nombre='ST Capital Servicios Financieros', + contacto_telefono=None, + contacto_email='APrat@Financiaenlinea.com', + ) + self.assertIsInstance(obj, CesionL2) + + self.obj_1_dte_natural_key = obj_dte_natural_key + self.obj_1 = obj + + def test_create_new_empty_instance(self) -> None: + with self.assertRaises(TypeError): + CesionL2() + + def test_str_and_repr(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = ( + "CesionL2(" + "dte_key=DteNaturalKey(" + "emisor_rut=Rut('76354771-K')," + " tipo_dte=," + " folio=170" + ")," + " seq=32," + " cedente_rut=Rut('76389992-6')," + " cesionario_rut=Rut('76598556-0')," + " fecha_cesion_dt=datetime.datetime(" + "2019, 4, 5, 12, 57, 32," + " tzinfo=" + ")," + " monto_cedido=2996301," + " fecha_ultimo_vencimiento=datetime.date(2019, 5, 1)," + " dte_fecha_emision=datetime.date(2019, 4, 1)," + " dte_receptor_rut=Rut('96790240-3')," + " dte_monto_total=2996301," + " fecha_firma_dt=datetime.datetime(" + "2019, 4, 5, 12, 57, 32," + " tzinfo=" + ")," + " dte_fecha_vencimiento=None," + " contacto_nombre='ST Capital Servicios Financieros'" + ")" + ) + self.assertEqual(str(obj), expected_output) + self.assertEqual(repr(obj), expected_output) + + def test_as_dict(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = dict( + dte_key=dict( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ), + seq=32, + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=datetime.fromisoformat('2019-04-05T12:57:32-03:00'), + monto_cedido=2996301, + fecha_ultimo_vencimiento=date(2019, 5, 1), + dte_fecha_emision=date(2019, 4, 1), + dte_receptor_rut=Rut('96790240-3'), + dte_monto_total=2996301, + fecha_firma_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=CesionL2.DATETIME_FIELDS_TZ, + ), + cedente_razon_social='ST CAPITAL S.A.', + cesionario_razon_social='Fondo de Inversión Privado Deuda y Facturas', + cedente_email='APrat@Financiaenlinea.com', + cesionario_email='solicitudes@stcapital.cl', + dte_emisor_razon_social='INGENIERIA ENACON SPA', + dte_receptor_razon_social='MINERA LOS PELAMBRES', + dte_deudor_email=None, + cedente_declaracion_jurada=( + 'Se declara bajo juramento que ST CAPITAL S.A., RUT 76389992-6 ha puesto ' + 'a disposicion del cesionario Fondo de Inversión Privado Deuda y Facturas, ' + 'RUT 76598556-0, el documento validamente emitido al deudor MINERA LOS ' + 'PELAMBRES, RUT 96790240-3.' + ), + dte_fecha_vencimiento=None, + contacto_nombre='ST Capital Servicios Financieros', + contacto_telefono=None, + contacto_email='APrat@Financiaenlinea.com', + ) + self.assertEqual(obj.as_dict(), expected_output) + + def test_as_dte_data_l2(self): + self._set_obj_1() + + obj = self.obj_1 + expected_output = DteDataL2( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + fecha_emision_date=date(2019, 4, 1), + receptor_rut=Rut('96790240-3'), + monto_total=2996301, + emisor_razon_social='INGENIERIA ENACON SPA', + receptor_razon_social='MINERA LOS PELAMBRES', + fecha_vencimiento_date=None, + ) + self.assertEqual(obj.as_dte_data_l2(), expected_output) + + def test_validate_datetime_tz(self) -> None: + super().test_validate_datetime_tz() + + obj = self.obj_1 + + # Test TZ-awareness: + + expected_validation_errors = [ + { + 'loc': ('fecha_cesion_dt',), + 'msg': 'Value must be a timezone-aware datetime object.', + 'type': 'value_error', + }, + { + 'loc': ('fecha_firma_dt',), + 'msg': 'Value must be a timezone-aware datetime object.', + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + fecha_cesion_dt=datetime(2019, 4, 5, 12, 57, 32), + fecha_firma_dt=datetime(2019, 4, 5, 12, 57, 32), + ) + + 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) + + # Test TZ-value: + + expected_validation_errors = [ + { + 'loc': ('fecha_cesion_dt',), + 'msg': + '(' + '''"Timezone of datetime value must be 'America/Santiago'.",''' + ' datetime.datetime(2019, 4, 5, 12, 57, 32, tzinfo=)' + ')', + 'type': 'value_error', + }, + { + 'loc': ('fecha_firma_dt',), + 'msg': + '(' + '''"Timezone of datetime value must be 'America/Santiago'.",''' + ' datetime.datetime(2019, 4, 5, 12, 57, 32, tzinfo=)' + ')', + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=tz_utils.TZ_UTC, + ), + fecha_firma_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 5, 12, 57, 32), + tz=tz_utils.TZ_UTC, + ), + ) + + 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_contribuyente_razon_social(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_validation_errors = [ + { + 'loc': ('cedente_razon_social',), + 'msg': 'ensure this value has at least 1 characters', + 'type': 'value_error.any_str.min_length', + 'ctx': {'limit_value': 1}, + }, + { + 'loc': ('cesionario_razon_social',), + 'msg': 'Value exceeds max allowed length.', + 'type': 'value_error', + }, + { + 'loc': ('dte_emisor_razon_social',), + 'msg': 'ensure this value has at least 1 characters', + 'type': 'value_error.any_str.min_length', + 'ctx': {'limit_value': 1}, + }, + { + 'loc': ('dte_receptor_razon_social',), + 'msg': 'Value exceeds max allowed length.', + 'type': 'value_error', + }, + ] + + with self.assertRaises(pydantic.ValidationError) as assert_raises_cm: + dataclasses.replace( + obj, + cedente_razon_social='', + cesionario_razon_social='C' * 101, + dte_emisor_razon_social='', + dte_receptor_razon_social='R' * 200, + ) + + 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 0f33beb5815374757916b9eef0b810bac65b1c92 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Tue, 12 Jan 2021 20:37:39 -0300 Subject: [PATCH 2/4] rtc.data_models_cesiones_periodo: Create CesionL2 from Ces. Per. Entry Add a method to create a `CesionL2` from a `CesionesPeriodoEntry`. --- cl_sii/rtc/data_models_cesiones_periodo.py | 21 +++++ .../test_rtc_data_models_cesiones_periodo.py | 77 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/cl_sii/rtc/data_models_cesiones_periodo.py b/cl_sii/rtc/data_models_cesiones_periodo.py index 633f6ffe..ae9e77f8 100644 --- a/cl_sii/rtc/data_models_cesiones_periodo.py +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -11,6 +11,7 @@ from cl_sii.libs import tz_utils from cl_sii.rut import Rut +from . import data_models from .constants import CESION_MONTO_CEDIDO_FIELD_MIN_VALUE, TIPO_DTE_CEDIBLES @@ -272,3 +273,23 @@ def as_dte_data_l1(self) -> cl_sii.dte.data_models.DteDataL1: raise return dte_data + + def as_cesion_l2(self) -> data_models.CesionL2: + dte = self.as_dte_data_l1() + + return data_models.CesionL2( + dte_key=dte.natural_key, + seq=None, + cedente_rut=self.cedente_rut, + cesionario_rut=self.cesionario_rut, + fecha_cesion_dt=self.fecha_cesion_dt, + monto_cedido=self.monto_cedido, + dte_receptor_rut=dte.receptor_rut, + dte_fecha_emision=dte.fecha_emision_date, + dte_monto_total=dte.monto_total, + fecha_ultimo_vencimiento=self.fecha_ultimo_vencimiento, + cedente_razon_social=self.cedente_razon_social, + cedente_email=self.cedente_email, + cesionario_razon_social=self.cesionario_razon_social, + cesionario_email=self.cesionario_emails, + ) diff --git a/tests/test_rtc_data_models_cesiones_periodo.py b/tests/test_rtc_data_models_cesiones_periodo.py index 5d3c0b40..daee46ab 100644 --- a/tests/test_rtc_data_models_cesiones_periodo.py +++ b/tests/test_rtc_data_models_cesiones_periodo.py @@ -6,7 +6,9 @@ import cl_sii.dte.data_models from cl_sii.base.constants import SII_OFFICIAL_TZ from cl_sii.dte.constants import TipoDteEnum +from cl_sii.libs import tz_utils from cl_sii.libs.tz_utils import convert_naive_dt_to_tz_aware +from cl_sii.rtc.data_models import CesionL2 from cl_sii.rtc.data_models_cesiones_periodo import CesionesPeriodoEntry from cl_sii.rut import Rut @@ -115,3 +117,78 @@ def test_as_dte_data_l1_ok_2(self) -> None: self.assertEqual(dte_obj.vendedor_rut, obj.dte_vendedor_rut) self.assertEqual(dte_obj.emisor_rut, obj.dte_deudor_rut) self.assertEqual(dte_obj.comprador_rut, obj.dte_deudor_rut) + + def test_as_cesion_l2_ok_1(self) -> None: + obj = CesionesPeriodoEntry(**self.valid_kwargs) + expected_output = CesionL2( + dte_key=cl_sii.dte.data_models.DteNaturalKey( + emisor_rut=Rut('51532520-4'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=3608460, + ), + seq=None, + cedente_rut=Rut('51532520-4'), + cesionario_rut=Rut('96667560-8'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 3, 7, 13, 32), + tz=CesionL2.DATETIME_FIELDS_TZ, + ), + monto_cedido=256357, + dte_receptor_rut=Rut('75320502-0'), + dte_fecha_emision=date(2019, 2, 11), + dte_monto_total=256357, + fecha_ultimo_vencimiento=date(2019, 4, 12), + cedente_razon_social='MI CAMPITO SA', + cedente_email='mi@campito.cl', + cesionario_razon_social='POBRES SERVICIOS FINANCIEROS S.A.', + cesionario_email='un-poco@pobres.cl,super.ejecutivo@pobres.cl', + ) + obj_cesion_l2 = obj.as_cesion_l2() + self.assertEqual(obj_cesion_l2, expected_output) + + self.assertIsNone(obj_cesion_l2.natural_key) + self.assertEqual(obj_cesion_l2.alt_natural_key.dte_key.emisor_rut, obj.dte_vendedor_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.cedente_rut, obj.cedente_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.cesionario_rut, obj.cesionario_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.fecha_cesion_dt, obj.fecha_cesion_dt) + + self.assertEqual(obj_cesion_l2.dte_receptor_rut, obj.dte_deudor_rut) + + def test_as_cesion_l2_ok_2(self) -> None: + self.valid_kwargs.update(dict( + dte_tipo_dte=TipoDteEnum.FACTURA_COMPRA_ELECTRONICA, + )) + obj = CesionesPeriodoEntry(**self.valid_kwargs) + expected_output = CesionL2( + dte_key=cl_sii.dte.data_models.DteNaturalKey( + emisor_rut=Rut('75320502-0'), + tipo_dte=TipoDteEnum.FACTURA_COMPRA_ELECTRONICA, + folio=3608460, + ), + seq=None, + cedente_rut=Rut('51532520-4'), + cesionario_rut=Rut('96667560-8'), + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 3, 7, 13, 32), + tz=CesionL2.DATETIME_FIELDS_TZ, + ), + monto_cedido=256357, + dte_receptor_rut=Rut('51532520-4'), + dte_fecha_emision=date(2019, 2, 11), + dte_monto_total=256357, + fecha_ultimo_vencimiento=date(2019, 4, 12), + cedente_razon_social='MI CAMPITO SA', + cedente_email='mi@campito.cl', + cesionario_razon_social='POBRES SERVICIOS FINANCIEROS S.A.', + cesionario_email='un-poco@pobres.cl,super.ejecutivo@pobres.cl', + ) + obj_cesion_l2 = obj.as_cesion_l2() + self.assertEqual(obj_cesion_l2, expected_output) + + self.assertIsNone(obj_cesion_l2.natural_key) + self.assertEqual(obj_cesion_l2.alt_natural_key.dte_key.emisor_rut, obj.dte_deudor_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.cedente_rut, obj.cedente_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.cesionario_rut, obj.cesionario_rut) + self.assertEqual(obj_cesion_l2.alt_natural_key.fecha_cesion_dt, obj.fecha_cesion_dt) + + self.assertEqual(obj_cesion_l2.dte_receptor_rut, obj.dte_vendedor_rut) From 19a13e3af583b4a40dbf7b98fadc900ec3da156f Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Tue, 12 Jan 2021 20:47:22 -0300 Subject: [PATCH 3/4] rtc.data_models_cesiones_periodo: Refactor validation of c. & DTE montos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate consistency of cesión and DTE amounts with validator from `validate_cesion_and_dte_montos`. --- cl_sii/rtc/data_models_cesiones_periodo.py | 8 ++++---- tests/test_rtc_data_models_cesiones_periodo.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cl_sii/rtc/data_models_cesiones_periodo.py b/cl_sii/rtc/data_models_cesiones_periodo.py index ae9e77f8..0fc4d4e2 100644 --- a/cl_sii/rtc/data_models_cesiones_periodo.py +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -231,10 +231,10 @@ def __post_init__(self) -> None: raise ValueError( f"Amount 'monto_cedido' must be >= {CESION_MONTO_CEDIDO_FIELD_MIN_VALUE}.", self.monto_cedido) - if not self.monto_cedido <= self.dte_monto_total: - raise ValueError( - "Amount 'monto_cedido' must be <= 'dte_monto_total'.", - self.monto_cedido, self.dte_monto_total) + data_models.validate_cesion_and_dte_montos( + cesion_value=self.monto_cedido, + dte_value=self.dte_monto_total, + ) if not isinstance(self.fecha_ultimo_vencimiento, date): raise TypeError("Inappropriate type of 'fecha_ultimo_vencimiento'.") diff --git a/tests/test_rtc_data_models_cesiones_periodo.py b/tests/test_rtc_data_models_cesiones_periodo.py index daee46ab..97ed47d3 100644 --- a/tests/test_rtc_data_models_cesiones_periodo.py +++ b/tests/test_rtc_data_models_cesiones_periodo.py @@ -68,7 +68,7 @@ def test_init_error_monto_cedido_2(self) -> None: CesionesPeriodoEntry(**self.valid_kwargs) self.assertEqual( cm.exception.args, - ("Amount 'monto_cedido' must be <= 'dte_monto_total'.", 256358, 256357)) + ('Value of "cesión" must be <= value of DTE.', 256358, 256357)) def test_init_error_dte_tipo_dte_1(self) -> None: self.valid_kwargs.update(dict( From 648bf17a4c89564434595261c59a6e072a43b629 Mon Sep 17 00:00:00 2001 From: Jose Tomas Robles Hahn Date: Tue, 12 Jan 2021 20:50:40 -0300 Subject: [PATCH 4/4] rtc.data_models_cesiones_periodo: Add note about fecha_cesion_dt to CPE Add comments clarifying the meaning of the attribute `CesionesPeriodoEntry.fecha_cesion_dt`. --- cl_sii/rtc/data_models_cesiones_periodo.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cl_sii/rtc/data_models_cesiones_periodo.py b/cl_sii/rtc/data_models_cesiones_periodo.py index 0fc4d4e2..a9c3bb23 100644 --- a/cl_sii/rtc/data_models_cesiones_periodo.py +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -107,13 +107,17 @@ class CesionesPeriodoEntry: Email address of the "deudor". """ - # TODO: verify to what we are referring to exactly: - # digitally signed? received by the SII? processed by the SII? fecha_cesion_dt: datetime """ Datetime on which the "cesion" happened. Must be consistent with ``fecha_cesion`` (considering timezone). + + .. note:: This is the timestamp of when the "cesión"'s AEC was digitally + signed, but truncated to the minute (AEC's timestamp has seconds, + this one only has minutes). + + ..seealso:: Docstring of :attr:`data_models.CesionL0.fecha_cesion_dt`. """ fecha_cesion: date