qvapaykit 0.1.0__tar.gz
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-0.1.0/.gitignore +27 -0
- qvapaykit-0.1.0/PKG-INFO +28 -0
- qvapaykit-0.1.0/README.md +7 -0
- qvapaykit-0.1.0/pyproject.toml +33 -0
- qvapaykit-0.1.0/src/qvapaykit/__init__.py +91 -0
- qvapaykit-0.1.0/src/qvapaykit/client.py +310 -0
- qvapaykit-0.1.0/src/qvapaykit/config.py +20 -0
- qvapaykit-0.1.0/src/qvapaykit/exceptions.py +111 -0
- qvapaykit-0.1.0/src/qvapaykit/http.py +250 -0
- qvapaykit-0.1.0/src/qvapaykit/models.py +170 -0
- qvapaykit-0.1.0/src/qvapaykit/py.typed +0 -0
- qvapaykit-0.1.0/src/qvapaykit/webhooks.py +112 -0
- qvapaykit-0.1.0/tests/__init__.py +0 -0
- qvapaykit-0.1.0/tests/conftest.py +119 -0
- qvapaykit-0.1.0/tests/test_client_async.py +76 -0
- qvapaykit-0.1.0/tests/test_client_sync.py +267 -0
- qvapaykit-0.1.0/tests/test_models.py +123 -0
- qvapaykit-0.1.0/tests/test_webhooks.py +92 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
|
|
10
|
+
# Entorno y secretos
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
|
|
15
|
+
# Bases de datos locales
|
|
16
|
+
*.db
|
|
17
|
+
*.sqlite3
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
|
|
22
|
+
# Cobertura
|
|
23
|
+
.coverage
|
|
24
|
+
htmlcov/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
.ruff_cache/
|
qvapaykit-0.1.0/PKG-INFO
ADDED
|
@@ -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,7 @@
|
|
|
1
|
+
# QvaPayKit
|
|
2
|
+
|
|
3
|
+
**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.
|
|
4
|
+
|
|
5
|
+
Diferenciado del wrapper genérico comunitario: QvaPayKit está enfocado en que tu app **cobre de forma segura**, no en envolver toda la API.
|
|
6
|
+
|
|
7
|
+
> Documentación completa al finalizar el monorepo (Fase 6 del roadmap).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qvapaykit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "QvaPayKit: SDK Python para integrar pagos con QvaPay — facturas, webhooks verificados, dinero en Decimal"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "RolandoJV" }]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Office/Business :: Financial",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"httpx>=0.27",
|
|
21
|
+
"pydantic>=2.7",
|
|
22
|
+
"pydantic-settings>=2.3",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://www.qvapay.com/docs"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/qvapaykit"]
|
|
@@ -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
|
+
]
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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)."""
|