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..f7a9c501 --- /dev/null +++ b/cl_sii/rtc/data_models_cesiones_periodo.py @@ -0,0 +1,273 @@ +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 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 >= 0: + raise ValueError( + "Amount 'monto_cedido' must be >= 0.", 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/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)