diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 63ada29b..08f60306 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.11.1 +current_version = 0.11.2 commit = True tag = True diff --git a/HISTORY.rst b/HISTORY.rst index 6f1faf38..56779533 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,17 @@ History ------- +0.11.2 (2021-01-11) ++++++++++++++++++++++++ + +* (PR #166, 2020-12-15) requirements: Update 'cryptography' +* (PR #169, 2020-12-16) build(deps): bump coverage from 4.5.3 to 5.3 +* (PR #172, 2020-12-22) rtc: Add data model for "Cesiones Periodo" entries +* (PR #173, 2021-01-05) requirements: Add 'pydantic' +* (PR #175, 2021-01-06) libs.tz_utils: Add checks to validate_dt_tz +* (PR #174, 2021-01-07) rtc: Add constants and "cesión" natural keys +* (PR #171, 2021-01-07) build(deps): bump codecov from 2.1.9 to 2.1.11 + 0.11.1 (2020-12-15) +++++++++++++++++++++++ diff --git a/cl_sii/__init__.py b/cl_sii/__init__.py index 4fb02dbc..d21a8869 100644 --- a/cl_sii/__init__.py +++ b/cl_sii/__init__.py @@ -5,4 +5,4 @@ """ -__version__ = '0.11.1' +__version__ = '0.11.2' diff --git a/cl_sii/libs/tz_utils.py b/cl_sii/libs/tz_utils.py index 74a98208..3ba84e82 100644 --- a/cl_sii/libs/tz_utils.py +++ b/cl_sii/libs/tz_utils.py @@ -166,5 +166,12 @@ def validate_dt_tz(value: datetime, tz: PytzTimezone) -> None: """ if not dt_is_aware(value): raise ValueError("Value must be a timezone-aware datetime object.") + + # The 'zone' attribute is not defined in the abstract base class 'datetime.tzinfo'. We need to + # check that it is there before using it below to prevent unexpected exceptions when dealing + # with Python Standard Library time zones that are instances of class 'datetime.timezone'. + assert hasattr(value.tzinfo, 'zone'), f"Object {value.tzinfo!r} must have 'zone' attribute." + assert hasattr(tz, 'zone'), f"Object {tz!r} must have 'zone' attribute." + if value.tzinfo.zone != tz.zone: # type: ignore raise ValueError(f"Timezone of datetime value must be '{tz.zone!s}'.", value) 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 new file mode 100644 index 00000000..633f6ffe --- /dev/null +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import dataclasses +import logging +from datetime import date, datetime +from typing import Optional + +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.rut import Rut + +from .constants import CESION_MONTO_CEDIDO_FIELD_MIN_VALUE, TIPO_DTE_CEDIBLES + + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class CesionesPeriodoEntry: + """ + Entry of a list of "cesiones" in a period. + + In case of doubts about the concepts (particularly for "vendedor" and + "deudor"), read the documentation of :mod:`cl_sii.dte.data_models`. + """ + + ########################################################################### + # fields of DTE + ########################################################################### + + # In case of doubts about the concepts (particularly for 'vendedor' and 'deudor'), + # see 'cl_sii.dte.data_models'. + + dte_vendedor_rut: Rut + """ + RUT of the DTE's "vendedor". + """ + + dte_deudor_rut: Rut + """ + RUT of the DTE's "deudor". + """ + + dte_tipo_dte: TipoDteEnum + """ + The DTE's "tipo DTE" (sighs). + """ + + dte_folio: int + """ + The sequential number of the DTE of given kind issued by 'emisor_rut'. + """ + + 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_monto_total: int + """ + Total amount of the DTE. + """ + + ########################################################################### + # fields of "cesion" + ########################################################################### + + cedente_rut: Rut + """ + RUT of the "cedente". + """ + + cedente_razon_social: str + """ + "Razón social" (legal name) of the "cedente". + """ + + cedente_email: Optional[str] + """ + Email address of the "cedente". + """ + + cesionario_rut: Rut + """ + RUT of the "cesionario". + """ + + cesionario_razon_social: str + """ + "Razón social" (legal name) of the "cesionario". + """ + + cesionario_emails: Optional[str] + """ + Email address(es) of the "cesionario". + """ + + # note: this is not a field of the DTE even though 'dte_deudor_rut' is. + deudor_email: Optional[str] + """ + 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). + """ + + fecha_cesion: date + """ + Date on which the "cesion" happened. + + Must be consistent with ``fecha_cesion_dt`` (considering timezone). + """ + + # TODO: find out if there is a valid case for which it can be different from 'dte_monto_total'. + monto_cedido: int + """ + Amount of the "cesion" ("monto del crédito cedido"). + """ + + fecha_ultimo_vencimiento: date + """ + "Fecha del último vencimiento del pago". + + Even though the DTE field ``fecha_vencimiento`` is optional, this field + of the "cesión" is mandatory. + """ + + estado: str + """ + "Estado" of the "cesion". + """ + + def __post_init__(self) -> None: + """ + Run validation automatically after setting the fields values. + + :raises TypeError, ValueError: + + """ + ####################################################################### + # fields of DTE + ####################################################################### + + if not isinstance(self.dte_vendedor_rut, Rut): + raise TypeError("Inappropriate type of 'dte_vendedor_rut'.") + + if not isinstance(self.dte_deudor_rut, Rut): + raise TypeError("Inappropriate type of 'dte_deudor_rut'.") + + if not isinstance(self.dte_tipo_dte, TipoDteEnum): + raise TypeError("Inappropriate type of 'dte_tipo_dte'.") + if self.dte_tipo_dte not in TIPO_DTE_CEDIBLES: + raise ValueError( + "The \"tipo DTE\" in 'dte_tipo_dte' is not \"cedible\".", + self.dte_tipo_dte) + + if not isinstance(self.dte_folio, int): + raise TypeError("Inappropriate type of 'dte_folio'.") + if not self.dte_folio > 0: + raise ValueError("Inappropriate value of 'dte_folio'.") + + if not isinstance(self.dte_fecha_emision, date): + raise TypeError("Inappropriate type of 'dte_fecha_emision'.") + + # TODO: figure out validation rules of 'dte_monto_total' + if not isinstance(self.dte_monto_total, int): + raise TypeError("Inappropriate type of 'dte_monto_total'.") + + ####################################################################### + # fields of "cesion" + ####################################################################### + + if not isinstance(self.cedente_rut, Rut): + raise TypeError("Inappropriate type of 'cedente_rut'.") + + if not isinstance(self.cedente_razon_social, str): + raise TypeError("Inappropriate type of 'cedente_razon_social'.") + cl_sii.dte.data_models.validate_contribuyente_razon_social(self.cedente_razon_social) + + if self.cedente_email is not None: + if not isinstance(self.cedente_email, str): + raise TypeError("Inappropriate type of 'cedente_email'.") + cl_sii.dte.data_models.validate_clean_str(self.cedente_email) + cl_sii.dte.data_models.validate_non_empty_str(self.cedente_email) + + if not isinstance(self.cesionario_rut, Rut): + raise TypeError("Inappropriate type of 'cesionario_rut'.") + + if not isinstance(self.cesionario_razon_social, str): + raise TypeError("Inappropriate type of 'cesionario_razon_social'.") + cl_sii.dte.data_models.validate_contribuyente_razon_social(self.cesionario_razon_social) + + if self.cesionario_emails is not None: + if not isinstance(self.cesionario_emails, str): + raise TypeError("Inappropriate type of 'cesionario_emails'.") + cl_sii.dte.data_models.validate_clean_str(self.cesionario_emails) + cl_sii.dte.data_models.validate_non_empty_str(self.cesionario_emails) + + if self.deudor_email is not None: + if not isinstance(self.deudor_email, str): + raise TypeError("Inappropriate type of 'deudor_email'.") + cl_sii.dte.data_models.validate_clean_str(self.deudor_email) + cl_sii.dte.data_models.validate_non_empty_str(self.deudor_email) + + if not isinstance(self.fecha_cesion_dt, datetime): + raise TypeError("Inappropriate type of 'fecha_cesion_dt'.") + tz_utils.validate_dt_tz(self.fecha_cesion_dt, SII_OFFICIAL_TZ) + + if not isinstance(self.fecha_cesion, date): + raise TypeError("Inappropriate type of 'fecha_cesion'.") + if self.fecha_cesion_dt.date() != self.fecha_cesion: + raise ValueError( + "Date of 'fecha_cesion_dt' (considering timezone) does not match 'fecha_cesion'.", + self.fecha_cesion_dt, self.fecha_cesion) + + if not isinstance(self.monto_cedido, int): + raise TypeError("Inappropriate type of 'monto_cedido'.") + if not self.monto_cedido >= CESION_MONTO_CEDIDO_FIELD_MIN_VALUE: + 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) + + if not isinstance(self.fecha_ultimo_vencimiento, date): + raise TypeError("Inappropriate type of 'fecha_ultimo_vencimiento'.") + + if not isinstance(self.estado, str): + raise TypeError("Inappropriate type of 'estado'.") + cl_sii.dte.data_models.validate_clean_str(self.estado) + cl_sii.dte.data_models.validate_non_empty_str(self.estado) + + @property + def monto_cedido_eq_dte_monto_total(self) -> bool: + return self.monto_cedido == self.dte_monto_total + + def as_dte_data_l1(self) -> cl_sii.dte.data_models.DteDataL1: + if self.dte_tipo_dte.emisor_is_vendedor: + dte_emisor_rut = self.dte_vendedor_rut + dte_receptor_rut = self.dte_deudor_rut + elif self.dte_tipo_dte.receptor_is_vendedor: + dte_emisor_rut = self.dte_deudor_rut + dte_receptor_rut = self.dte_vendedor_rut + else: + raise ValueError( + 'Programming error: the "vendedor" is neither the "emisor" nor the "vendedor".', + self) + + try: + dte_data = cl_sii.dte.data_models.DteDataL1( + emisor_rut=dte_emisor_rut, + tipo_dte=self.dte_tipo_dte, + folio=self.dte_folio, + fecha_emision_date=self.dte_fecha_emision, + receptor_rut=dte_receptor_rut, + monto_total=self.dte_monto_total, + ) + except (TypeError, ValueError): + raise + + return dte_data diff --git a/requirements/base.txt b/requirements/base.txt index 27baa355..4b7e307c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,11 +2,12 @@ # note: it is mandatory to register all dependencies of the required packages. # Required packages: -cryptography==3.2 +cryptography==3.3.1 defusedxml==0.6.0 jsonschema==3.2.0 lxml==4.5.0 marshmallow==2.19.5 +pydantic==1.6.1 pyOpenSSL==18.0.0 pytz==2019.3 signxml==2.8.1 diff --git a/requirements/test.txt b/requirements/test.txt index 4967c1e7..9cad8ebf 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,8 +3,8 @@ -r extras.txt # Required packages: -codecov==2.1.9 -coverage==4.5.3 +codecov==2.1.11 +coverage==5.3 flake8==3.8.4 mypy==0.790 tox==3.20.1 diff --git a/setup.cfg b/setup.cfg index 9c4a8b24..97c770b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,8 @@ directory = test-reports/coverage/html [mypy] python_version = 3.7 platform = linux +plugins = + pydantic.mypy follow_imports = normal ignore_missing_imports = False @@ -62,6 +64,12 @@ ignore_missing_imports = True [mypy-pytz.*] ignore_missing_imports = True +[pydantic-mypy] +init_forbid_extra = True +init_typed = True +warn_required_dynamic_aliases = True +warn_untyped_fields = True + [flake8] ignore = # W503 line break before binary operator diff --git a/setup.py b/setup.py index f406d9a0..0e3eb46b 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ def get_version(*file_paths: Sequence[str]) -> str: 'jsonschema>=3.1.1', 'lxml>=4.5.0,<5', 'marshmallow>=2.19.2,<3', + 'pydantic>=1.6.1', # TODO: remove upper-bound after a new release of 'signxml' drops the requirement 'pyOpenSSL<20' 'pyOpenSSL>=18.0.0,<20', 'pytz>=2019.3', diff --git a/tests/test_libs_crypto_utils.py b/tests/test_libs_crypto_utils.py index 95fb7f05..c4880bee 100644 --- a/tests/test_libs_crypto_utils.py +++ b/tests/test_libs_crypto_utils.py @@ -846,7 +846,7 @@ def test_load_pem_x509_cert_fail_value_error(self) -> None: self.assertEqual( cm.exception.args, ("Unable to load certificate. See " - "https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file " + "https://cryptography.io/en/latest/faq.html#why-can-t-i-import-my-pem-file " "for more details.", )) def test_x509_cert_der_to_pem_pem_to_der_ok_1(self) -> None: diff --git a/tests/test_libs_tz_utils.py b/tests/test_libs_tz_utils.py index a685c5a5..4a203af0 100644 --- a/tests/test_libs_tz_utils.py +++ b/tests/test_libs_tz_utils.py @@ -1,9 +1,11 @@ +import datetime +import re import unittest from cl_sii.libs.tz_utils import ( # noqa: F401 convert_naive_dt_to_tz_aware, convert_tz_aware_dt_to_naive, dt_is_aware, dt_is_naive, get_now_tz_aware, validate_dt_tz, - PytzTimezone, TZ_UTC, + PytzTimezone, _TZ_CL_SANTIAGO, TZ_UTC, ) @@ -37,3 +39,46 @@ def test_dt_is_naive(self) -> None: def test_validate_dt_tz(self) -> None: # TODO: implement for 'validate_dt_tz' pass + + def test_validate_dt_tz_tzinfo_zone_attribute_check(self) -> None: + # Time zone: UTC. Source: Pytz: + tzinfo_utc_pytz = TZ_UTC + dt_with_tzinfo_utc_pytz = convert_naive_dt_to_tz_aware( + datetime.datetime(2021, 1, 6, 15, 21), + tzinfo_utc_pytz, + ) + + # Time zone: UTC. Source: Python Standard Library: + tzinfo_utc_stdlib = datetime.timezone.utc + dt_with_tzinfo_utc_stdlib = datetime.datetime.fromisoformat('2021-01-06T15:04+00:00') + + # Time zone: Not UTC. Source: Pytz: + tzinfo_not_utc_pytz = _TZ_CL_SANTIAGO + dt_with_tzinfo_not_utc_pytz = convert_naive_dt_to_tz_aware( + datetime.datetime(2021, 1, 6, 15, 21), + tzinfo_not_utc_pytz, + ) + + # Time zone: Not UTC. Source: Python Standard Library: + tzinfo_not_utc_stdlib = datetime.timezone(datetime.timedelta(days=-1, seconds=75600)) + dt_with_tzinfo_not_utc_stdlib = datetime.datetime.fromisoformat('2021-01-06T15:04-03:00') + + # Test datetimes with UTC time zone: + expected_error_message = re.compile( + r"^Object datetime.timezone.utc must have 'zone' attribute.$" + ) + with self.assertRaisesRegex(AssertionError, expected_error_message): + validate_dt_tz(dt_with_tzinfo_utc_pytz, tzinfo_utc_stdlib) + with self.assertRaisesRegex(AssertionError, expected_error_message): + validate_dt_tz(dt_with_tzinfo_utc_stdlib, tzinfo_utc_pytz) + + # Test datetimes with non-UTC time zone: + expected_error_message = re.compile( + r"^Object" + r" datetime.timezone\(datetime.timedelta\(days=-1, seconds=75600\)\)" + r" must have 'zone' attribute.$" + ) + with self.assertRaisesRegex(AssertionError, expected_error_message): + validate_dt_tz(dt_with_tzinfo_not_utc_pytz, tzinfo_not_utc_stdlib) # type: ignore + with self.assertRaisesRegex(AssertionError, expected_error_message): + validate_dt_tz(dt_with_tzinfo_not_utc_stdlib, tzinfo_not_utc_pytz) 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) diff --git a/tests/test_rtc_data_models_cesiones_periodo.py b/tests/test_rtc_data_models_cesiones_periodo.py new file mode 100644 index 00000000..5d3c0b40 --- /dev/null +++ b/tests/test_rtc_data_models_cesiones_periodo.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime + +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.tz_utils import convert_naive_dt_to_tz_aware +from cl_sii.rtc.data_models_cesiones_periodo import CesionesPeriodoEntry +from cl_sii.rut import Rut + + +class CesionesPeriodoEntryTest(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + + self.valid_kwargs = dict( + dte_vendedor_rut=Rut('51532520-4'), + dte_deudor_rut=Rut('75320502-0'), + dte_tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + dte_folio=3608460, + dte_fecha_emision=date(2019, 2, 11), + dte_monto_total=256357, + cedente_rut=Rut('51532520-4'), + cedente_razon_social='MI CAMPITO SA', + cedente_email='mi@campito.cl', + cesionario_rut=Rut('96667560-8'), + cesionario_razon_social='POBRES SERVICIOS FINANCIEROS S.A.', + cesionario_emails='un-poco@pobres.cl,super.ejecutivo@pobres.cl', + deudor_email=None, + fecha_cesion_dt=convert_naive_dt_to_tz_aware( + datetime(2019, 3, 7, 13, 32), tz=SII_OFFICIAL_TZ), + fecha_cesion=date(2019, 3, 7), + monto_cedido=256357, + fecha_ultimo_vencimiento=date(2019, 4, 12), + estado='Cesion Vigente', + ) + + def test_init_ok_1(self) -> None: + obj = CesionesPeriodoEntry(**self.valid_kwargs) + self.assertTrue(obj.monto_cedido_eq_dte_monto_total) + + def test_init_ok_2(self) -> None: + self.valid_kwargs.update(dict( + monto_cedido=self.valid_kwargs['dte_monto_total'] - 1, + )) + obj = CesionesPeriodoEntry(**self.valid_kwargs) + self.assertFalse(obj.monto_cedido_eq_dte_monto_total) + + def test_init_error_monto_cedido_1(self) -> None: + self.valid_kwargs.update(dict( + monto_cedido=-1, + )) + with self.assertRaises(ValueError) as cm: + CesionesPeriodoEntry(**self.valid_kwargs) + self.assertEqual( + cm.exception.args, + ("Amount 'monto_cedido' must be >= 0.", -1)) + + def test_init_error_monto_cedido_2(self) -> None: + self.valid_kwargs.update(dict( + monto_cedido=self.valid_kwargs['dte_monto_total'] + 1, + )) + with self.assertRaises(ValueError) as cm: + CesionesPeriodoEntry(**self.valid_kwargs) + self.assertEqual( + cm.exception.args, + ("Amount 'monto_cedido' must be <= 'dte_monto_total'.", 256358, 256357)) + + def test_init_error_dte_tipo_dte_1(self) -> None: + self.valid_kwargs.update(dict( + dte_tipo_dte=TipoDteEnum.NOTA_CREDITO_ELECTRONICA, + )) + with self.assertRaises(ValueError) as cm: + CesionesPeriodoEntry(**self.valid_kwargs) + self.assertEqual( + cm.exception.args, + ("The \"tipo DTE\" in 'dte_tipo_dte' is not \"cedible\".", + TipoDteEnum.NOTA_CREDITO_ELECTRONICA)) + + def test_as_dte_data_l1_ok_1(self) -> None: + obj = CesionesPeriodoEntry(**self.valid_kwargs) + dte_obj = cl_sii.dte.data_models.DteDataL1( + emisor_rut=Rut('51532520-4'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=3608460, + receptor_rut=Rut('75320502-0'), + fecha_emision_date=date(2019, 2, 11), + monto_total=256357, + ) + + self.assertEqual(obj.as_dte_data_l1(), dte_obj) + self.assertEqual(dte_obj.emisor_rut, obj.dte_vendedor_rut) + self.assertEqual(dte_obj.vendedor_rut, obj.dte_vendedor_rut) + self.assertEqual(dte_obj.receptor_rut, obj.dte_deudor_rut) + self.assertEqual(dte_obj.comprador_rut, obj.dte_deudor_rut) + + def test_as_dte_data_l1_ok_2(self) -> None: + self.valid_kwargs.update(dict( + dte_tipo_dte=TipoDteEnum.FACTURA_COMPRA_ELECTRONICA, + )) + obj = CesionesPeriodoEntry(**self.valid_kwargs) + dte_obj = cl_sii.dte.data_models.DteDataL1( + emisor_rut=Rut('75320502-0'), + tipo_dte=TipoDteEnum.FACTURA_COMPRA_ELECTRONICA, + folio=3608460, + receptor_rut=Rut('51532520-4'), + fecha_emision_date=date(2019, 2, 11), + monto_total=256357, + ) + + self.assertEqual(obj.as_dte_data_l1(), dte_obj) + self.assertEqual(dte_obj.receptor_rut, obj.dte_vendedor_rut) + 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)