kuti-pe 1.0.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.
- kuti_pe-1.0.0/.gitignore +13 -0
- kuti_pe-1.0.0/LICENSE +21 -0
- kuti_pe-1.0.0/PKG-INFO +130 -0
- kuti_pe-1.0.0/README.md +101 -0
- kuti_pe-1.0.0/pyproject.toml +48 -0
- kuti_pe-1.0.0/src/kuti/__init__.py +43 -0
- kuti_pe-1.0.0/src/kuti/client.py +112 -0
- kuti_pe-1.0.0/src/kuti/errors.py +103 -0
- kuti_pe-1.0.0/src/kuti/py.typed +1 -0
- kuti_pe-1.0.0/src/kuti/resources/__init__.py +1 -0
- kuti_pe-1.0.0/src/kuti/resources/checkout_sessions.py +107 -0
- kuti_pe-1.0.0/src/kuti/resources/payment_intents.py +62 -0
- kuti_pe-1.0.0/src/kuti/types.py +160 -0
- kuti_pe-1.0.0/src/kuti/webhooks.py +57 -0
- kuti_pe-1.0.0/tests/test_client.py +223 -0
- kuti_pe-1.0.0/tests/test_webhooks.py +62 -0
kuti_pe-1.0.0/.gitignore
ADDED
kuti_pe-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KUTI (kuti-pe)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kuti_pe-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kuti-pe
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK oficial de KUTI para Python — crea checkout sessions, consulta pagos y verifica webhooks.
|
|
5
|
+
Project-URL: Homepage, https://docs.kuti.pe
|
|
6
|
+
Project-URL: Documentation, https://docs.kuti.pe/sdks
|
|
7
|
+
Project-URL: Repository, https://github.com/kuti-pe/kuti-python
|
|
8
|
+
Project-URL: Issues, https://github.com/kuti-pe/kuti-python/issues
|
|
9
|
+
Author-email: KUTI <soporte@kuti.pe>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: checkout,kuti,payments,peru,qr,yape
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# kuti-pe
|
|
31
|
+
|
|
32
|
+
SDK oficial de KUTI para Python. Crea sesiones de checkout, consulta el estado de un pago y verifica webhooks — sin reimplementar auth, manejo de errores ni firma HMAC a mano.
|
|
33
|
+
|
|
34
|
+
> **Solo servidor.** Este paquete usa tu secret key (`kuti_live_...` / `kuti_test_...`). Nunca lo importes en código que se sirva al navegador.
|
|
35
|
+
|
|
36
|
+
## Instalación
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install kuti-pe
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
from kuti import KutiClient
|
|
47
|
+
|
|
48
|
+
kuti = KutiClient(os.environ["KUTI_SECRET_KEY"])
|
|
49
|
+
|
|
50
|
+
# El monto SIEMPRE se resuelve en tu backend — nunca confíes en un monto
|
|
51
|
+
# que te mande el navegador del comprador.
|
|
52
|
+
session = kuti.checkout_sessions.create(
|
|
53
|
+
amount={"amount": "249.90", "currency": "PEN"},
|
|
54
|
+
payment_method_types=["INTEROPERABLE_QR", "BANK_TRANSFER"],
|
|
55
|
+
description="Zapatillas running talla 42",
|
|
56
|
+
customer={"name": "María López", "email": "maria@example.com"},
|
|
57
|
+
idempotency_key=f"order-{order_id}", # evita duplicar el cobro si reintentas
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Envía session.checkout_url al frontend y ábrelo con Checkout.js:
|
|
61
|
+
# window.Kuti.open({ checkoutUrl: session.checkout_url, onSuccess, onFailure })
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Confirmar un pago (sin necesitar webhooks)
|
|
65
|
+
|
|
66
|
+
`onSuccess` de Checkout.js corre en el navegador del comprador — no es confiable por sí solo. Vuelve a preguntarle a la API:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
intent = kuti.payment_intents.retrieve(payment_intent_id)
|
|
70
|
+
if intent.status == "SUCCEEDED":
|
|
71
|
+
# entrega el producto / activa el servicio
|
|
72
|
+
pass
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Verificar un webhook
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from flask import Flask, request
|
|
79
|
+
from kuti import verify_webhook_signature, KutiSignatureVerificationError
|
|
80
|
+
import os
|
|
81
|
+
|
|
82
|
+
app = Flask(__name__)
|
|
83
|
+
|
|
84
|
+
@app.post("/webhooks/kuti")
|
|
85
|
+
def kuti_webhook():
|
|
86
|
+
payload = request.get_data(as_text=True) # body CRUDO, sin json.loads antes
|
|
87
|
+
try:
|
|
88
|
+
verify_webhook_signature(
|
|
89
|
+
payload,
|
|
90
|
+
request.headers["X-Kuti-Signature"],
|
|
91
|
+
request.headers["X-Kuti-Timestamp"],
|
|
92
|
+
os.environ["KUTI_WEBHOOK_SECRET"],
|
|
93
|
+
)
|
|
94
|
+
except KutiSignatureVerificationError:
|
|
95
|
+
return "Invalid signature", 400
|
|
96
|
+
|
|
97
|
+
event = request.get_json(force=True)
|
|
98
|
+
# payment.succeeded, checkout.session.completed, …
|
|
99
|
+
return "", 200
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Manejo de errores
|
|
103
|
+
|
|
104
|
+
Todas las excepciones de la API extienden `KutiApiError` (`status`, `code`, `request_id`, `doc_url`, `details`):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from kuti import KutiValidationError, KutiNotFoundError, KutiApiError
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
kuti.checkout_sessions.create(...)
|
|
111
|
+
except KutiValidationError as err:
|
|
112
|
+
print(err.details) # [ErrorDetail(field="amount.amount", ...)]
|
|
113
|
+
except KutiNotFoundError:
|
|
114
|
+
pass
|
|
115
|
+
except KutiApiError as err:
|
|
116
|
+
print(err.code, err.request_id) # úsalo al reportar un bug a soporte
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Los `GET` y los `POST` con `idempotency_key` se reintentan automáticamente en errores de red o `429`/`503`. Un `POST` sin `idempotency_key` nunca se reintenta, para no duplicar un cobro.
|
|
120
|
+
|
|
121
|
+
## API
|
|
122
|
+
|
|
123
|
+
- `KutiClient(secret_key, base_url=None)`
|
|
124
|
+
- `kuti.checkout_sessions.create(*, amount, payment_method_types, …, idempotency_key=None)`
|
|
125
|
+
- `kuti.payment_intents.retrieve(id)`
|
|
126
|
+
- `verify_webhook_signature(payload, signature_header, timestamp_header, secret, tolerance_seconds=300)`
|
|
127
|
+
|
|
128
|
+
## Requisitos
|
|
129
|
+
|
|
130
|
+
Python 3.9+ · sin dependencias de runtime.
|
kuti_pe-1.0.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# kuti-pe
|
|
2
|
+
|
|
3
|
+
SDK oficial de KUTI para Python. Crea sesiones de checkout, consulta el estado de un pago y verifica webhooks — sin reimplementar auth, manejo de errores ni firma HMAC a mano.
|
|
4
|
+
|
|
5
|
+
> **Solo servidor.** Este paquete usa tu secret key (`kuti_live_...` / `kuti_test_...`). Nunca lo importes en código que se sirva al navegador.
|
|
6
|
+
|
|
7
|
+
## Instalación
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install kuti-pe
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from kuti import KutiClient
|
|
18
|
+
|
|
19
|
+
kuti = KutiClient(os.environ["KUTI_SECRET_KEY"])
|
|
20
|
+
|
|
21
|
+
# El monto SIEMPRE se resuelve en tu backend — nunca confíes en un monto
|
|
22
|
+
# que te mande el navegador del comprador.
|
|
23
|
+
session = kuti.checkout_sessions.create(
|
|
24
|
+
amount={"amount": "249.90", "currency": "PEN"},
|
|
25
|
+
payment_method_types=["INTEROPERABLE_QR", "BANK_TRANSFER"],
|
|
26
|
+
description="Zapatillas running talla 42",
|
|
27
|
+
customer={"name": "María López", "email": "maria@example.com"},
|
|
28
|
+
idempotency_key=f"order-{order_id}", # evita duplicar el cobro si reintentas
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Envía session.checkout_url al frontend y ábrelo con Checkout.js:
|
|
32
|
+
# window.Kuti.open({ checkoutUrl: session.checkout_url, onSuccess, onFailure })
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Confirmar un pago (sin necesitar webhooks)
|
|
36
|
+
|
|
37
|
+
`onSuccess` de Checkout.js corre en el navegador del comprador — no es confiable por sí solo. Vuelve a preguntarle a la API:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
intent = kuti.payment_intents.retrieve(payment_intent_id)
|
|
41
|
+
if intent.status == "SUCCEEDED":
|
|
42
|
+
# entrega el producto / activa el servicio
|
|
43
|
+
pass
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Verificar un webhook
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from flask import Flask, request
|
|
50
|
+
from kuti import verify_webhook_signature, KutiSignatureVerificationError
|
|
51
|
+
import os
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
|
|
55
|
+
@app.post("/webhooks/kuti")
|
|
56
|
+
def kuti_webhook():
|
|
57
|
+
payload = request.get_data(as_text=True) # body CRUDO, sin json.loads antes
|
|
58
|
+
try:
|
|
59
|
+
verify_webhook_signature(
|
|
60
|
+
payload,
|
|
61
|
+
request.headers["X-Kuti-Signature"],
|
|
62
|
+
request.headers["X-Kuti-Timestamp"],
|
|
63
|
+
os.environ["KUTI_WEBHOOK_SECRET"],
|
|
64
|
+
)
|
|
65
|
+
except KutiSignatureVerificationError:
|
|
66
|
+
return "Invalid signature", 400
|
|
67
|
+
|
|
68
|
+
event = request.get_json(force=True)
|
|
69
|
+
# payment.succeeded, checkout.session.completed, …
|
|
70
|
+
return "", 200
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Manejo de errores
|
|
74
|
+
|
|
75
|
+
Todas las excepciones de la API extienden `KutiApiError` (`status`, `code`, `request_id`, `doc_url`, `details`):
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from kuti import KutiValidationError, KutiNotFoundError, KutiApiError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
kuti.checkout_sessions.create(...)
|
|
82
|
+
except KutiValidationError as err:
|
|
83
|
+
print(err.details) # [ErrorDetail(field="amount.amount", ...)]
|
|
84
|
+
except KutiNotFoundError:
|
|
85
|
+
pass
|
|
86
|
+
except KutiApiError as err:
|
|
87
|
+
print(err.code, err.request_id) # úsalo al reportar un bug a soporte
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Los `GET` y los `POST` con `idempotency_key` se reintentan automáticamente en errores de red o `429`/`503`. Un `POST` sin `idempotency_key` nunca se reintenta, para no duplicar un cobro.
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
- `KutiClient(secret_key, base_url=None)`
|
|
95
|
+
- `kuti.checkout_sessions.create(*, amount, payment_method_types, …, idempotency_key=None)`
|
|
96
|
+
- `kuti.payment_intents.retrieve(id)`
|
|
97
|
+
- `verify_webhook_signature(payload, signature_header, timestamp_header, secret, tolerance_seconds=300)`
|
|
98
|
+
|
|
99
|
+
## Requisitos
|
|
100
|
+
|
|
101
|
+
Python 3.9+ · sin dependencias de runtime.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kuti-pe"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK oficial de KUTI para Python — crea checkout sessions, consulta pagos y verifica webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "KUTI", email = "soporte@kuti.pe" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["kuti", "payments", "peru", "yape", "qr", "checkout"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://docs.kuti.pe"
|
|
33
|
+
Documentation = "https://docs.kuti.pe/sdks"
|
|
34
|
+
Repository = "https://github.com/kuti-pe/kuti-python"
|
|
35
|
+
Issues = "https://github.com/kuti-pe/kuti-python/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest>=8.0", "respx>=0.21"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/kuti"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/kuti", "LICENSE", "README.md", "tests"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""SDK oficial de KUTI para Python."""
|
|
2
|
+
|
|
3
|
+
from .client import KutiClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
KutiApiError,
|
|
6
|
+
KutiAuthenticationError,
|
|
7
|
+
KutiConflictError,
|
|
8
|
+
KutiConnectionError,
|
|
9
|
+
KutiNotFoundError,
|
|
10
|
+
KutiPermissionError,
|
|
11
|
+
KutiRateLimitError,
|
|
12
|
+
KutiSignatureVerificationError,
|
|
13
|
+
KutiValidationError,
|
|
14
|
+
)
|
|
15
|
+
from .types import (
|
|
16
|
+
CheckoutSession,
|
|
17
|
+
CheckoutSessionCustomer,
|
|
18
|
+
Money,
|
|
19
|
+
PaymentIntent,
|
|
20
|
+
PaymentMethodType,
|
|
21
|
+
)
|
|
22
|
+
from .webhooks import verify_webhook_signature
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"KutiClient",
|
|
26
|
+
"Money",
|
|
27
|
+
"CheckoutSessionCustomer",
|
|
28
|
+
"CheckoutSession",
|
|
29
|
+
"PaymentIntent",
|
|
30
|
+
"PaymentMethodType",
|
|
31
|
+
"verify_webhook_signature",
|
|
32
|
+
"KutiApiError",
|
|
33
|
+
"KutiAuthenticationError",
|
|
34
|
+
"KutiPermissionError",
|
|
35
|
+
"KutiNotFoundError",
|
|
36
|
+
"KutiValidationError",
|
|
37
|
+
"KutiConflictError",
|
|
38
|
+
"KutiRateLimitError",
|
|
39
|
+
"KutiConnectionError",
|
|
40
|
+
"KutiSignatureVerificationError",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
from .errors import KutiConnectionError, error_for_status
|
|
9
|
+
from .resources.checkout_sessions import CheckoutSessionsResource
|
|
10
|
+
from .resources.payment_intents import PaymentIntentsResource
|
|
11
|
+
from .types import RequestOptions
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.kuti.pe/v1"
|
|
14
|
+
_SECRET_KEY_PREFIXES = ("kuti_live_", "kuti_test_")
|
|
15
|
+
_MAX_RETRIES = 2
|
|
16
|
+
_RETRYABLE_STATUS = {429, 503}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KutiClient:
|
|
20
|
+
"""Cliente HTTP central de KUTI.
|
|
21
|
+
|
|
22
|
+
Cuelgan de aquí los recursos (``checkout_sessions``, ``payment_intents``);
|
|
23
|
+
esta clase solo resuelve auth, reintentos y mapeo de errores.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, secret_key: str, *, base_url: Optional[str] = None) -> None:
|
|
27
|
+
if not any(secret_key.startswith(p) for p in _SECRET_KEY_PREFIXES):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"KutiClient expects a secret key (kuti_live_... / kuti_test_...), "
|
|
30
|
+
"not a publishable key."
|
|
31
|
+
)
|
|
32
|
+
self._secret_key = secret_key
|
|
33
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
34
|
+
self.checkout_sessions = CheckoutSessionsResource(self)
|
|
35
|
+
self.payment_intents = PaymentIntentsResource(self)
|
|
36
|
+
|
|
37
|
+
def request(
|
|
38
|
+
self,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
body: Optional[Dict[str, Any]] = None,
|
|
42
|
+
opts: Optional[RequestOptions] = None,
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Uso interno de los recursos — no lo llames directo."""
|
|
45
|
+
headers = {
|
|
46
|
+
"Authorization": f"Bearer {self._secret_key}",
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
"User-Agent": "kuti-pe/1.0.0 (python)",
|
|
49
|
+
}
|
|
50
|
+
data: Optional[bytes] = None
|
|
51
|
+
if body is not None:
|
|
52
|
+
headers["Content-Type"] = "application/json"
|
|
53
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
54
|
+
if opts and opts.idempotency_key:
|
|
55
|
+
headers["Idempotency-Key"] = opts.idempotency_key
|
|
56
|
+
|
|
57
|
+
# Solo se reintenta si el request es idempotente por diseño (GET) o el caller
|
|
58
|
+
# ya proveyó una Idempotency-Key — nunca un POST "a ciegas".
|
|
59
|
+
can_retry = method == "GET" or bool(opts and opts.idempotency_key)
|
|
60
|
+
max_attempts = _MAX_RETRIES + 1 if can_retry else 1
|
|
61
|
+
|
|
62
|
+
last_error: Optional[BaseException] = None
|
|
63
|
+
for attempt in range(max_attempts):
|
|
64
|
+
if attempt > 0:
|
|
65
|
+
time.sleep((2**attempt) * 0.2)
|
|
66
|
+
try:
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
f"{self._base_url}{path}",
|
|
69
|
+
data=data,
|
|
70
|
+
headers=headers,
|
|
71
|
+
method=method,
|
|
72
|
+
)
|
|
73
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
74
|
+
raw = resp.read().decode("utf-8")
|
|
75
|
+
return json.loads(raw) if raw else {}
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
envelope = _safe_parse_error(exc)
|
|
78
|
+
err_body = envelope.get("error") or {}
|
|
79
|
+
api_error = error_for_status(
|
|
80
|
+
exc.code,
|
|
81
|
+
code=err_body.get("code") or "UNKNOWN_ERROR",
|
|
82
|
+
message=err_body.get("message")
|
|
83
|
+
or envelope.get("message")
|
|
84
|
+
or exc.reason,
|
|
85
|
+
request_id=err_body.get("request_id"),
|
|
86
|
+
doc_url=err_body.get("doc_url"),
|
|
87
|
+
details=err_body.get("details"),
|
|
88
|
+
)
|
|
89
|
+
if can_retry and exc.code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES:
|
|
90
|
+
last_error = api_error
|
|
91
|
+
continue
|
|
92
|
+
raise api_error from None
|
|
93
|
+
except urllib.error.URLError as exc:
|
|
94
|
+
last_error = KutiConnectionError("Could not reach the KUTI API.", exc)
|
|
95
|
+
if not can_retry:
|
|
96
|
+
raise last_error from exc
|
|
97
|
+
except TimeoutError as exc:
|
|
98
|
+
last_error = KutiConnectionError("Could not reach the KUTI API.", exc)
|
|
99
|
+
if not can_retry:
|
|
100
|
+
raise last_error from exc
|
|
101
|
+
|
|
102
|
+
assert last_error is not None
|
|
103
|
+
raise last_error
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe_parse_error(exc: urllib.error.HTTPError) -> Dict[str, Any]:
|
|
107
|
+
try:
|
|
108
|
+
raw = exc.read().decode("utf-8")
|
|
109
|
+
parsed = json.loads(raw) if raw else {}
|
|
110
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
111
|
+
except Exception:
|
|
112
|
+
return {}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from .types import ErrorDetail
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KutiApiError(Exception):
|
|
9
|
+
"""Error base de la API — cualquier respuesta HTTP != 2xx."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
status: int,
|
|
15
|
+
code: str,
|
|
16
|
+
message: str,
|
|
17
|
+
request_id: Optional[str] = None,
|
|
18
|
+
doc_url: Optional[str] = None,
|
|
19
|
+
details: Optional[List[Any]] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.request_id = request_id
|
|
25
|
+
self.doc_url = doc_url
|
|
26
|
+
self.details: List[ErrorDetail] = []
|
|
27
|
+
for d in details or []:
|
|
28
|
+
if isinstance(d, dict):
|
|
29
|
+
self.details.append(
|
|
30
|
+
ErrorDetail(
|
|
31
|
+
field=d.get("field"),
|
|
32
|
+
code=d.get("code"),
|
|
33
|
+
message=d.get("message"),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class KutiAuthenticationError(KutiApiError):
|
|
39
|
+
"""401 — secret key inválida, revocada o ausente."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KutiPermissionError(KutiApiError):
|
|
43
|
+
"""403 — key válida pero sin permiso."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class KutiNotFoundError(KutiApiError):
|
|
47
|
+
"""404 — recurso inexistente o de otro merchant."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class KutiValidationError(KutiApiError):
|
|
51
|
+
"""400 / 422 — request inválido. Revisa `.details`."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KutiConflictError(KutiApiError):
|
|
55
|
+
"""409 — conflicto de estado."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class KutiRateLimitError(KutiApiError):
|
|
59
|
+
"""429 — demasiadas requests."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class KutiConnectionError(Exception):
|
|
63
|
+
"""Fallo de red o timeout — no hubo respuesta HTTP de KUTI."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.__cause__ = cause
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class KutiSignatureVerificationError(Exception):
|
|
71
|
+
"""Firma de webhook inválida o timestamp fuera de tolerancia."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def error_for_status(
|
|
75
|
+
status: int,
|
|
76
|
+
*,
|
|
77
|
+
code: str,
|
|
78
|
+
message: str,
|
|
79
|
+
request_id: Optional[str] = None,
|
|
80
|
+
doc_url: Optional[str] = None,
|
|
81
|
+
details: Optional[List[Any]] = None,
|
|
82
|
+
) -> KutiApiError:
|
|
83
|
+
kwargs = dict(
|
|
84
|
+
status=status,
|
|
85
|
+
code=code,
|
|
86
|
+
message=message,
|
|
87
|
+
request_id=request_id,
|
|
88
|
+
doc_url=doc_url,
|
|
89
|
+
details=details,
|
|
90
|
+
)
|
|
91
|
+
if status == 401:
|
|
92
|
+
return KutiAuthenticationError(**kwargs)
|
|
93
|
+
if status == 403:
|
|
94
|
+
return KutiPermissionError(**kwargs)
|
|
95
|
+
if status == 404:
|
|
96
|
+
return KutiNotFoundError(**kwargs)
|
|
97
|
+
if status in (400, 422):
|
|
98
|
+
return KutiValidationError(**kwargs)
|
|
99
|
+
if status == 409:
|
|
100
|
+
return KutiConflictError(**kwargs)
|
|
101
|
+
if status == 429:
|
|
102
|
+
return KutiRateLimitError(**kwargs)
|
|
103
|
+
return KutiApiError(**kwargs)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker for PEP 561 — typed package.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Resources package
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from ..types import (
|
|
6
|
+
CheckoutSession,
|
|
7
|
+
CheckoutSessionCustomer,
|
|
8
|
+
Money,
|
|
9
|
+
PaymentMethodType,
|
|
10
|
+
RequestOptions,
|
|
11
|
+
money_from_api,
|
|
12
|
+
payment_method_from_api,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..client import KutiClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CheckoutSessionsResource:
|
|
20
|
+
def __init__(self, client: KutiClient) -> None:
|
|
21
|
+
self._client = client
|
|
22
|
+
|
|
23
|
+
def create(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
amount: Union[Money, Dict[str, str]],
|
|
27
|
+
payment_method_types: List[PaymentMethodType],
|
|
28
|
+
customer: Optional[Union[CheckoutSessionCustomer, Dict[str, Any]]] = None,
|
|
29
|
+
description: Optional[str] = None,
|
|
30
|
+
external_reference: Optional[str] = None,
|
|
31
|
+
success_url: Optional[str] = None,
|
|
32
|
+
expires_at: Optional[str] = None,
|
|
33
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
34
|
+
idempotency_key: Optional[str] = None,
|
|
35
|
+
) -> CheckoutSession:
|
|
36
|
+
"""Crea una sesión de cargo único.
|
|
37
|
+
|
|
38
|
+
El monto SIEMPRE debe resolverse en tu backend. Pasa ``idempotency_key``
|
|
39
|
+
(ej. tu id de orden) para no duplicar el cobro al reintentar.
|
|
40
|
+
"""
|
|
41
|
+
money = (
|
|
42
|
+
amount
|
|
43
|
+
if isinstance(amount, Money)
|
|
44
|
+
else Money(amount=str(amount["amount"]), currency=str(amount["currency"]))
|
|
45
|
+
)
|
|
46
|
+
cust_obj: Optional[CheckoutSessionCustomer] = None
|
|
47
|
+
if customer is not None:
|
|
48
|
+
if isinstance(customer, CheckoutSessionCustomer):
|
|
49
|
+
cust_obj = customer
|
|
50
|
+
else:
|
|
51
|
+
cust_obj = CheckoutSessionCustomer(
|
|
52
|
+
id=customer.get("id"),
|
|
53
|
+
external_id=customer.get("external_id"),
|
|
54
|
+
name=customer.get("name"),
|
|
55
|
+
email=customer.get("email"),
|
|
56
|
+
phone=customer.get("phone"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
body: Dict[str, Any] = {
|
|
60
|
+
"amount": {"amount": money.amount, "currency": money.currency},
|
|
61
|
+
"payment_method_types": list(payment_method_types),
|
|
62
|
+
}
|
|
63
|
+
if cust_obj is not None:
|
|
64
|
+
cust: Dict[str, Any] = {}
|
|
65
|
+
if cust_obj.id is not None:
|
|
66
|
+
cust["id"] = cust_obj.id
|
|
67
|
+
if cust_obj.external_id is not None:
|
|
68
|
+
cust["external_id"] = cust_obj.external_id
|
|
69
|
+
if cust_obj.name is not None:
|
|
70
|
+
cust["name"] = cust_obj.name
|
|
71
|
+
if cust_obj.email is not None:
|
|
72
|
+
cust["email"] = cust_obj.email
|
|
73
|
+
if cust_obj.phone is not None:
|
|
74
|
+
cust["phone"] = cust_obj.phone
|
|
75
|
+
body["customer"] = cust
|
|
76
|
+
if description is not None:
|
|
77
|
+
body["description"] = description
|
|
78
|
+
if external_reference is not None:
|
|
79
|
+
body["external_reference"] = external_reference
|
|
80
|
+
if success_url is not None:
|
|
81
|
+
body["success_url"] = success_url
|
|
82
|
+
if expires_at is not None:
|
|
83
|
+
body["expires_at"] = expires_at
|
|
84
|
+
if metadata is not None:
|
|
85
|
+
body["metadata"] = metadata
|
|
86
|
+
|
|
87
|
+
opts = RequestOptions(idempotency_key=idempotency_key) if idempotency_key else None
|
|
88
|
+
response = self._client.request("POST", "/checkout-sessions", body, opts)
|
|
89
|
+
return _from_api(response["data"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _from_api(dto: Dict[str, Any]) -> CheckoutSession:
|
|
93
|
+
return CheckoutSession(
|
|
94
|
+
id=dto["id"],
|
|
95
|
+
merchant_id=dto["merchant_id"],
|
|
96
|
+
customer_id=dto.get("customer_id"),
|
|
97
|
+
payment_intent_id=dto["payment_intent_id"],
|
|
98
|
+
amount=money_from_api(dto["amount"]),
|
|
99
|
+
status=dto["status"],
|
|
100
|
+
description=dto.get("description"),
|
|
101
|
+
success_url=dto.get("success_url"),
|
|
102
|
+
client_secret=dto.get("client_secret"),
|
|
103
|
+
checkout_url=dto.get("checkout_url"),
|
|
104
|
+
payment_method=payment_method_from_api(dto.get("payment_method")),
|
|
105
|
+
expires_at=dto.get("expires_at"),
|
|
106
|
+
created_at=dto["created_at"],
|
|
107
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from ..types import PaidWith, PaymentIntent, money_from_api, payment_method_from_api
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..client import KutiClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PaymentIntentsResource:
|
|
13
|
+
def __init__(self, client: KutiClient) -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def retrieve(self, payment_intent_id: str) -> PaymentIntent:
|
|
17
|
+
"""Consulta el estado real de un cobro.
|
|
18
|
+
|
|
19
|
+
Es la fuente de verdad — nunca confíes solo en un callback del frontend
|
|
20
|
+
(``onSuccess`` de Checkout.js). Verifica ``status == "SUCCEEDED"`` aquí
|
|
21
|
+
antes de entregar un producto o servicio.
|
|
22
|
+
"""
|
|
23
|
+
response = self._client.request(
|
|
24
|
+
"GET",
|
|
25
|
+
f"/payment-intents/{quote(payment_intent_id, safe='')}",
|
|
26
|
+
)
|
|
27
|
+
return _from_api(response["data"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _from_api(dto: Dict[str, Any]) -> PaymentIntent:
|
|
31
|
+
customer = dto.get("customer") or {}
|
|
32
|
+
customer_id = customer.get("id") if isinstance(customer, dict) else None
|
|
33
|
+
if customer_id is None:
|
|
34
|
+
customer_id = dto.get("customer_id")
|
|
35
|
+
|
|
36
|
+
paid_raw = dto.get("paid_with")
|
|
37
|
+
paid_with = None
|
|
38
|
+
if paid_raw:
|
|
39
|
+
paid_with = PaidWith(
|
|
40
|
+
method_type=paid_raw.get("method_type"),
|
|
41
|
+
paid_at=paid_raw.get("paid_at"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return PaymentIntent(
|
|
45
|
+
id=dto["id"],
|
|
46
|
+
merchant_id=dto["merchant_id"],
|
|
47
|
+
livemode=dto.get("livemode"),
|
|
48
|
+
customer_id=customer_id,
|
|
49
|
+
amount=money_from_api(dto["amount"]),
|
|
50
|
+
status=dto["status"],
|
|
51
|
+
payment_method_types=dto.get("payment_method_types"),
|
|
52
|
+
payment_method=payment_method_from_api(dto.get("payment_method")),
|
|
53
|
+
paid_with=paid_with,
|
|
54
|
+
checkout_url=dto.get("checkout_url"),
|
|
55
|
+
expires_at=dto.get("expires_at"),
|
|
56
|
+
external_reference=dto.get("external_reference"),
|
|
57
|
+
description=dto.get("description"),
|
|
58
|
+
category_id=dto.get("category_id"),
|
|
59
|
+
metadata=dto.get("metadata"),
|
|
60
|
+
requires_customer_info=dto.get("requires_customer_info"),
|
|
61
|
+
created_at=dto["created_at"],
|
|
62
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
5
|
+
|
|
6
|
+
PaymentMethodType = Literal["INTEROPERABLE_QR", "BANK_TRANSFER"]
|
|
7
|
+
|
|
8
|
+
CheckoutSessionStatus = Literal["OPEN", "COMPLETED", "EXPIRED", "CANCELLED"]
|
|
9
|
+
|
|
10
|
+
PaymentIntentStatus = Literal[
|
|
11
|
+
"REQUIRES_PAYMENT_METHOD",
|
|
12
|
+
"PENDING",
|
|
13
|
+
"PROCESSING",
|
|
14
|
+
"SUCCEEDED",
|
|
15
|
+
"FAILED",
|
|
16
|
+
"CANCELLED",
|
|
17
|
+
"EXPIRED",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Money:
|
|
23
|
+
"""Monto decimal como string. Nunca float."""
|
|
24
|
+
|
|
25
|
+
amount: str
|
|
26
|
+
currency: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CheckoutSessionCustomer:
|
|
31
|
+
"""Id de un Customer ya existente (cus_…). Si viene, se ignora el resto."""
|
|
32
|
+
|
|
33
|
+
id: Optional[str] = None
|
|
34
|
+
external_id: Optional[str] = None
|
|
35
|
+
name: Optional[str] = None
|
|
36
|
+
email: Optional[str] = None
|
|
37
|
+
phone: Optional[str] = None # E.164
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PaymentMethodQr:
|
|
42
|
+
type: Optional[PaymentMethodType] = None
|
|
43
|
+
payload: Optional[str] = None
|
|
44
|
+
image_url: Optional[str] = None
|
|
45
|
+
expires_at: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PaymentMethodPaymentCode:
|
|
50
|
+
type: Optional[PaymentMethodType] = None
|
|
51
|
+
code: Optional[str] = None
|
|
52
|
+
expires_at: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PaymentMethod:
|
|
57
|
+
qr: Optional[PaymentMethodQr] = None
|
|
58
|
+
payment_code: Optional[PaymentMethodPaymentCode] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PaidWith:
|
|
63
|
+
method_type: Optional[PaymentMethodType] = None
|
|
64
|
+
paid_at: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class CheckoutSession:
|
|
69
|
+
id: str
|
|
70
|
+
merchant_id: str
|
|
71
|
+
payment_intent_id: str
|
|
72
|
+
amount: Money
|
|
73
|
+
status: CheckoutSessionStatus
|
|
74
|
+
created_at: str
|
|
75
|
+
customer_id: Optional[str] = None
|
|
76
|
+
description: Optional[str] = None
|
|
77
|
+
success_url: Optional[str] = None
|
|
78
|
+
client_secret: Optional[str] = None
|
|
79
|
+
checkout_url: Optional[str] = None
|
|
80
|
+
payment_method: Optional[PaymentMethod] = None
|
|
81
|
+
expires_at: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class PaymentIntent:
|
|
86
|
+
id: str
|
|
87
|
+
merchant_id: str
|
|
88
|
+
amount: Money
|
|
89
|
+
status: PaymentIntentStatus
|
|
90
|
+
created_at: str
|
|
91
|
+
livemode: Optional[bool] = None
|
|
92
|
+
customer_id: Optional[str] = None
|
|
93
|
+
payment_method_types: Optional[List[PaymentMethodType]] = None
|
|
94
|
+
payment_method: Optional[PaymentMethod] = None
|
|
95
|
+
paid_with: Optional[PaidWith] = None
|
|
96
|
+
checkout_url: Optional[str] = None
|
|
97
|
+
expires_at: Optional[str] = None
|
|
98
|
+
external_reference: Optional[str] = None
|
|
99
|
+
description: Optional[str] = None
|
|
100
|
+
category_id: Optional[str] = None
|
|
101
|
+
metadata: Optional[Dict[str, str]] = None
|
|
102
|
+
requires_customer_info: Optional[bool] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CreateCheckoutSessionParams:
|
|
107
|
+
amount: Money
|
|
108
|
+
payment_method_types: List[PaymentMethodType]
|
|
109
|
+
customer: Optional[CheckoutSessionCustomer] = None
|
|
110
|
+
description: Optional[str] = None
|
|
111
|
+
external_reference: Optional[str] = None
|
|
112
|
+
success_url: Optional[str] = None
|
|
113
|
+
expires_at: Optional[str] = None
|
|
114
|
+
metadata: Optional[Dict[str, str]] = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class RequestOptions:
|
|
119
|
+
"""Evita duplicar la operación si el request se reintenta."""
|
|
120
|
+
|
|
121
|
+
idempotency_key: Optional[str] = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ErrorDetail:
|
|
126
|
+
field: Optional[str] = None
|
|
127
|
+
code: Optional[str] = None
|
|
128
|
+
message: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def money_from_api(raw: Dict[str, Any]) -> Money:
|
|
132
|
+
return Money(amount=str(raw["amount"]), currency=str(raw["currency"]))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def payment_method_from_api(raw: Optional[Dict[str, Any]]) -> Optional[PaymentMethod]:
|
|
136
|
+
if not raw:
|
|
137
|
+
return None
|
|
138
|
+
qr_raw = raw.get("qr")
|
|
139
|
+
code_raw = raw.get("payment_code")
|
|
140
|
+
return PaymentMethod(
|
|
141
|
+
qr=(
|
|
142
|
+
PaymentMethodQr(
|
|
143
|
+
type=qr_raw.get("type"),
|
|
144
|
+
payload=qr_raw.get("payload"),
|
|
145
|
+
image_url=qr_raw.get("image_url"),
|
|
146
|
+
expires_at=qr_raw.get("expires_at"),
|
|
147
|
+
)
|
|
148
|
+
if qr_raw
|
|
149
|
+
else None
|
|
150
|
+
),
|
|
151
|
+
payment_code=(
|
|
152
|
+
PaymentMethodPaymentCode(
|
|
153
|
+
type=code_raw.get("type"),
|
|
154
|
+
code=code_raw.get("code"),
|
|
155
|
+
expires_at=code_raw.get("expires_at"),
|
|
156
|
+
)
|
|
157
|
+
if code_raw
|
|
158
|
+
else None
|
|
159
|
+
),
|
|
160
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .errors import KutiSignatureVerificationError
|
|
8
|
+
|
|
9
|
+
_SIGNATURE_PREFIX = "v1="
|
|
10
|
+
_DEFAULT_TOLERANCE_SECONDS = 300
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_webhook_signature(
|
|
14
|
+
payload: str | bytes,
|
|
15
|
+
signature_header: str,
|
|
16
|
+
timestamp_header: str,
|
|
17
|
+
secret: str,
|
|
18
|
+
tolerance_seconds: int = _DEFAULT_TOLERANCE_SECONDS,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Verifica la firma de un webhook de KUTI.
|
|
21
|
+
|
|
22
|
+
Recomputa ``HMAC_SHA256(secret, "<timestamp>.<body>")`` con el *payload* crudo
|
|
23
|
+
(el string exacto del body, SIN parsear a JSON primero) y lo compara en tiempo
|
|
24
|
+
constante contra el header ``X-Kuti-Signature``. También rechaza timestamps
|
|
25
|
+
viejos para evitar ataques de replay.
|
|
26
|
+
|
|
27
|
+
:raises KutiSignatureVerificationError: si la firma no coincide o el timestamp
|
|
28
|
+
está fuera de tolerancia.
|
|
29
|
+
"""
|
|
30
|
+
if not signature_header.startswith(_SIGNATURE_PREFIX):
|
|
31
|
+
raise KutiSignatureVerificationError(
|
|
32
|
+
"Unexpected signature format (expected v1=<hex>)."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
timestamp_seconds = int(timestamp_header)
|
|
37
|
+
except (TypeError, ValueError) as exc:
|
|
38
|
+
raise KutiSignatureVerificationError("Invalid timestamp header.") from exc
|
|
39
|
+
|
|
40
|
+
now = int(time.time())
|
|
41
|
+
if abs(now - timestamp_seconds) > tolerance_seconds:
|
|
42
|
+
raise KutiSignatureVerificationError(
|
|
43
|
+
"Timestamp outside of tolerance — possible replay attack, "
|
|
44
|
+
"or your clock is out of sync."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
body = payload if isinstance(payload, str) else payload.decode("utf-8")
|
|
48
|
+
expected = _compute_signature(secret, timestamp_seconds, body)
|
|
49
|
+
received = signature_header[len(_SIGNATURE_PREFIX) :]
|
|
50
|
+
|
|
51
|
+
if not hmac.compare_digest(expected, received):
|
|
52
|
+
raise KutiSignatureVerificationError("Signature mismatch.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _compute_signature(secret: str, timestamp_seconds: int, body: str) -> str:
|
|
56
|
+
signed = f"{timestamp_seconds}.{body}".encode("utf-8")
|
|
57
|
+
return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from kuti import (
|
|
12
|
+
KutiAuthenticationError,
|
|
13
|
+
KutiClient,
|
|
14
|
+
KutiNotFoundError,
|
|
15
|
+
KutiRateLimitError,
|
|
16
|
+
KutiValidationError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SECRET_KEY = "kuti_test_abc123"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeHTTPResponse:
|
|
23
|
+
def __init__(self, body: Any) -> None:
|
|
24
|
+
self._raw = json.dumps(body).encode("utf-8")
|
|
25
|
+
|
|
26
|
+
def read(self) -> bytes:
|
|
27
|
+
return self._raw
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> "FakeHTTPResponse":
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, *args: object) -> None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def http_error(code: int, body: Any, reason: str = "error") -> HTTPError:
|
|
37
|
+
raw = json.dumps(body).encode("utf-8")
|
|
38
|
+
return HTTPError("https://example.test/", code, reason, hdrs=None, fp=io.BytesIO(raw)) # type: ignore[arg-type]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error_envelope(code: str, message: str) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"success": False,
|
|
44
|
+
"message": message,
|
|
45
|
+
"error": {"code": code, "message": message, "request_id": "req_test_1"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_rejects_publishable_key() -> None:
|
|
50
|
+
with pytest.raises(ValueError, match="secret key"):
|
|
51
|
+
KutiClient("kuti_pub_test_abc")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_sends_bearer_token() -> None:
|
|
55
|
+
captured: dict = {}
|
|
56
|
+
|
|
57
|
+
def fake_urlopen(req: Any, timeout: Optional[float] = None) -> FakeHTTPResponse:
|
|
58
|
+
captured["url"] = req.full_url
|
|
59
|
+
captured["headers"] = {k: v for k, v in req.header_items()}
|
|
60
|
+
return FakeHTTPResponse(
|
|
61
|
+
{
|
|
62
|
+
"data": {
|
|
63
|
+
"id": "pi_1",
|
|
64
|
+
"merchant_id": "mer_1",
|
|
65
|
+
"amount": {"amount": "10.00", "currency": "PEN"},
|
|
66
|
+
"status": "PENDING",
|
|
67
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
with patch("kuti.client.urllib.request.urlopen", side_effect=fake_urlopen):
|
|
73
|
+
client = KutiClient(SECRET_KEY, base_url="https://example.test/v1")
|
|
74
|
+
client.payment_intents.retrieve("pi_1")
|
|
75
|
+
|
|
76
|
+
assert captured["url"] == "https://example.test/v1/payment-intents/pi_1"
|
|
77
|
+
assert captured["headers"]["Authorization"] == f"Bearer {SECRET_KEY}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_maps_nested_customer_id() -> None:
|
|
81
|
+
def fake_urlopen(req: Any, timeout: Optional[float] = None) -> FakeHTTPResponse:
|
|
82
|
+
return FakeHTTPResponse(
|
|
83
|
+
{
|
|
84
|
+
"data": {
|
|
85
|
+
"id": "pi_1",
|
|
86
|
+
"merchant_id": "mer_1",
|
|
87
|
+
"customer": {
|
|
88
|
+
"id": "cus_01ABC",
|
|
89
|
+
"type": "INDIVIDUAL",
|
|
90
|
+
"name": "María López",
|
|
91
|
+
},
|
|
92
|
+
"amount": {"amount": "50.00", "currency": "PEN"},
|
|
93
|
+
"status": "SUCCEEDED",
|
|
94
|
+
"paid_with": {
|
|
95
|
+
"method_type": "INTEROPERABLE_QR",
|
|
96
|
+
"paid_at": "2026-01-01T00:01:00Z",
|
|
97
|
+
},
|
|
98
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
with patch("kuti.client.urllib.request.urlopen", side_effect=fake_urlopen):
|
|
104
|
+
intent = KutiClient(SECRET_KEY).payment_intents.retrieve("pi_1")
|
|
105
|
+
|
|
106
|
+
assert intent.customer_id == "cus_01ABC"
|
|
107
|
+
assert intent.paid_with is not None
|
|
108
|
+
assert intent.paid_with.method_type == "INTEROPERABLE_QR"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_maps_401() -> None:
|
|
112
|
+
with patch(
|
|
113
|
+
"kuti.client.urllib.request.urlopen",
|
|
114
|
+
side_effect=http_error(401, error_envelope("INVALID_API_KEY", "Invalid")),
|
|
115
|
+
):
|
|
116
|
+
with pytest.raises(KutiAuthenticationError):
|
|
117
|
+
KutiClient(SECRET_KEY).payment_intents.retrieve("pi_1")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_maps_404_with_code() -> None:
|
|
121
|
+
with patch(
|
|
122
|
+
"kuti.client.urllib.request.urlopen",
|
|
123
|
+
side_effect=http_error(
|
|
124
|
+
404, error_envelope("PAYMENT_INTENT_NOT_FOUND", "Not found")
|
|
125
|
+
),
|
|
126
|
+
):
|
|
127
|
+
with pytest.raises(KutiNotFoundError) as exc:
|
|
128
|
+
KutiClient(SECRET_KEY).payment_intents.retrieve("pi_missing")
|
|
129
|
+
assert exc.value.code == "PAYMENT_INTENT_NOT_FOUND"
|
|
130
|
+
assert exc.value.request_id == "req_test_1"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_maps_422() -> None:
|
|
134
|
+
with patch(
|
|
135
|
+
"kuti.client.urllib.request.urlopen",
|
|
136
|
+
side_effect=http_error(
|
|
137
|
+
422, error_envelope("VALIDATION_ERROR", "amount is required")
|
|
138
|
+
),
|
|
139
|
+
):
|
|
140
|
+
with pytest.raises(KutiValidationError):
|
|
141
|
+
KutiClient(SECRET_KEY).checkout_sessions.create(
|
|
142
|
+
amount={"amount": "", "currency": "PEN"},
|
|
143
|
+
payment_method_types=[],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_retries_get_on_429() -> None:
|
|
148
|
+
calls = {"n": 0}
|
|
149
|
+
|
|
150
|
+
def fake_urlopen(req: Any, timeout: Optional[float] = None):
|
|
151
|
+
calls["n"] += 1
|
|
152
|
+
if calls["n"] == 1:
|
|
153
|
+
raise http_error(429, error_envelope("RATE_LIMITED", "Too many"))
|
|
154
|
+
return FakeHTTPResponse(
|
|
155
|
+
{
|
|
156
|
+
"data": {
|
|
157
|
+
"id": "pi_1",
|
|
158
|
+
"merchant_id": "mer_1",
|
|
159
|
+
"amount": {"amount": "10.00", "currency": "PEN"},
|
|
160
|
+
"status": "PENDING",
|
|
161
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
with patch("kuti.client.time.sleep"):
|
|
167
|
+
with patch("kuti.client.urllib.request.urlopen", side_effect=fake_urlopen):
|
|
168
|
+
intent = KutiClient(SECRET_KEY).payment_intents.retrieve("pi_1")
|
|
169
|
+
|
|
170
|
+
assert intent.id == "pi_1"
|
|
171
|
+
assert calls["n"] == 2
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_does_not_retry_post_without_idempotency() -> None:
|
|
175
|
+
calls = {"n": 0}
|
|
176
|
+
|
|
177
|
+
def fake_urlopen(req: Any, timeout: Optional[float] = None):
|
|
178
|
+
calls["n"] += 1
|
|
179
|
+
raise http_error(429, error_envelope("RATE_LIMITED", "Too many"))
|
|
180
|
+
|
|
181
|
+
with patch("kuti.client.urllib.request.urlopen", side_effect=fake_urlopen):
|
|
182
|
+
with pytest.raises(KutiRateLimitError):
|
|
183
|
+
KutiClient(SECRET_KEY).checkout_sessions.create(
|
|
184
|
+
amount={"amount": "10.00", "currency": "PEN"},
|
|
185
|
+
payment_method_types=["INTEROPERABLE_QR"],
|
|
186
|
+
)
|
|
187
|
+
assert calls["n"] == 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_retries_post_with_idempotency_key() -> None:
|
|
191
|
+
calls = {"n": 0}
|
|
192
|
+
captured_headers: dict = {}
|
|
193
|
+
|
|
194
|
+
def fake_urlopen(req: Any, timeout: Optional[float] = None):
|
|
195
|
+
calls["n"] += 1
|
|
196
|
+
captured_headers.update({k: v for k, v in req.header_items()})
|
|
197
|
+
if calls["n"] == 1:
|
|
198
|
+
raise http_error(429, error_envelope("RATE_LIMITED", "Too many"))
|
|
199
|
+
return FakeHTTPResponse(
|
|
200
|
+
{
|
|
201
|
+
"data": {
|
|
202
|
+
"id": "cs_1",
|
|
203
|
+
"merchant_id": "mer_1",
|
|
204
|
+
"payment_intent_id": "pi_1",
|
|
205
|
+
"amount": {"amount": "10.00", "currency": "PEN"},
|
|
206
|
+
"status": "OPEN",
|
|
207
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
with patch("kuti.client.time.sleep"):
|
|
213
|
+
with patch("kuti.client.urllib.request.urlopen", side_effect=fake_urlopen):
|
|
214
|
+
session = KutiClient(SECRET_KEY).checkout_sessions.create(
|
|
215
|
+
amount={"amount": "10.00", "currency": "PEN"},
|
|
216
|
+
payment_method_types=["INTEROPERABLE_QR"],
|
|
217
|
+
idempotency_key="order-42",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
assert session.id == "cs_1"
|
|
221
|
+
assert calls["n"] == 2
|
|
222
|
+
headers_lower = {k.lower(): v for k, v in captured_headers.items()}
|
|
223
|
+
assert headers_lower.get("idempotency-key") == "order-42"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from kuti import KutiSignatureVerificationError, verify_webhook_signature
|
|
10
|
+
|
|
11
|
+
SECRET = "whsec_test_1234567890"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sign(secret: str, timestamp_seconds: int, body: str) -> str:
|
|
15
|
+
digest = hmac.new(
|
|
16
|
+
secret.encode(),
|
|
17
|
+
f"{timestamp_seconds}.{body}".encode(),
|
|
18
|
+
hashlib.sha256,
|
|
19
|
+
).hexdigest()
|
|
20
|
+
return f"v1={digest}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_accepts_valid_signature() -> None:
|
|
24
|
+
body = '{"type":"payment.succeeded","id":"evt_1"}'
|
|
25
|
+
now = int(time.time())
|
|
26
|
+
verify_webhook_signature(body, sign(SECRET, now, body), str(now), SECRET)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_rejects_wrong_secret() -> None:
|
|
30
|
+
body = "{}"
|
|
31
|
+
now = int(time.time())
|
|
32
|
+
with pytest.raises(KutiSignatureVerificationError):
|
|
33
|
+
verify_webhook_signature(body, sign("wrong", now, body), str(now), SECRET)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_rejects_tampered_body() -> None:
|
|
37
|
+
now = int(time.time())
|
|
38
|
+
signature = sign(SECRET, now, '{"amount":"10.00"}')
|
|
39
|
+
with pytest.raises(KutiSignatureVerificationError):
|
|
40
|
+
verify_webhook_signature('{"amount":"999.00"}', signature, str(now), SECRET)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_rejects_stale_timestamp() -> None:
|
|
44
|
+
body = "{}"
|
|
45
|
+
stale = int(time.time()) - 3600
|
|
46
|
+
with pytest.raises(KutiSignatureVerificationError):
|
|
47
|
+
verify_webhook_signature(body, sign(SECRET, stale, body), str(stale), SECRET)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_accepts_stale_with_wide_tolerance() -> None:
|
|
51
|
+
body = "{}"
|
|
52
|
+
stale = int(time.time()) - 3600
|
|
53
|
+
verify_webhook_signature(
|
|
54
|
+
body, sign(SECRET, stale, body), str(stale), SECRET, tolerance_seconds=7200
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_rejects_malformed_header() -> None:
|
|
59
|
+
with pytest.raises(KutiSignatureVerificationError):
|
|
60
|
+
verify_webhook_signature(
|
|
61
|
+
"{}", "not-a-valid-signature", str(int(time.time())), SECRET
|
|
62
|
+
)
|