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.
- arca_client/__init__.py +81 -0
- arca_client/certificates.py +121 -0
- arca_client/environments.py +50 -0
- arca_client/exceptions.py +113 -0
- arca_client/setup_wizard.py +382 -0
- arca_client/transport.py +36 -0
- arca_client/wsaa/__init__.py +13 -0
- arca_client/wsaa/cache.py +194 -0
- arca_client/wsaa/client.py +138 -0
- arca_client/wsaa/signer.py +35 -0
- arca_client/wsaa/ticket.py +67 -0
- arca_client/wsaa/tra.py +37 -0
- arca_client/wscdc/__init__.py +3 -0
- arca_client/wscdc/client.py +305 -0
- arca_client/wsfe/__init__.py +41 -0
- arca_client/wsfe/client.py +997 -0
- arca_client/wsfe/fonts/LiberationSans-Bold.ttf +0 -0
- arca_client/wsfe/fonts/LiberationSans-Regular.ttf +0 -0
- arca_client/wsfe/fonts/OFL.txt +104 -0
- arca_client/wsfe/pdf.py +628 -0
- arca_client/wsfe/qr.py +125 -0
- arca_client/wsfe/types.py +279 -0
- arca_client/wspci/__init__.py +3 -0
- arca_client/wspci/client.py +369 -0
- arca_client-0.6.0.dist-info/METADATA +465 -0
- arca_client-0.6.0.dist-info/RECORD +29 -0
- arca_client-0.6.0.dist-info/WHEEL +4 -0
- arca_client-0.6.0.dist-info/entry_points.txt +2 -0
- arca_client-0.6.0.dist-info/licenses/LICENSE +21 -0
arca_client/__init__.py
ADDED
|
@@ -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)
|