diff --git a/cl_sii/rtc/constants.py b/cl_sii/rtc/constants.py index 73ce4909..fc6eb328 100644 --- a/cl_sii/rtc/constants.py +++ b/cl_sii/rtc/constants.py @@ -1,7 +1,7 @@ import enum from typing import FrozenSet -from cl_sii.dte.constants import TipoDteEnum +from cl_sii.dte.constants import DTE_MONTO_TOTAL_FIELD_MAX_VALUE, TipoDteEnum # The collection of "tipo DTE" for which it is possible to "ceder" a "DTE". @@ -25,6 +25,42 @@ }) +############################################################################### +# Cesion Fields / "Monto Cedido" +############################################################################### + +# Amount of the "cesión". +# +# Ref: +# - https://github.com/fyntex/lib-cl-sii-api-python/blob/v0.4.4/cl_sii_api/rtc/data_models.py#L231 +# - Document "Formato Archivo Electrónico de Cesión 2013-02-11" (retrieved on 2019-08-12) +# (https://www.sii.cl/factura_electronica/cesion.pdf) +CESION_MONTO_CEDIDO_FIELD_MIN_VALUE: int = 0 +CESION_MONTO_CEDIDO_FIELD_MAX_VALUE: int = DTE_MONTO_TOTAL_FIELD_MAX_VALUE + + +############################################################################### +# Cesion Fields / "Secuencia" +############################################################################### + +# Sequence number of the "cesión" +# +# > Campo: Número de Cesión +# > Descripción: Secuencia de la cesión +# > Tipo: NUM +# > Validación: 1 hasta 40 +# +# Source: +# Document "Formato Archivo Electrónico de Cesión 2013-02-11" (retrieved on 2019-08-12) +# (https://www.sii.cl/factura_electronica/cesion.pdf) +CESION_SEQUENCE_NUMBER_MIN_VALUE: int = 1 +CESION_SEQUENCE_NUMBER_MAX_VALUE: int = 40 + + +############################################################################### +# Other +############################################################################### + @enum.unique class RolContribuyenteEnCesion(enum.Enum): diff --git a/cl_sii/rtc/data_models.py b/cl_sii/rtc/data_models.py new file mode 100644 index 00000000..be7af2af --- /dev/null +++ b/cl_sii/rtc/data_models.py @@ -0,0 +1,237 @@ +""" +Data models for RTC +=================== + +In this domain we care about the data of transactions that consist in: +a "cesión" of a DTE, by a "cedente" to a "cesionario". + +Natural key of a cesion +----------------------- + +Each transaction can be uniquely identified by the group of fields defined in +:class:`CesionNaturalKey`. However, because of SII's inconsistent systems +implementations, there are several information sources *where the "cesión"'s +sequence number is not available*. Thus the usefulness of that class is +limited, unlike :class:`cl_sii.dte.data_models.DteNaturalKey` for a DTE. +In some cases, the alternative natural key :class:`CesionAltNaturalKey` may +be used as a workaround when the sequence number is not available. +""" + +from __future__ import annotations + +import dataclasses +from datetime import datetime +from typing import ClassVar, Mapping + +import pydantic + +from cl_sii.base.constants import SII_OFFICIAL_TZ +from cl_sii.dte import data_models as dte_data_models +from cl_sii.dte.constants import TipoDteEnum +from cl_sii.libs import tz_utils +from cl_sii.rut import Rut + +from . import constants + + +def validate_cesion_seq(value: int) -> None: + """ + Validate value for sequence number of a "cesión". + + :raises ValueError: + """ + if ( + value < constants.CESION_SEQUENCE_NUMBER_MIN_VALUE + or value > constants.CESION_SEQUENCE_NUMBER_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". + + :raises ValueError: + """ + if value not in constants.TIPO_DTE_CEDIBLES: + raise ValueError('Value is not "cedible".', value) + + +@pydantic.dataclasses.dataclass( + frozen=True, + config=type('Config', (), dict( + arbitrary_types_allowed=True, + )) +) +class CesionNaturalKey: + """ + Natural key of a "cesión" of a DTE. + + The class instances are immutable. + + This group of fields uniquely identifies a "cesión". + + Example: + + >>> instance = CesionNaturalKey( + ... dte_data_models.DteNaturalKey( + ... Rut('60910000-1'), TipoDteEnum.FACTURA_ELECTRONICA, 2093465, + ... ), + ... 1, + ... ) + """ + + ########################################################################### + # Fields + ########################################################################### + + dte_key: dte_data_models.DteNaturalKey + """ + Natural key of the "cesión"'s DTE. + """ + + seq: int + """ + Sequence number of the "cesión". Must be >= 1. + """ + + @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 f'{self.dte_key.slug}--{self.seq}' + + ########################################################################### + # 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.dataclasses.dataclass( + frozen=True, + config=type('Config', (), dict( + arbitrary_types_allowed=True, + )) +) +class CesionAltNaturalKey: + """ + Alternative natural key of a "cesión" of a DTE. + + Useful when the sequence number is unavailable, such as in "cesiones periodo". + + The class instances are immutable. + + .. warning:: + It is assumed that it is impossible to "ceder" a given DTE by a given "cedente" to a given + "cesionario" more than once in a particular instant (``fecha_cesion_dt``). + + Example: + + >>> instance = CesionAltNaturalKey( + ... dte_data_models.DteNaturalKey( + ... Rut('60910000-1'), TipoDteEnum.FACTURA_ELECTRONICA, 2093465, + ... ), + ... Rut('76389992-6'), + ... Rut('76598556-0'), + ... datetime.fromisoformat('2019-04-05T12:57:32-03:00'), + ... ) + """ + + ########################################################################### + # 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. + """ + + 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. + + .. warning:: The value will always be truncated to the minute, even if the + original value has seconds. This has to be done because this field is + part of a key and in some data sources the timestamp has seconds and in + others it has not (e.g. AEC and Cesión Periodo). + """ + + @property + def slug(self) -> str: + """ + Return a slug representation (that preserves uniquess) of the instance. + """ + # Note: Based on 'cl_sii.dte.data_models.DteNaturalKey.slug'. + + _fecha_cesion_dt = self.fecha_cesion_dt.astimezone(self.DATETIME_FIELDS_TZ) + fecha_cesion_dt: str = _fecha_cesion_dt.isoformat(timespec='minutes') + + return f'{self.dte_key.slug}--{self.cedente_rut}--{self.cesionario_rut}--{fecha_cesion_dt}' + + ########################################################################### + # 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('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.validator('fecha_cesion_dt') + def truncate_fecha_cesion_dt_to_minutes(cls, v: object) -> object: + if isinstance(v, datetime): + if v.second != 0: + v = v.replace(second=0) + if v.microsecond != 0: + v = v.replace(microsecond=0) + return v diff --git a/cl_sii/rtc/data_models_cesiones_periodo.py b/cl_sii/rtc/data_models_cesiones_periodo.py index f7a9c501..633f6ffe 100644 --- a/cl_sii/rtc/data_models_cesiones_periodo.py +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -11,7 +11,7 @@ from cl_sii.libs import tz_utils from cl_sii.rut import Rut -from .constants import TIPO_DTE_CEDIBLES +from .constants import CESION_MONTO_CEDIDO_FIELD_MIN_VALUE, TIPO_DTE_CEDIBLES logger = logging.getLogger(__name__) @@ -226,9 +226,10 @@ def __post_init__(self) -> None: if not isinstance(self.monto_cedido, int): raise TypeError("Inappropriate type of 'monto_cedido'.") - if not self.monto_cedido >= 0: + if not self.monto_cedido >= CESION_MONTO_CEDIDO_FIELD_MIN_VALUE: raise ValueError( - "Amount 'monto_cedido' must be >= 0.", self.monto_cedido) + 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'.", diff --git a/tests/test_rtc_data_models.py b/tests/test_rtc_data_models.py new file mode 100644 index 00000000..eab7ba72 --- /dev/null +++ b/tests/test_rtc_data_models.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import dataclasses +import unittest +from datetime import datetime + +import pydantic + +from cl_sii.dte.data_models import DteNaturalKey +from cl_sii.dte.constants import TipoDteEnum +from cl_sii.libs import tz_utils +from cl_sii.rtc.data_models import ( + CesionNaturalKey, + CesionAltNaturalKey, +) +from cl_sii.rut import Rut + + +class CesionNaturalKeyTest(unittest.TestCase): + """ + Tests for :class:`CesionNaturalKey`. + """ + + def _set_obj_1(self) -> None: + obj_dte_natural_key = DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ) + + obj = CesionNaturalKey( + dte_key=obj_dte_natural_key, + seq=32, + ) + self.assertIsInstance(obj, CesionNaturalKey) + + self.obj_1 = obj + + def test_create_new_empty_instance(self) -> None: + with self.assertRaises(TypeError): + CesionNaturalKey() + + def test_str_and_repr(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = ( + "CesionNaturalKey(" + "dte_key=DteNaturalKey(" + "emisor_rut=Rut('76354771-K')," + " tipo_dte=," + " folio=170" + ")," + " seq=32" + ")" + ) + 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, + ) + 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--32' + self.assertEqual(obj.slug, expected_output) + + def test_validate_dte_tipo_dte(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_validation_error = { + '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.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_error = { + '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.assertIn(expected_validation_error, validation_errors) + + +class CesionAltNaturalKeyTest(unittest.TestCase): + """ + Tests for :class:`CesionAltNaturalKey`. + """ + + def _set_obj_1(self) -> None: + obj_dte_natural_key = DteNaturalKey( + emisor_rut=Rut('76354771-K'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=170, + ) + + obj = CesionAltNaturalKey( + dte_key=obj_dte_natural_key, + 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=CesionAltNaturalKey.DATETIME_FIELDS_TZ, + ), + ) + self.assertIsInstance(obj, CesionAltNaturalKey) + + 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): + CesionAltNaturalKey() + + def test_str_and_repr(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_output = ( + "CesionAltNaturalKey(" + "dte_key=DteNaturalKey(" + "emisor_rut=Rut('76354771-K')," + " tipo_dte=," + " folio=170" + ")," + " cedente_rut=Rut('76389992-6')," + " cesionario_rut=Rut('76598556-0')," + " fecha_cesion_dt=datetime.datetime(" + "2019, 4, 5, 12, 57," + " 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, + ), + cedente_rut=Rut('76389992-6'), + cesionario_rut=Rut('76598556-0'), + fecha_cesion_dt=datetime.fromisoformat('2019-04-05T15:57+00: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_validate_dte_tipo_dte(self) -> None: + self._set_obj_1() + + obj = self.obj_1 + expected_validation_error = { + '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.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_error = { + '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), + ) + + validation_errors = assert_raises_cm.exception.errors() + self.assertIn(expected_validation_error, validation_errors) + + # Test TZ-value: + + expected_validation_error = { + 'loc': ('fecha_cesion_dt',), + 'msg': + '(' + '''"Timezone of datetime value must be 'America/Santiago'.",''' + ' datetime.datetime(2019, 4, 5, 12, 57, 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), + tz=tz_utils.TZ_UTC, + ), + ) + + 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() + + obj = self.obj_1 + expected_fecha_cesion_dt = datetime.fromisoformat('2020-12-31T22:33-03:00') + self.assertEqual(expected_fecha_cesion_dt.second, 0) + self.assertEqual(expected_fecha_cesion_dt.microsecond, 0) + + obj_with_microseconds = dataclasses.replace( + obj, + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2020, 12, 31, 22, 33, 44, 555555), + tz=CesionAltNaturalKey.DATETIME_FIELDS_TZ, + ), + ) + obj_with_datetime_truncated = dataclasses.replace( + obj, + fecha_cesion_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2020, 12, 31, 22, 33), + tz=CesionAltNaturalKey.DATETIME_FIELDS_TZ, + ), + ) + 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)