diff --git a/cl_sii/dte/data_models.py b/cl_sii/dte/data_models.py index 235bd2f2..be44f07a 100644 --- a/cl_sii/dte/data_models.py +++ b/cl_sii/dte/data_models.py @@ -324,6 +324,9 @@ class DteDataL2(DteDataL1): """ DTE data level 2. + Very similar to :class:`DteXmlData` (and a lot of duplicated code, + unfortunately). + About fields - ``emisor_razon_social``: redundant but required by the DTE XML schema. - ``receptor_razon_social``: redundant but required by the DTE XML schema. @@ -459,3 +462,168 @@ def as_dte_data_l1(self) -> DteDataL1: fecha_emision_date=self.fecha_emision_date, receptor_rut=self.receptor_rut, monto_total=self.monto_total) + + +@dataclasses.dataclass(frozen=True) +class DteXmlData(DteDataL1): + + """ + DTE XML data. + + Very similar to :class:`DteDataL2` (and a lot of duplicated code, + unfortunately). + + About fields + - ``emisor_razon_social``: redundant but required by the DTE XML schema. + - ``receptor_razon_social``: redundant but required by the DTE XML schema. + - ``fecha_vencimiento`` (date): important for some business logic + but it is not required by the DTE XML schema. + + The class instances are immutable. + + """ + + ########################################################################### + # constants + ########################################################################### + + DATETIME_FIELDS_TZ = SII_OFFICIAL_TZ + + ########################################################################### + # fields + ########################################################################### + + emisor_razon_social: str = dc_field() + """ + "Razón social" (legal name) of the "emisor" of the DTE. + """ + + receptor_razon_social: str = dc_field() + """ + "Razón social" (legal name) of the "receptor" of the DTE. + """ + + fecha_vencimiento_date: Optional[date] = dc_field(default=None) + """ + "Fecha de vencimiento (pago)" of the DTE. + """ + + firma_documento_dt: Optional[datetime] = dc_field(default=None) + """ + Datetime on which the "documento" was digitally signed. + """ + + signature_value: Optional[bytes] = dc_field(default=None) + """ + DTE's digital signature's value (raw bytes, without base64 encoding). + """ + + signature_x509_cert_der: Optional[bytes] = dc_field(default=None) + """ + DTE's digital signature's DER-encoded X.509 cert. + + .. seealso:: + Functions :func:`cl_sii.libs.crypto_utils.load_der_x509_cert` + and :func:`cl_sii.libs.crypto_utils.x509_cert_der_to_pem`. + """ + + emisor_giro: Optional[str] = dc_field(default=None) + """ + "Giro" of the "emisor" of the DTE. + """ + + emisor_email: Optional[str] = dc_field(default=None) + """ + Email address of the "emisor" of the DTE. + """ + + receptor_email: Optional[str] = dc_field(default=None) + """ + Email address of the "receptor" of the DTE. + """ + + def __post_init__(self) -> None: + """ + Run validation automatically after setting the fields values. + + :raises TypeError, ValueError: + + """ + super().__post_init__() + + if not isinstance(self.emisor_razon_social, str): + raise TypeError("Inappropriate type of 'emisor_razon_social'.") + validate_contribuyente_razon_social(self.emisor_razon_social) + + if not isinstance(self.receptor_razon_social, str): + raise TypeError("Inappropriate type of 'receptor_razon_social'.") + validate_contribuyente_razon_social(self.receptor_razon_social) + + if self.fecha_vencimiento_date is not None: + if not isinstance(self.fecha_vencimiento_date, date): + raise TypeError("Inappropriate type of 'fecha_vencimiento_date'.") + + if self.firma_documento_dt is not None: + if not isinstance(self.firma_documento_dt, datetime): + raise TypeError("Inappropriate type of 'firma_documento_dt'.") + tz_utils.validate_dt_tz(self.firma_documento_dt, self.DATETIME_FIELDS_TZ) + + if self.signature_value is not None: + if not isinstance(self.signature_value, bytes): + raise TypeError("Inappropriate type of 'signature_value'.") + # warning: do NOT strip a bytes value because "strip" implies an ASCII-encoded text, + # which in this case it is not. + validate_non_empty_bytes(self.signature_value) + + if self.signature_x509_cert_der is not None: + if not isinstance(self.signature_x509_cert_der, bytes): + raise TypeError("Inappropriate type of 'signature_x509_cert_der'.") + # warning: do NOT strip a bytes value because "strip" implies an ASCII-encoded text, + # which in this case it is not. + validate_non_empty_bytes(self.signature_x509_cert_der) + + if self.emisor_giro is not None: + if not isinstance(self.emisor_giro, str): + raise TypeError("Inappropriate type of 'emisor_giro'.") + validate_clean_str(self.emisor_giro) + validate_non_empty_str(self.emisor_giro) + + if self.emisor_email is not None: + if not isinstance(self.emisor_email, str): + raise TypeError("Inappropriate type of 'emisor_email'.") + validate_clean_str(self.emisor_email) + validate_non_empty_str(self.emisor_email) + + if self.receptor_email is not None: + if not isinstance(self.receptor_email, str): + raise TypeError("Inappropriate type of 'receptor_email'.") + validate_clean_str(self.receptor_email) + validate_non_empty_str(self.receptor_email) + + def as_dte_data_l1(self) -> DteDataL1: + return DteDataL1( + emisor_rut=self.emisor_rut, + tipo_dte=self.tipo_dte, + folio=self.folio, + fecha_emision_date=self.fecha_emision_date, + receptor_rut=self.receptor_rut, + monto_total=self.monto_total) + + def as_dte_data_l2(self) -> DteDataL2: + return DteDataL2( + emisor_rut=self.emisor_rut, + tipo_dte=self.tipo_dte, + folio=self.folio, + fecha_emision_date=self.fecha_emision_date, + receptor_rut=self.receptor_rut, + monto_total=self.monto_total, + emisor_razon_social=self.emisor_razon_social, + receptor_razon_social=self.receptor_razon_social, + fecha_vencimiento_date=self.fecha_vencimiento_date, + firma_documento_dt=self.firma_documento_dt, + signature_value=self.signature_value, + signature_x509_cert_der=self.signature_x509_cert_der, + emisor_giro=self.emisor_giro, + emisor_email=self.emisor_email, + receptor_email=self.receptor_email, + ) diff --git a/cl_sii/dte/parse.py b/cl_sii/dte/parse.py index 39b42f1e..8f581937 100644 --- a/cl_sii/dte/parse.py +++ b/cl_sii/dte/parse.py @@ -14,7 +14,7 @@ >>> parse.clean_dte_xml(xml_doc) True >>> parse.validate_dte_xml(xml_doc) ->>> dte_struct = parse.parse_dte_xml(xml_doc) +>>> dte_xml_data = parse.parse_dte_xml(xml_doc) """ import io @@ -114,8 +114,7 @@ def validate_dte_xml(xml_doc: XmlElement) -> None: xml_utils.validate_xml_doc(DTE_XML_SCHEMA_OBJ, xml_doc) -# TODO: rename to 'parse_dte_xml_data' -def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2: +def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteXmlData: """ Parse data from a DTE XML doc. @@ -128,7 +127,6 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2: :raises NotImplementedError: """ - # TODO: change response type to a dataclass like 'DteXmlData'. # TODO: separate the XML parsing stage from the deserialization stage, which could be # performed by XML-agnostic code (perhaps using Marshmallow or data clacases?). # See :class:`cl_sii.rcv.parse_csv.RcvVentaCsvRowSchema`. @@ -455,14 +453,14 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2: tmst_firma_value = tz_utils.convert_naive_dt_to_tz_aware( dt=datetime.fromisoformat(_text_strip_or_raise(tmst_firma_em)), - tz=data_models.DteDataL2.DATETIME_FIELDS_TZ) + tz=data_models.DteXmlData.DATETIME_FIELDS_TZ) signature_signature_value = encoding_utils.decode_base64_strict( _text_strip_or_raise(signature_signature_value_em)) signature_key_info_x509_cert_der = encoding_utils.decode_base64_strict( _text_strip_or_raise(signature_key_info_x509_cert_em)) - return data_models.DteDataL2( + return data_models.DteXmlData( emisor_rut=emisor_rut_value, tipo_dte=tipo_dte_value, folio=folio_value, diff --git a/tests/test_dte_data_models.py b/tests/test_dte_data_models.py index 620362a1..2af47153 100644 --- a/tests/test_dte_data_models.py +++ b/tests/test_dte_data_models.py @@ -12,7 +12,7 @@ TipoDteEnum, ) from cl_sii.dte.data_models import ( # noqa: F401 - DteDataL0, DteDataL1, DteDataL2, DteNaturalKey, + DteDataL0, DteDataL1, DteDataL2, DteNaturalKey, DteXmlData, validate_contribuyente_razon_social, validate_dte_folio, validate_dte_monto_total, ) @@ -214,10 +214,44 @@ def setUp(self) -> None: receptor_email=None, ) + def test_constants_match(self): + self.assertEqual( + DteXmlData.DATETIME_FIELDS_TZ, + DteDataL2.DATETIME_FIELDS_TZ, + ) + def test_init_fail(self) -> None: # TODO: implement for 'DteDataL2()' pass + def test_init_fail_razon_social_empty(self) -> None: + with self.assertRaises(ValueError) as cm: + dataclasses.replace( + self.dte_l2_1, + emisor_razon_social='', + ) + self.assertEqual(cm.exception.args, ("Value must not be empty.", )) + with self.assertRaises(ValueError) as cm: + dataclasses.replace( + self.dte_l2_1, + receptor_razon_social='', + ) + self.assertEqual(cm.exception.args, ("Value must not be empty.", )) + + def test_init_fail_razon_social_none(self) -> None: + with self.assertRaises(TypeError) as cm: + dataclasses.replace( + self.dte_l2_1, + emisor_razon_social=None, + ) + self.assertEqual(cm.exception.args, ("Inappropriate type of 'emisor_razon_social'.", )) + with self.assertRaises(TypeError) as cm: + dataclasses.replace( + self.dte_l2_1, + receptor_razon_social=None, + ) + self.assertEqual(cm.exception.args, ("Inappropriate type of 'receptor_razon_social'.", )) + def test_init_fail_regression_signature_value_bytes_with_x20(self) -> None: bytes_value_with_x20_as_base64 = 'IN2pkDBxqDnGl4Pfvboi' bytes_value_with_x20 = b'\x20\xdd\xa9\x900q\xa89\xc6\x97\x83\xdf\xbd\xba"' @@ -327,6 +361,256 @@ def test_as_dte_data_l1(self) -> None: ) +class DteXmlDataTest(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.dte_1_xml_signature_value = encoding_utils.decode_base64_strict(read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-signature-value-base64.txt')) + cls.dte_1_xml_cert_der = read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + cls.dte_2_xml_signature_value = encoding_utils.decode_base64_strict(read_test_file_bytes( + 'test_data/sii-crypto/DTE--60910000-1--33--2336600-signature-value-base64.txt')) + cls.dte_2_xml_cert_der = read_test_file_bytes( + 'test_data/sii-crypto/DTE--60910000-1--33--2336600-cert.der') + + def setUp(self) -> None: + super().setUp() + + self.dte_xml_data_1 = DteXmlData( + 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, + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_1_xml_signature_value, + signature_x509_cert_der=self.dte_1_xml_cert_der, + emisor_giro='Ingenieria y Construccion', + emisor_email='hello@example.com', + receptor_email=None, + ) + self.dte_xml_data_2 = DteXmlData( + emisor_rut=Rut('60910000-1'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=2336600, + fecha_emision_date=date(2019, 8, 8), + receptor_rut=Rut('76555835-2'), + monto_total=10642, + emisor_razon_social='Universidad de Chile', + receptor_razon_social='FYNPAL SPA', + fecha_vencimiento_date=date(2019, 8, 8), + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 8, 9, 9, 41, 9), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_2_xml_signature_value, + signature_x509_cert_der=self.dte_2_xml_cert_der, + emisor_giro='Corporación Educacional y Servicios Profesionales', + emisor_email=None, + receptor_email=None, + ) + + def test_constants_match(self): + self.assertEqual( + DteXmlData.DATETIME_FIELDS_TZ, + DteDataL2.DATETIME_FIELDS_TZ, + ) + + def test_init_fail(self) -> None: + # TODO: implement for 'DteXmlData()' + pass + + def test_init_fail_razon_social_empty(self) -> None: + with self.assertRaises(ValueError) as cm: + dataclasses.replace( + self.dte_xml_data_1, + emisor_razon_social='', + ) + self.assertEqual(cm.exception.args, ("Value must not be empty.", )) + with self.assertRaises(ValueError) as cm: + dataclasses.replace( + self.dte_xml_data_1, + receptor_razon_social='', + ) + self.assertEqual(cm.exception.args, ("Value must not be empty.", )) + + def test_init_fail_razon_social_none(self) -> None: + with self.assertRaises(TypeError) as cm: + dataclasses.replace( + self.dte_xml_data_1, + emisor_razon_social=None, + ) + self.assertEqual(cm.exception.args, ("Inappropriate type of 'emisor_razon_social'.", )) + with self.assertRaises(TypeError) as cm: + dataclasses.replace( + self.dte_xml_data_1, + receptor_razon_social=None, + ) + self.assertEqual(cm.exception.args, ("Inappropriate type of 'receptor_razon_social'.", )) + + def test_init_fail_regression_signature_value_bytes_with_x20(self) -> None: + bytes_value_with_x20_as_base64 = 'IN2pkDBxqDnGl4Pfvboi' + bytes_value_with_x20 = b'\x20\xdd\xa9\x900q\xa89\xc6\x97\x83\xdf\xbd\xba"' + + self.assertEqual(b'\x20', b' ') + self.assertEqual( + bytes_value_with_x20, + base64.b64decode(bytes_value_with_x20_as_base64, validate=True)) + + init_kwars = self.dte_xml_data_1.as_dict() + init_kwars.update(dict(signature_value=bytes_value_with_x20)) + + # with self.assertRaises(ValueError) as cm: + # _ = DteXmlData(**init_kwars) + # self.assertEqual( + # cm.exception.args, + # ('Value has leading or trailing whitespace characters.', bytes_value_with_x20) + # ) + _ = DteXmlData(**init_kwars) + + def test_init_fail_regression_signature_cert_der_bytes_with_x20(self) -> None: + bytes_value_with_x20_as_base64 = 'IN2pkDBxqDnGl4Pfvboi' + bytes_value_with_x20 = b'\x20\xdd\xa9\x900q\xa89\xc6\x97\x83\xdf\xbd\xba"' + + self.assertEqual(b'\x20', b' ') + self.assertEqual( + bytes_value_with_x20, + base64.b64decode(bytes_value_with_x20_as_base64, validate=True)) + + init_kwars = self.dte_xml_data_1.as_dict() + init_kwars.update(dict(signature_x509_cert_der=bytes_value_with_x20)) + + # with self.assertRaises(ValueError) as cm: + # _ = DteXmlData(**init_kwars) + # self.assertEqual( + # cm.exception.args, + # ('Value has leading or trailing whitespace characters.', bytes_value_with_x20) + # ) + _ = DteXmlData(**init_kwars) + + def test_as_dict(self) -> None: + self.assertDictEqual( + self.dte_xml_data_1.as_dict(), + dict( + 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, + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_1_xml_signature_value, + signature_x509_cert_der=self.dte_1_xml_cert_der, + emisor_giro='Ingenieria y Construccion', + emisor_email='hello@example.com', + receptor_email=None, + )) + self.assertDictEqual( + self.dte_xml_data_2.as_dict(), + dict( + emisor_rut=Rut('60910000-1'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=2336600, + fecha_emision_date=date(2019, 8, 8), + receptor_rut=Rut('76555835-2'), + monto_total=10642, + emisor_razon_social='Universidad de Chile', + receptor_razon_social='FYNPAL SPA', + fecha_vencimiento_date=date(2019, 8, 8), + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 8, 9, 9, 41, 9), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_2_xml_signature_value, + signature_x509_cert_der=self.dte_2_xml_cert_der, + emisor_giro='Corporación Educacional y Servicios Profesionales', + emisor_email=None, + receptor_email=None, + )) + + def test_as_dte_data_l1(self) -> None: + self.assertEqual( + self.dte_xml_data_1.as_dte_data_l1(), + 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( + self.dte_xml_data_2.as_dte_data_l1(), + DteDataL1( + emisor_rut=Rut('60910000-1'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=2336600, + fecha_emision_date=date(2019, 8, 8), + receptor_rut=Rut('76555835-2'), + monto_total=10642, + ) + ) + + def test_as_dte_data_l2(self) -> None: + self.assertEqual( + self.dte_xml_data_1.as_dte_data_l2(), + 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, + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_1_xml_signature_value, + signature_x509_cert_der=self.dte_1_xml_cert_der, + emisor_giro='Ingenieria y Construccion', + emisor_email='hello@example.com', + receptor_email=None, + ) + ) + self.assertEqual( + self.dte_xml_data_2.as_dte_data_l2(), + DteDataL2( + emisor_rut=Rut('60910000-1'), + tipo_dte=TipoDteEnum.FACTURA_ELECTRONICA, + folio=2336600, + fecha_emision_date=date(2019, 8, 8), + receptor_rut=Rut('76555835-2'), + monto_total=10642, + emisor_razon_social='Universidad de Chile', + receptor_razon_social='FYNPAL SPA', + fecha_vencimiento_date=date(2019, 8, 8), + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 8, 9, 9, 41, 9), + tz=DteXmlData.DATETIME_FIELDS_TZ), + signature_value=self.dte_2_xml_signature_value, + signature_x509_cert_der=self.dte_2_xml_cert_der, + emisor_giro='Corporación Educacional y Servicios Profesionales', + emisor_email=None, + receptor_email=None, + ) + ) + + class FunctionsTest(unittest.TestCase): def test_validate_contribuyente_razon_social(self) -> None: diff --git a/tests/test_dte_parse.py b/tests/test_dte_parse.py index 86af7646..bd78b977 100644 --- a/tests/test_dte_parse.py +++ b/tests/test_dte_parse.py @@ -456,9 +456,9 @@ def test_data(self): def test_parse_dte_xml_ok_1(self) -> None: xml_doc = xml_utils.parse_untrusted_xml(self.dte_clean_xml_1_xml_bytes) - parsed_dte = parse_dte_xml(xml_doc) + dte_xml_data = parse_dte_xml(xml_doc) self.assertDictEqual( - dict(parsed_dte.as_dict()), + dict(dte_xml_data.as_dict()), dict( emisor_rut=Rut('76354771-K'), tipo_dte=cl_sii.dte.constants.TipoDteEnum.FACTURA_ELECTRONICA, @@ -482,9 +482,9 @@ def test_parse_dte_xml_ok_1(self) -> None: def test_parse_dte_xml_ok_3(self) -> None: xml_doc = xml_utils.parse_untrusted_xml(self.dte_clean_xml_3_xml_bytes) - parsed_dte = parse_dte_xml(xml_doc) + dte_xml_data = parse_dte_xml(xml_doc) self.assertDictEqual( - dict(parsed_dte.as_dict()), + dict(dte_xml_data.as_dict()), dict( emisor_rut=Rut('60910000-1'), tipo_dte=cl_sii.dte.constants.TipoDteEnum.FACTURA_ELECTRONICA, @@ -508,9 +508,9 @@ def test_parse_dte_xml_ok_3(self) -> None: def test_parse_dte_xml_ok_1b(self) -> None: xml_doc = xml_utils.parse_untrusted_xml(self.dte_clean_xml_1b_xml_bytes) - parsed_dte = parse_dte_xml(xml_doc) + dte_xml_data = parse_dte_xml(xml_doc) self.assertDictEqual( - dict(parsed_dte.as_dict()), + dict(dte_xml_data.as_dict()), dict( emisor_rut=Rut('76354771-K'), tipo_dte=cl_sii.dte.constants.TipoDteEnum.FACTURA_ELECTRONICA, @@ -534,9 +534,9 @@ def test_parse_dte_xml_ok_1b(self) -> None: def test_parse_dte_xml_ok_2(self) -> None: xml_doc = xml_utils.parse_untrusted_xml(self.dte_clean_xml_2_xml_bytes) - parsed_dte = parse_dte_xml(xml_doc) + dte_xml_data = parse_dte_xml(xml_doc) self.assertDictEqual( - dict(parsed_dte.as_dict()), + dict(dte_xml_data.as_dict()), dict( emisor_rut=Rut('76399752-9'), tipo_dte=cl_sii.dte.constants.TipoDteEnum.FACTURA_ELECTRONICA,