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.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
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.
@@ -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
+ )