rayacash 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rayacash/__init__.py ADDED
@@ -0,0 +1,126 @@
1
+ """El SDK oficial de Python para la API de Raya Cash.
2
+
3
+ Estilo cliente (el recomendado)::
4
+
5
+ import rayacash
6
+
7
+ client = rayacash.RayaClient() # lee la clave secreta de RAYA_SECRET_KEY
8
+ payment = client.payments.create(amount=15000, description="Pedido #1042")
9
+
10
+ Estilo global, como stripe-python::
11
+
12
+ rayacash.api_key = os.environ["RAYA_SECRET_KEY"]
13
+ payment = rayacash.Payment.create(amount=15000)
14
+
15
+ Referencia de la API: https://raya.cash/api
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import httpx
21
+ from typing_extensions import Literal
22
+
23
+ from rayacash._requestor import DEFAULT_API_BASE, DEFAULT_MAX_NETWORK_RETRIES, DEFAULT_TIMEOUT
24
+ from rayacash._util import AppInfo, validate_app_info
25
+ from rayacash._version import VERSION
26
+
27
+ __version__ = VERSION
28
+
29
+ # --- Configuración del estilo global -------------------------------------------------------
30
+ # Solo la usan rayacash.Balance / Transfer / Payment. Un RayaClient tiene la suya.
31
+
32
+ #: Tu clave secreta. Si es None, se lee de la variable de entorno RAYA_SECRET_KEY.
33
+ api_key: str | None = None
34
+ api_base: str = DEFAULT_API_BASE
35
+ max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES
36
+ #: Segundos por intento o un httpx.Timeout.
37
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT
38
+ #: Clientes httpx propios para el estilo global (proxies, certificados…). No los cerramos.
39
+ http_client: httpx.Client | None = None
40
+ async_http_client: httpx.AsyncClient | None = None
41
+ app_info: AppInfo | None = None
42
+ #: "info" o "debug" para ver las requests en stderr (también con RAYA_LOG).
43
+ log: Literal["debug", "info"] | None = None
44
+
45
+
46
+ def set_app_info(name: str, *, version: str | None = None, url: str | None = None) -> None:
47
+ """Identifica tu plugin en el User-Agent del estilo global (lo ves en Developers › Logs)."""
48
+ global app_info
49
+ info: AppInfo = {"name": name}
50
+ if version is not None:
51
+ info["version"] = version
52
+ if url is not None:
53
+ info["url"] = url
54
+ app_info = validate_app_info(info)
55
+
56
+
57
+ from rayacash._balance import Balance # noqa: E402
58
+ from rayacash._client import RayaClient # noqa: E402
59
+ from rayacash._encoding import Amount # noqa: E402
60
+ from rayacash._error import ( # noqa: E402
61
+ APIConnectionError,
62
+ APIError,
63
+ AuthenticationError,
64
+ ConflictError,
65
+ IdempotencyError,
66
+ InvalidRequestError,
67
+ NotFoundError,
68
+ PermissionDeniedError,
69
+ RateLimitError,
70
+ RayaError,
71
+ SignatureVerificationError,
72
+ UnprocessableEntityError,
73
+ )
74
+ from rayacash._event import Event, EventType # noqa: E402
75
+ from rayacash._list_object import AnyIterator, ListObject # noqa: E402
76
+ from rayacash._payment import Payment, PaymentStatus # noqa: E402
77
+ from rayacash._raya_object import RayaObject # noqa: E402
78
+ from rayacash._request_options import RequestOptions # noqa: E402
79
+ from rayacash._response import RayaResponse # noqa: E402
80
+ from rayacash._transfer import Transfer, TransferDirection, TransferStatus # noqa: E402
81
+ from rayacash._webhook import Webhook, WebhookSignature # noqa: E402
82
+
83
+ __all__ = [
84
+ "DEFAULT_API_BASE",
85
+ "VERSION",
86
+ "APIConnectionError",
87
+ "APIError",
88
+ "Amount",
89
+ "AnyIterator",
90
+ "AppInfo",
91
+ "AuthenticationError",
92
+ "Balance",
93
+ "ConflictError",
94
+ "Event",
95
+ "EventType",
96
+ "IdempotencyError",
97
+ "InvalidRequestError",
98
+ "ListObject",
99
+ "NotFoundError",
100
+ "Payment",
101
+ "PaymentStatus",
102
+ "PermissionDeniedError",
103
+ "RateLimitError",
104
+ "RayaClient",
105
+ "RayaError",
106
+ "RayaObject",
107
+ "RayaResponse",
108
+ "RequestOptions",
109
+ "SignatureVerificationError",
110
+ "Transfer",
111
+ "TransferDirection",
112
+ "TransferStatus",
113
+ "UnprocessableEntityError",
114
+ "Webhook",
115
+ "WebhookSignature",
116
+ "__version__",
117
+ "api_base",
118
+ "api_key",
119
+ "app_info",
120
+ "async_http_client",
121
+ "http_client",
122
+ "log",
123
+ "max_network_retries",
124
+ "set_app_info",
125
+ "timeout",
126
+ ]
rayacash/_balance.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing_extensions import Literal
4
+
5
+ from rayacash._raya_object import RayaObject
6
+ from rayacash._request_options import RequestOptions
7
+
8
+
9
+ class Balance(RayaObject):
10
+ """Tu saldo disponible, en pesos.
11
+
12
+ Ya tiene descontadas las transferencias que enviaste, aunque sigan ``pending``.
13
+ """
14
+
15
+ OBJECT_NAME = "balance"
16
+
17
+ object: Literal["balance"]
18
+ #: Saldo disponible en pesos (puede ser negativo).
19
+ available: float
20
+ #: Siempre ``"ARS"``.
21
+ currency: str
22
+
23
+ @classmethod
24
+ def retrieve(cls, *, options: RequestOptions | None = None) -> Balance:
25
+ """Estilo global: ``rayacash.Balance.retrieve()``. Ver ``client.balance.retrieve()``."""
26
+ from rayacash._balance_service import BalanceService
27
+ from rayacash._global import default_requestor
28
+
29
+ return BalanceService(default_requestor()).retrieve(options=options)
30
+
31
+ @classmethod
32
+ async def retrieve_async(cls, *, options: RequestOptions | None = None) -> Balance:
33
+ from rayacash._balance_service import BalanceService
34
+ from rayacash._global import default_requestor
35
+
36
+ return await BalanceService(default_requestor()).retrieve_async(options=options)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ from rayacash._balance import Balance
6
+ from rayacash._request_options import RequestOptions
7
+
8
+ if TYPE_CHECKING:
9
+ from rayacash._requestor import APIRequestor
10
+
11
+
12
+ class BalanceService:
13
+ """``client.balance``: tu saldo disponible."""
14
+
15
+ def __init__(self, requestor: APIRequestor) -> None:
16
+ self._requestor = requestor
17
+
18
+ def retrieve(self, *, options: RequestOptions | None = None) -> Balance:
19
+ """Tu saldo disponible en pesos (``GET /v1/balance``)."""
20
+ return cast(
21
+ Balance,
22
+ self._requestor.request("GET", "/v1/balance", options=options, expected=Balance),
23
+ )
24
+
25
+ async def retrieve_async(self, *, options: RequestOptions | None = None) -> Balance:
26
+ """Como ``retrieve()``, para asyncio."""
27
+ return cast(
28
+ Balance,
29
+ await self._requestor.request_async(
30
+ "GET", "/v1/balance", options=options, expected=Balance
31
+ ),
32
+ )
rayacash/_client.py ADDED
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from types import TracebackType
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from typing_extensions import Self
10
+
11
+ from rayacash._balance_service import BalanceService
12
+ from rayacash._encoding import encode_query
13
+ from rayacash._event import Event
14
+ from rayacash._list_object import ListObject
15
+ from rayacash._payment_service import PaymentService
16
+ from rayacash._raya_object import RayaObject
17
+ from rayacash._request_options import RequestOptions, validate_options
18
+ from rayacash._requestor import (
19
+ DEFAULT_API_BASE,
20
+ DEFAULT_MAX_NETWORK_RETRIES,
21
+ DEFAULT_TIMEOUT,
22
+ APIRequestor,
23
+ HTTPClientManager,
24
+ RequestorConfig,
25
+ check_api_key,
26
+ normalize_api_base,
27
+ to_timeout,
28
+ validate_max_network_retries,
29
+ )
30
+ from rayacash._transfer_service import TransferService
31
+ from rayacash._util import AppInfo, mask_key, user_agent, validate_app_info
32
+ from rayacash._webhook import DEFAULT_TOLERANCE, Payload, construct_event
33
+
34
+
35
+ class _ClientConfig(RequestorConfig):
36
+ def __init__(
37
+ self,
38
+ *,
39
+ api_key: str | None,
40
+ api_base: str,
41
+ timeout: httpx.Timeout,
42
+ max_network_retries: int,
43
+ user_agent: str,
44
+ http: HTTPClientManager,
45
+ ) -> None:
46
+ self._api_key = api_key
47
+ self._api_base = api_base
48
+ self._timeout = timeout
49
+ self._max_network_retries = max_network_retries
50
+ self._user_agent = user_agent
51
+ self._http = http
52
+
53
+ def api_key(self) -> str | None:
54
+ return self._api_key
55
+
56
+ def api_base(self) -> str:
57
+ return self._api_base
58
+
59
+ def timeout(self) -> httpx.Timeout:
60
+ return self._timeout
61
+
62
+ def max_network_retries(self) -> int:
63
+ return self._max_network_retries
64
+
65
+ def user_agent(self) -> str:
66
+ return self._user_agent
67
+
68
+ def http(self) -> HTTPClientManager:
69
+ return self._http
70
+
71
+
72
+ class RayaClient:
73
+ """Cliente de la API de Raya Cash.
74
+
75
+ >>> client = rayacash.RayaClient() # lee la clave de RAYA_SECRET_KEY
76
+ >>> client.balance.retrieve().available
77
+
78
+ La clave secreta va solo en tu servidor, leída de una variable de entorno: nunca en el
79
+ navegador, en una app mobile ni en el repositorio.
80
+
81
+ :param api_key: Tu clave secreta (``raya_sk_live_…``). Si no la pasás, se lee de la variable
82
+ de entorno ``RAYA_SECRET_KEY``.
83
+ :param api_base: Por defecto ``https://api.raya.cash``.
84
+ :param timeout: Segundos por intento (por defecto 65, con 10 para conectar) o un
85
+ ``httpx.Timeout``. El servidor corta a los 60 s y crear un pago puede tardar ~40 s.
86
+ :param max_network_retries: Reintentos ante cortes, 5xx, 429 e
87
+ ``IDEMPOTENCY_KEY_IN_USE`` (por defecto 2). Los POST reintentan con la misma
88
+ Idempotency-Key, así que nunca se ejecutan dos veces.
89
+ :param http_client: Un ``httpx.Client`` propio (proxies, certificados…). No lo cerramos.
90
+ :param async_http_client: Lo mismo, para los métodos ``*_async``.
91
+ :param app_info: ``{"name", "version", "url"}`` de tu plugin, para el User-Agent.
92
+ """
93
+
94
+ balance: BalanceService
95
+ transfers: TransferService
96
+ payments: PaymentService
97
+
98
+ def __init__(
99
+ self,
100
+ api_key: str | None = None,
101
+ *,
102
+ api_base: str | None = None,
103
+ timeout: float | httpx.Timeout | None = None,
104
+ max_network_retries: int | None = None,
105
+ http_client: httpx.Client | None = None,
106
+ async_http_client: httpx.AsyncClient | None = None,
107
+ app_info: AppInfo | None = None,
108
+ ) -> None:
109
+ if api_key is None:
110
+ api_key = os.environ.get("RAYA_SECRET_KEY") or None
111
+ if api_key is not None:
112
+ api_key = check_api_key(api_key)
113
+ if http_client is not None and not isinstance(http_client, httpx.Client):
114
+ raise TypeError("http_client tiene que ser un httpx.Client.")
115
+ if async_http_client is not None and not isinstance(async_http_client, httpx.AsyncClient):
116
+ raise TypeError("async_http_client tiene que ser un httpx.AsyncClient.")
117
+
118
+ self._config = _ClientConfig(
119
+ api_key=api_key,
120
+ api_base=normalize_api_base(api_base if api_base is not None else DEFAULT_API_BASE),
121
+ timeout=to_timeout(timeout) if timeout is not None else DEFAULT_TIMEOUT,
122
+ max_network_retries=(
123
+ validate_max_network_retries(max_network_retries)
124
+ if max_network_retries is not None
125
+ else DEFAULT_MAX_NETWORK_RETRIES
126
+ ),
127
+ user_agent=user_agent(validate_app_info(app_info) if app_info is not None else None),
128
+ http=HTTPClientManager(http_client, async_http_client),
129
+ )
130
+ self._requestor = APIRequestor(self._config)
131
+ self.balance = BalanceService(self._requestor)
132
+ self.transfers = TransferService(self._requestor)
133
+ self.payments = PaymentService(self._requestor)
134
+
135
+ def raw_request(
136
+ self,
137
+ method: str,
138
+ path: str,
139
+ *,
140
+ params: Mapping[str, Any] | None = None,
141
+ body: Mapping[str, Any] | None = None,
142
+ idempotency_key: str | None = None,
143
+ options: RequestOptions | None = None,
144
+ ) -> RayaObject:
145
+ """Una request a cualquier ruta, con la misma autenticación, reintentos y errores.
146
+
147
+ Para endpoints que el SDK todavía no tiene: ``client.raw_request("get", "/v1/…")``.
148
+ ``params`` va en la query y ``body`` en el JSON del POST.
149
+ """
150
+ query = encode_query(params) if params else None
151
+ result = self._requestor.request(
152
+ method, path, query=query, body=body, idempotency_key=idempotency_key, options=options
153
+ )
154
+ return self._with_paging(result, method, path, query, options)
155
+
156
+ async def raw_request_async(
157
+ self,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ params: Mapping[str, Any] | None = None,
162
+ body: Mapping[str, Any] | None = None,
163
+ idempotency_key: str | None = None,
164
+ options: RequestOptions | None = None,
165
+ ) -> RayaObject:
166
+ """Como ``raw_request()``, para asyncio."""
167
+ query = encode_query(params) if params else None
168
+ result = await self._requestor.request_async(
169
+ method, path, query=query, body=body, idempotency_key=idempotency_key, options=options
170
+ )
171
+ return self._with_paging(result, method, path, query, options)
172
+
173
+ @staticmethod
174
+ def _with_paging(
175
+ result: RayaObject,
176
+ method: str,
177
+ path: str,
178
+ query: Mapping[str, str] | None,
179
+ options: RequestOptions | None,
180
+ ) -> RayaObject:
181
+ if isinstance(result, ListObject) and method.upper() == "GET":
182
+ result._set_retrieve_context(path, dict(query or {}), validate_options(options))
183
+ return result
184
+
185
+ def construct_event(
186
+ self,
187
+ payload: Payload,
188
+ sig_header: str | None,
189
+ secret: str,
190
+ tolerance: float | None = DEFAULT_TOLERANCE,
191
+ ) -> Event:
192
+ """Como ``rayacash.Webhook.construct_event``, con el evento ligado a este cliente.
193
+
194
+ Así, ``event.data.object.refund()`` usa la clave de este cliente.
195
+ """
196
+ return construct_event(payload, sig_header, secret, tolerance, requestor=self._requestor)
197
+
198
+ def close(self) -> None:
199
+ """Cierra el cliente httpx propio (el que pasaste en ``http_client`` no se toca)."""
200
+ self._config.http().close()
201
+
202
+ async def aclose(self) -> None:
203
+ """Cierra los clientes httpx propios, también el async."""
204
+ await self._config.http().aclose()
205
+
206
+ def __enter__(self) -> Self:
207
+ return self
208
+
209
+ def __exit__(
210
+ self,
211
+ exc_type: type[BaseException] | None,
212
+ exc: BaseException | None,
213
+ traceback: TracebackType | None,
214
+ ) -> None:
215
+ self.close()
216
+
217
+ async def __aenter__(self) -> Self:
218
+ return self
219
+
220
+ async def __aexit__(
221
+ self,
222
+ exc_type: type[BaseException] | None,
223
+ exc: BaseException | None,
224
+ traceback: TracebackType | None,
225
+ ) -> None:
226
+ await self.aclose()
227
+
228
+ def __repr__(self) -> str:
229
+ return (
230
+ f"<RayaClient api_base={self._config.api_base()!r} "
231
+ f"api_key={mask_key(self._config.api_key())!r}>"
232
+ )
rayacash/_encoding.py ADDED
@@ -0,0 +1,162 @@
1
+ """Cómo viajan los parámetros: JSON con montos exactos, query de las listas e ids en la ruta.
2
+
3
+ La API parsea los montos como números exactos y rechaza los que tienen más de 2 decimales
4
+ (``PARAMETER_INVALID``). Por eso un ``Decimal`` viaja como número JSON, nunca como string, y
5
+ el SDK no redondea nunca: si un float trae ruido (``0.1 + 0.2``), lo responde el servidor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ from collections.abc import Mapping
14
+ from datetime import datetime
15
+ from decimal import Decimal, InvalidOperation
16
+ from typing import Any, Union
17
+ from urllib.parse import quote
18
+
19
+ #: Un monto en pesos: ``1500``, ``1500.5``, ``Decimal("1500.50")`` o ``"1500.50"``.
20
+ Amount = Union[int, float, Decimal, str]
21
+
22
+ _IDEMPOTENCY_KEY = re.compile(r"[\x21-\x7e]{1,255}")
23
+
24
+
25
+ def canonical_amount(value: object, name: str = "amount") -> Decimal:
26
+ """El monto como ``Decimal`` exacto, sin redondear. El rango lo valida la API."""
27
+ if isinstance(value, bool):
28
+ raise TypeError(
29
+ f"{name} no puede ser un bool: mandá un número en pesos, como 1500 o 1500.5."
30
+ )
31
+ if isinstance(value, int):
32
+ return Decimal(value)
33
+ if isinstance(value, float):
34
+ if not math.isfinite(value):
35
+ raise ValueError(f"{name} tiene que ser un número finito, no {value!r}.")
36
+ # repr da el decimal más corto que representa el float: 1500.1 -> "1500.1".
37
+ return Decimal(repr(value))
38
+ if isinstance(value, Decimal):
39
+ if not value.is_finite():
40
+ raise ValueError(f"{name} tiene que ser un número finito, no {value!r}.")
41
+ return value
42
+ if isinstance(value, str):
43
+ try:
44
+ parsed = Decimal(value.strip())
45
+ except InvalidOperation:
46
+ raise ValueError(
47
+ f'{name} {value!r} no es un número: usá punto decimal y nada más, como "1500.50".'
48
+ ) from None
49
+ if not parsed.is_finite():
50
+ raise ValueError(f"{name} tiene que ser un número finito, no {value!r}.")
51
+ return parsed
52
+ raise TypeError(
53
+ f"{name} tiene que ser un número en pesos (int, float, Decimal o str decimal), "
54
+ f"no {type(value).__name__}."
55
+ )
56
+
57
+
58
+ def format_decimal(value: Decimal) -> str:
59
+ """El número JSON canónico: Decimal("1500.50") -> 1500.5, Decimal("1E+3") -> 1000."""
60
+ if not value.is_finite():
61
+ raise ValueError(f"No se puede mandar {value!r} en JSON.")
62
+ return format(value.normalize(), "f")
63
+
64
+
65
+ def dumps(value: Any) -> str:
66
+ """JSON compacto en ASCII, con los ``Decimal`` como números (json.dumps no los acepta)."""
67
+ out: list[str] = []
68
+ _encode(value, out)
69
+ return "".join(out)
70
+
71
+
72
+ def _encode(value: Any, out: list[str]) -> None:
73
+ if value is None:
74
+ out.append("null")
75
+ elif value is True:
76
+ out.append("true")
77
+ elif value is False:
78
+ out.append("false")
79
+ elif isinstance(value, str):
80
+ out.append(json.dumps(value))
81
+ elif isinstance(value, int):
82
+ out.append(int.__repr__(value))
83
+ elif isinstance(value, float):
84
+ if not math.isfinite(value):
85
+ raise ValueError(f"No se puede mandar {value!r} en JSON.")
86
+ out.append(float.__repr__(value))
87
+ elif isinstance(value, Decimal):
88
+ out.append(format_decimal(value))
89
+ elif isinstance(value, Mapping):
90
+ out.append("{")
91
+ for index, (key, item) in enumerate(value.items()):
92
+ if not isinstance(key, str):
93
+ raise TypeError(f"Las claves de un objeto JSON tienen que ser str, no {key!r}.")
94
+ if index:
95
+ out.append(",")
96
+ out.append(json.dumps(key))
97
+ out.append(":")
98
+ _encode(item, out)
99
+ out.append("}")
100
+ elif isinstance(value, (list, tuple)):
101
+ out.append("[")
102
+ for index, item in enumerate(value):
103
+ if index:
104
+ out.append(",")
105
+ _encode(item, out)
106
+ out.append("]")
107
+ else:
108
+ raise TypeError(f"No se puede mandar un {type(value).__name__} en JSON.")
109
+
110
+
111
+ def encode_body(params: Mapping[str, Any]) -> bytes:
112
+ """El body de un POST: solo los parámetros que vinieron (la API rechaza los desconocidos)."""
113
+ return dumps({key: value for key, value in params.items() if value is not None}).encode("ascii")
114
+
115
+
116
+ def format_datetime(name: str, value: datetime) -> str:
117
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
118
+ raise ValueError(
119
+ f"{name} necesita zona horaria: usá un datetime con tzinfo (por ejemplo, "
120
+ 'timezone.utc) o un string ISO 8601 con zona, como "2026-10-01T00:00:00-03:00".'
121
+ )
122
+ return value.isoformat()
123
+
124
+
125
+ def encode_query(params: Mapping[str, Any]) -> dict[str, str]:
126
+ """La query de una lista: solo los filtros que vinieron."""
127
+ query: dict[str, str] = {}
128
+ for name, value in params.items():
129
+ if value is None:
130
+ continue
131
+ if isinstance(value, bool):
132
+ raise TypeError(f"{name} no puede ser un bool.")
133
+ if isinstance(value, datetime):
134
+ query[name] = format_datetime(name, value)
135
+ elif isinstance(value, (int, str)):
136
+ query[name] = str(value)
137
+ else:
138
+ raise TypeError(f"{name} no acepta un {type(value).__name__}.")
139
+ return query
140
+
141
+
142
+ def path_id(name: str, value: object) -> str:
143
+ """Un id para la ruta, codificado: nunca deja una barra final ni un segmento vacío."""
144
+ if not isinstance(value, str):
145
+ raise TypeError(f"{name} tiene que ser un str, no {type(value).__name__}.")
146
+ if not value.strip():
147
+ raise ValueError(f"{name} no puede estar vacío.")
148
+ return quote(value, safe="")
149
+
150
+
151
+ def validate_idempotency_key(value: object) -> str:
152
+ if not isinstance(value, str):
153
+ raise TypeError(
154
+ "idempotency_key tiene que ser un str: generá un UUID v4 con str(uuid.uuid4()) "
155
+ "y guardalo junto a la operación antes de llamar."
156
+ )
157
+ if not _IDEMPOTENCY_KEY.fullmatch(value):
158
+ raise ValueError(
159
+ "idempotency_key tiene que tener de 1 a 255 caracteres ASCII visibles, sin espacios "
160
+ "(por ejemplo, un UUID v4)."
161
+ )
162
+ return value