arca-client 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ from .certificates import certificate_paths, cuit_from_certificate
2
+ from .environments import Environment
3
+ from .exceptions import (
4
+ ArcaError,
5
+ PersonaNoEncontrada,
6
+ RespuestaNoReconocida,
7
+ WSAAError,
8
+ WSCDCError,
9
+ WSFEConnectionError,
10
+ WSFEError,
11
+ WSPCIError,
12
+ )
13
+ from .wsaa import WSAA, AccessTicket, FileTicketCache, MemoryTicketCache
14
+ from .wscdc import WSCDC, ComprobanteConsultado, Constatacion, ModoAutorizacion
15
+ from .wsfe import (
16
+ WSFE,
17
+ Alicuota,
18
+ CbteTipo,
19
+ ComprobanteAsociado,
20
+ ComprobanteAutorizado,
21
+ Concepto,
22
+ CondicionIVAReceptor,
23
+ DocTipo,
24
+ Emisor,
25
+ Item,
26
+ IvaAlicuota,
27
+ Receptor,
28
+ TipoTributo,
29
+ Tributo,
30
+ neto_por_alicuota,
31
+ parse_qr_url,
32
+ qr_url,
33
+ render_invoice_pdf,
34
+ )
35
+ from .wspci import WSPCI, Categoria, Constancia, Domicilio, Impuesto, TipoPersona
36
+
37
+ __all__ = [
38
+ "WSAA",
39
+ "WSCDC",
40
+ "WSFE",
41
+ "WSPCI",
42
+ "AccessTicket",
43
+ "Alicuota",
44
+ "ArcaError",
45
+ "Categoria",
46
+ "CbteTipo",
47
+ "ComprobanteAsociado",
48
+ "ComprobanteAutorizado",
49
+ "Concepto",
50
+ "Constancia",
51
+ "ComprobanteConsultado",
52
+ "Constatacion",
53
+ "CondicionIVAReceptor",
54
+ "DocTipo",
55
+ "Domicilio",
56
+ "Emisor",
57
+ "Impuesto",
58
+ "Item",
59
+ "IvaAlicuota",
60
+ "Receptor",
61
+ "Environment",
62
+ "FileTicketCache",
63
+ "MemoryTicketCache",
64
+ "ModoAutorizacion",
65
+ "PersonaNoEncontrada",
66
+ "RespuestaNoReconocida",
67
+ "TipoPersona",
68
+ "TipoTributo",
69
+ "Tributo",
70
+ "WSAAError",
71
+ "WSCDCError",
72
+ "WSFEConnectionError",
73
+ "WSFEError",
74
+ "WSPCIError",
75
+ "certificate_paths",
76
+ "cuit_from_certificate",
77
+ "neto_por_alicuota",
78
+ "parse_qr_url",
79
+ "qr_url",
80
+ "render_invoice_pdf",
81
+ ]
@@ -0,0 +1,121 @@
1
+ """Generación de clave privada y CSR para obtener certificados de ARCA."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from cryptography import x509
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import rsa
10
+ from cryptography.x509.oid import NameOID
11
+
12
+ from .environments import Environment
13
+
14
+ _CUIT_WEIGHTS = (5, 4, 3, 2, 7, 6, 5, 4, 3, 2)
15
+ _ALIAS_RE = re.compile(r"^[A-Za-z0-9]+$")
16
+
17
+
18
+ def normalize_cuit(cuit: str) -> str:
19
+ """Quita guiones y espacios: ``20-12345678-6`` -> ``20123456786``."""
20
+ return re.sub(r"[\s-]", "", cuit)
21
+
22
+
23
+ def is_valid_cuit(cuit: str) -> bool:
24
+ """Valida longitud y dígito verificador (módulo 11)."""
25
+ cuit = normalize_cuit(cuit)
26
+ if not re.fullmatch(r"\d{11}", cuit):
27
+ return False
28
+ total = sum(int(d) * w for d, w in zip(cuit[:10], _CUIT_WEIGHTS, strict=True))
29
+ check = 11 - total % 11
30
+ if check == 11:
31
+ check = 0
32
+ return check != 10 and check == int(cuit[10])
33
+
34
+
35
+ def is_valid_alias(alias: str) -> bool:
36
+ """ARCA solo admite letras y números en el nombre del certificado (CN)."""
37
+ return bool(_ALIAS_RE.fullmatch(alias))
38
+
39
+
40
+ def generate_private_key(key_size: int = 2048) -> rsa.RSAPrivateKey:
41
+ return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
42
+
43
+
44
+ def private_key_pem(key, password: bytes | None = None) -> bytes:
45
+ encryption = (
46
+ serialization.BestAvailableEncryption(password)
47
+ if password
48
+ else serialization.NoEncryption()
49
+ )
50
+ return key.private_bytes(
51
+ serialization.Encoding.PEM,
52
+ serialization.PrivateFormat.TraditionalOpenSSL,
53
+ encryption,
54
+ )
55
+
56
+
57
+ def build_csr(key, cuit: str, organization: str, alias: str) -> x509.CertificateSigningRequest:
58
+ """CSR con el DN que exige ARCA: C=AR, O=<razón social>, CN=<alias>, serialNumber=CUIT <n>."""
59
+ subject = x509.Name(
60
+ [
61
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "AR"),
62
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
63
+ x509.NameAttribute(NameOID.COMMON_NAME, alias),
64
+ x509.NameAttribute(NameOID.SERIAL_NUMBER, f"CUIT {normalize_cuit(cuit)}"),
65
+ ]
66
+ )
67
+ return x509.CertificateSigningRequestBuilder().subject_name(subject).sign(key, hashes.SHA256())
68
+
69
+
70
+ def csr_pem(csr: x509.CertificateSigningRequest) -> bytes:
71
+ return csr.public_bytes(serialization.Encoding.PEM)
72
+
73
+
74
+ def certificate_matches_key(cert_pem: bytes, key) -> bool:
75
+ cert = x509.load_pem_x509_certificate(cert_pem)
76
+ return cert.public_key().public_numbers() == key.public_key().public_numbers()
77
+
78
+
79
+ def write_private(path: Path, data: bytes) -> None:
80
+ """Escribe un archivo legible solo por el dueño (0600)."""
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
83
+ with os.fdopen(fd, "wb") as f:
84
+ f.write(data)
85
+
86
+
87
+ def read_pem(value: bytes | str | os.PathLike) -> bytes:
88
+ """Acepta el contenido PEM en bytes o una ruta a archivo."""
89
+ if isinstance(value, bytes):
90
+ return value
91
+ with open(value, "rb") as f:
92
+ return f.read()
93
+
94
+
95
+ def certificate_paths(
96
+ alias: str, env: Environment | str, base_dir: str | os.PathLike = "certs"
97
+ ) -> tuple[Path, Path]:
98
+ """Rutas (certificado, clave) donde las guarda `arca-setup`: ``<base_dir>/<ambiente>/<alias>``.
99
+
100
+ Un `base_dir` relativo se resuelve contra el directorio actual; una app conviene
101
+ que pase una ruta absoluta (ej. ``BASE_DIR / "certs"``).
102
+ """
103
+ if not is_valid_alias(alias):
104
+ raise ValueError(f"Alias inválido {alias!r}: solo se permiten letras y números")
105
+ folder = Path(base_dir) / Environment(env).value
106
+ return folder / f"{alias}.crt", folder / f"{alias}.key"
107
+
108
+
109
+ def cuit_from_certificate(cert: x509.Certificate | bytes | str | os.PathLike) -> str | None:
110
+ """Extrae la CUIT del campo serialNumber del DN (``CUIT 20123456786``).
111
+
112
+ Acepta el certificado ya cargado, el PEM en bytes o una ruta a archivo, igual
113
+ que ``WSAA(cert=...)``. Devuelve `None` si el certificado no tiene CUIT.
114
+ """
115
+ if not isinstance(cert, x509.Certificate):
116
+ cert = x509.load_pem_x509_certificate(read_pem(cert))
117
+ for attr in cert.subject.get_attributes_for_oid(NameOID.SERIAL_NUMBER):
118
+ match = re.fullmatch(r"CUIT\s*(\d{11})", str(attr.value).strip())
119
+ if match:
120
+ return match.group(1)
121
+ return None
@@ -0,0 +1,50 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Environment(str, Enum):
5
+ """Ambientes de ARCA."""
6
+
7
+ HOMOLOGACION = "homologacion"
8
+ PRODUCCION = "produccion"
9
+
10
+
11
+ WSAA_URLS = {
12
+ Environment.HOMOLOGACION: "https://wsaahomo.afip.gov.ar/ws/services/LoginCms",
13
+ Environment.PRODUCCION: "https://wsaa.afip.gov.ar/ws/services/LoginCms",
14
+ }
15
+
16
+
17
+ def wsaa_wsdl(env: Environment) -> str:
18
+ return f"{WSAA_URLS[env]}?wsdl"
19
+
20
+
21
+ WSFE_URLS = {
22
+ Environment.HOMOLOGACION: "https://wswhomo.afip.gov.ar/wsfev1/service.asmx",
23
+ Environment.PRODUCCION: "https://servicios1.afip.gov.ar/wsfev1/service.asmx",
24
+ }
25
+
26
+
27
+ def wsfe_wsdl(env: Environment) -> str:
28
+ return f"{WSFE_URLS[env]}?WSDL"
29
+
30
+
31
+ WSCDC_URLS = {
32
+ Environment.HOMOLOGACION: "https://wswhomo.afip.gov.ar/WSCDC/service.asmx",
33
+ Environment.PRODUCCION: "https://servicios1.afip.gov.ar/WSCDC/service.asmx",
34
+ }
35
+
36
+
37
+ def wscdc_wsdl(env: Environment) -> str:
38
+ return f"{WSCDC_URLS[env]}?WSDL"
39
+
40
+
41
+ # El manual de la Constancia de Inscripción indica hosts `*.arca.gov.ar`, pero no conectaron
42
+ # en la prueba; los `*.afip.gov.ar` sí responden.
43
+ WSPCI_URLS = {
44
+ Environment.HOMOLOGACION: "https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA5",
45
+ Environment.PRODUCCION: "https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA5",
46
+ }
47
+
48
+
49
+ def wspci_wsdl(env: Environment) -> str:
50
+ return f"{WSPCI_URLS[env]}?WSDL"
@@ -0,0 +1,113 @@
1
+ class ArcaError(Exception):
2
+ """Error base de arca-client."""
3
+
4
+
5
+ class WSAAError(ArcaError):
6
+ """Error devuelto por el WSAA como SoapFault.
7
+
8
+ `code` es el código de la especificación (ej. ``coe.alreadyAuthenticated``,
9
+ ``cms.cert.expired``).
10
+ """
11
+
12
+ def __init__(self, code: str, message: str):
13
+ self.code = code
14
+ self.message = message
15
+ super().__init__(f"{code}: {message}")
16
+
17
+ @property
18
+ def retryable(self) -> bool:
19
+ """Según la spec, solo `wsaa.*` y `wsn.unavailable` admiten reintento (tras 60 s)."""
20
+ return self.code.startswith("wsaa.") or self.code == "wsn.unavailable"
21
+
22
+
23
+ class RespuestaNoReconocida(ArcaError):
24
+ """ARCA respondió algo que no se pudo interpretar.
25
+
26
+ El mensaje no incluye la respuesta (puede tener CUIT, CAE y datos del receptor, y
27
+ terminar en logs); para depurar, está completa en el atributo ``respuesta``.
28
+ """
29
+
30
+ def __init__(self, message: str, respuesta=None):
31
+ self.respuesta = respuesta
32
+ super().__init__(message)
33
+
34
+
35
+ class WSFEError(ArcaError):
36
+ """WSFE rechazó el pedido.
37
+
38
+ `errores` son los ``<Errors>`` generales y `observaciones` los ``<Obs>`` del
39
+ comprobante; ambos son listas de :class:`arca_client.wsfe.Mensaje`.
40
+ """
41
+
42
+ def __init__(self, errores: list, observaciones: list | None = None):
43
+ self.errores = errores
44
+ self.observaciones = observaciones or []
45
+ detalle = "; ".join(str(m) for m in [*self.errores, *self.observaciones])
46
+ super().__init__(detalle or "WSFE rechazó el comprobante sin detalle")
47
+
48
+ @property
49
+ def codes(self) -> list[int]:
50
+ return [m.code for m in [*self.errores, *self.observaciones]]
51
+
52
+
53
+ class WSCDCError(ArcaError):
54
+ """WSCDC no pudo procesar el pedido de constatación (``<Errors>`` sin resultado).
55
+
56
+ No significa que el comprobante sea inválido: no se llegó a verificarlo.
57
+ `errores` es una lista de :class:`arca_client.wsfe.Mensaje`.
58
+ """
59
+
60
+ def __init__(self, errores: list):
61
+ self.errores = errores
62
+ detalle = "; ".join(str(m) for m in errores)
63
+ super().__init__(detalle or "WSCDC no devolvió resultado")
64
+
65
+ @property
66
+ def codes(self) -> list[int]:
67
+ return [m.code for m in self.errores]
68
+
69
+
70
+ class WSPCIError(ArcaError):
71
+ """WSPCI respondió con un SOAP Fault: no se pudo obtener la constancia.
72
+
73
+ No dice nada sobre el contribuyente. `errores` es una lista de textos.
74
+ """
75
+
76
+ def __init__(self, errores: list[str]):
77
+ self.errores = errores
78
+ super().__init__("; ".join(errores) or "WSPCI no devolvió detalle")
79
+
80
+
81
+ class PersonaNoEncontrada(WSPCIError):
82
+ """ARCA no registra ninguna persona con esa CUIT."""
83
+
84
+
85
+ class WSFEConnectionError(ArcaError):
86
+ """Se interrumpió la comunicación al pedir el CAE.
87
+
88
+ - ``safe_to_retry=True``: se verificó con ``FECompConsultar`` que ARCA no
89
+ registró el comprobante; se puede volver a llamar a ``create_invoice_c``.
90
+ - ``safe_to_retry=False``: no se pudo verificar. **No reintentar** con
91
+ ``create_invoice_c`` (emitiría otro número): consultá
92
+ ``WSFE.get_voucher(punto_venta, tipo, numero)`` cuando vuelva la conexión.
93
+
94
+ Si el corte fue una respuesta que no se pudo interpretar, ``respuesta`` la tiene
95
+ completa (no va en el mensaje, para que no termine en logs); si no, es ``None``.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ message: str,
101
+ *,
102
+ punto_venta: int,
103
+ tipo,
104
+ numero: int,
105
+ safe_to_retry: bool,
106
+ respuesta=None,
107
+ ):
108
+ self.punto_venta = punto_venta
109
+ self.tipo = tipo
110
+ self.numero = numero
111
+ self.safe_to_retry = safe_to_retry
112
+ self.respuesta = respuesta
113
+ super().__init__(message)