pyvelv 0.1.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.
- pyvelv/__init__.py +22 -0
- pyvelv/client.py +203 -0
- pyvelv/crypto.py +140 -0
- pyvelv/exceptions.py +67 -0
- pyvelv/models.py +137 -0
- pyvelv/resources/__init__.py +11 -0
- pyvelv/resources/base.py +27 -0
- pyvelv/resources/payment_links.py +42 -0
- pyvelv/resources/transactions.py +42 -0
- pyvelv-0.1.0.dist-info/METADATA +76 -0
- pyvelv-0.1.0.dist-info/RECORD +12 -0
- pyvelv-0.1.0.dist-info/WHEEL +4 -0
pyvelv/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pyvelv — Async Python SDK for the Velvpay payment gateway.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from pyvelv import Velvpay
|
|
6
|
+
|
|
7
|
+
async with Velvpay(secret_key="sk_...") as client:
|
|
8
|
+
link = await client.payment_links.create(...)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pyvelv.client import VelvpayClient
|
|
12
|
+
|
|
13
|
+
# Ergonomic alias so users can do: from pyvelv import Velvpay
|
|
14
|
+
Velvpay = VelvpayClient
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Velvpay",
|
|
20
|
+
"VelvpayClient",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
pyvelv/client.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async HTTP client for the Velvpay payment gateway.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
async with VelvpayClient(
|
|
7
|
+
secret_key="sk_...",
|
|
8
|
+
public_key="pk_...",
|
|
9
|
+
encryption_key="enc_...",
|
|
10
|
+
) as client:
|
|
11
|
+
link = await client.payment_links.create(...)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from pyvelv.crypto import generate_api_key_header
|
|
22
|
+
from pyvelv.exceptions import VelvpayAPIError, VelvpayAuthError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VelvpayClient:
|
|
26
|
+
"""
|
|
27
|
+
Asynchronous client for the Velvpay API.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
secret_key: Your Velvpay merchant secret key.
|
|
31
|
+
public_key: Your Velvpay merchant public key.
|
|
32
|
+
encryption_key: Your Velvpay encryption key/passphrase.
|
|
33
|
+
base_url: API base URL. If not provided, it is chosen based on the sandbox flag.
|
|
34
|
+
sandbox: If True, uses the test sandbox base URL.
|
|
35
|
+
timeout: Request timeout in seconds.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_PRODUCTION_BASE_URL = "https://api.velvpay.com/api/v1/service"
|
|
39
|
+
_SANDBOX_BASE_URL = "https://testapi.velpay.io"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
secret_key: str,
|
|
44
|
+
public_key: str,
|
|
45
|
+
encryption_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
sandbox: bool = False,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not secret_key:
|
|
52
|
+
raise ValueError("secret_key must not be empty")
|
|
53
|
+
if not public_key:
|
|
54
|
+
raise ValueError("public_key must not be empty")
|
|
55
|
+
if not encryption_key:
|
|
56
|
+
raise ValueError("encryption_key must not be empty")
|
|
57
|
+
|
|
58
|
+
self._secret_key = secret_key
|
|
59
|
+
self._public_key = public_key
|
|
60
|
+
self._encryption_key = encryption_key
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if base_url:
|
|
65
|
+
self._base_url = base_url.rstrip("/")
|
|
66
|
+
else:
|
|
67
|
+
self._base_url = (self._SANDBOX_BASE_URL if sandbox else self._PRODUCTION_BASE_URL).rstrip("/")
|
|
68
|
+
|
|
69
|
+
self._timeout = timeout
|
|
70
|
+
self._http: httpx.AsyncClient | None = None
|
|
71
|
+
|
|
72
|
+
# Lazily-initialised resource namespaces
|
|
73
|
+
self._payment_links: PaymentLinksResource | None = None
|
|
74
|
+
self._transactions: TransactionsResource | None = None
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# HTTP transport helpers
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def http(self) -> httpx.AsyncClient:
|
|
82
|
+
"""Return (or lazily create) the underlying ``httpx.AsyncClient``."""
|
|
83
|
+
if self._http is None or self._http.is_closed:
|
|
84
|
+
self._http = httpx.AsyncClient(
|
|
85
|
+
base_url=self._base_url,
|
|
86
|
+
timeout=self._timeout,
|
|
87
|
+
)
|
|
88
|
+
return self._http
|
|
89
|
+
|
|
90
|
+
async def _request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
json: Any | None = None,
|
|
96
|
+
params: dict[str, Any] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""
|
|
99
|
+
Send an authenticated request to the Velvpay API.
|
|
100
|
+
|
|
101
|
+
Dynamic headers including the encrypted `api-key` and `reference-id`
|
|
102
|
+
are generated for each call.
|
|
103
|
+
"""
|
|
104
|
+
reference_id = f"ref_{uuid.uuid4().hex}"
|
|
105
|
+
api_key = generate_api_key_header(
|
|
106
|
+
secret_key=self._secret_key,
|
|
107
|
+
public_key=self._public_key,
|
|
108
|
+
reference_id=reference_id,
|
|
109
|
+
encryption_key=self._encryption_key,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
headers = {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"api-key": api_key,
|
|
115
|
+
"public-key": self._public_key,
|
|
116
|
+
"reference-id": reference_id,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if method.upper() == "POST":
|
|
120
|
+
headers["idempotencykey"] = str(uuid.uuid4())
|
|
121
|
+
|
|
122
|
+
response = await self.http.request(
|
|
123
|
+
method,
|
|
124
|
+
path,
|
|
125
|
+
json=json,
|
|
126
|
+
params=params,
|
|
127
|
+
headers=headers,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# --- Error handling ---
|
|
131
|
+
if response.status_code in (401, 403):
|
|
132
|
+
body = self._safe_json(response)
|
|
133
|
+
raise VelvpayAuthError(
|
|
134
|
+
message=body.get("reason", "Authentication failed") if isinstance(body, dict) else "Authentication failed",
|
|
135
|
+
status_code=response.status_code,
|
|
136
|
+
response_body=body,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if not (200 <= response.status_code < 300):
|
|
140
|
+
body = self._safe_json(response)
|
|
141
|
+
msg = None
|
|
142
|
+
if isinstance(body, dict):
|
|
143
|
+
msg = body.get("reason") or body.get("msg") or body.get("err") or body.get("message")
|
|
144
|
+
raise VelvpayAPIError(
|
|
145
|
+
message=msg or response.reason_phrase or "API request failed",
|
|
146
|
+
status_code=response.status_code,
|
|
147
|
+
response_body=body,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return response.json()
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
154
|
+
"""Attempt to parse JSON from a response, falling back to the raw text."""
|
|
155
|
+
try:
|
|
156
|
+
return response.json()
|
|
157
|
+
except Exception:
|
|
158
|
+
return response.text
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Resource namespaces (lazy properties)
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def payment_links(self) -> PaymentLinksResource:
|
|
166
|
+
"""Access payment link operations."""
|
|
167
|
+
if self._payment_links is None:
|
|
168
|
+
from pyvelv.resources.payment_links import PaymentLinksResource
|
|
169
|
+
self._payment_links = PaymentLinksResource(self)
|
|
170
|
+
return self._payment_links
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def transactions(self) -> TransactionsResource:
|
|
174
|
+
"""Access transaction operations."""
|
|
175
|
+
if self._transactions is None:
|
|
176
|
+
from pyvelv.resources.transactions import TransactionsResource
|
|
177
|
+
self._transactions = TransactionsResource(self)
|
|
178
|
+
return self._transactions
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Context manager & lifecycle
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
async def close(self) -> None:
|
|
185
|
+
"""Close the underlying HTTP client and release resources."""
|
|
186
|
+
if self._http and not self._http.is_closed:
|
|
187
|
+
await self._http.aclose()
|
|
188
|
+
|
|
189
|
+
async def __aenter__(self) -> VelvpayClient:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
193
|
+
await self.close()
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
return f"<VelvpayClient base_url={self._base_url!r}>"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Forward-reference imports so the type annotations above resolve at runtime.
|
|
200
|
+
# Actual classes are imported lazily inside the properties to avoid circular
|
|
201
|
+
# imports.
|
|
202
|
+
from pyvelv.resources.payment_links import PaymentLinksResource # noqa: E402
|
|
203
|
+
from pyvelv.resources.transactions import TransactionsResource # noqa: E402
|
pyvelv/crypto.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crypto module for Velvpay API key header generation.
|
|
3
|
+
|
|
4
|
+
Implements the legacy OpenSSL EVP_BytesToKey key derivation routine
|
|
5
|
+
for AES-256-CBC encryption. The generated ciphertext is used as the
|
|
6
|
+
`api-key` header value on every API request.
|
|
7
|
+
|
|
8
|
+
OpenSSL envelope format:
|
|
9
|
+
base64( b"Salted__" + salt(8 bytes) + ciphertext )
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from Crypto.Cipher import AES
|
|
21
|
+
from Crypto.Util.Padding import pad, unpad
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _evp_bytes_to_key(
|
|
25
|
+
password: bytes,
|
|
26
|
+
salt: bytes | None,
|
|
27
|
+
key_len: int = 32,
|
|
28
|
+
iv_len: int = 16,
|
|
29
|
+
) -> tuple[bytes, bytes]:
|
|
30
|
+
"""
|
|
31
|
+
Derive key and IV using the OpenSSL EVP_BytesToKey algorithm.
|
|
32
|
+
|
|
33
|
+
This replicates the behaviour of ``openssl enc -aes-256-cbc`` with MD5 as
|
|
34
|
+
the digest function.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
password: The passphrase as raw bytes.
|
|
38
|
+
salt: An 8-byte salt, or ``None`` for unsalted derivation.
|
|
39
|
+
key_len: Desired key length in bytes (32 for AES-256).
|
|
40
|
+
iv_len: Desired IV length in bytes (16 for AES-CBC).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A ``(key, iv)`` tuple of the derived key and initialisation vector.
|
|
44
|
+
"""
|
|
45
|
+
d_tot = b""
|
|
46
|
+
d_list: list[bytes] = []
|
|
47
|
+
|
|
48
|
+
while len(d_tot) < (key_len + iv_len):
|
|
49
|
+
prev = d_list[-1] if d_list else b""
|
|
50
|
+
d_i = hashlib.md5(prev + password + (salt or b"")).digest()
|
|
51
|
+
d_tot += d_i
|
|
52
|
+
d_list.append(d_i)
|
|
53
|
+
|
|
54
|
+
return d_tot[:key_len], d_tot[key_len : key_len + iv_len]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def encrypt_aes_256_cbc(plaintext: str, passphrase: str) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Encrypt *plaintext* with AES-256-CBC using the OpenSSL envelope format.
|
|
60
|
+
|
|
61
|
+
1. Generate a random 8-byte salt.
|
|
62
|
+
2. Derive ``(key, iv)`` via :func:`_evp_bytes_to_key`.
|
|
63
|
+
3. PKCS#7-pad the plaintext and encrypt.
|
|
64
|
+
4. Return ``base64(b"Salted__" + salt + ciphertext)``.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
plaintext: The data to encrypt (UTF-8 string).
|
|
68
|
+
passphrase: The secret passphrase used for key derivation.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A base64-encoded string in the standard OpenSSL format.
|
|
72
|
+
"""
|
|
73
|
+
salt = os.urandom(8)
|
|
74
|
+
key, iv = _evp_bytes_to_key(passphrase.encode("utf-8"), salt)
|
|
75
|
+
|
|
76
|
+
cipher = AES.new(key, AES.MODE_CBC, iv)
|
|
77
|
+
padded = pad(plaintext.encode("utf-8"), AES.block_size)
|
|
78
|
+
ciphertext = cipher.encrypt(padded)
|
|
79
|
+
|
|
80
|
+
# OpenSSL envelope: "Salted__" magic + salt + ciphertext
|
|
81
|
+
envelope = b"Salted__" + salt + ciphertext
|
|
82
|
+
return base64.b64encode(envelope).decode("ascii")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decrypt_aes_256_cbc(encoded: str, passphrase: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Decrypt an OpenSSL-format AES-256-CBC ciphertext.
|
|
88
|
+
|
|
89
|
+
This is the inverse of :func:`encrypt_aes_256_cbc` and is provided
|
|
90
|
+
primarily for testing round-trip correctness.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
encoded: The base64-encoded OpenSSL envelope.
|
|
94
|
+
passphrase: The passphrase that was used for encryption.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The original plaintext string.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If the envelope does not start with ``Salted__``.
|
|
101
|
+
"""
|
|
102
|
+
raw = base64.b64decode(encoded)
|
|
103
|
+
|
|
104
|
+
if not raw.startswith(b"Salted__"):
|
|
105
|
+
raise ValueError("Invalid OpenSSL envelope: missing 'Salted__' magic header")
|
|
106
|
+
|
|
107
|
+
salt = raw[8:16]
|
|
108
|
+
ciphertext = raw[16:]
|
|
109
|
+
|
|
110
|
+
key, iv = _evp_bytes_to_key(passphrase.encode("utf-8"), salt)
|
|
111
|
+
cipher = AES.new(key, AES.MODE_CBC, iv)
|
|
112
|
+
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
|
|
113
|
+
|
|
114
|
+
return plaintext.decode("utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate_api_key_header(
|
|
118
|
+
secret_key: str,
|
|
119
|
+
public_key: str,
|
|
120
|
+
reference_id: str,
|
|
121
|
+
encryption_key: str,
|
|
122
|
+
) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Generate the ``api-key`` header value for a Velvpay API request.
|
|
125
|
+
|
|
126
|
+
The payload (secretKey + publicKey + referenceId) is encrypted
|
|
127
|
+
using AES-256-CBC with the merchant's encryption key.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
secret_key: The merchant's Velvpay secret key.
|
|
131
|
+
public_key: The merchant's Velvpay public key.
|
|
132
|
+
reference_id: The unique reference ID for the request.
|
|
133
|
+
encryption_key: The merchant's Velvpay encryption key.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
A base64-encoded encrypted string suitable for the ``api-key`` header.
|
|
137
|
+
"""
|
|
138
|
+
payload = secret_key + public_key + reference_id
|
|
139
|
+
return encrypt_aes_256_cbc(payload, encryption_key)
|
|
140
|
+
|
pyvelv/exceptions.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy for the Pyvelv SDK.
|
|
3
|
+
|
|
4
|
+
All exceptions inherit from :class:`VelvpayError` so callers can catch
|
|
5
|
+
a single base class for broad error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VelvpayError(Exception):
|
|
14
|
+
"""Base exception for all Velvpay SDK errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "An unexpected Velvpay error occurred") -> None:
|
|
17
|
+
self.message = message
|
|
18
|
+
super().__init__(self.message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VelvpayAPIError(VelvpayError):
|
|
22
|
+
"""
|
|
23
|
+
Raised when the Velvpay API returns a non-2xx HTTP response.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
status_code: The HTTP status code.
|
|
27
|
+
response_body: The raw response body (decoded JSON or string).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
status_code: int,
|
|
34
|
+
response_body: Any = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.response_body = response_body
|
|
38
|
+
super().__init__(f"[HTTP {status_code}] {message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VelvpayAuthError(VelvpayAPIError):
|
|
42
|
+
"""
|
|
43
|
+
Raised when the API rejects the request due to authentication failure.
|
|
44
|
+
|
|
45
|
+
Triggered by HTTP 401 (Unauthorized) and 403 (Forbidden) responses.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str = "Authentication failed",
|
|
51
|
+
status_code: int = 401,
|
|
52
|
+
response_body: Any = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message, status_code, response_body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VelvpayValidationError(VelvpayError):
|
|
58
|
+
"""
|
|
59
|
+
Raised when request data fails Pydantic validation before being sent.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
errors: The list of validation error details from Pydantic.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, errors: list[Any] | None = None) -> None:
|
|
66
|
+
self.errors = errors or []
|
|
67
|
+
super().__init__(message)
|
pyvelv/models.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic v2 schemas for Velvpay API request and response payloads.
|
|
3
|
+
|
|
4
|
+
All models use ``populate_by_name=True`` so they accept both the Python
|
|
5
|
+
attribute name and the optional JSON alias.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Generic API response wrapper
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class APIResponse(BaseModel, Generic[T]):
|
|
23
|
+
"""
|
|
24
|
+
Standard envelope returned by every Velvpay API endpoint.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
28
|
+
|
|
29
|
+
status: str | None = None
|
|
30
|
+
msg: str | None = None
|
|
31
|
+
success: bool | None = None
|
|
32
|
+
message: str | None = None
|
|
33
|
+
reason: str | None = None
|
|
34
|
+
err: str | None = None
|
|
35
|
+
data: T | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_success(self) -> bool:
|
|
39
|
+
"""Helper property to check if the API call was successful."""
|
|
40
|
+
if self.status == "success":
|
|
41
|
+
return True
|
|
42
|
+
if self.success is True:
|
|
43
|
+
return True
|
|
44
|
+
if self.message == "Successful" or self.msg == "successful":
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Payment Links / Initiation
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class CreatePaymentLinkRequest(BaseModel):
|
|
54
|
+
"""Request body for ``POST /payment/initiate``."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
57
|
+
|
|
58
|
+
title: str = Field(..., description="Payment title or name")
|
|
59
|
+
description: str = Field(..., description="Human-readable description")
|
|
60
|
+
amount: Decimal = Field(..., description="Payment amount in kobo or Naira")
|
|
61
|
+
is_naira: bool = Field(
|
|
62
|
+
default=False,
|
|
63
|
+
alias="isNaira",
|
|
64
|
+
description="True if amount is in Naira, False if in Kobo",
|
|
65
|
+
)
|
|
66
|
+
charge_customer: bool = Field(
|
|
67
|
+
default=True,
|
|
68
|
+
alias="chargeCustomer",
|
|
69
|
+
description="True if transaction fees should be charged to the customer",
|
|
70
|
+
)
|
|
71
|
+
post_payment_instructions: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
alias="postPaymentInstructions",
|
|
74
|
+
description="Instructions to show after payment is complete",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PaymentLink(BaseModel):
|
|
79
|
+
"""A Velvpay payment link / virtual account initiation object."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
82
|
+
|
|
83
|
+
status: str
|
|
84
|
+
message: str | None = None
|
|
85
|
+
account_number: str = Field(alias="accountNumber")
|
|
86
|
+
reference: str
|
|
87
|
+
amount: Decimal
|
|
88
|
+
transaction_id: str = Field(alias="transactionId")
|
|
89
|
+
bank: str
|
|
90
|
+
validity_time: int = Field(alias="validityTime")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Transactions
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
class TransactionData(BaseModel):
|
|
98
|
+
"""Inner transaction details data."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
101
|
+
|
|
102
|
+
status: str
|
|
103
|
+
message: str
|
|
104
|
+
account_number: str = Field(alias="accountNumber")
|
|
105
|
+
reference: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Transaction(BaseModel):
|
|
109
|
+
"""A Velvpay transaction record."""
|
|
110
|
+
|
|
111
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
112
|
+
|
|
113
|
+
id: str = Field(alias="_id")
|
|
114
|
+
account_number: str = Field(alias="accountNumber")
|
|
115
|
+
tx_id: str = Field(alias="txId")
|
|
116
|
+
link: str | None = None
|
|
117
|
+
name: str | None = None
|
|
118
|
+
amount: Decimal
|
|
119
|
+
status: str
|
|
120
|
+
channel: str
|
|
121
|
+
type: str | None = None
|
|
122
|
+
method: str | None = None
|
|
123
|
+
date: str | None = None
|
|
124
|
+
description: str | None = None
|
|
125
|
+
metadata: list[dict[str, Any]] | None = None
|
|
126
|
+
webhook_url: str | None = Field(default=None, alias="webhook_url")
|
|
127
|
+
data: TransactionData | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TransactionList(BaseModel):
|
|
131
|
+
"""Paginated list of transactions."""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
134
|
+
|
|
135
|
+
data: list[Transaction]
|
|
136
|
+
total: int
|
|
137
|
+
page: int
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Resource namespace package for Velvpay API endpoints."""
|
|
2
|
+
|
|
3
|
+
from pyvelv.resources.base import BaseResource
|
|
4
|
+
from pyvelv.resources.payment_links import PaymentLinksResource
|
|
5
|
+
from pyvelv.resources.transactions import TransactionsResource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BaseResource",
|
|
9
|
+
"PaymentLinksResource",
|
|
10
|
+
"TransactionsResource",
|
|
11
|
+
]
|
pyvelv/resources/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base resource class that all Velvpay API resource namespaces inherit from.
|
|
3
|
+
|
|
4
|
+
Each resource receives a reference to the parent :class:`~pyvelv.client.VelvpayClient`
|
|
5
|
+
so it can make authenticated HTTP requests through the shared transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pyvelv.client import VelvpayClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseResource:
|
|
17
|
+
"""
|
|
18
|
+
Abstract base for API resource namespaces.
|
|
19
|
+
|
|
20
|
+
Subclasses access ``self._client._request(...)`` to call the API.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: VelvpayClient) -> None:
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"<{self.__class__.__name__} client={self._client!r}>"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Payment Links resource — ``client.payment_links.*`` operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pyvelv.models import APIResponse, CreatePaymentLinkRequest, PaymentLink
|
|
8
|
+
from pyvelv.resources.base import BaseResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PaymentLinksResource(BaseResource):
|
|
12
|
+
"""
|
|
13
|
+
Operations on Velvpay payment links (Payment Initiation).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
async def create(self, data: CreatePaymentLinkRequest) -> APIResponse[PaymentLink]:
|
|
17
|
+
"""
|
|
18
|
+
Initiate a new payment.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: The payment initiation payload.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
An :class:`~pyvelv.models.APIResponse` wrapping a :class:`~pyvelv.models.PaymentLink`.
|
|
25
|
+
"""
|
|
26
|
+
payload = data.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
27
|
+
raw = await self._client._request("POST", "/payment/initiate", json=payload)
|
|
28
|
+
return APIResponse[PaymentLink].model_validate(raw)
|
|
29
|
+
|
|
30
|
+
async def get(self, transaction_id: str) -> APIResponse[PaymentLink]:
|
|
31
|
+
"""
|
|
32
|
+
Retrieve details of an initiated payment.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
transaction_id: The unique transaction identifier (e.g. FR-...).
|
|
36
|
+
"""
|
|
37
|
+
raw = await self._client._request(
|
|
38
|
+
"GET",
|
|
39
|
+
"/payment/collection/transaction/details",
|
|
40
|
+
params={"transaction_id": transaction_id},
|
|
41
|
+
)
|
|
42
|
+
return APIResponse[PaymentLink].model_validate(raw)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactions resource — ``client.transactions.*`` operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pyvelv.models import APIResponse, Transaction
|
|
8
|
+
from pyvelv.resources.base import BaseResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TransactionsResource(BaseResource):
|
|
12
|
+
"""
|
|
13
|
+
Operations on Velvpay transactions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
async def get(self, transaction_id: str) -> APIResponse[Transaction]:
|
|
17
|
+
"""
|
|
18
|
+
Retrieve details of a transaction.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
transaction_id: The unique transaction identifier (e.g. FR-...).
|
|
22
|
+
"""
|
|
23
|
+
raw = await self._client._request(
|
|
24
|
+
"GET",
|
|
25
|
+
"/payment/collection/transaction/details",
|
|
26
|
+
params={"transaction_id": transaction_id},
|
|
27
|
+
)
|
|
28
|
+
return APIResponse[Transaction].model_validate(raw)
|
|
29
|
+
|
|
30
|
+
async def verify(self, transaction_id: str) -> APIResponse[Transaction]:
|
|
31
|
+
"""
|
|
32
|
+
Resolve/verify a transaction status.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
transaction_id: The unique transaction identifier (e.g. FR-...).
|
|
36
|
+
"""
|
|
37
|
+
raw = await self._client._request(
|
|
38
|
+
"GET",
|
|
39
|
+
"/payment/collection/transaction/resolve",
|
|
40
|
+
params={"transaction_id": transaction_id},
|
|
41
|
+
)
|
|
42
|
+
return APIResponse[Transaction].model_validate(raw)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyvelv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python SDK for the Velvpay payment gateway
|
|
5
|
+
Author: Velvpay SDK Team
|
|
6
|
+
Requires-Python: >=3.10,<4.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: httpx (>=0.27.0)
|
|
14
|
+
Requires-Dist: pycryptodome (>=3.20.0)
|
|
15
|
+
Requires-Dist: pydantic (>=2.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Pyvelv
|
|
19
|
+
|
|
20
|
+
> Async Python SDK for the **Velvpay** payment gateway.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install pyvelv
|
|
26
|
+
# or
|
|
27
|
+
poetry add pyvelv
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from pyvelv import Velvpay
|
|
35
|
+
from pyvelv.models import CreatePaymentLinkRequest
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
async with Velvpay(secret_key="your-secret-key") as client:
|
|
39
|
+
# Create a payment link
|
|
40
|
+
response = await client.payment_links.create(
|
|
41
|
+
CreatePaymentLinkRequest(
|
|
42
|
+
amount=1500.00,
|
|
43
|
+
currency="NGN",
|
|
44
|
+
description="Order #12345",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
print(response.data.url)
|
|
48
|
+
|
|
49
|
+
# Verify a transaction
|
|
50
|
+
txn = await client.transactions.verify(reference="ref_abc123")
|
|
51
|
+
print(txn.data.status)
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Authentication
|
|
57
|
+
|
|
58
|
+
Pyvelv handles authentication automatically. The SDK generates the required `api-key` header using a legacy OpenSSL `EVP_BytesToKey` routine (AES-256-CBC) with a random 8-byte salt on every request.
|
|
59
|
+
|
|
60
|
+
Simply pass your Velvpay secret key when initializing the client:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
client = Velvpay(secret_key="sk_live_...")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Resources
|
|
67
|
+
|
|
68
|
+
| Resource | Methods |
|
|
69
|
+
|-----------------------|--------------------------------|
|
|
70
|
+
| `payment_links` | `create()`, `get()`, `list()` |
|
|
71
|
+
| `transactions` | `get()`, `list()`, `verify()` |
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
76
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyvelv/__init__.py,sha256=P5c0FHHOYhtiJRSYouhT7Yko3ljbtJK7W_USI09p0h4,439
|
|
2
|
+
pyvelv/client.py,sha256=F4JYemtoYuqnsKkrClTRzjGgPU5cHZPQiUaUOalV9jg,6756
|
|
3
|
+
pyvelv/crypto.py,sha256=UHEdtUAXwX-QJc6dLL5paFA92blKe331GznEFVSxtPA,4200
|
|
4
|
+
pyvelv/exceptions.py,sha256=71MSSrow-by6LaA0r1kFxu7nZ9fNxTciFGNgsWGm218,1796
|
|
5
|
+
pyvelv/models.py,sha256=hGEjL_7JJzAewU4FFf6Qm7AH2yMpgLWrhk3aWU3juFQ,4030
|
|
6
|
+
pyvelv/resources/__init__.py,sha256=4jq5OHs9cJQo9U5jCQBFR5qtSWZTKbGNizFy9hDw3_Q,326
|
|
7
|
+
pyvelv/resources/base.py,sha256=njtr8k56QLARN4O3qKz2W7JEb3jzYpnuUYbB7xe85Po,721
|
|
8
|
+
pyvelv/resources/payment_links.py,sha256=j9oHc4Z2nOQokO0bGBImBeyVqQg30V1ME8CsRN8Joks,1393
|
|
9
|
+
pyvelv/resources/transactions.py,sha256=Cqu3dzeaWLqVhIkNIjqRujrnffhpj7ehonKvCwDrSmE,1264
|
|
10
|
+
pyvelv-0.1.0.dist-info/METADATA,sha256=05qb4kSccdN30fIvoXA5Hm8UOdyBt6rM8gipRxiLjzU,2011
|
|
11
|
+
pyvelv-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
12
|
+
pyvelv-0.1.0.dist-info/RECORD,,
|