xhandler 6.0.0a1__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.
- xhandler/__init__.py +10 -0
- xhandler/_core/VERSION +1 -0
- xhandler/_core/__init__.py +132 -0
- xhandler/_core/xhandler.wasm +0 -0
- xhandler/py.typed +0 -0
- xhandler/xbuilder/__init__.py +47 -0
- xhandler/xbuilder/models.py +707 -0
- xhandler/xsender/__init__.py +280 -0
- xhandler/xsender/models.py +197 -0
- xhandler/xsigner/__init__.py +48 -0
- xhandler-6.0.0a1.dist-info/METADATA +93 -0
- xhandler-6.0.0a1.dist-info/RECORD +13 -0
- xhandler-6.0.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Sends signed UBL documents to SUNAT: the SOAP services (billService,
|
|
2
|
+
consult and validation) and the REST API of the guias de remision with its
|
|
3
|
+
OAuth token.
|
|
4
|
+
|
|
5
|
+
The xhandler core builds every request and reads every answer (CDR, tickets,
|
|
6
|
+
faults); this package only sends them over HTTP with httpx.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from xhandler._core import ErrorKind, XhandlerError, invoke
|
|
19
|
+
from xhandler.xsender.models import (
|
|
20
|
+
BILL_CONSULT_SERVICE_URL,
|
|
21
|
+
BILL_VALID_SERVICE_URL,
|
|
22
|
+
CDR,
|
|
23
|
+
ConsultDocument,
|
|
24
|
+
ConsultResult,
|
|
25
|
+
ConsultStatus,
|
|
26
|
+
Credentials,
|
|
27
|
+
Fault,
|
|
28
|
+
File,
|
|
29
|
+
NoCDR,
|
|
30
|
+
SendResult,
|
|
31
|
+
Status,
|
|
32
|
+
Target,
|
|
33
|
+
URLs,
|
|
34
|
+
ValidateCDP,
|
|
35
|
+
VerifyResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BILL_CONSULT_SERVICE_URL",
|
|
40
|
+
"BILL_VALID_SERVICE_URL",
|
|
41
|
+
"CDR",
|
|
42
|
+
"ConsultDocument",
|
|
43
|
+
"ConsultResult",
|
|
44
|
+
"ConsultStatus",
|
|
45
|
+
"Credentials",
|
|
46
|
+
"Fault",
|
|
47
|
+
"File",
|
|
48
|
+
"FileSender",
|
|
49
|
+
"NoCDR",
|
|
50
|
+
"SendResult",
|
|
51
|
+
"Status",
|
|
52
|
+
"SunatClient",
|
|
53
|
+
"Target",
|
|
54
|
+
"URLs",
|
|
55
|
+
"ValidateCDP",
|
|
56
|
+
"VerifyResult",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_RATE_LIMIT_PAUSES = (1, 2)
|
|
61
|
+
"""Seconds before retrying a request SUNAT's front proxy refused."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SunatClient:
|
|
65
|
+
"""Sends the requests to SUNAT and keeps the OAuth tokens of the guias de
|
|
66
|
+
remision. Share one between the FileSenders of several companies."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self, http: httpx.Client | None = None, security_api_url: str | None = None
|
|
70
|
+
) -> None:
|
|
71
|
+
"""``http`` defaults to a client with a 30s connection timeout and a
|
|
72
|
+
120s timeout for the rest. ``security_api_url`` is where the tokens of
|
|
73
|
+
the guias are requested (SUNAT's by default)."""
|
|
74
|
+
self._http = http or httpx.Client(timeout=httpx.Timeout(120, connect=30))
|
|
75
|
+
self._security_api_url = security_api_url
|
|
76
|
+
self._lock = threading.Lock() # guards _tokens
|
|
77
|
+
self._tokens: dict[str, tuple[str, float]] = {}
|
|
78
|
+
# Held while a token is requested, so concurrent calls don't revoke
|
|
79
|
+
# each other's tokens
|
|
80
|
+
self._requesting = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
"""Closes the HTTP connections."""
|
|
84
|
+
self._http.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> SunatClient:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *_: object) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
def get_status(
|
|
93
|
+
self, url: str, document: ConsultDocument, credentials: Credentials
|
|
94
|
+
) -> ConsultResult:
|
|
95
|
+
"""billConsultService getStatus: the status of a document already sent."""
|
|
96
|
+
call = {"get_status": {"url": url, "document": _json(document)}}
|
|
97
|
+
return ConsultResult.model_validate(self._exchange(call, False, credentials))
|
|
98
|
+
|
|
99
|
+
def get_status_cdr(
|
|
100
|
+
self, url: str, document: ConsultDocument, credentials: Credentials
|
|
101
|
+
) -> ConsultResult:
|
|
102
|
+
"""billConsultService getStatusCdr: the CDR of a document already sent."""
|
|
103
|
+
call = {"get_status_cdr": {"url": url, "document": _json(document)}}
|
|
104
|
+
return ConsultResult.model_validate(self._exchange(call, False, credentials))
|
|
105
|
+
|
|
106
|
+
def validate_cdp(self, url: str, cdp: ValidateCDP, credentials: Credentials) -> ConsultResult:
|
|
107
|
+
"""billValidService validaCDPcriterios."""
|
|
108
|
+
call = {"validate_cdp": {"url": url, "cdp": _json(cdp)}}
|
|
109
|
+
return ConsultResult.model_validate(self._exchange(call, False, credentials))
|
|
110
|
+
|
|
111
|
+
def validate_file(self, url: str, file: File, credentials: Credentials) -> ConsultResult:
|
|
112
|
+
"""billValidService verificaCPEarchivo."""
|
|
113
|
+
call = {"validate_file": {"url": url, "file": _json(file)}}
|
|
114
|
+
return ConsultResult.model_validate(self._exchange(call, False, credentials))
|
|
115
|
+
|
|
116
|
+
def _exchange(self, call: Any, rest: bool, credentials: Credentials) -> Any:
|
|
117
|
+
"""Sends a call and returns its answer. SUNAT revokes the tokens of the
|
|
118
|
+
guias before they expire: a REST call rejected with HTTP 401 is retried
|
|
119
|
+
once with a new token. A SOAP call refused by SUNAT's rate limiting is
|
|
120
|
+
retried after a pause."""
|
|
121
|
+
if rest:
|
|
122
|
+
token = self._token(credentials)
|
|
123
|
+
response = self._send(self._request(call, credentials, token))
|
|
124
|
+
if response.status_code == 401 and credentials.token is None:
|
|
125
|
+
self._forget(credentials, token)
|
|
126
|
+
response = self._send(self._request(call, credentials, self._token(credentials)))
|
|
127
|
+
return _parse(call, response)
|
|
128
|
+
|
|
129
|
+
request = self._request(call, credentials, None)
|
|
130
|
+
for pause in (*_RATE_LIMIT_PAUSES, None):
|
|
131
|
+
try:
|
|
132
|
+
return _parse(call, self._send(request))
|
|
133
|
+
except XhandlerError as error:
|
|
134
|
+
if error.kind is not ErrorKind.RATE_LIMITED or pause is None:
|
|
135
|
+
raise
|
|
136
|
+
time.sleep(pause)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _request(call: Any, credentials: Credentials, token: str | None) -> Any:
|
|
140
|
+
return invoke("request", {"call": call, "credentials": _json(credentials), "token": token})
|
|
141
|
+
|
|
142
|
+
def _send(self, request: dict[str, Any]) -> httpx.Response:
|
|
143
|
+
def send() -> httpx.Response:
|
|
144
|
+
return self._http.request(
|
|
145
|
+
request["method"],
|
|
146
|
+
request["url"],
|
|
147
|
+
headers=dict(request["headers"]),
|
|
148
|
+
content=request["body"],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
return send()
|
|
153
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
154
|
+
return send() # once more: the request never reached SUNAT
|
|
155
|
+
|
|
156
|
+
def _token(self, credentials: Credentials) -> str:
|
|
157
|
+
"""The token of the credentials, a cached one (reused until a minute
|
|
158
|
+
before it expires), or a new one."""
|
|
159
|
+
if credentials.token is not None:
|
|
160
|
+
return credentials.token
|
|
161
|
+
key = _token_key(credentials)
|
|
162
|
+
cached = self._cached_token(key)
|
|
163
|
+
if cached:
|
|
164
|
+
return cached
|
|
165
|
+
with self._requesting:
|
|
166
|
+
cached = self._cached_token(key)
|
|
167
|
+
if cached:
|
|
168
|
+
return cached
|
|
169
|
+
return self._request_token(key, credentials)
|
|
170
|
+
|
|
171
|
+
def _cached_token(self, key: str) -> str | None:
|
|
172
|
+
with self._lock:
|
|
173
|
+
cached = self._tokens.get(key)
|
|
174
|
+
return cached[0] if cached and time.monotonic() < cached[1] else None
|
|
175
|
+
|
|
176
|
+
def _request_token(self, key: str, credentials: Credentials) -> str:
|
|
177
|
+
call = {"token": {"url": self._security_api_url}}
|
|
178
|
+
token = self._exchange(call, False, credentials)["token"]
|
|
179
|
+
with self._lock:
|
|
180
|
+
reuse_before = time.monotonic() + max(token["expires_in"] - 60, 0)
|
|
181
|
+
self._tokens[key] = (token["access_token"], reuse_before)
|
|
182
|
+
return str(token["access_token"])
|
|
183
|
+
|
|
184
|
+
def _forget(self, credentials: Credentials, rejected: str) -> None:
|
|
185
|
+
"""Forgets the rejected token, unless another call already replaced it."""
|
|
186
|
+
key = _token_key(credentials)
|
|
187
|
+
with self._lock:
|
|
188
|
+
if self._tokens.get(key, ("",))[0] == rejected:
|
|
189
|
+
del self._tokens[key]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse(call: Any, response: httpx.Response) -> Any:
|
|
193
|
+
return invoke(
|
|
194
|
+
"parse",
|
|
195
|
+
{
|
|
196
|
+
"call": call,
|
|
197
|
+
"status": response.status_code,
|
|
198
|
+
"headers": list(response.headers.multi_items()),
|
|
199
|
+
"body": response.text,
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _token_key(credentials: Credentials) -> str:
|
|
205
|
+
return f"{credentials.client_id or ''}:{credentials.username}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _json(model: Any) -> Any:
|
|
209
|
+
return model.model_dump(mode="json")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _b64(data: bytes) -> str:
|
|
213
|
+
return base64.b64encode(data).decode()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_default_client: SunatClient | None = None
|
|
217
|
+
_default_client_lock = threading.Lock()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class FileSender:
|
|
221
|
+
"""Sends the signed documents of one company. The document decides
|
|
222
|
+
everything else: the file name, the service and the operation."""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self, urls: URLs, credentials: Credentials, client: SunatClient | None = None
|
|
226
|
+
) -> None:
|
|
227
|
+
self.urls = urls
|
|
228
|
+
self.credentials = credentials
|
|
229
|
+
self._client = client
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def client(self) -> SunatClient:
|
|
233
|
+
global _default_client
|
|
234
|
+
if self._client is None:
|
|
235
|
+
with _default_client_lock:
|
|
236
|
+
_default_client = _default_client or SunatClient()
|
|
237
|
+
self._client = _default_client
|
|
238
|
+
return self._client
|
|
239
|
+
|
|
240
|
+
def send_file(self, xml: bytes | str) -> SendResult:
|
|
241
|
+
"""Zips the signed XML and sends it to its SUNAT service."""
|
|
242
|
+
prepared = invoke("prepare_send_file", {"xml": _b64(_bytes(xml)), "urls": _json(self.urls)})
|
|
243
|
+
return self._send(prepared)
|
|
244
|
+
|
|
245
|
+
def send_pack(self, name: str, files: list[bytes | str]) -> SendResult:
|
|
246
|
+
"""Sends several invoices in one zip called ``name`` (sendPack)."""
|
|
247
|
+
prepared = invoke(
|
|
248
|
+
"prepare_send_pack",
|
|
249
|
+
{"name": name, "files": [_b64(_bytes(f)) for f in files], "urls": _json(self.urls)},
|
|
250
|
+
)
|
|
251
|
+
return self._send(prepared)
|
|
252
|
+
|
|
253
|
+
def _send(self, prepared: dict[str, Any]) -> SendResult:
|
|
254
|
+
target = prepared["send_target"]
|
|
255
|
+
call = {"send_file": {"target": target, "file": prepared["file"]}}
|
|
256
|
+
answer = self.client._exchange(call, target["protocol"] == "rest", self.credentials)
|
|
257
|
+
return SendResult.model_validate(
|
|
258
|
+
{
|
|
259
|
+
**answer,
|
|
260
|
+
"send_target": target,
|
|
261
|
+
"verify_ticket_target": prepared["verify_ticket_target"],
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def verify_ticket(self, target: Target, ticket: str) -> VerifyResult:
|
|
266
|
+
"""Asks the service ``target`` for the status of a ticket."""
|
|
267
|
+
call = {"verify_ticket": {"target": _json(target), "ticket": ticket}}
|
|
268
|
+
answer = self.client._exchange(call, target.protocol == "rest", self.credentials)
|
|
269
|
+
return VerifyResult.model_validate(answer)
|
|
270
|
+
|
|
271
|
+
def verify_file_ticket(self, xml: bytes | str, ticket: str) -> VerifyResult:
|
|
272
|
+
""":meth:`verify_ticket` with the service of the sent document."""
|
|
273
|
+
target = invoke(
|
|
274
|
+
"verify_ticket_target", {"xml": _b64(_bytes(xml)), "urls": _json(self.urls)}
|
|
275
|
+
)
|
|
276
|
+
return self.verify_ticket(Target.model_validate(target), ticket)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _bytes(xml: bytes | str) -> bytes:
|
|
280
|
+
return xml.encode() if isinstance(xml, str) else xml
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Inputs and answers of the SUNAT services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _from_base64(value: object) -> object:
|
|
14
|
+
return base64.b64decode(value) if isinstance(value, str) else value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Bytes = Annotated[
|
|
18
|
+
bytes,
|
|
19
|
+
BeforeValidator(_from_base64),
|
|
20
|
+
PlainSerializer(lambda b: base64.b64encode(b).decode(), return_type=str, when_used="json"),
|
|
21
|
+
]
|
|
22
|
+
"""Raw bytes in Python, base64 in the JSON of the core."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Model(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class URLs(_Model):
|
|
30
|
+
"""The SUNAT services where a company sends its documents."""
|
|
31
|
+
|
|
32
|
+
invoice: str
|
|
33
|
+
perception_retention: str
|
|
34
|
+
despatch: str = ""
|
|
35
|
+
"""The REST API of the guias de remision; SUNAT has no beta for it."""
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def beta(cls) -> URLs:
|
|
39
|
+
"""SUNAT beta (testing) services; without guias de remision."""
|
|
40
|
+
return cls(
|
|
41
|
+
invoice="https://e-beta.sunat.gob.pe/ol-ti-itcpfegem-beta/billService",
|
|
42
|
+
perception_retention="https://e-beta.sunat.gob.pe/ol-ti-itemision-otroscpe-gem-beta/billService",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def production(cls) -> URLs:
|
|
47
|
+
"""SUNAT production services."""
|
|
48
|
+
return cls(
|
|
49
|
+
invoice="https://e-factura.sunat.gob.pe/ol-ti-itcpfegem/billService",
|
|
50
|
+
perception_retention="https://e-factura.sunat.gob.pe/ol-ti-itemision-otroscpe-gem/billService",
|
|
51
|
+
despatch="https://api-cpe.sunat.gob.pe/v1/contribuyente/gem",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
BILL_CONSULT_SERVICE_URL = "https://e-factura.sunat.gob.pe/ol-it-wsconscpegem/billConsultService"
|
|
56
|
+
"""Looks up documents already sent (get_status, get_status_cdr)."""
|
|
57
|
+
BILL_VALID_SERVICE_URL = "https://e-factura.sunat.gob.pe/ol-it-wsconsvalidcpe/billValidService"
|
|
58
|
+
"""Validates documents (validate_cdp, validate_file)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Credentials(_Model):
|
|
62
|
+
"""Credentials of the company. They never show their secrets."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
|
|
65
|
+
|
|
66
|
+
username: str
|
|
67
|
+
"""SOL user, e.g. "20123456789MODDATOS"."""
|
|
68
|
+
password: str
|
|
69
|
+
client_id: str | None = None
|
|
70
|
+
"""API client of the REST services (guias de remision), from SUNAT's SOL menu."""
|
|
71
|
+
client_secret: str | None = None
|
|
72
|
+
token: str | None = Field(default=None, exclude=True)
|
|
73
|
+
"""A token of the guias obtained elsewhere; used as is when set."""
|
|
74
|
+
|
|
75
|
+
def __repr_args__(self) -> Iterator[tuple[str | None, Any]]:
|
|
76
|
+
"""For repr and str: only the username."""
|
|
77
|
+
yield "username", self.username
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Target(_Model):
|
|
81
|
+
"""A SUNAT service for a file or a ticket."""
|
|
82
|
+
|
|
83
|
+
protocol: Literal["soap", "rest"]
|
|
84
|
+
url: str
|
|
85
|
+
action: Literal["bill", "summary", "pack"] | None = None
|
|
86
|
+
"""The operation of the SOAP files."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class File(_Model):
|
|
90
|
+
"""A zip for SUNAT."""
|
|
91
|
+
|
|
92
|
+
filename: str
|
|
93
|
+
zip: Bytes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ConsultDocument(_Model):
|
|
97
|
+
"""A document already sent to SUNAT."""
|
|
98
|
+
|
|
99
|
+
ruc: str
|
|
100
|
+
tipo_comprobante: str
|
|
101
|
+
"""Catalog1, e.g. "01"."""
|
|
102
|
+
serie: str
|
|
103
|
+
numero: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ValidateCDP(ConsultDocument):
|
|
107
|
+
"""The criteria of validaCDPcriterios."""
|
|
108
|
+
|
|
109
|
+
tipo_documento_identidad_receptor: str
|
|
110
|
+
"""Catalog6."""
|
|
111
|
+
numero_documento_identidad_receptor: str
|
|
112
|
+
fecha_emision: str
|
|
113
|
+
"""dd/MM/yyyy."""
|
|
114
|
+
importe_total: str
|
|
115
|
+
numero_autorizacion: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Status(str, Enum):
|
|
119
|
+
"""The SUNAT verdict of a document; prints as SUNAT says it, e.g. ACEPTADO."""
|
|
120
|
+
|
|
121
|
+
ACCEPTED = "ACEPTADO"
|
|
122
|
+
REJECTED = "RECHAZADO"
|
|
123
|
+
EXCEPTION = "EXCEPCION"
|
|
124
|
+
IN_PROGRESS = "EN_PROCESO"
|
|
125
|
+
UNKNOWN = "UNKNOWN"
|
|
126
|
+
|
|
127
|
+
def __str__(self) -> str:
|
|
128
|
+
return self.value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CDR(_Model):
|
|
132
|
+
"""The receipt SUNAT gives back (Constancia de Recepcion)."""
|
|
133
|
+
|
|
134
|
+
zip: Bytes
|
|
135
|
+
response_code: str
|
|
136
|
+
description: str
|
|
137
|
+
notes: list[str]
|
|
138
|
+
status: Status
|
|
139
|
+
status_code: str | None = None
|
|
140
|
+
"""The ticket status, only when verifying a ticket."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Fault(_Model):
|
|
144
|
+
"""An error answered by SUNAT."""
|
|
145
|
+
|
|
146
|
+
code: str
|
|
147
|
+
"""SUNAT's code as given: the SOAP faultcode (e.g. "soap-env:Client.0111") or
|
|
148
|
+
the error code of a REST answer (e.g. "1033")."""
|
|
149
|
+
sunat_code: int | None = None
|
|
150
|
+
"""The number in ``code`` (111), when it has one."""
|
|
151
|
+
http_status: int | None = None
|
|
152
|
+
"""The status of the answer, e.g. 500 for SOAP faults or 422 for REST errors."""
|
|
153
|
+
message: str
|
|
154
|
+
status: Status
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class NoCDR(_Model):
|
|
158
|
+
"""A ticket status without CDR, e.g. "98" (in progress)."""
|
|
159
|
+
|
|
160
|
+
status_code: str
|
|
161
|
+
status: Status
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SendResult(_Model):
|
|
165
|
+
"""The answer to a sent file: a CDR, a ticket to verify later (summaries,
|
|
166
|
+
voided documents, packs, guias) or a fault."""
|
|
167
|
+
|
|
168
|
+
cdr: CDR | None = None
|
|
169
|
+
ticket: str | None = None
|
|
170
|
+
fault: Fault | None = None
|
|
171
|
+
send_target: Target
|
|
172
|
+
verify_ticket_target: Target | None = None
|
|
173
|
+
"""Where the ticket is verified."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class VerifyResult(_Model):
|
|
177
|
+
"""The answer to a ticket: a CDR, no CDR yet (e.g. in progress) or a fault."""
|
|
178
|
+
|
|
179
|
+
cdr: CDR | None = None
|
|
180
|
+
no_cdr: NoCDR | None = None
|
|
181
|
+
fault: Fault | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ConsultStatus(_Model):
|
|
185
|
+
"""e.g. "0001" (accepted) for get_status, or "0" for validate_cdp."""
|
|
186
|
+
|
|
187
|
+
status_code: str
|
|
188
|
+
status_message: str | None = None
|
|
189
|
+
cdr_zip: Bytes | None = None
|
|
190
|
+
"""The CDR of get_status_cdr."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ConsultResult(_Model):
|
|
194
|
+
"""The answer of the consult and validation services."""
|
|
195
|
+
|
|
196
|
+
status: ConsultStatus | None = None
|
|
197
|
+
fault: Fault | None = None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Signs UBL documents with the enveloped RSA-SHA256 XML signature SUNAT
|
|
2
|
+
requires. The signature is placed in the first empty ``ext:ExtensionContent``
|
|
3
|
+
of the document, as rendered by :mod:`xhandler.xbuilder`."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
from typing import Any, overload
|
|
9
|
+
|
|
10
|
+
from xhandler._core import invoke
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SigningKey:
|
|
14
|
+
"""The private key and certificate that sign the documents. The key is
|
|
15
|
+
checked when signing. It never shows its secrets in ``repr``."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_key",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, key: dict[str, Any]) -> None:
|
|
20
|
+
self._key = key
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_pem(cls, private_key: str, certificate: str) -> SigningKey:
|
|
24
|
+
"""A PEM private key (PKCS#8 or PKCS#1) and a PEM X.509 certificate."""
|
|
25
|
+
return cls({"pem": {"private_key": private_key, "certificate": certificate}})
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_pkcs12(cls, der: bytes, password: str) -> SigningKey:
|
|
29
|
+
"""The signing key stored in a PKCS#12 keystore (.pfx, .p12)."""
|
|
30
|
+
return cls({"pkcs12": {"der": base64.b64encode(der).decode(), "password": password}})
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return "SigningKey(<redacted>)"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@overload
|
|
37
|
+
def sign(xml: bytes, key: SigningKey) -> bytes: ...
|
|
38
|
+
@overload
|
|
39
|
+
def sign(xml: str, key: SigningKey) -> str: ...
|
|
40
|
+
def sign(xml: bytes | str, key: SigningKey) -> bytes | str:
|
|
41
|
+
"""Returns the XML with its signature, as bytes for bytes and as text for
|
|
42
|
+
text. Bytes may be UTF-8 or ISO-8859-1 (as their XML declaration says);
|
|
43
|
+
the signed bytes are UTF-8."""
|
|
44
|
+
data = xml.encode() if isinstance(xml, str) else xml
|
|
45
|
+
signed = base64.b64decode(
|
|
46
|
+
invoke("sign", {"xml": base64.b64encode(data).decode(), "key": key._key})
|
|
47
|
+
)
|
|
48
|
+
return signed.decode() if isinstance(xml, str) else signed
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xhandler
|
|
3
|
+
Version: 6.0.0-alpha.1
|
|
4
|
+
Summary: Create, sign and send the electronic documents (UBL XML) of SUNAT Peru
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.7
|
|
9
|
+
Requires-Dist: wasmtime>=30
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# xhandler-python
|
|
13
|
+
|
|
14
|
+
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
15
|
+
[](https://github.com/project-openubl/xhandler-python/actions/workflows/ci.yml)
|
|
16
|
+
|
|
17
|
+
Crea, firma y envía los comprobantes electrónicos (XML UBL) de la SUNAT del Perú desde Python. Sin
|
|
18
|
+
extensiones nativas que compilar.
|
|
19
|
+
|
|
20
|
+
## Instalación
|
|
21
|
+
|
|
22
|
+
Hasta la primera versión en PyPI, instálalo desde GitHub:
|
|
23
|
+
|
|
24
|
+
```shell
|
|
25
|
+
pip install "xhandler @ git+https://github.com/project-openubl/xhandler-python"
|
|
26
|
+
# o
|
|
27
|
+
uv add "xhandler @ git+https://github.com/project-openubl/xhandler-python"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Python 3.10 o superior, en Linux, macOS y Windows.
|
|
31
|
+
|
|
32
|
+
## Uso
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from decimal import Decimal
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from xhandler import xbuilder
|
|
39
|
+
from xhandler.xsender import Credentials, FileSender, URLs
|
|
40
|
+
from xhandler.xsigner import SigningKey, sign
|
|
41
|
+
|
|
42
|
+
# Crear
|
|
43
|
+
invoice = xbuilder.Invoice(
|
|
44
|
+
serie_numero="F001-1",
|
|
45
|
+
proveedor=xbuilder.Proveedor(ruc="12345678912", razon_social="Softgreen S.A.C."),
|
|
46
|
+
cliente=xbuilder.Cliente(
|
|
47
|
+
tipo_documento_identidad="6", # RUC (Catalog6)
|
|
48
|
+
numero_documento_identidad="12121212121",
|
|
49
|
+
nombre="Carlos Feria",
|
|
50
|
+
),
|
|
51
|
+
detalles=[xbuilder.Detalle(descripcion="Item", cantidad=Decimal(2), precio=Decimal("100.00"))],
|
|
52
|
+
)
|
|
53
|
+
xml = xbuilder.build(invoice) # fecha de hoy en Perú y tasas vigentes
|
|
54
|
+
|
|
55
|
+
# Firmar
|
|
56
|
+
key = SigningKey.from_pkcs12(Path("certificado.pfx").read_bytes(), "password")
|
|
57
|
+
signed = sign(xml.encode(), key)
|
|
58
|
+
|
|
59
|
+
# Enviar
|
|
60
|
+
sender = FileSender(URLs.beta(), Credentials(username="12345678959MODDATOS", password="MODDATOS"))
|
|
61
|
+
result = sender.send_file(signed)
|
|
62
|
+
if result.cdr:
|
|
63
|
+
# ACEPTADO La Factura numero F001-1, ha sido aceptada
|
|
64
|
+
print(result.cdr.status, result.cdr.description)
|
|
65
|
+
elif result.ticket: # resúmenes, bajas y guías: consulta el ticket después
|
|
66
|
+
print(sender.verify_file_ticket(signed, result.ticket))
|
|
67
|
+
else:
|
|
68
|
+
print(result.fault.status, result.fault.code, result.fault.message)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **Crear**: facturas, boletas, notas de crédito y débito, guías de remisión, percepciones,
|
|
72
|
+
retenciones, resúmenes diarios, bajas y reversiones. `xbuilder.enrich` devuelve el documento con
|
|
73
|
+
los valores calculados (impuestos, totales, leyendas) en lugar del XML. `xbuilder.Defaults`
|
|
74
|
+
cambia la fecha de emisión o las tasas.
|
|
75
|
+
- **Firmar**: `SigningKey.from_pem(llave_privada, certificado)` para archivos PEM. `sign` devuelve
|
|
76
|
+
texto si recibe texto y bytes si recibe bytes.
|
|
77
|
+
- **Enviar**: `send_pack` envía varias facturas en un zip. `SunatClient` también tiene los
|
|
78
|
+
servicios de consulta y validación (`get_status`, `get_status_cdr`, `validate_cdp`,
|
|
79
|
+
`validate_file`). Si envías por varias empresas, comparte un `SunatClient` (conexiones y tokens)
|
|
80
|
+
entre sus `FileSender`.
|
|
81
|
+
- **Guías de remisión**: usan `URLs.production()` (la SUNAT no tiene beta para ellas) y el
|
|
82
|
+
`client_id`/`client_secret` de la API.
|
|
83
|
+
- **Errores**: los del núcleo lanzan `xhandler.XhandlerError` con un `kind`: `ErrorKind.VALIDATION`
|
|
84
|
+
(p. ej. un código de catálogo inválido), `ErrorKind.INVALID_KEY`, `ErrorKind.RESPONSE`... Los
|
|
85
|
+
rechazos de la SUNAT no son errores: llegan en `result.fault`.
|
|
86
|
+
|
|
87
|
+
## Contribuir
|
|
88
|
+
|
|
89
|
+
Revisa [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
90
|
+
|
|
91
|
+
## Licencia
|
|
92
|
+
|
|
93
|
+
[Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
xhandler/__init__.py,sha256=dgZM4-fSIja7qJpqd0JuBwR1fd2ZEdnpI3qG9s0do3s,370
|
|
2
|
+
xhandler/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
xhandler/_core/VERSION,sha256=9IGx4GPPQn13Mbt2yoHgxYIXZkqPCFEx32Klaz7caSE,14
|
|
4
|
+
xhandler/_core/__init__.py,sha256=751IUS3fx0T3JPeb7d5jfQA2UC9QkxANSArqfjfxyiY,5115
|
|
5
|
+
xhandler/_core/xhandler.wasm,sha256=DSiAXV0mWEebxT1pvV6xzFJSkfzq6DsORXb4h71O8kU,4371842
|
|
6
|
+
xhandler/xbuilder/__init__.py,sha256=GhZboB6JB_Karx0hr2v8QSoYPz_4X1oW7RdkjfbRT1w,1805
|
|
7
|
+
xhandler/xbuilder/models.py,sha256=PmYjkUN4VHRKNUiLE9i9YnR6gsDh2xaYJ2sLHxFyNRg,24783
|
|
8
|
+
xhandler/xsender/__init__.py,sha256=2C3clKlLfMEPOxpF2ATmaPfWAmeWu_UelrWY7ZO-Gxo,10216
|
|
9
|
+
xhandler/xsender/models.py,sha256=Aj_IppTn3-RSF3rrk5IYRYnYDDm6vibdXcymPCI_bKE,5636
|
|
10
|
+
xhandler/xsigner/__init__.py,sha256=r6hFV0WFmvU1J59g0CkXH6Jx8niEyXWM-O6i0OsvKsE,1791
|
|
11
|
+
xhandler-6.0.0a1.dist-info/METADATA,sha256=wdjFfxb4XMGOXvZFRzLl817eLMe6gdOKsdaO3gd7Y18,3745
|
|
12
|
+
xhandler-6.0.0a1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
xhandler-6.0.0a1.dist-info/RECORD,,
|