qvapaykit 0.1.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.
- qvapaykit/__init__.py +91 -0
- qvapaykit/client.py +310 -0
- qvapaykit/config.py +20 -0
- qvapaykit/exceptions.py +111 -0
- qvapaykit/http.py +250 -0
- qvapaykit/models.py +170 -0
- qvapaykit/py.typed +0 -0
- qvapaykit/webhooks.py +112 -0
- qvapaykit-0.1.0.dist-info/METADATA +28 -0
- qvapaykit-0.1.0.dist-info/RECORD +11 -0
- qvapaykit-0.1.0.dist-info/WHEEL +4 -0
qvapaykit/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""qvapay: SDK Python para la API de QvaPay.
|
|
2
|
+
|
|
3
|
+
Documentación de la API: https://www.qvapay.com/docs
|
|
4
|
+
|
|
5
|
+
Quickstart (modo App, credenciales en ``QVAPAY_APP_ID``/``QVAPAY_APP_SECRET``)::
|
|
6
|
+
|
|
7
|
+
from qvapaykit import QvaPayClient
|
|
8
|
+
|
|
9
|
+
with QvaPayClient.from_env() as client:
|
|
10
|
+
invoice = client.create_invoice(
|
|
11
|
+
amount="25.00",
|
|
12
|
+
description="Compra en Mi Tienda",
|
|
13
|
+
remote_id="orden-1234",
|
|
14
|
+
)
|
|
15
|
+
print(invoice.url) # enlace de pago para compartir
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from qvapaykit.client import AsyncQvaPayClient, QvaPayClient
|
|
19
|
+
from qvapaykit.config import QvaPaySettings
|
|
20
|
+
from qvapaykit.exceptions import (
|
|
21
|
+
ATTESTATION_CODES,
|
|
22
|
+
QvaPayAttestationRequiredError,
|
|
23
|
+
QvaPayAuthError,
|
|
24
|
+
QvaPayDuplicateRequestError,
|
|
25
|
+
QvaPayError,
|
|
26
|
+
QvaPayForbiddenError,
|
|
27
|
+
QvaPayKYCRequiredError,
|
|
28
|
+
QvaPayNetworkError,
|
|
29
|
+
QvaPayNotFoundError,
|
|
30
|
+
QvaPayRateLimitError,
|
|
31
|
+
QvaPayServerError,
|
|
32
|
+
QvaPayValidationError,
|
|
33
|
+
WebhookMismatchError,
|
|
34
|
+
WebhookParseError,
|
|
35
|
+
)
|
|
36
|
+
from qvapaykit.models import (
|
|
37
|
+
AppInfo,
|
|
38
|
+
Balance,
|
|
39
|
+
Invoice,
|
|
40
|
+
InvoiceRequest,
|
|
41
|
+
Payer,
|
|
42
|
+
Product,
|
|
43
|
+
Transaction,
|
|
44
|
+
TransactionStatus,
|
|
45
|
+
)
|
|
46
|
+
from qvapaykit.webhooks import (
|
|
47
|
+
IdempotencyStore,
|
|
48
|
+
InMemoryIdempotencyStore,
|
|
49
|
+
WebhookEvent,
|
|
50
|
+
areconcile_webhook,
|
|
51
|
+
assert_matches,
|
|
52
|
+
parse_webhook,
|
|
53
|
+
reconcile_webhook,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ATTESTATION_CODES",
|
|
60
|
+
"AppInfo",
|
|
61
|
+
"AsyncQvaPayClient",
|
|
62
|
+
"Balance",
|
|
63
|
+
"IdempotencyStore",
|
|
64
|
+
"InMemoryIdempotencyStore",
|
|
65
|
+
"Invoice",
|
|
66
|
+
"InvoiceRequest",
|
|
67
|
+
"Payer",
|
|
68
|
+
"Product",
|
|
69
|
+
"QvaPayAttestationRequiredError",
|
|
70
|
+
"QvaPayAuthError",
|
|
71
|
+
"QvaPayClient",
|
|
72
|
+
"QvaPayDuplicateRequestError",
|
|
73
|
+
"QvaPayError",
|
|
74
|
+
"QvaPayForbiddenError",
|
|
75
|
+
"QvaPayKYCRequiredError",
|
|
76
|
+
"QvaPayNetworkError",
|
|
77
|
+
"QvaPayNotFoundError",
|
|
78
|
+
"QvaPayRateLimitError",
|
|
79
|
+
"QvaPayServerError",
|
|
80
|
+
"QvaPaySettings",
|
|
81
|
+
"QvaPayValidationError",
|
|
82
|
+
"Transaction",
|
|
83
|
+
"TransactionStatus",
|
|
84
|
+
"WebhookEvent",
|
|
85
|
+
"WebhookMismatchError",
|
|
86
|
+
"WebhookParseError",
|
|
87
|
+
"areconcile_webhook",
|
|
88
|
+
"assert_matches",
|
|
89
|
+
"parse_webhook",
|
|
90
|
+
"reconcile_webhook",
|
|
91
|
+
]
|
qvapaykit/client.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Clientes del SDK: ``QvaPayClient`` (sync) y ``AsyncQvaPayClient`` (async).
|
|
2
|
+
|
|
3
|
+
Modo App (server-to-server): headers ``app-id`` + ``app-secret``, endpoints
|
|
4
|
+
``/v2/*``. Las credenciales se pasan explícitas o se leen del entorno
|
|
5
|
+
(``QVAPAY_APP_ID`` / ``QVAPAY_APP_SECRET``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from qvapaykit.config import QvaPaySettings
|
|
16
|
+
from qvapaykit.exceptions import QvaPayValidationError
|
|
17
|
+
from qvapaykit.http import AsyncQvaPayTransport, QvaPayTransport, RateLimiter
|
|
18
|
+
from qvapaykit.models import (
|
|
19
|
+
AppInfo,
|
|
20
|
+
Balance,
|
|
21
|
+
Invoice,
|
|
22
|
+
InvoiceRequest,
|
|
23
|
+
Product,
|
|
24
|
+
Transaction,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_DEFAULT_BASE_URL = "https://api.qvapay.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _credentials(
|
|
31
|
+
app_id: str | None,
|
|
32
|
+
app_secret: str | None,
|
|
33
|
+
) -> tuple[str, str]:
|
|
34
|
+
if app_id is None or app_secret is None:
|
|
35
|
+
# Los campos llegan por variables de entorno (QVAPAY_APP_ID/SECRET);
|
|
36
|
+
# mypy no puede verlos, pero pydantic-settings los exige en runtime.
|
|
37
|
+
settings = QvaPaySettings()
|
|
38
|
+
app_id = app_id if app_id is not None else settings.app_id
|
|
39
|
+
app_secret = app_secret if app_secret is not None else settings.app_secret
|
|
40
|
+
return app_id, app_secret
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_page_params(page: int, take: int) -> None:
|
|
44
|
+
if page < 1:
|
|
45
|
+
msg = "page debe ser >= 1"
|
|
46
|
+
raise QvaPayValidationError(msg)
|
|
47
|
+
if not 1 <= take <= 100:
|
|
48
|
+
msg = "take debe estar entre 1 y 100"
|
|
49
|
+
raise QvaPayValidationError(msg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_invoice_payload(
|
|
53
|
+
*,
|
|
54
|
+
amount: Decimal | int | str,
|
|
55
|
+
description: str,
|
|
56
|
+
remote_id: str,
|
|
57
|
+
webhook: str | None,
|
|
58
|
+
products: list[Product] | None,
|
|
59
|
+
expire_at: datetime | None,
|
|
60
|
+
) -> dict[str, object]:
|
|
61
|
+
request = InvoiceRequest.model_validate(
|
|
62
|
+
{
|
|
63
|
+
"amount": amount,
|
|
64
|
+
"description": description,
|
|
65
|
+
"remote_id": remote_id,
|
|
66
|
+
"webhook": webhook,
|
|
67
|
+
"products": products,
|
|
68
|
+
"expire_at": expire_at,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return request.model_dump(mode="json", exclude_none=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class QvaPayClient:
|
|
75
|
+
"""Cliente síncrono del modo App de QvaPay.
|
|
76
|
+
|
|
77
|
+
Ejemplo::
|
|
78
|
+
|
|
79
|
+
from qvapaykit import QvaPayClient
|
|
80
|
+
client = QvaPayClient.from_env()
|
|
81
|
+
invoice = client.create_invoice(
|
|
82
|
+
amount="25.00", description="Café", remote_id="orden-1234",
|
|
83
|
+
)
|
|
84
|
+
print(invoice.url)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
app_id: str | None = None,
|
|
90
|
+
app_secret: str | None = None,
|
|
91
|
+
*,
|
|
92
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
93
|
+
timeout: float = 10.0,
|
|
94
|
+
max_retries: int = 3,
|
|
95
|
+
transport: httpx.BaseTransport | None = None,
|
|
96
|
+
rate_limiter: RateLimiter | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
resolved_id, resolved_secret = _credentials(app_id, app_secret)
|
|
99
|
+
self._transport = QvaPayTransport(
|
|
100
|
+
resolved_id,
|
|
101
|
+
resolved_secret,
|
|
102
|
+
base_url=base_url,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
max_retries=max_retries,
|
|
105
|
+
transport=transport,
|
|
106
|
+
rate_limiter=rate_limiter,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_env(cls) -> QvaPayClient:
|
|
111
|
+
settings = QvaPaySettings()
|
|
112
|
+
return cls(
|
|
113
|
+
settings.app_id,
|
|
114
|
+
settings.app_secret,
|
|
115
|
+
base_url=settings.base_url,
|
|
116
|
+
timeout=settings.timeout,
|
|
117
|
+
max_retries=settings.max_retries,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def info(self) -> AppInfo:
|
|
121
|
+
"""``POST /v2/info``: datos de la app; útil para verificar credenciales."""
|
|
122
|
+
response = self._transport.post("/v2/info", {})
|
|
123
|
+
return AppInfo.model_validate(response.json())
|
|
124
|
+
|
|
125
|
+
def balance(self) -> Decimal:
|
|
126
|
+
"""``POST /v2/balance``: balance disponible en USD."""
|
|
127
|
+
response = self._transport.post("/v2/balance", {})
|
|
128
|
+
return Balance.model_validate(response.json()).balance
|
|
129
|
+
|
|
130
|
+
def create_invoice(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
amount: Decimal | int | str,
|
|
134
|
+
description: str,
|
|
135
|
+
remote_id: str,
|
|
136
|
+
webhook: str | None = None,
|
|
137
|
+
products: list[Product] | None = None,
|
|
138
|
+
expire_at: datetime | None = None,
|
|
139
|
+
) -> Invoice:
|
|
140
|
+
"""``POST /v2/create_invoice``: crea una factura y devuelve su enlace de pago.
|
|
141
|
+
|
|
142
|
+
``remote_id`` es la clave de idempotencia: usa el ID de tu orden.
|
|
143
|
+
Repetirlo genera ``QvaPayDuplicateRequestError``.
|
|
144
|
+
"""
|
|
145
|
+
payload = _build_invoice_payload(
|
|
146
|
+
amount=amount,
|
|
147
|
+
description=description,
|
|
148
|
+
remote_id=remote_id,
|
|
149
|
+
webhook=webhook,
|
|
150
|
+
products=products,
|
|
151
|
+
expire_at=expire_at,
|
|
152
|
+
)
|
|
153
|
+
response = self._transport.post("/v2/create_invoice", payload, bucket="charge")
|
|
154
|
+
return Invoice.model_validate(response.json())
|
|
155
|
+
|
|
156
|
+
def transactions(self, *, page: int = 1, take: int = 30) -> list[Transaction]:
|
|
157
|
+
"""``POST /v2/transactions``: listado paginado de transacciones de la app."""
|
|
158
|
+
_validate_page_params(page, take)
|
|
159
|
+
response = self._transport.post("/v2/transactions", {"page": page, "take": take})
|
|
160
|
+
data = response.json()
|
|
161
|
+
items = data.get("transactions", []) if isinstance(data, dict) else []
|
|
162
|
+
return [Transaction.model_validate(item) for item in items]
|
|
163
|
+
|
|
164
|
+
def transaction_detail(self, transaction_uuid: str) -> Transaction:
|
|
165
|
+
"""``POST /v2/transactions/{uuid}``: detalle con datos del pagador si fue pagada."""
|
|
166
|
+
if not transaction_uuid:
|
|
167
|
+
msg = "transaction_uuid es obligatorio"
|
|
168
|
+
raise QvaPayValidationError(msg)
|
|
169
|
+
response = self._transport.post(f"/v2/transactions/{transaction_uuid}", {})
|
|
170
|
+
data = response.json()
|
|
171
|
+
tx_data = data.get("transaction", data) if isinstance(data, dict) else data
|
|
172
|
+
return Transaction.model_validate(tx_data)
|
|
173
|
+
|
|
174
|
+
def find_transaction_by_remote_id(
|
|
175
|
+
self,
|
|
176
|
+
remote_id: str,
|
|
177
|
+
*,
|
|
178
|
+
max_pages: int = 10,
|
|
179
|
+
) -> Transaction | None:
|
|
180
|
+
"""Busca en el listado paginado la primera transacción con ese ``remote_id``."""
|
|
181
|
+
for page in range(1, max_pages + 1):
|
|
182
|
+
page_items = self.transactions(page=page, take=100)
|
|
183
|
+
for transaction in page_items:
|
|
184
|
+
if transaction.remote_id == remote_id:
|
|
185
|
+
return transaction
|
|
186
|
+
if len(page_items) < 100:
|
|
187
|
+
break
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def health_check(self) -> tuple[AppInfo, Decimal]:
|
|
191
|
+
"""Verificación de salud para despliegues: credenciales + balance responden."""
|
|
192
|
+
return self.info(), self.balance()
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
self._transport.close()
|
|
196
|
+
|
|
197
|
+
def __enter__(self) -> QvaPayClient:
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
201
|
+
self.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class AsyncQvaPayClient:
|
|
205
|
+
"""Cliente asíncrono del modo App (httpx.AsyncClient)."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
app_id: str | None = None,
|
|
210
|
+
app_secret: str | None = None,
|
|
211
|
+
*,
|
|
212
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
213
|
+
timeout: float = 10.0,
|
|
214
|
+
max_retries: int = 3,
|
|
215
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
216
|
+
rate_limiter: RateLimiter | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
resolved_id, resolved_secret = _credentials(app_id, app_secret)
|
|
219
|
+
self._transport = AsyncQvaPayTransport(
|
|
220
|
+
resolved_id,
|
|
221
|
+
resolved_secret,
|
|
222
|
+
base_url=base_url,
|
|
223
|
+
timeout=timeout,
|
|
224
|
+
max_retries=max_retries,
|
|
225
|
+
transport=transport,
|
|
226
|
+
rate_limiter=rate_limiter,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_env(cls) -> AsyncQvaPayClient:
|
|
231
|
+
settings = QvaPaySettings()
|
|
232
|
+
return cls(
|
|
233
|
+
settings.app_id,
|
|
234
|
+
settings.app_secret,
|
|
235
|
+
base_url=settings.base_url,
|
|
236
|
+
timeout=settings.timeout,
|
|
237
|
+
max_retries=settings.max_retries,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def info(self) -> AppInfo:
|
|
241
|
+
response = await self._transport.post("/v2/info", {})
|
|
242
|
+
return AppInfo.model_validate(response.json())
|
|
243
|
+
|
|
244
|
+
async def balance(self) -> Decimal:
|
|
245
|
+
response = await self._transport.post("/v2/balance", {})
|
|
246
|
+
return Balance.model_validate(response.json()).balance
|
|
247
|
+
|
|
248
|
+
async def create_invoice(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
amount: Decimal | int | str,
|
|
252
|
+
description: str,
|
|
253
|
+
remote_id: str,
|
|
254
|
+
webhook: str | None = None,
|
|
255
|
+
products: list[Product] | None = None,
|
|
256
|
+
expire_at: datetime | None = None,
|
|
257
|
+
) -> Invoice:
|
|
258
|
+
payload = _build_invoice_payload(
|
|
259
|
+
amount=amount,
|
|
260
|
+
description=description,
|
|
261
|
+
remote_id=remote_id,
|
|
262
|
+
webhook=webhook,
|
|
263
|
+
products=products,
|
|
264
|
+
expire_at=expire_at,
|
|
265
|
+
)
|
|
266
|
+
response = await self._transport.post("/v2/create_invoice", payload, bucket="charge")
|
|
267
|
+
return Invoice.model_validate(response.json())
|
|
268
|
+
|
|
269
|
+
async def transactions(self, *, page: int = 1, take: int = 30) -> list[Transaction]:
|
|
270
|
+
_validate_page_params(page, take)
|
|
271
|
+
response = await self._transport.post("/v2/transactions", {"page": page, "take": take})
|
|
272
|
+
data = response.json()
|
|
273
|
+
items = data.get("transactions", []) if isinstance(data, dict) else []
|
|
274
|
+
return [Transaction.model_validate(item) for item in items]
|
|
275
|
+
|
|
276
|
+
async def transaction_detail(self, transaction_uuid: str) -> Transaction:
|
|
277
|
+
if not transaction_uuid:
|
|
278
|
+
msg = "transaction_uuid es obligatorio"
|
|
279
|
+
raise QvaPayValidationError(msg)
|
|
280
|
+
response = await self._transport.post(f"/v2/transactions/{transaction_uuid}", {})
|
|
281
|
+
data = response.json()
|
|
282
|
+
tx_data = data.get("transaction", data) if isinstance(data, dict) else data
|
|
283
|
+
return Transaction.model_validate(tx_data)
|
|
284
|
+
|
|
285
|
+
async def find_transaction_by_remote_id(
|
|
286
|
+
self,
|
|
287
|
+
remote_id: str,
|
|
288
|
+
*,
|
|
289
|
+
max_pages: int = 10,
|
|
290
|
+
) -> Transaction | None:
|
|
291
|
+
for page in range(1, max_pages + 1):
|
|
292
|
+
page_items = await self.transactions(page=page, take=100)
|
|
293
|
+
for transaction in page_items:
|
|
294
|
+
if transaction.remote_id == remote_id:
|
|
295
|
+
return transaction
|
|
296
|
+
if len(page_items) < 100:
|
|
297
|
+
break
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
async def health_check(self) -> tuple[AppInfo, Decimal]:
|
|
301
|
+
return await self.info(), await self.balance()
|
|
302
|
+
|
|
303
|
+
async def aclose(self) -> None:
|
|
304
|
+
await self._transport.aclose()
|
|
305
|
+
|
|
306
|
+
async def __aenter__(self) -> AsyncQvaPayClient:
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
310
|
+
await self.aclose()
|
qvapaykit/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Configuración del SDK por entorno (prefijo ``QVAPAY_`` o archivo .env)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QvaPaySettings(BaseSettings):
|
|
9
|
+
model_config = SettingsConfigDict(
|
|
10
|
+
env_prefix="QVAPAY_",
|
|
11
|
+
env_file=".env",
|
|
12
|
+
env_file_encoding="utf-8",
|
|
13
|
+
extra="ignore",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app_id: str
|
|
17
|
+
app_secret: str
|
|
18
|
+
base_url: str = "https://api.qvapay.com"
|
|
19
|
+
timeout: float = 10.0
|
|
20
|
+
max_retries: int = 3
|
qvapaykit/exceptions.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Jerarquía de excepciones del SDK qvapay.
|
|
2
|
+
|
|
3
|
+
La API de QvaPay documenta códigos estables (``KYC_REQUIRED``,
|
|
4
|
+
``DUPLICATE_REQUEST``, etc.); este módulo los traduce a excepciones
|
|
5
|
+
específicas para programar contra el tipo, no contra el texto del error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
ATTESTATION_CODES: frozenset[str] = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"US_PERSON_ATTESTATION_REQUIRED",
|
|
13
|
+
"W1_ATTESTATION_REQUIRED",
|
|
14
|
+
"CACR_ATTESTATION_REQUIRED",
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QvaPayError(Exception):
|
|
20
|
+
"""Base de todas las excepciones que genera este SDK."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
status_code: int | None = None,
|
|
27
|
+
code: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.code = code
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
if self.code:
|
|
36
|
+
return f"{self.message} (code={self.code})"
|
|
37
|
+
return self.message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QvaPayNetworkError(QvaPayError):
|
|
41
|
+
"""Fallo de transporte: timeout, DNS, conexión caída, etc."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class QvaPayAuthError(QvaPayError):
|
|
45
|
+
"""401: credenciales inválidas o token expirado."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QvaPayValidationError(QvaPayError):
|
|
49
|
+
"""400 genérico: request inválido, datos faltantes o validación fallida."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class QvaPayDuplicateRequestError(QvaPayValidationError):
|
|
53
|
+
"""400 con code=DUPLICATE_REQUEST: remote_id repetido. Clave de idempotencia."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QvaPayAttestationRequiredError(QvaPayValidationError):
|
|
57
|
+
"""400 con code de atestación: falta la autocertificación del cliente.
|
|
58
|
+
|
|
59
|
+
Se trata como "falta el formulario": la UI debe mostrar las declaraciones
|
|
60
|
+
al usuario; nunca autocumplimentarlas desde el código.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QvaPayKYCRequiredError(QvaPayError):
|
|
65
|
+
"""403 con code=KYC_REQUIRED: la cuenta no completó la verificación de identidad."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
message: str,
|
|
70
|
+
*,
|
|
71
|
+
status_code: int | None = None,
|
|
72
|
+
code: str | None = None,
|
|
73
|
+
kyc_url: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
super().__init__(message, status_code=status_code, code=code)
|
|
76
|
+
self.kyc_url = kyc_url
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class QvaPayForbiddenError(QvaPayError):
|
|
80
|
+
"""403 sin código específico: sin permisos para la operación."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class QvaPayNotFoundError(QvaPayError):
|
|
84
|
+
"""404: recurso no encontrado o no pertenece a la app."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class QvaPayRateLimitError(QvaPayError):
|
|
88
|
+
"""429: rate limit excedido; reintentar con backoff."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
message: str,
|
|
93
|
+
*,
|
|
94
|
+
status_code: int | None = None,
|
|
95
|
+
code: str | None = None,
|
|
96
|
+
retry_after: float | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
super().__init__(message, status_code=status_code, code=code)
|
|
99
|
+
self.retry_after = retry_after
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class QvaPayServerError(QvaPayError):
|
|
103
|
+
"""5xx: error interno del servidor de QvaPay."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebhookParseError(QvaPayError):
|
|
107
|
+
"""El payload del webhook no es JSON válido o no contiene transaction_uuid."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WebhookMismatchError(QvaPayError):
|
|
111
|
+
"""La transacción reconciliada no coincide con lo esperado (posible evento alterado)."""
|
qvapaykit/http.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Transporte HTTP hacia QvaPay: rate-limit, reintentos y mapeo de errores.
|
|
2
|
+
|
|
3
|
+
Límites documentados que este módulo respeta por diseño:
|
|
4
|
+
- Endpoints de cobro: 5 requests cada 20 segundos por app.
|
|
5
|
+
- Endpoints generales: 3 requests cada 5 segundos.
|
|
6
|
+
- 429/5xx/fallo de red: backoff exponencial con jitter (máx. configurable).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import random
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from qvapaykit.exceptions import (
|
|
20
|
+
ATTESTATION_CODES,
|
|
21
|
+
QvaPayAttestationRequiredError,
|
|
22
|
+
QvaPayAuthError,
|
|
23
|
+
QvaPayDuplicateRequestError,
|
|
24
|
+
QvaPayError,
|
|
25
|
+
QvaPayForbiddenError,
|
|
26
|
+
QvaPayKYCRequiredError,
|
|
27
|
+
QvaPayNetworkError,
|
|
28
|
+
QvaPayNotFoundError,
|
|
29
|
+
QvaPayRateLimitError,
|
|
30
|
+
QvaPayServerError,
|
|
31
|
+
QvaPayValidationError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_MAX_BACKOFF_SECONDS = 30.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenBucket:
|
|
38
|
+
"""Cubo de tokens thread-safe; el transporte async lo consulta y espera con asyncio.sleep."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, rate_per_second: float, capacity: int) -> None:
|
|
41
|
+
self.rate_per_second = rate_per_second
|
|
42
|
+
self.capacity = capacity
|
|
43
|
+
self._tokens = float(capacity)
|
|
44
|
+
self._updated = time.monotonic()
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
def acquire(self) -> float:
|
|
48
|
+
"""Consume un token y devuelve segundos de espera (0.0 si había disponible)."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
elapsed = now - self._updated
|
|
52
|
+
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate_per_second)
|
|
53
|
+
self._updated = now
|
|
54
|
+
if self._tokens >= 1.0:
|
|
55
|
+
self._tokens -= 1.0
|
|
56
|
+
return 0.0
|
|
57
|
+
return (1.0 - self._tokens) / self.rate_per_second
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimiter:
|
|
61
|
+
"""Buckets según los límites documentados por QvaPay."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.charge = TokenBucket(rate_per_second=5 / 20, capacity=5)
|
|
65
|
+
self.general = TokenBucket(rate_per_second=3 / 5, capacity=3)
|
|
66
|
+
|
|
67
|
+
def wait(self, bucket_name: str) -> float:
|
|
68
|
+
bucket = self.charge if bucket_name == "charge" else self.general
|
|
69
|
+
return bucket.acquire()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_retryable(response: httpx.Response) -> bool:
|
|
73
|
+
return response.status_code == 429 or response.status_code >= 500
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
|
|
77
|
+
if response is not None:
|
|
78
|
+
retry_after = response.headers.get("Retry-After")
|
|
79
|
+
if retry_after is not None:
|
|
80
|
+
try:
|
|
81
|
+
return max(0.0, float(retry_after))
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
backoff = 0.5 * (2**attempt)
|
|
85
|
+
jittered = backoff * (1.0 + random.uniform(0.0, 0.25))
|
|
86
|
+
return min(_MAX_BACKOFF_SECONDS, jittered)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _error_dict(response: httpx.Response) -> dict[str, Any]:
|
|
90
|
+
try:
|
|
91
|
+
data = response.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return {}
|
|
94
|
+
return data if isinstance(data, dict) else {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def raise_api_error(response: httpx.Response) -> None:
|
|
98
|
+
"""Traduce una respuesta no-2xx a la excepción específica del SDK.
|
|
99
|
+
|
|
100
|
+
Se programa contra el ``code`` estable de la API, nunca contra el texto
|
|
101
|
+
del campo ``error`` (que puede cambiar).
|
|
102
|
+
"""
|
|
103
|
+
payload = _error_dict(response)
|
|
104
|
+
raw_message = payload.get("error", response.text or "Error de QvaPay")
|
|
105
|
+
message = raw_message if isinstance(raw_message, str) else str(raw_message)
|
|
106
|
+
code = payload.get("code")
|
|
107
|
+
status = response.status_code
|
|
108
|
+
|
|
109
|
+
if status == 401:
|
|
110
|
+
raise QvaPayAuthError(message, status_code=status, code=code)
|
|
111
|
+
if status == 403 and code == "KYC_REQUIRED":
|
|
112
|
+
kyc_url = payload.get("kyc_url")
|
|
113
|
+
raise QvaPayKYCRequiredError(
|
|
114
|
+
message,
|
|
115
|
+
status_code=status,
|
|
116
|
+
code=code,
|
|
117
|
+
kyc_url=kyc_url if isinstance(kyc_url, str) else None,
|
|
118
|
+
)
|
|
119
|
+
if status == 403:
|
|
120
|
+
raise QvaPayForbiddenError(message, status_code=status, code=code)
|
|
121
|
+
if status == 404:
|
|
122
|
+
raise QvaPayNotFoundError(message, status_code=status, code=code)
|
|
123
|
+
if status == 429:
|
|
124
|
+
retry_after = response.headers.get("Retry-After")
|
|
125
|
+
parsed_after: float | None = None
|
|
126
|
+
if retry_after is not None:
|
|
127
|
+
try:
|
|
128
|
+
parsed_after = max(0.0, float(retry_after))
|
|
129
|
+
except ValueError:
|
|
130
|
+
parsed_after = None
|
|
131
|
+
raise QvaPayRateLimitError(message, status_code=status, code=code, retry_after=parsed_after)
|
|
132
|
+
if status == 400 and code == "DUPLICATE_REQUEST":
|
|
133
|
+
raise QvaPayDuplicateRequestError(message, status_code=status, code=code)
|
|
134
|
+
if status == 400 and isinstance(code, str) and code in ATTESTATION_CODES:
|
|
135
|
+
raise QvaPayAttestationRequiredError(message, status_code=status, code=code)
|
|
136
|
+
if status == 400:
|
|
137
|
+
raise QvaPayValidationError(message, status_code=status, code=code)
|
|
138
|
+
if status >= 500:
|
|
139
|
+
raise QvaPayServerError(message, status_code=status, code=code)
|
|
140
|
+
raise QvaPayError(message, status_code=status, code=code)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class QvaPayTransport:
|
|
144
|
+
"""Transporte síncrono (httpx.Client)."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
app_id: str,
|
|
149
|
+
app_secret: str,
|
|
150
|
+
*,
|
|
151
|
+
base_url: str,
|
|
152
|
+
timeout: float,
|
|
153
|
+
max_retries: int,
|
|
154
|
+
transport: httpx.BaseTransport | None = None,
|
|
155
|
+
rate_limiter: RateLimiter | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
headers = {"app-id": app_id, "app-secret": app_secret}
|
|
158
|
+
self._client = httpx.Client(
|
|
159
|
+
base_url=base_url,
|
|
160
|
+
headers=headers,
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
transport=transport,
|
|
163
|
+
)
|
|
164
|
+
self._max_retries = max_retries
|
|
165
|
+
self._limiter = rate_limiter if rate_limiter is not None else RateLimiter()
|
|
166
|
+
|
|
167
|
+
def post(
|
|
168
|
+
self,
|
|
169
|
+
path: str,
|
|
170
|
+
payload: dict[str, Any],
|
|
171
|
+
*,
|
|
172
|
+
bucket: str = "general",
|
|
173
|
+
) -> httpx.Response:
|
|
174
|
+
for attempt in range(self._max_retries + 1):
|
|
175
|
+
can_retry = attempt < self._max_retries
|
|
176
|
+
wait = self._limiter.wait(bucket)
|
|
177
|
+
if wait > 0:
|
|
178
|
+
time.sleep(wait)
|
|
179
|
+
try:
|
|
180
|
+
response = self._client.post(path, json=payload)
|
|
181
|
+
except httpx.HTTPError as exc:
|
|
182
|
+
if not can_retry:
|
|
183
|
+
raise QvaPayNetworkError(f"fallo de red: {exc}") from exc
|
|
184
|
+
time.sleep(_retry_delay(None, attempt))
|
|
185
|
+
continue
|
|
186
|
+
if _is_retryable(response) and can_retry:
|
|
187
|
+
time.sleep(_retry_delay(response, attempt))
|
|
188
|
+
continue
|
|
189
|
+
if not response.is_success:
|
|
190
|
+
raise_api_error(response)
|
|
191
|
+
return response
|
|
192
|
+
raise QvaPayNetworkError("reintentos agotados antes de obtener respuesta")
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
self._client.close()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class AsyncQvaPayTransport:
|
|
199
|
+
"""Transporte asíncrono (httpx.AsyncClient)."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
app_id: str,
|
|
204
|
+
app_secret: str,
|
|
205
|
+
*,
|
|
206
|
+
base_url: str,
|
|
207
|
+
timeout: float,
|
|
208
|
+
max_retries: int,
|
|
209
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
210
|
+
rate_limiter: RateLimiter | None = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
headers = {"app-id": app_id, "app-secret": app_secret}
|
|
213
|
+
self._client = httpx.AsyncClient(
|
|
214
|
+
base_url=base_url,
|
|
215
|
+
headers=headers,
|
|
216
|
+
timeout=timeout,
|
|
217
|
+
transport=transport,
|
|
218
|
+
)
|
|
219
|
+
self._max_retries = max_retries
|
|
220
|
+
self._limiter = rate_limiter if rate_limiter is not None else RateLimiter()
|
|
221
|
+
|
|
222
|
+
async def post(
|
|
223
|
+
self,
|
|
224
|
+
path: str,
|
|
225
|
+
payload: dict[str, Any],
|
|
226
|
+
*,
|
|
227
|
+
bucket: str = "general",
|
|
228
|
+
) -> httpx.Response:
|
|
229
|
+
for attempt in range(self._max_retries + 1):
|
|
230
|
+
can_retry = attempt < self._max_retries
|
|
231
|
+
wait = self._limiter.wait(bucket)
|
|
232
|
+
if wait > 0:
|
|
233
|
+
await asyncio.sleep(wait)
|
|
234
|
+
try:
|
|
235
|
+
response = await self._client.post(path, json=payload)
|
|
236
|
+
except httpx.HTTPError as exc:
|
|
237
|
+
if not can_retry:
|
|
238
|
+
raise QvaPayNetworkError(f"fallo de red: {exc}") from exc
|
|
239
|
+
await asyncio.sleep(_retry_delay(None, attempt))
|
|
240
|
+
continue
|
|
241
|
+
if _is_retryable(response) and can_retry:
|
|
242
|
+
await asyncio.sleep(_retry_delay(response, attempt))
|
|
243
|
+
continue
|
|
244
|
+
if not response.is_success:
|
|
245
|
+
raise_api_error(response)
|
|
246
|
+
return response
|
|
247
|
+
raise QvaPayNetworkError("reintentos agotados antes de obtener respuesta")
|
|
248
|
+
|
|
249
|
+
async def aclose(self) -> None:
|
|
250
|
+
await self._client.aclose()
|
qvapaykit/models.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Modelos de dominio del SDK (pydantic v2).
|
|
2
|
+
|
|
3
|
+
Reglas de dinero: TODO monto es ``Decimal``. Se rechaza ``float`` de forma
|
|
4
|
+
explícita para evitar errores de precisión; usa ``Decimal("25.00")``, ``str``
|
|
5
|
+
o ``int``. La conversión a número JSON ocurre únicamente en la capa HTTP,
|
|
6
|
+
en el último límite antes del cable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _reject_float(value: object) -> object:
|
|
19
|
+
# float para dinero pierde precisión silenciosamente; el error explícito
|
|
20
|
+
# educa al integrador desde el primer uso. ValueError (no TypeError) para
|
|
21
|
+
# que pydantic lo convierta en ValidationError.
|
|
22
|
+
if isinstance(value, float):
|
|
23
|
+
msg = "usa Decimal/str/int para montos, nunca float (p. ej. Decimal('25.00'))"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _max_two_decimals(value: Decimal) -> Decimal:
|
|
29
|
+
exponent = value.as_tuple().exponent
|
|
30
|
+
# El exponente es int salvo valores especiales (NaN/Inf) que no llegan aquí,
|
|
31
|
+
# pero mypy exige el guard antes del unary -.
|
|
32
|
+
if isinstance(exponent, int) and -exponent > 2:
|
|
33
|
+
msg = "el monto admite como máximo 2 decimales"
|
|
34
|
+
raise ValueError(msg)
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TransactionStatus(StrEnum):
|
|
39
|
+
PENDING = "pending"
|
|
40
|
+
PAID = "paid"
|
|
41
|
+
CANCELLED = "cancelled"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _QvaPayModel(BaseModel):
|
|
45
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Product(_QvaPayModel):
|
|
49
|
+
name: str = Field(min_length=1, max_length=255)
|
|
50
|
+
price: Decimal = Field(gt=0)
|
|
51
|
+
quantity: int = Field(default=1, ge=1)
|
|
52
|
+
|
|
53
|
+
# Sin rechazo de float aquí: este modelo TAMBIÉN parsea respuestas de la
|
|
54
|
+
# API, donde los montos llegan como números JSON (float en json.loads).
|
|
55
|
+
# El guardrail de "nunca float" vive en InvoiceRequest.amount (entrada
|
|
56
|
+
# del integrador); pydantic convierte el float del cable a Decimal.
|
|
57
|
+
_decimals = field_validator("price")(_max_two_decimals)
|
|
58
|
+
|
|
59
|
+
@field_serializer("price")
|
|
60
|
+
def _serialize_price(self, value: Decimal) -> float:
|
|
61
|
+
# Único punto donde Decimal se convierte a float: el JSON de la API
|
|
62
|
+
# exige número y montos <= 100000.00 con 2 decimales caben exactos en float64.
|
|
63
|
+
return float(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InvoiceRequest(_QvaPayModel):
|
|
67
|
+
"""Payload de ``POST /v2/create_invoice``.
|
|
68
|
+
|
|
69
|
+
``remote_id`` es la clave de idempotencia: identificador externo de tu
|
|
70
|
+
sistema (p. ej. el ID de la orden). Reutilizarlo dispara
|
|
71
|
+
``QvaPayDuplicateRequestError``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
amount: Decimal = Field(gt=0, le=100000)
|
|
75
|
+
description: str = Field(min_length=1, max_length=255)
|
|
76
|
+
remote_id: str = Field(min_length=1, max_length=255)
|
|
77
|
+
webhook: str | None = None
|
|
78
|
+
products: list[Product] | None = None
|
|
79
|
+
expire_at: datetime | None = None
|
|
80
|
+
|
|
81
|
+
_reject = field_validator("amount", mode="before")(_reject_float)
|
|
82
|
+
_decimals = field_validator("amount")(_max_two_decimals)
|
|
83
|
+
|
|
84
|
+
@field_serializer("amount")
|
|
85
|
+
def _serialize_amount(self, value: Decimal) -> float:
|
|
86
|
+
# Ídem Product.price: float solo en el cable, Decimal en todo el dominio.
|
|
87
|
+
return float(value)
|
|
88
|
+
|
|
89
|
+
@field_validator("webhook")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _webhook_format(cls, value: str | None) -> str | None:
|
|
92
|
+
if value is None:
|
|
93
|
+
return None
|
|
94
|
+
if not value.startswith(("http://", "https://")):
|
|
95
|
+
msg = "webhook debe ser una URL http(s) válida"
|
|
96
|
+
raise ValueError(msg)
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
@field_validator("expire_at")
|
|
100
|
+
@classmethod
|
|
101
|
+
def _expire_in_future(cls, value: datetime | None) -> datetime | None:
|
|
102
|
+
if value is None:
|
|
103
|
+
return None
|
|
104
|
+
if value.tzinfo is None:
|
|
105
|
+
value = value.replace(tzinfo=UTC)
|
|
106
|
+
if value <= datetime.now(UTC):
|
|
107
|
+
msg = "expire_at debe ser una fecha futura"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Invoice(_QvaPayModel):
|
|
113
|
+
"""Factura creada; ``url`` es el enlace de pago para compartir."""
|
|
114
|
+
|
|
115
|
+
app_id: str
|
|
116
|
+
amount: Decimal
|
|
117
|
+
description: str
|
|
118
|
+
remote_id: str
|
|
119
|
+
transaction_uuid: str
|
|
120
|
+
expire_at: datetime | None = None
|
|
121
|
+
url: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AppInfo(_QvaPayModel):
|
|
125
|
+
uuid: str
|
|
126
|
+
name: str
|
|
127
|
+
url: str | None = None
|
|
128
|
+
desc: str | None = None
|
|
129
|
+
callback: str | None = None
|
|
130
|
+
success_url: str | None = None
|
|
131
|
+
cancel_url: str | None = None
|
|
132
|
+
logo: str | None = None
|
|
133
|
+
active: bool | None = None
|
|
134
|
+
enabled: bool | None = None
|
|
135
|
+
card: bool | None = None
|
|
136
|
+
created_at: datetime | None = None
|
|
137
|
+
updated_at: datetime | None = None
|
|
138
|
+
app_photo_url: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Balance(_QvaPayModel):
|
|
142
|
+
balance: Decimal
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Payer(_QvaPayModel):
|
|
146
|
+
uuid: str
|
|
147
|
+
name: str | None = None
|
|
148
|
+
username: str | None = None
|
|
149
|
+
lastname: str | None = None
|
|
150
|
+
image: str | None = None
|
|
151
|
+
vip: bool | None = None
|
|
152
|
+
kyc: str | None = None
|
|
153
|
+
golden_check: bool | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Transaction(_QvaPayModel):
|
|
157
|
+
uuid: str
|
|
158
|
+
amount: Decimal
|
|
159
|
+
description: str
|
|
160
|
+
remote_id: str
|
|
161
|
+
status: TransactionStatus
|
|
162
|
+
products: list[Product] | None = None
|
|
163
|
+
created_at: datetime | None = None
|
|
164
|
+
updated_at: datetime | None = None
|
|
165
|
+
# La API devuelve "PaidBy" (PascalCase); el alias permite el nombre pythonico.
|
|
166
|
+
payer: Payer | None = Field(default=None, alias="PaidBy")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def is_paid(self) -> bool:
|
|
170
|
+
return self.status == TransactionStatus.PAID
|
qvapaykit/py.typed
ADDED
|
File without changes
|
qvapaykit/webhooks.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Capa de webhooks: parseo del evento, reconciliación con la API e idempotencia.
|
|
2
|
+
|
|
3
|
+
Principio rector: el POST que llega al endpoint es una señal NO confiable.
|
|
4
|
+
Solo indica QUÉ transacción verificar; la confirmación real siempre sale de
|
|
5
|
+
``POST /v2/transactions/{uuid}`` con las credenciales del comercio.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
|
|
16
|
+
|
|
17
|
+
from qvapaykit.client import AsyncQvaPayClient, QvaPayClient
|
|
18
|
+
from qvapaykit.exceptions import WebhookMismatchError, WebhookParseError
|
|
19
|
+
from qvapaykit.models import Transaction
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WebhookEvent(BaseModel):
|
|
23
|
+
"""Campos mínimos que usamos del webhook; el resto se ignora a propósito.
|
|
24
|
+
|
|
25
|
+
La API puede enviar el identificador como ``uuid`` o ``transaction_uuid``;
|
|
26
|
+
aceptamos ambos. Monto y demás datos no se extraen porque la reconciliación
|
|
27
|
+
con la API es la fuente de verdad.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
31
|
+
|
|
32
|
+
transaction_uuid: str = Field(
|
|
33
|
+
min_length=1,
|
|
34
|
+
validation_alias=AliasChoices("transaction_uuid", "uuid"),
|
|
35
|
+
)
|
|
36
|
+
status: str | None = None
|
|
37
|
+
description: str | None = None
|
|
38
|
+
remote_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_webhook(body: str | bytes | dict[str, Any]) -> WebhookEvent:
|
|
42
|
+
"""Convierte el payload crudo en ``WebhookEvent``; falla con ``WebhookParseError``."""
|
|
43
|
+
data: Any = body
|
|
44
|
+
if isinstance(body, (str, bytes)):
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(body)
|
|
47
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
48
|
+
msg = "payload del webhook no es JSON válido"
|
|
49
|
+
raise WebhookParseError(msg) from exc
|
|
50
|
+
if not isinstance(data, dict):
|
|
51
|
+
msg = "payload del webhook no es un objeto JSON"
|
|
52
|
+
raise WebhookParseError(msg)
|
|
53
|
+
try:
|
|
54
|
+
return WebhookEvent.model_validate(data)
|
|
55
|
+
except ValidationError as exc:
|
|
56
|
+
msg = f"payload del webhook inválido: {exc.error_count()} error(es) de validación"
|
|
57
|
+
raise WebhookParseError(msg) from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reconcile_webhook(client: QvaPayClient, event: WebhookEvent) -> Transaction:
|
|
61
|
+
"""Verifica el evento contra la API (sync). Marca ``transaction.is_paid``."""
|
|
62
|
+
return client.transaction_detail(event.transaction_uuid)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def areconcile_webhook(client: AsyncQvaPayClient, event: WebhookEvent) -> Transaction:
|
|
66
|
+
"""Verifica el evento contra la API (async). Marca ``transaction.is_paid``."""
|
|
67
|
+
return await client.transaction_detail(event.transaction_uuid)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def assert_matches(
|
|
71
|
+
transaction: Transaction,
|
|
72
|
+
*,
|
|
73
|
+
remote_id: str | None = None,
|
|
74
|
+
amount: Decimal | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Defensa anti-manipulación: la transacción debe ser la esperada por el integrador.
|
|
77
|
+
|
|
78
|
+
Si el POST entrante fue alterado (p. ej. un uuid ajeno), la reconciliación
|
|
79
|
+
devolverá una transacción cuyo ``remote_id``/``amount`` no coincide y esta
|
|
80
|
+
función lo detecta.
|
|
81
|
+
"""
|
|
82
|
+
if remote_id is not None and transaction.remote_id != remote_id:
|
|
83
|
+
msg = f"remote_id no coincide: esperado {remote_id!r}, recibido {transaction.remote_id!r}"
|
|
84
|
+
raise WebhookMismatchError(msg)
|
|
85
|
+
if amount is not None and transaction.amount != amount:
|
|
86
|
+
msg = f"monto no coincide: esperado {amount!r}, recibido {transaction.amount!r}"
|
|
87
|
+
raise WebhookMismatchError(msg)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class IdempotencyStore(Protocol):
|
|
91
|
+
"""Contrato para deduplicar eventos: devuelve False si ya fue procesado."""
|
|
92
|
+
|
|
93
|
+
def mark_processed(self, key: str) -> bool: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class InMemoryIdempotencyStore:
|
|
97
|
+
"""Implementación en memoria para tests y prototipos.
|
|
98
|
+
|
|
99
|
+
En producción usa una restricción UNIQUE sobre ``transaction_uuid`` en la
|
|
100
|
+
base de datos (ver apps/links): es la única garante bajo concurrencia.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._seen: set[str] = set()
|
|
105
|
+
self._lock = threading.Lock()
|
|
106
|
+
|
|
107
|
+
def mark_processed(self, key: str) -> bool:
|
|
108
|
+
with self._lock:
|
|
109
|
+
if key in self._seen:
|
|
110
|
+
return False
|
|
111
|
+
self._seen.add(key)
|
|
112
|
+
return True
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: qvapaykit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: QvaPayKit: SDK Python para integrar pagos con QvaPay — facturas, webhooks verificados, dinero en Decimal
|
|
5
|
+
Project-URL: Homepage, https://www.qvapay.com/docs
|
|
6
|
+
Author: RolandoJV
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# QvaPayKit
|
|
23
|
+
|
|
24
|
+
**SDK Python para integrar pagos con [QvaPay](https://www.qvapay.com/docs)**: facturas, enlaces de pago, webhooks verificados contra la API y transacciones — con dinero en `Decimal` (nunca `float`) e idempotencia garantizada por base de datos.
|
|
25
|
+
|
|
26
|
+
Diferenciado del wrapper genérico comunitario: QvaPayKit está enfocado en que tu app **cobre de forma segura**, no en envolver toda la API.
|
|
27
|
+
|
|
28
|
+
> Documentación completa al finalizar el monorepo (Fase 6 del roadmap).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
qvapaykit/__init__.py,sha256=49ZDceAyEOxUplDzX7NXKCUB8NvnIidEiuPjtkHR3ww,2141
|
|
2
|
+
qvapaykit/client.py,sha256=B4fOXEjcC4hN_uz2h8dwZwgDJI6DHxCzYl3xC422fsY,10538
|
|
3
|
+
qvapaykit/config.py,sha256=HTiLtguKjT1_u8VweqHvD-owKkY21EbyXWnw-pSRg0A,509
|
|
4
|
+
qvapaykit/exceptions.py,sha256=BiAnlimx9mReEshj5LrKsuYry80sEP-8YONMfVeV5ro,3153
|
|
5
|
+
qvapaykit/http.py,sha256=xgXkdVe2nagOpYQELXkGHihPur2TxdKHVWFd6k9xzss,8592
|
|
6
|
+
qvapaykit/models.py,sha256=iHYwp9pchI8ypA_woUJo4Ll2ez60UoibFxf2gKXF8BQ,5482
|
|
7
|
+
qvapaykit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
qvapaykit/webhooks.py,sha256=pexyyhQKFWPiRzr74lcbne2jVnHOgzrW_FATpXcDMhM,4137
|
|
9
|
+
qvapaykit-0.1.0.dist-info/METADATA,sha256=W8_I39Toku89fK45Nr9twgEFf8BvImo-Z3LwHiDbqeA,1275
|
|
10
|
+
qvapaykit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
qvapaykit-0.1.0.dist-info/RECORD,,
|