nxgate-sdk-python 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.
nxgate/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """NXGATE PIX SDK for Python.
2
+
3
+ Usage::
4
+
5
+ from nxgate import NXGate, NXGateWebhook
6
+
7
+ nx = NXGate(client_id="...", client_secret="...")
8
+ charge = nx.pix_generate(
9
+ valor=100.00,
10
+ nome_pagador="Joao da Silva",
11
+ documento_pagador="12345678901",
12
+ )
13
+ """
14
+
15
+ from .client import NXGate
16
+ from .errors import (
17
+ NXGateAuthError,
18
+ NXGateError,
19
+ NXGateRetryError,
20
+ NXGateTimeoutError,
21
+ )
22
+ from .types import (
23
+ BalanceResponse,
24
+ CashInEvent,
25
+ CashInEventData,
26
+ CashOutEvent,
27
+ PixGenerateResponse,
28
+ PixWithdrawResponse,
29
+ SplitUser,
30
+ TokenResponse,
31
+ TransactionResponse,
32
+ WebhookEvent,
33
+ )
34
+ from .webhook import NXGateWebhook
35
+
36
+ __all__ = [
37
+ # Client
38
+ "NXGate",
39
+ # Webhook
40
+ "NXGateWebhook",
41
+ # Errors
42
+ "NXGateError",
43
+ "NXGateAuthError",
44
+ "NXGateRetryError",
45
+ "NXGateTimeoutError",
46
+ # Types
47
+ "BalanceResponse",
48
+ "CashInEvent",
49
+ "CashInEventData",
50
+ "CashOutEvent",
51
+ "PixGenerateResponse",
52
+ "PixWithdrawResponse",
53
+ "SplitUser",
54
+ "TokenResponse",
55
+ "TransactionResponse",
56
+ "WebhookEvent",
57
+ ]
58
+
59
+ __version__ = "1.0.0"
nxgate/auth.py ADDED
@@ -0,0 +1,105 @@
1
+ """Token management for the NXGATE SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import TYPE_CHECKING
10
+
11
+ from .errors import NXGateAuthError
12
+ from .types import TokenResponse
13
+
14
+ if TYPE_CHECKING:
15
+ pass
16
+
17
+ _TOKEN_MARGIN_SECONDS = 60 # refresh token 60 s before it expires
18
+
19
+
20
+ class TokenManager:
21
+ """Fetches and caches OAuth2 Bearer tokens.
22
+
23
+ The token is automatically refreshed when it is about to expire (with a
24
+ 60-second safety margin).
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ client_id: str,
31
+ client_secret: str,
32
+ *,
33
+ timeout: int = 30,
34
+ ) -> None:
35
+ self._base_url = base_url.rstrip("/")
36
+ self._client_id = client_id
37
+ self._client_secret = client_secret
38
+ self._timeout = timeout
39
+
40
+ self._token: str | None = None
41
+ self._expires_at: float = 0.0
42
+
43
+ # ── public API ────────────────────────────────────────────────────────
44
+
45
+ def get_token(self) -> str:
46
+ """Return a valid access token, refreshing when needed."""
47
+ if self._token is None or time.time() >= self._expires_at:
48
+ self._refresh()
49
+ assert self._token is not None
50
+ return self._token
51
+
52
+ def invalidate(self) -> None:
53
+ """Force the next call to ``get_token`` to fetch a new token."""
54
+ self._token = None
55
+ self._expires_at = 0.0
56
+
57
+ # ── internals ─────────────────────────────────────────────────────────
58
+
59
+ def _refresh(self) -> None:
60
+ url = f"{self._base_url}/oauth2/token"
61
+ payload = json.dumps(
62
+ {
63
+ "grant_type": "client_credentials",
64
+ "client_id": self._client_id,
65
+ "client_secret": self._client_secret,
66
+ }
67
+ ).encode("utf-8")
68
+
69
+ req = urllib.request.Request(
70
+ url,
71
+ data=payload,
72
+ headers={"Content-Type": "application/json"},
73
+ method="POST",
74
+ )
75
+
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
78
+ body = json.loads(resp.read().decode("utf-8"))
79
+ except urllib.error.HTTPError as exc:
80
+ try:
81
+ detail = json.loads(exc.read().decode("utf-8"))
82
+ except Exception:
83
+ detail = {}
84
+ err = detail.get("error", {})
85
+ if isinstance(err, str):
86
+ msg = err
87
+ else:
88
+ msg = err.get("description", str(exc))
89
+ raise NXGateAuthError(
90
+ f"Failed to obtain token: {msg}",
91
+ status_code=exc.code,
92
+ ) from exc
93
+ except urllib.error.URLError as exc:
94
+ raise NXGateAuthError(
95
+ f"Failed to connect to {url}: {exc.reason}"
96
+ ) from exc
97
+
98
+ token_resp = TokenResponse(
99
+ access_token=body["access_token"],
100
+ token_type=body.get("token_type", "Bearer"),
101
+ expires_in=int(body.get("expires_in", 3600)),
102
+ )
103
+
104
+ self._token = token_resp.access_token
105
+ self._expires_at = time.time() + token_resp.expires_in - _TOKEN_MARGIN_SECONDS
nxgate/client.py ADDED
@@ -0,0 +1,251 @@
1
+ """Main client for the NXGATE PIX SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ from .auth import TokenManager
13
+ from .errors import NXGateError, NXGateRetryError, NXGateTimeoutError
14
+ from .hmac_signer import HmacSigner
15
+ from .types import (
16
+ BalanceResponse,
17
+ PixGenerateRequest,
18
+ PixGenerateResponse,
19
+ PixWithdrawRequest,
20
+ PixWithdrawResponse,
21
+ SplitUser,
22
+ TransactionResponse,
23
+ )
24
+
25
+ _DEFAULT_BASE_URL = "https://api.nxgate.com.br"
26
+ _DEFAULT_TIMEOUT = 30
27
+ _MAX_RETRIES = 2
28
+ _RETRY_BASE_DELAY = 1.0 # seconds
29
+
30
+
31
+ class NXGate:
32
+ """Synchronous client for the NXGATE PIX API.
33
+
34
+ Parameters:
35
+ client_id: Your NXGATE ``client_id``.
36
+ client_secret: Your NXGATE ``client_secret``.
37
+ hmac_secret: Optional HMAC secret. When provided every request is
38
+ automatically signed with HMAC-SHA256 headers.
39
+ base_url: API base URL (defaults to ``https://api.nxgate.com.br``).
40
+ timeout: HTTP request timeout in seconds (default ``30``).
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ client_id: str,
46
+ client_secret: str,
47
+ hmac_secret: str | None = None,
48
+ *,
49
+ base_url: str = _DEFAULT_BASE_URL,
50
+ timeout: int = _DEFAULT_TIMEOUT,
51
+ ) -> None:
52
+ self._base_url = base_url.rstrip("/")
53
+ self._timeout = timeout
54
+ self._client_id = client_id
55
+
56
+ self._token_manager = TokenManager(
57
+ base_url=self._base_url,
58
+ client_id=client_id,
59
+ client_secret=client_secret,
60
+ timeout=timeout,
61
+ )
62
+
63
+ self._signer: HmacSigner | None = None
64
+ if hmac_secret:
65
+ self._signer = HmacSigner(client_id, hmac_secret)
66
+
67
+ # ── Public API ────────────────────────────────────────────────────────
68
+
69
+ def pix_generate(
70
+ self,
71
+ valor: float,
72
+ nome_pagador: str,
73
+ documento_pagador: str,
74
+ *,
75
+ forcar_pagador: bool | None = None,
76
+ email_pagador: str | None = None,
77
+ celular: str | None = None,
78
+ descricao: str | None = None,
79
+ webhook: str | None = None,
80
+ magic_id: str | None = None,
81
+ api_key: str | None = None,
82
+ split_users: list[dict[str, Any]] | None = None,
83
+ ) -> PixGenerateResponse:
84
+ """Generate a PIX charge (cash-in) and return a QR Code.
85
+
86
+ Returns:
87
+ :class:`PixGenerateResponse` with payment code and transaction id.
88
+ """
89
+ parsed_splits: list[SplitUser] | None = None
90
+ if split_users is not None:
91
+ parsed_splits = [
92
+ SplitUser(username=s["username"], percentage=s["percentage"])
93
+ for s in split_users
94
+ ]
95
+
96
+ req = PixGenerateRequest(
97
+ valor=valor,
98
+ nome_pagador=nome_pagador,
99
+ documento_pagador=documento_pagador,
100
+ forcar_pagador=forcar_pagador,
101
+ email_pagador=email_pagador,
102
+ celular=celular,
103
+ descricao=descricao,
104
+ webhook=webhook,
105
+ magic_id=magic_id,
106
+ api_key=api_key,
107
+ split_users=parsed_splits,
108
+ )
109
+
110
+ data = self._request("POST", "/pix/gerar", body=req.to_dict())
111
+ return PixGenerateResponse.from_dict(data)
112
+
113
+ def pix_withdraw(
114
+ self,
115
+ valor: float,
116
+ chave_pix: str,
117
+ tipo_chave: str,
118
+ *,
119
+ documento: str | None = None,
120
+ webhook: str | None = None,
121
+ magic_id: str | None = None,
122
+ api_key: str | None = None,
123
+ ) -> PixWithdrawResponse:
124
+ """Execute a PIX withdrawal (cash-out).
125
+
126
+ Returns:
127
+ :class:`PixWithdrawResponse` with status and internal reference.
128
+ """
129
+ req = PixWithdrawRequest(
130
+ valor=valor,
131
+ chave_pix=chave_pix,
132
+ tipo_chave=tipo_chave,
133
+ documento=documento,
134
+ webhook=webhook,
135
+ magic_id=magic_id,
136
+ api_key=api_key,
137
+ )
138
+
139
+ data = self._request("POST", "/pix/sacar", body=req.to_dict())
140
+ return PixWithdrawResponse.from_dict(data)
141
+
142
+ def get_balance(self) -> BalanceResponse:
143
+ """Check current account balance.
144
+
145
+ Returns:
146
+ :class:`BalanceResponse` with balance, blocked and available.
147
+ """
148
+ data = self._request("GET", "/v1/balance")
149
+ return BalanceResponse.from_dict(data)
150
+
151
+ def get_transaction(
152
+ self,
153
+ type: str,
154
+ txid: str,
155
+ ) -> TransactionResponse:
156
+ """Retrieve a specific transaction.
157
+
158
+ Parameters:
159
+ type: ``"cash-in"`` or ``"cash-out"``.
160
+ txid: The transaction identifier.
161
+
162
+ Returns:
163
+ :class:`TransactionResponse` with transaction details.
164
+ """
165
+ params = urllib.parse.urlencode({"type": type, "txid": txid})
166
+ data = self._request("GET", f"/v1/transactions?{params}")
167
+ return TransactionResponse.from_dict(data)
168
+
169
+ # ── Internals ─────────────────────────────────────────────────────────
170
+
171
+ def _request(
172
+ self,
173
+ method: str,
174
+ path: str,
175
+ *,
176
+ body: dict | None = None,
177
+ ) -> dict:
178
+ """Execute an authenticated HTTP request with retry on 503."""
179
+ token = self._token_manager.get_token()
180
+
181
+ body_bytes: bytes | None = None
182
+ body_str = ""
183
+ if body is not None:
184
+ body_str = json.dumps(body)
185
+ body_bytes = body_str.encode("utf-8")
186
+
187
+ # Resolve full URL - path may already contain query params
188
+ url = f"{self._base_url}{path}"
189
+
190
+ last_exc: Exception | None = None
191
+ for attempt in range(_MAX_RETRIES + 1):
192
+ headers: dict[str, str] = {
193
+ "Authorization": f"Bearer {token}",
194
+ "Content-Type": "application/json",
195
+ "Accept": "application/json",
196
+ }
197
+
198
+ if self._signer is not None:
199
+ # Sign using only the path portion (before query string)
200
+ sign_path = path.split("?")[0]
201
+ hmac_headers = self._signer.sign(method, sign_path, body_str)
202
+ headers.update(hmac_headers)
203
+
204
+ req = urllib.request.Request(
205
+ url,
206
+ data=body_bytes,
207
+ headers=headers,
208
+ method=method,
209
+ )
210
+
211
+ try:
212
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
213
+ return json.loads(resp.read().decode("utf-8"))
214
+ except urllib.error.HTTPError as exc:
215
+ if exc.code == 503 and attempt < _MAX_RETRIES:
216
+ last_exc = exc
217
+ time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
218
+ continue
219
+
220
+ # Try to parse structured error
221
+ try:
222
+ detail = json.loads(exc.read().decode("utf-8"))
223
+ except Exception:
224
+ detail = {}
225
+
226
+ err = detail.get("error", {})
227
+ if isinstance(err, str):
228
+ raise NXGateError(
229
+ err,
230
+ status_code=exc.code,
231
+ ) from exc
232
+ raise NXGateError(
233
+ err.get("description", str(exc)),
234
+ title=err.get("title"),
235
+ code=err.get("code"),
236
+ description=err.get("description"),
237
+ status_code=exc.code,
238
+ ) from exc
239
+ except urllib.error.URLError as exc:
240
+ if "timed out" in str(exc.reason):
241
+ raise NXGateTimeoutError(
242
+ f"Request timed out after {self._timeout}s"
243
+ ) from exc
244
+ raise NXGateError(
245
+ f"Connection error: {exc.reason}"
246
+ ) from exc
247
+
248
+ raise NXGateRetryError(
249
+ f"All {_MAX_RETRIES + 1} attempts failed for {method} {path}",
250
+ status_code=503,
251
+ )
nxgate/errors.py ADDED
@@ -0,0 +1,50 @@
1
+ """Exceptions for the NXGATE SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NXGateError(Exception):
7
+ """Base exception for all NXGATE SDK errors.
8
+
9
+ Attributes:
10
+ title: Short error title (e.g. ``"Unauthorized"``).
11
+ code: Machine-readable error code returned by the API.
12
+ description: Human-readable explanation of the error.
13
+ status_code: HTTP status code, when available.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ title: str | None = None,
21
+ code: str | None = None,
22
+ description: str | None = None,
23
+ status_code: int | None = None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.title = title
27
+ self.code = code
28
+ self.description = description
29
+ self.status_code = status_code
30
+
31
+ def __repr__(self) -> str:
32
+ parts = [f"NXGateError({str(self)!r}"]
33
+ if self.status_code is not None:
34
+ parts.append(f", status_code={self.status_code}")
35
+ if self.code is not None:
36
+ parts.append(f", code={self.code!r}")
37
+ parts.append(")")
38
+ return "".join(parts)
39
+
40
+
41
+ class NXGateAuthError(NXGateError):
42
+ """Raised when authentication / token retrieval fails."""
43
+
44
+
45
+ class NXGateTimeoutError(NXGateError):
46
+ """Raised when a request exceeds the configured timeout."""
47
+
48
+
49
+ class NXGateRetryError(NXGateError):
50
+ """Raised when all retry attempts have been exhausted."""
nxgate/hmac_signer.py ADDED
@@ -0,0 +1,56 @@
1
+ """HMAC request signing for the NXGATE SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ class HmacSigner:
13
+ """Generates HMAC-SHA256 headers for API request authentication.
14
+
15
+ The signature is computed over the canonical string::
16
+
17
+ METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nBODY
18
+ """
19
+
20
+ def __init__(self, client_id: str, hmac_secret: str) -> None:
21
+ self._client_id = client_id
22
+ self._secret = hmac_secret.encode("utf-8")
23
+
24
+ def sign(
25
+ self,
26
+ method: str,
27
+ path: str,
28
+ body: str = "",
29
+ ) -> dict[str, str]:
30
+ """Return the HMAC headers for a single request.
31
+
32
+ Parameters:
33
+ method: HTTP method (e.g. ``"POST"``).
34
+ path: Request path (e.g. ``"/pix/gerar"``).
35
+ body: Serialised request body (empty string for GET).
36
+
37
+ Returns:
38
+ A dict with the four required headers.
39
+ """
40
+ timestamp = datetime.now(timezone.utc).isoformat()
41
+ nonce = uuid.uuid4().hex
42
+
43
+ canonical = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}"
44
+ signature = hmac.new(
45
+ self._secret,
46
+ canonical.encode("utf-8"),
47
+ hashlib.sha256,
48
+ ).digest()
49
+ signature_b64 = base64.b64encode(signature).decode("ascii")
50
+
51
+ return {
52
+ "X-Client-ID": self._client_id,
53
+ "X-HMAC-Signature": signature_b64,
54
+ "X-HMAC-Timestamp": timestamp,
55
+ "X-HMAC-Nonce": nonce,
56
+ }
nxgate/types.py ADDED
@@ -0,0 +1,297 @@
1
+ """Data classes for the NXGATE SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ # ── Auth ──────────────────────────────────────────────────────────────────
9
+
10
+ @dataclass(frozen=True)
11
+ class TokenResponse:
12
+ """Represents the response from ``POST /oauth2/token``."""
13
+
14
+ access_token: str
15
+ token_type: str
16
+ expires_in: int
17
+
18
+
19
+ # ── PIX Cash-in ───────────────────────────────────────────────────────────
20
+
21
+ @dataclass(frozen=True)
22
+ class SplitUser:
23
+ """Describes a split payment participant."""
24
+
25
+ username: str
26
+ percentage: float
27
+
28
+
29
+ @dataclass
30
+ class PixGenerateRequest:
31
+ """Payload for ``POST /pix/gerar``."""
32
+
33
+ valor: float
34
+ nome_pagador: str
35
+ documento_pagador: str
36
+ forcar_pagador: bool | None = None
37
+ email_pagador: str | None = None
38
+ celular: str | None = None
39
+ descricao: str | None = None
40
+ webhook: str | None = None
41
+ magic_id: str | None = None
42
+ api_key: str | None = None
43
+ split_users: list[SplitUser] | None = None
44
+
45
+ def to_dict(self) -> dict:
46
+ data: dict = {
47
+ "valor": self.valor,
48
+ "nome_pagador": self.nome_pagador,
49
+ "documento_pagador": self.documento_pagador,
50
+ }
51
+ if self.forcar_pagador is not None:
52
+ data["forcar_pagador"] = self.forcar_pagador
53
+ if self.email_pagador is not None:
54
+ data["email_pagador"] = self.email_pagador
55
+ if self.celular is not None:
56
+ data["celular"] = self.celular
57
+ if self.descricao is not None:
58
+ data["descricao"] = self.descricao
59
+ if self.webhook is not None:
60
+ data["webhook"] = self.webhook
61
+ if self.magic_id is not None:
62
+ data["magic_id"] = self.magic_id
63
+ if self.api_key is not None:
64
+ data["api_key"] = self.api_key
65
+ if self.split_users is not None:
66
+ data["split_users"] = [
67
+ {"username": s.username, "percentage": s.percentage}
68
+ for s in self.split_users
69
+ ]
70
+ return data
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class PixGenerateResponse:
75
+ """Response from ``POST /pix/gerar``."""
76
+
77
+ status: str
78
+ message: str
79
+ paymentCode: str
80
+ idTransaction: str
81
+ paymentCodeBase64: str
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict) -> PixGenerateResponse:
85
+ return cls(
86
+ status=data.get("status", ""),
87
+ message=data.get("message", ""),
88
+ paymentCode=data.get("paymentCode", ""),
89
+ idTransaction=data.get("idTransaction", ""),
90
+ paymentCodeBase64=data.get("paymentCodeBase64", ""),
91
+ )
92
+
93
+
94
+ # ── PIX Cash-out ──────────────────────────────────────────────────────────
95
+
96
+ TIPO_CHAVE_VALUES = {"CPF", "CNPJ", "PHONE", "EMAIL", "RANDOM"}
97
+
98
+
99
+ @dataclass
100
+ class PixWithdrawRequest:
101
+ """Payload for ``POST /pix/sacar``."""
102
+
103
+ valor: float
104
+ chave_pix: str
105
+ tipo_chave: str # CPF | CNPJ | PHONE | EMAIL | RANDOM
106
+ documento: str | None = None
107
+ webhook: str | None = None
108
+ magic_id: str | None = None
109
+ api_key: str | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if self.tipo_chave not in TIPO_CHAVE_VALUES:
113
+ raise ValueError(
114
+ f"tipo_chave must be one of {TIPO_CHAVE_VALUES}, "
115
+ f"got {self.tipo_chave!r}"
116
+ )
117
+
118
+ def to_dict(self) -> dict:
119
+ data: dict = {
120
+ "valor": self.valor,
121
+ "chave_pix": self.chave_pix,
122
+ "tipo_chave": self.tipo_chave,
123
+ }
124
+ if self.documento is not None:
125
+ data["documento"] = self.documento
126
+ if self.webhook is not None:
127
+ data["webhook"] = self.webhook
128
+ if self.magic_id is not None:
129
+ data["magic_id"] = self.magic_id
130
+ if self.api_key is not None:
131
+ data["api_key"] = self.api_key
132
+ return data
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class PixWithdrawResponse:
137
+ """Response from ``POST /pix/sacar``."""
138
+
139
+ status: str
140
+ message: str
141
+ internalreference: str
142
+
143
+ @classmethod
144
+ def from_dict(cls, data: dict) -> PixWithdrawResponse:
145
+ return cls(
146
+ status=data.get("status", ""),
147
+ message=data.get("message", ""),
148
+ internalreference=data.get("internalreference", ""),
149
+ )
150
+
151
+
152
+ # ── Balance ───────────────────────────────────────────────────────────────
153
+
154
+ @dataclass(frozen=True)
155
+ class BalanceResponse:
156
+ """Response from ``GET /v1/balance``."""
157
+
158
+ balance: float
159
+ blocked: float
160
+ available: float
161
+
162
+ @classmethod
163
+ def from_dict(cls, data: dict) -> BalanceResponse:
164
+ return cls(
165
+ balance=float(data.get("balance", 0)),
166
+ blocked=float(data.get("blocked", 0)),
167
+ available=float(data.get("available", 0)),
168
+ )
169
+
170
+
171
+ # ── Transactions ──────────────────────────────────────────────────────────
172
+
173
+ @dataclass(frozen=True)
174
+ class TransactionResponse:
175
+ """Response from ``GET /v1/transactions``."""
176
+
177
+ idTransaction: str
178
+ status: str
179
+ amount: float
180
+ paidAt: str | None
181
+ endToEnd: str | None
182
+ raw: dict = field(default_factory=dict)
183
+
184
+ @classmethod
185
+ def from_dict(cls, data: dict) -> TransactionResponse:
186
+ return cls(
187
+ idTransaction=data.get("idTransaction", ""),
188
+ status=data.get("status", ""),
189
+ amount=float(data.get("amount", 0)),
190
+ paidAt=data.get("paidAt"),
191
+ endToEnd=data.get("endToEnd"),
192
+ raw=data,
193
+ )
194
+
195
+
196
+ # ── Webhook Events ───────────────────────────────────────────────────────
197
+
198
+ @dataclass(frozen=True)
199
+ class CashInEventData:
200
+ """Payload inside a cash-in webhook event."""
201
+
202
+ amount: float
203
+ status: str
204
+ worked: bool
205
+ tag: str
206
+ tx_id: str
207
+ end_to_end: str
208
+ payment_date: str
209
+ debtor_name: str
210
+ debtor_document: str
211
+ type_document: str
212
+ magic_id: str
213
+ fee: float
214
+ raw: dict = field(default_factory=dict)
215
+
216
+ @classmethod
217
+ def from_dict(cls, data: dict) -> CashInEventData:
218
+ return cls(
219
+ amount=float(data.get("amount", 0)),
220
+ status=data.get("status", ""),
221
+ worked=bool(data.get("worked", False)),
222
+ tag=data.get("tag", ""),
223
+ tx_id=data.get("tx_id", ""),
224
+ end_to_end=data.get("end_to_end", ""),
225
+ payment_date=data.get("payment_date", ""),
226
+ debtor_name=data.get("debtor_name", ""),
227
+ debtor_document=data.get("debtor_document", ""),
228
+ type_document=data.get("type_document", ""),
229
+ magic_id=data.get("magic_id", ""),
230
+ fee=float(data.get("fee", 0)),
231
+ raw=data,
232
+ )
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class CashInEvent:
237
+ """Cash-in webhook event."""
238
+
239
+ type: str # QR_CODE_COPY_AND_PASTE_PAID | QR_CODE_COPY_AND_PASTE_REFUNDED
240
+ data: CashInEventData
241
+
242
+ @classmethod
243
+ def from_dict(cls, payload: dict) -> CashInEvent:
244
+ return cls(
245
+ type=payload["type"],
246
+ data=CashInEventData.from_dict(payload.get("data", {})),
247
+ )
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class CashOutEvent:
252
+ """Cash-out webhook event."""
253
+
254
+ type: str # PIX_CASHOUT_SUCCESS | PIX_CASHOUT_ERROR | PIX_CASHOUT_REFUNDED
255
+ worked: bool
256
+ status: str
257
+ idTransaction: str
258
+ amount: float
259
+ key: str
260
+ end_to_end: str
261
+ payment_date: str
262
+ magic_id: str
263
+ fee: float
264
+ error: str | None
265
+ raw: dict = field(default_factory=dict)
266
+
267
+ @classmethod
268
+ def from_dict(cls, payload: dict) -> CashOutEvent:
269
+ return cls(
270
+ type=payload["type"],
271
+ worked=bool(payload.get("worked", False)),
272
+ status=payload.get("status", ""),
273
+ idTransaction=payload.get("idTransaction", ""),
274
+ amount=float(payload.get("amount", 0)),
275
+ key=payload.get("key", ""),
276
+ end_to_end=payload.get("end_to_end", ""),
277
+ payment_date=payload.get("payment_date", ""),
278
+ magic_id=payload.get("magic_id", ""),
279
+ fee=float(payload.get("fee", 0)),
280
+ error=payload.get("error"),
281
+ raw=payload,
282
+ )
283
+
284
+
285
+ # Union-like alias for webhook events
286
+ WebhookEvent = CashInEvent | CashOutEvent
287
+
288
+ CASHIN_TYPES = {
289
+ "QR_CODE_COPY_AND_PASTE_PAID",
290
+ "QR_CODE_COPY_AND_PASTE_REFUNDED",
291
+ }
292
+
293
+ CASHOUT_TYPES = {
294
+ "PIX_CASHOUT_SUCCESS",
295
+ "PIX_CASHOUT_ERROR",
296
+ "PIX_CASHOUT_REFUNDED",
297
+ }
nxgate/webhook.py ADDED
@@ -0,0 +1,48 @@
1
+ """Webhook event parser for the NXGATE SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import NXGateError
6
+ from .types import (
7
+ CASHIN_TYPES,
8
+ CASHOUT_TYPES,
9
+ CashInEvent,
10
+ CashOutEvent,
11
+ WebhookEvent,
12
+ )
13
+
14
+
15
+ class NXGateWebhook:
16
+ """Stateless helper to parse incoming webhook payloads."""
17
+
18
+ @staticmethod
19
+ def parse(payload: dict) -> WebhookEvent:
20
+ """Parse a raw webhook JSON payload into a typed event.
21
+
22
+ Parameters:
23
+ payload: The decoded JSON body sent by NXGATE to your webhook
24
+ endpoint.
25
+
26
+ Returns:
27
+ A :class:`CashInEvent` or :class:`CashOutEvent`.
28
+
29
+ Raises:
30
+ NXGateError: If the ``type`` field is missing or unrecognised.
31
+ """
32
+ event_type = payload.get("type")
33
+ if not event_type:
34
+ raise NXGateError(
35
+ "Webhook payload is missing the 'type' field.",
36
+ code="INVALID_WEBHOOK",
37
+ )
38
+
39
+ if event_type in CASHIN_TYPES:
40
+ return CashInEvent.from_dict(payload)
41
+
42
+ if event_type in CASHOUT_TYPES:
43
+ return CashOutEvent.from_dict(payload)
44
+
45
+ raise NXGateError(
46
+ f"Unknown webhook event type: {event_type!r}",
47
+ code="UNKNOWN_WEBHOOK_TYPE",
48
+ )
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: nxgate-sdk-python
3
+ Version: 1.0.0
4
+ Summary: SDK oficial da NXGATE para integração com a API PIX
5
+ Author-email: NXGATE <suporte@nxgate.com.br>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/nxgate/nxgate-sdk-python
8
+ Project-URL: Documentation, https://github.com/nxgate/nxgate-sdk-python#readme
9
+ Project-URL: Repository, https://github.com/nxgate/nxgate-sdk-python
10
+ Keywords: nxgate,pix,pagamentos,qrcode,sdk
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # NXGATE PIX SDK para Python
28
+
29
+ SDK oficial da NXGATE para integração com a API PIX. Permite gerar cobranças (cash-in), realizar saques (cash-out), consultar saldo e transações, tudo com tipagem completa e zero dependências externas.
30
+
31
+ ## Requisitos
32
+
33
+ - Python 3.10 ou superior
34
+ - Sem dependências externas (usa apenas a biblioteca padrão)
35
+
36
+ ## Instalação
37
+
38
+ ```bash
39
+ pip install nxgate
40
+ ```
41
+
42
+ Ou instale diretamente do repositório:
43
+
44
+ ```bash
45
+ pip install git+https://github.com/nxgate/sdk-python.git
46
+ ```
47
+
48
+ ## Início Rápido
49
+
50
+ ```python
51
+ from nxgate import NXGate, NXGateWebhook
52
+
53
+ nx = NXGate(
54
+ client_id="nxgate_xxx",
55
+ client_secret="secret",
56
+ hmac_secret="opcional", # opcional - quando informado, todas as requisições são assinadas com HMAC-SHA256
57
+ )
58
+ ```
59
+
60
+ ## Funcionalidades
61
+
62
+ ### Gerar cobrança PIX (Cash-in)
63
+
64
+ Gera uma cobrança PIX e retorna o QR Code para pagamento.
65
+
66
+ ```python
67
+ charge = nx.pix_generate(
68
+ valor=100.00,
69
+ nome_pagador="João da Silva",
70
+ documento_pagador="12345678901",
71
+ webhook="https://meusite.com/webhook",
72
+ descricao="Pedido #1234",
73
+ email_pagador="joao@email.com",
74
+ celular="11999999999",
75
+ )
76
+
77
+ print(charge.status) # "ACTIVE"
78
+ print(charge.paymentCode) # código copia-e-cola
79
+ print(charge.idTransaction) # ID da transação
80
+ print(charge.paymentCodeBase64) # QR Code em base64
81
+ ```
82
+
83
+ #### Cobrança com split de pagamento
84
+
85
+ ```python
86
+ charge = nx.pix_generate(
87
+ valor=200.00,
88
+ nome_pagador="Maria Souza",
89
+ documento_pagador="98765432100",
90
+ split_users=[
91
+ {"username": "loja_a", "percentage": 70.0},
92
+ {"username": "loja_b", "percentage": 30.0},
93
+ ],
94
+ )
95
+ ```
96
+
97
+ ### Saque PIX (Cash-out)
98
+
99
+ Realiza uma transferência PIX para uma chave destino.
100
+
101
+ ```python
102
+ withdrawal = nx.pix_withdraw(
103
+ valor=50.00,
104
+ chave_pix="joao@email.com",
105
+ tipo_chave="EMAIL", # CPF | CNPJ | PHONE | EMAIL | RANDOM
106
+ webhook="https://meusite.com/webhook",
107
+ )
108
+
109
+ print(withdrawal.status) # "PROCESSING"
110
+ print(withdrawal.internalreference) # referência interna
111
+ ```
112
+
113
+ ### Consultar saldo
114
+
115
+ ```python
116
+ balance = nx.get_balance()
117
+
118
+ print(balance.balance) # saldo total
119
+ print(balance.blocked) # saldo bloqueado
120
+ print(balance.available) # saldo disponível
121
+ ```
122
+
123
+ ### Consultar transação
124
+
125
+ ```python
126
+ tx = nx.get_transaction(type="cash-in", txid="px_xxx")
127
+
128
+ print(tx.idTransaction) # ID da transação
129
+ print(tx.status) # status atual
130
+ print(tx.amount) # valor
131
+ print(tx.paidAt) # data do pagamento
132
+ print(tx.endToEnd) # identificador end-to-end
133
+ ```
134
+
135
+ ## Webhooks
136
+
137
+ O SDK fornece um parser para eventos recebidos via webhook.
138
+
139
+ ### Recebendo eventos (exemplo com Flask)
140
+
141
+ ```python
142
+ from flask import Flask, request, jsonify
143
+ from nxgate import NXGateWebhook, NXGateError
144
+ from nxgate.types import CashInEvent, CashOutEvent
145
+
146
+ app = Flask(__name__)
147
+
148
+ @app.route("/webhook", methods=["POST"])
149
+ def webhook():
150
+ try:
151
+ event = NXGateWebhook.parse(request.json)
152
+ except NXGateError as e:
153
+ return jsonify({"error": str(e)}), 400
154
+
155
+ if isinstance(event, CashInEvent):
156
+ print(f"Cash-in: {event.type}")
157
+ print(f" Valor: R$ {event.data.amount:.2f}")
158
+ print(f" Pagador: {event.data.debtor_name}")
159
+ print(f" TX ID: {event.data.tx_id}")
160
+ print(f" Status: {event.data.status}")
161
+
162
+ elif isinstance(event, CashOutEvent):
163
+ print(f"Cash-out: {event.type}")
164
+ print(f" Valor: R$ {event.amount:.2f}")
165
+ print(f" Chave: {event.key}")
166
+ if event.error:
167
+ print(f" Erro: {event.error}")
168
+
169
+ return jsonify({"ok": True})
170
+ ```
171
+
172
+ ### Tipos de evento
173
+
174
+ **Cash-in:**
175
+ - `QR_CODE_COPY_AND_PASTE_PAID` - pagamento confirmado
176
+ - `QR_CODE_COPY_AND_PASTE_REFUNDED` - pagamento devolvido
177
+
178
+ **Cash-out:**
179
+ - `PIX_CASHOUT_SUCCESS` - saque realizado com sucesso
180
+ - `PIX_CASHOUT_ERROR` - erro no saque
181
+ - `PIX_CASHOUT_REFUNDED` - saque devolvido
182
+
183
+ ## Assinatura HMAC
184
+
185
+ Quando o parâmetro `hmac_secret` é informado na inicialização do cliente, todas as requisições são automaticamente assinadas com HMAC-SHA256. Os seguintes headers são adicionados:
186
+
187
+ | Header | Descrição |
188
+ |--------|-----------|
189
+ | `X-Client-ID` | Seu `client_id` |
190
+ | `X-HMAC-Signature` | Assinatura HMAC-SHA256 em base64 |
191
+ | `X-HMAC-Timestamp` | Timestamp ISO 8601 |
192
+ | `X-HMAC-Nonce` | Valor único por requisição |
193
+
194
+ A assinatura é gerada sobre a string canônica:
195
+
196
+ ```
197
+ METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY
198
+ ```
199
+
200
+ ## Gerenciamento de Token
201
+
202
+ O SDK gerencia automaticamente o token OAuth2:
203
+
204
+ - O token é obtido na primeira requisição
205
+ - É mantido em cache enquanto válido
206
+ - É renovado automaticamente 60 segundos antes de expirar
207
+ - Em caso de falha na autenticação, `NXGateAuthError` é lançado
208
+
209
+ ## Tratamento de Erros
210
+
211
+ ```python
212
+ from nxgate import NXGate, NXGateError
213
+ from nxgate.errors import NXGateAuthError, NXGateTimeoutError, NXGateRetryError
214
+
215
+ nx = NXGate(client_id="xxx", client_secret="yyy")
216
+
217
+ try:
218
+ charge = nx.pix_generate(
219
+ valor=100.00,
220
+ nome_pagador="Teste",
221
+ documento_pagador="00000000000",
222
+ )
223
+ except NXGateAuthError as e:
224
+ print(f"Erro de autenticação: {e}")
225
+ print(f"Status HTTP: {e.status_code}")
226
+ except NXGateTimeoutError as e:
227
+ print(f"Timeout: {e}")
228
+ except NXGateRetryError as e:
229
+ print(f"Todas as tentativas falharam: {e}")
230
+ except NXGateError as e:
231
+ print(f"Erro da API: {e}")
232
+ print(f"Título: {e.title}")
233
+ print(f"Código: {e.code}")
234
+ print(f"Descrição: {e.description}")
235
+ print(f"Status HTTP: {e.status_code}")
236
+ ```
237
+
238
+ ### Hierarquia de exceções
239
+
240
+ ```
241
+ Exception
242
+ └── NXGateError
243
+ ├── NXGateAuthError # falha na autenticação
244
+ ├── NXGateTimeoutError # timeout na requisição
245
+ └── NXGateRetryError # tentativas esgotadas (503)
246
+ ```
247
+
248
+ ## Retry Automático
249
+
250
+ Requisições que retornam HTTP 503 são automaticamente retentadas com backoff exponencial:
251
+
252
+ - Máximo de 2 retentativas (3 tentativas no total)
253
+ - Delay entre tentativas: 1s, 2s
254
+ - Se todas as tentativas falharem, `NXGateRetryError` é lançado
255
+
256
+ ## Referência da API
257
+
258
+ ### `NXGate(client_id, client_secret, hmac_secret=None, *, base_url, timeout)`
259
+
260
+ | Parâmetro | Tipo | Obrigatório | Descrição |
261
+ |-----------|------|-------------|-----------|
262
+ | `client_id` | `str` | Sim | Seu client_id NXGATE |
263
+ | `client_secret` | `str` | Sim | Seu client_secret NXGATE |
264
+ | `hmac_secret` | `str \| None` | Não | Secret para assinatura HMAC |
265
+ | `base_url` | `str` | Não | URL base da API (padrão: `https://api.nxgate.com.br`) |
266
+ | `timeout` | `int` | Não | Timeout em segundos (padrão: `30`) |
267
+
268
+ ### Métodos
269
+
270
+ | Método | Retorno | Descrição |
271
+ |--------|---------|-----------|
272
+ | `pix_generate(...)` | `PixGenerateResponse` | Gera cobrança PIX |
273
+ | `pix_withdraw(...)` | `PixWithdrawResponse` | Realiza saque PIX |
274
+ | `get_balance()` | `BalanceResponse` | Consulta saldo |
275
+ | `get_transaction(type, txid)` | `TransactionResponse` | Consulta transação |
276
+
277
+ ## Desenvolvimento
278
+
279
+ ### Executar testes
280
+
281
+ ```bash
282
+ pip install pytest
283
+ pytest
284
+ ```
285
+
286
+ ### Estrutura do projeto
287
+
288
+ ```
289
+ nxgate/
290
+ ├── __init__.py # exports públicos
291
+ ├── client.py # classe NXGate
292
+ ├── auth.py # gerenciamento de token
293
+ ├── hmac_signer.py # assinatura HMAC
294
+ ├── webhook.py # parser de webhook
295
+ ├── errors.py # exceções
296
+ └── types.py # dataclasses tipadas
297
+ ```
298
+
299
+ ## Licença
300
+
301
+ MIT - veja o arquivo [LICENSE](LICENSE) para detalhes.
@@ -0,0 +1,12 @@
1
+ nxgate/__init__.py,sha256=RXcUkCzrlJyS0iIh_YjbsVtyX7VI2VEB5FuJseUJjrY,1120
2
+ nxgate/auth.py,sha256=EsND1FEl1jemmWNuIZLL1yhmJAdEpKHV3TBcvgRQ7_o,3406
3
+ nxgate/client.py,sha256=Cl-pJibhAOi06TweebsvqZbKXM3caiDATPfiCzSD5tQ,8259
4
+ nxgate/errors.py,sha256=ae5m9N7meE2voTafcEqc6rhVb9sc-5yP14YvVPWGMFY,1439
5
+ nxgate/hmac_signer.py,sha256=kYcY977WnqC2gn1GEnbVXNQ0KswGSE_nA0ijjciJybQ,1557
6
+ nxgate/types.py,sha256=sMl1pZU5lnIOpmdSSvUjyFNZsnENnFcXUHEnGEuRrrY,9059
7
+ nxgate/webhook.py,sha256=hT_UtJ8WDToimqYiB9kPHuR2w93ltEfg-rf45cY2Y3s,1285
8
+ nxgate_sdk_python-1.0.0.dist-info/licenses/LICENSE,sha256=rzvC6KhelMbCpKgYRDIbrqN8m4J6XwiWHNih4NTFf7U,1063
9
+ nxgate_sdk_python-1.0.0.dist-info/METADATA,sha256=JbiQ-u2JqzsgrfkdIMAlhf-g_w968ecm9pO0_USFBSQ,8555
10
+ nxgate_sdk_python-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ nxgate_sdk_python-1.0.0.dist-info/top_level.txt,sha256=Kem3KARfoXYGgH_omjOBK8axGMfqIhj8DgIe5NGeGVQ,7
12
+ nxgate_sdk_python-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 NXGATE
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.
@@ -0,0 +1 @@
1
+ nxgate