snippe 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.
- snippe/__init__.py +67 -0
- snippe/client.py +496 -0
- snippe/exceptions.py +41 -0
- snippe/models.py +165 -0
- snippe/py.typed +0 -0
- snippe/types.py +13 -0
- snippe/webhooks.py +159 -0
- snippe-0.1.0.dist-info/METADATA +249 -0
- snippe-0.1.0.dist-info/RECORD +10 -0
- snippe-0.1.0.dist-info/WHEEL +4 -0
snippe/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snippe - Python SDK for Snippe Payment API.
|
|
3
|
+
|
|
4
|
+
Accept payments via mobile money, card, and QR code.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
>>> from snippe import Snippe, Customer
|
|
8
|
+
>>> client = Snippe("your_api_key")
|
|
9
|
+
>>> payment = client.create_mobile_payment(
|
|
10
|
+
... amount=1000,
|
|
11
|
+
... currency="TZS",
|
|
12
|
+
... phone_number="0788500000",
|
|
13
|
+
... customer=Customer(firstname="John", lastname="Doe")
|
|
14
|
+
... )
|
|
15
|
+
>>> print(payment.reference)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .client import AsyncSnippe, Snippe
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServerError,
|
|
24
|
+
SnippeError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
WebhookVerificationError,
|
|
27
|
+
)
|
|
28
|
+
from .models import (
|
|
29
|
+
Balance,
|
|
30
|
+
Customer,
|
|
31
|
+
Payment,
|
|
32
|
+
PaymentDetails,
|
|
33
|
+
PaymentList,
|
|
34
|
+
WebhookPayload,
|
|
35
|
+
)
|
|
36
|
+
from .types import Currency, PaymentStatus, PaymentType, WebhookEvent
|
|
37
|
+
from .webhooks import WebhookHandler, verify_webhook
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Clients
|
|
42
|
+
"Snippe",
|
|
43
|
+
"AsyncSnippe",
|
|
44
|
+
# Models
|
|
45
|
+
"Customer",
|
|
46
|
+
"Payment",
|
|
47
|
+
"PaymentDetails",
|
|
48
|
+
"PaymentList",
|
|
49
|
+
"Balance",
|
|
50
|
+
"WebhookPayload",
|
|
51
|
+
# Types
|
|
52
|
+
"PaymentType",
|
|
53
|
+
"PaymentStatus",
|
|
54
|
+
"Currency",
|
|
55
|
+
"WebhookEvent",
|
|
56
|
+
# Webhooks
|
|
57
|
+
"WebhookHandler",
|
|
58
|
+
"verify_webhook",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"SnippeError",
|
|
61
|
+
"AuthenticationError",
|
|
62
|
+
"ValidationError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"ServerError",
|
|
66
|
+
"WebhookVerificationError",
|
|
67
|
+
]
|
snippe/client.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Snippe Payment API client."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ServerError,
|
|
13
|
+
SnippeError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from .models import Balance, Customer, Payment, PaymentDetails, PaymentList
|
|
17
|
+
from .types import Currency, PaymentType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Snippe:
|
|
21
|
+
"""
|
|
22
|
+
Snippe Payment API client.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
>>> from snippe import Snippe, Customer
|
|
26
|
+
>>> client = Snippe("your_api_key")
|
|
27
|
+
>>> payment = client.create_mobile_payment(
|
|
28
|
+
... amount=1000,
|
|
29
|
+
... currency="TZS",
|
|
30
|
+
... phone_number="0788500000",
|
|
31
|
+
... customer=Customer(firstname="John", lastname="Doe")
|
|
32
|
+
... )
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
BASE_URL = "https://api.snippe.sh/api/v1"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: str,
|
|
40
|
+
base_url: Optional[str] = None,
|
|
41
|
+
timeout: float = 30.0,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Initialize Snippe client.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
api_key: Your Snippe API key
|
|
48
|
+
base_url: Override the base URL (for testing)
|
|
49
|
+
timeout: Request timeout in seconds
|
|
50
|
+
"""
|
|
51
|
+
self.api_key = api_key
|
|
52
|
+
self.base_url = base_url or self.BASE_URL
|
|
53
|
+
self._client = httpx.Client(
|
|
54
|
+
base_url=self.base_url,
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": f"Bearer {api_key}",
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
},
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _handle_response(self, response: httpx.Response) -> dict:
|
|
63
|
+
"""Handle API response and raise appropriate exceptions."""
|
|
64
|
+
try:
|
|
65
|
+
data = response.json()
|
|
66
|
+
except Exception:
|
|
67
|
+
data = {"message": response.text}
|
|
68
|
+
|
|
69
|
+
if response.status_code == 200 or response.status_code == 201:
|
|
70
|
+
return data.get("data", data)
|
|
71
|
+
|
|
72
|
+
message = data.get("message", "Unknown error")
|
|
73
|
+
error_code = data.get("error_code", "")
|
|
74
|
+
code = response.status_code
|
|
75
|
+
|
|
76
|
+
if code == 401:
|
|
77
|
+
raise AuthenticationError(message, code, error_code)
|
|
78
|
+
elif code == 400:
|
|
79
|
+
raise ValidationError(message, code, error_code)
|
|
80
|
+
elif code == 404:
|
|
81
|
+
raise NotFoundError(message, code, error_code)
|
|
82
|
+
elif code == 429:
|
|
83
|
+
raise RateLimitError(message, code, error_code)
|
|
84
|
+
elif code >= 500:
|
|
85
|
+
raise ServerError(message, code, error_code)
|
|
86
|
+
else:
|
|
87
|
+
raise SnippeError(message, code, error_code)
|
|
88
|
+
|
|
89
|
+
def _create_payment(
|
|
90
|
+
self,
|
|
91
|
+
payment_type: PaymentType,
|
|
92
|
+
amount: int,
|
|
93
|
+
currency: Currency,
|
|
94
|
+
phone_number: str,
|
|
95
|
+
customer: Customer,
|
|
96
|
+
callback_url: Optional[str] = None,
|
|
97
|
+
webhook_url: Optional[str] = None,
|
|
98
|
+
metadata: Optional[dict] = None,
|
|
99
|
+
idempotency_key: Optional[str] = None,
|
|
100
|
+
) -> Payment:
|
|
101
|
+
"""Internal method to create a payment."""
|
|
102
|
+
payload = {
|
|
103
|
+
"payment_type": payment_type,
|
|
104
|
+
"details": PaymentDetails(amount, currency, callback_url).to_dict(),
|
|
105
|
+
"phone_number": phone_number,
|
|
106
|
+
"customer": customer.to_dict(),
|
|
107
|
+
}
|
|
108
|
+
if webhook_url:
|
|
109
|
+
payload["webhook_url"] = webhook_url
|
|
110
|
+
if metadata:
|
|
111
|
+
payload["metadata"] = metadata
|
|
112
|
+
|
|
113
|
+
headers = {}
|
|
114
|
+
if idempotency_key:
|
|
115
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
116
|
+
|
|
117
|
+
response = self._client.post("/payments", json=payload, headers=headers)
|
|
118
|
+
data = self._handle_response(response)
|
|
119
|
+
return Payment.from_dict(data)
|
|
120
|
+
|
|
121
|
+
def create_mobile_payment(
|
|
122
|
+
self,
|
|
123
|
+
amount: int,
|
|
124
|
+
currency: Currency,
|
|
125
|
+
phone_number: str,
|
|
126
|
+
customer: Customer,
|
|
127
|
+
callback_url: Optional[str] = None,
|
|
128
|
+
webhook_url: Optional[str] = None,
|
|
129
|
+
metadata: Optional[dict] = None,
|
|
130
|
+
idempotency_key: Optional[str] = None,
|
|
131
|
+
) -> Payment:
|
|
132
|
+
"""
|
|
133
|
+
Create a mobile money payment (USSD push).
|
|
134
|
+
|
|
135
|
+
Customer receives a USSD prompt to confirm payment.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
amount: Amount in smallest currency unit (e.g., cents)
|
|
139
|
+
currency: Currency code (TZS, KES, UGX)
|
|
140
|
+
phone_number: Customer phone number
|
|
141
|
+
customer: Customer information
|
|
142
|
+
callback_url: URL to redirect after payment
|
|
143
|
+
webhook_url: URL to receive payment status updates
|
|
144
|
+
metadata: Custom key-value pairs
|
|
145
|
+
idempotency_key: Unique key to prevent duplicates
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Payment object with reference and status
|
|
149
|
+
"""
|
|
150
|
+
return self._create_payment(
|
|
151
|
+
payment_type="mobile",
|
|
152
|
+
amount=amount,
|
|
153
|
+
currency=currency,
|
|
154
|
+
phone_number=phone_number,
|
|
155
|
+
customer=customer,
|
|
156
|
+
callback_url=callback_url,
|
|
157
|
+
webhook_url=webhook_url,
|
|
158
|
+
metadata=metadata,
|
|
159
|
+
idempotency_key=idempotency_key,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def create_card_payment(
|
|
163
|
+
self,
|
|
164
|
+
amount: int,
|
|
165
|
+
currency: Currency,
|
|
166
|
+
phone_number: str,
|
|
167
|
+
customer: Customer,
|
|
168
|
+
callback_url: str,
|
|
169
|
+
webhook_url: Optional[str] = None,
|
|
170
|
+
metadata: Optional[dict] = None,
|
|
171
|
+
idempotency_key: Optional[str] = None,
|
|
172
|
+
) -> Payment:
|
|
173
|
+
"""
|
|
174
|
+
Create a card payment.
|
|
175
|
+
|
|
176
|
+
Returns a payment_url to redirect the customer.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
amount: Amount in smallest currency unit
|
|
180
|
+
currency: Currency code (TZS, KES, UGX)
|
|
181
|
+
phone_number: Customer phone number
|
|
182
|
+
customer: Customer information (must include address fields)
|
|
183
|
+
callback_url: URL to redirect after payment (required)
|
|
184
|
+
webhook_url: URL to receive payment status updates
|
|
185
|
+
metadata: Custom key-value pairs
|
|
186
|
+
idempotency_key: Unique key to prevent duplicates
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Payment object with payment_url for redirect
|
|
190
|
+
"""
|
|
191
|
+
return self._create_payment(
|
|
192
|
+
payment_type="card",
|
|
193
|
+
amount=amount,
|
|
194
|
+
currency=currency,
|
|
195
|
+
phone_number=phone_number,
|
|
196
|
+
customer=customer,
|
|
197
|
+
callback_url=callback_url,
|
|
198
|
+
webhook_url=webhook_url,
|
|
199
|
+
metadata=metadata,
|
|
200
|
+
idempotency_key=idempotency_key,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def create_qr_payment(
|
|
204
|
+
self,
|
|
205
|
+
amount: int,
|
|
206
|
+
currency: Currency,
|
|
207
|
+
phone_number: str,
|
|
208
|
+
customer: Customer,
|
|
209
|
+
callback_url: Optional[str] = None,
|
|
210
|
+
webhook_url: Optional[str] = None,
|
|
211
|
+
metadata: Optional[dict] = None,
|
|
212
|
+
idempotency_key: Optional[str] = None,
|
|
213
|
+
) -> Payment:
|
|
214
|
+
"""
|
|
215
|
+
Create a dynamic QR code payment.
|
|
216
|
+
|
|
217
|
+
Returns a QR code for the customer to scan.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
amount: Amount in smallest currency unit
|
|
221
|
+
currency: Currency code (TZS, KES, UGX)
|
|
222
|
+
phone_number: Customer phone number
|
|
223
|
+
customer: Customer information
|
|
224
|
+
callback_url: URL to redirect after payment
|
|
225
|
+
webhook_url: URL to receive payment status updates
|
|
226
|
+
metadata: Custom key-value pairs
|
|
227
|
+
idempotency_key: Unique key to prevent duplicates
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Payment object with qr_code and payment_token
|
|
231
|
+
"""
|
|
232
|
+
return self._create_payment(
|
|
233
|
+
payment_type="dynamic-qr",
|
|
234
|
+
amount=amount,
|
|
235
|
+
currency=currency,
|
|
236
|
+
phone_number=phone_number,
|
|
237
|
+
customer=customer,
|
|
238
|
+
callback_url=callback_url,
|
|
239
|
+
webhook_url=webhook_url,
|
|
240
|
+
metadata=metadata,
|
|
241
|
+
idempotency_key=idempotency_key,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def get_payment(self, reference: str) -> Payment:
|
|
245
|
+
"""
|
|
246
|
+
Get payment status by reference.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
reference: Payment reference from create response
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Payment object with current status
|
|
253
|
+
"""
|
|
254
|
+
response = self._client.get(f"/payments/{reference}")
|
|
255
|
+
data = self._handle_response(response)
|
|
256
|
+
return Payment.from_dict(data)
|
|
257
|
+
|
|
258
|
+
def list_payments(
|
|
259
|
+
self,
|
|
260
|
+
limit: int = 20,
|
|
261
|
+
offset: int = 0,
|
|
262
|
+
) -> PaymentList:
|
|
263
|
+
"""
|
|
264
|
+
List all payments for your account.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
limit: Results per page (max 100)
|
|
268
|
+
offset: Pagination offset
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
PaymentList with payments and pagination info
|
|
272
|
+
"""
|
|
273
|
+
response = self._client.get(
|
|
274
|
+
"/payments",
|
|
275
|
+
params={"limit": limit, "offset": offset},
|
|
276
|
+
)
|
|
277
|
+
data = self._handle_response(response)
|
|
278
|
+
return PaymentList.from_dict(data)
|
|
279
|
+
|
|
280
|
+
def get_balance(self) -> Balance:
|
|
281
|
+
"""
|
|
282
|
+
Get your current account balance.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Balance object with available and pending amounts
|
|
286
|
+
"""
|
|
287
|
+
response = self._client.get("/payments/balance")
|
|
288
|
+
data = self._handle_response(response)
|
|
289
|
+
return Balance.from_dict(data)
|
|
290
|
+
|
|
291
|
+
def close(self) -> None:
|
|
292
|
+
"""Close the HTTP client."""
|
|
293
|
+
self._client.close()
|
|
294
|
+
|
|
295
|
+
def __enter__(self) -> "Snippe":
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
def __exit__(self, *args) -> None:
|
|
299
|
+
self.close()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AsyncSnippe:
|
|
303
|
+
"""
|
|
304
|
+
Async Snippe Payment API client.
|
|
305
|
+
|
|
306
|
+
Usage:
|
|
307
|
+
>>> from snippe import AsyncSnippe, Customer
|
|
308
|
+
>>> async with AsyncSnippe("your_api_key") as client:
|
|
309
|
+
... payment = await client.create_mobile_payment(...)
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
BASE_URL = "https://api.snippe.sh/api/v1"
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
api_key: str,
|
|
317
|
+
base_url: Optional[str] = None,
|
|
318
|
+
timeout: float = 30.0,
|
|
319
|
+
):
|
|
320
|
+
"""Initialize async Snippe client."""
|
|
321
|
+
self.api_key = api_key
|
|
322
|
+
self.base_url = base_url or self.BASE_URL
|
|
323
|
+
self._client = httpx.AsyncClient(
|
|
324
|
+
base_url=self.base_url,
|
|
325
|
+
headers={
|
|
326
|
+
"Authorization": f"Bearer {api_key}",
|
|
327
|
+
"Content-Type": "application/json",
|
|
328
|
+
},
|
|
329
|
+
timeout=timeout,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def _handle_response(self, response: httpx.Response) -> dict:
|
|
333
|
+
"""Handle API response and raise appropriate exceptions."""
|
|
334
|
+
try:
|
|
335
|
+
data = response.json()
|
|
336
|
+
except Exception:
|
|
337
|
+
data = {"message": response.text}
|
|
338
|
+
|
|
339
|
+
if response.status_code == 200 or response.status_code == 201:
|
|
340
|
+
return data.get("data", data)
|
|
341
|
+
|
|
342
|
+
message = data.get("message", "Unknown error")
|
|
343
|
+
error_code = data.get("error_code", "")
|
|
344
|
+
code = response.status_code
|
|
345
|
+
|
|
346
|
+
if code == 401:
|
|
347
|
+
raise AuthenticationError(message, code, error_code)
|
|
348
|
+
elif code == 400:
|
|
349
|
+
raise ValidationError(message, code, error_code)
|
|
350
|
+
elif code == 404:
|
|
351
|
+
raise NotFoundError(message, code, error_code)
|
|
352
|
+
elif code == 429:
|
|
353
|
+
raise RateLimitError(message, code, error_code)
|
|
354
|
+
elif code >= 500:
|
|
355
|
+
raise ServerError(message, code, error_code)
|
|
356
|
+
else:
|
|
357
|
+
raise SnippeError(message, code, error_code)
|
|
358
|
+
|
|
359
|
+
async def _create_payment(
|
|
360
|
+
self,
|
|
361
|
+
payment_type: PaymentType,
|
|
362
|
+
amount: int,
|
|
363
|
+
currency: Currency,
|
|
364
|
+
phone_number: str,
|
|
365
|
+
customer: Customer,
|
|
366
|
+
callback_url: Optional[str] = None,
|
|
367
|
+
webhook_url: Optional[str] = None,
|
|
368
|
+
metadata: Optional[dict] = None,
|
|
369
|
+
idempotency_key: Optional[str] = None,
|
|
370
|
+
) -> Payment:
|
|
371
|
+
"""Internal method to create a payment."""
|
|
372
|
+
payload = {
|
|
373
|
+
"payment_type": payment_type,
|
|
374
|
+
"details": PaymentDetails(amount, currency, callback_url).to_dict(),
|
|
375
|
+
"phone_number": phone_number,
|
|
376
|
+
"customer": customer.to_dict(),
|
|
377
|
+
}
|
|
378
|
+
if webhook_url:
|
|
379
|
+
payload["webhook_url"] = webhook_url
|
|
380
|
+
if metadata:
|
|
381
|
+
payload["metadata"] = metadata
|
|
382
|
+
|
|
383
|
+
headers = {}
|
|
384
|
+
if idempotency_key:
|
|
385
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
386
|
+
|
|
387
|
+
response = await self._client.post("/payments", json=payload, headers=headers)
|
|
388
|
+
data = await self._handle_response(response)
|
|
389
|
+
return Payment.from_dict(data)
|
|
390
|
+
|
|
391
|
+
async def create_mobile_payment(
|
|
392
|
+
self,
|
|
393
|
+
amount: int,
|
|
394
|
+
currency: Currency,
|
|
395
|
+
phone_number: str,
|
|
396
|
+
customer: Customer,
|
|
397
|
+
callback_url: Optional[str] = None,
|
|
398
|
+
webhook_url: Optional[str] = None,
|
|
399
|
+
metadata: Optional[dict] = None,
|
|
400
|
+
idempotency_key: Optional[str] = None,
|
|
401
|
+
) -> Payment:
|
|
402
|
+
"""Create a mobile money payment (USSD push)."""
|
|
403
|
+
return await self._create_payment(
|
|
404
|
+
payment_type="mobile",
|
|
405
|
+
amount=amount,
|
|
406
|
+
currency=currency,
|
|
407
|
+
phone_number=phone_number,
|
|
408
|
+
customer=customer,
|
|
409
|
+
callback_url=callback_url,
|
|
410
|
+
webhook_url=webhook_url,
|
|
411
|
+
metadata=metadata,
|
|
412
|
+
idempotency_key=idempotency_key,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
async def create_card_payment(
|
|
416
|
+
self,
|
|
417
|
+
amount: int,
|
|
418
|
+
currency: Currency,
|
|
419
|
+
phone_number: str,
|
|
420
|
+
customer: Customer,
|
|
421
|
+
callback_url: str,
|
|
422
|
+
webhook_url: Optional[str] = None,
|
|
423
|
+
metadata: Optional[dict] = None,
|
|
424
|
+
idempotency_key: Optional[str] = None,
|
|
425
|
+
) -> Payment:
|
|
426
|
+
"""Create a card payment."""
|
|
427
|
+
return await self._create_payment(
|
|
428
|
+
payment_type="card",
|
|
429
|
+
amount=amount,
|
|
430
|
+
currency=currency,
|
|
431
|
+
phone_number=phone_number,
|
|
432
|
+
customer=customer,
|
|
433
|
+
callback_url=callback_url,
|
|
434
|
+
webhook_url=webhook_url,
|
|
435
|
+
metadata=metadata,
|
|
436
|
+
idempotency_key=idempotency_key,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
async def create_qr_payment(
|
|
440
|
+
self,
|
|
441
|
+
amount: int,
|
|
442
|
+
currency: Currency,
|
|
443
|
+
phone_number: str,
|
|
444
|
+
customer: Customer,
|
|
445
|
+
callback_url: Optional[str] = None,
|
|
446
|
+
webhook_url: Optional[str] = None,
|
|
447
|
+
metadata: Optional[dict] = None,
|
|
448
|
+
idempotency_key: Optional[str] = None,
|
|
449
|
+
) -> Payment:
|
|
450
|
+
"""Create a dynamic QR code payment."""
|
|
451
|
+
return await self._create_payment(
|
|
452
|
+
payment_type="dynamic-qr",
|
|
453
|
+
amount=amount,
|
|
454
|
+
currency=currency,
|
|
455
|
+
phone_number=phone_number,
|
|
456
|
+
customer=customer,
|
|
457
|
+
callback_url=callback_url,
|
|
458
|
+
webhook_url=webhook_url,
|
|
459
|
+
metadata=metadata,
|
|
460
|
+
idempotency_key=idempotency_key,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def get_payment(self, reference: str) -> Payment:
|
|
464
|
+
"""Get payment status by reference."""
|
|
465
|
+
response = await self._client.get(f"/payments/{reference}")
|
|
466
|
+
data = await self._handle_response(response)
|
|
467
|
+
return Payment.from_dict(data)
|
|
468
|
+
|
|
469
|
+
async def list_payments(
|
|
470
|
+
self,
|
|
471
|
+
limit: int = 20,
|
|
472
|
+
offset: int = 0,
|
|
473
|
+
) -> PaymentList:
|
|
474
|
+
"""List all payments for your account."""
|
|
475
|
+
response = await self._client.get(
|
|
476
|
+
"/payments",
|
|
477
|
+
params={"limit": limit, "offset": offset},
|
|
478
|
+
)
|
|
479
|
+
data = await self._handle_response(response)
|
|
480
|
+
return PaymentList.from_dict(data)
|
|
481
|
+
|
|
482
|
+
async def get_balance(self) -> Balance:
|
|
483
|
+
"""Get your current account balance."""
|
|
484
|
+
response = await self._client.get("/payments/balance")
|
|
485
|
+
data = await self._handle_response(response)
|
|
486
|
+
return Balance.from_dict(data)
|
|
487
|
+
|
|
488
|
+
async def close(self) -> None:
|
|
489
|
+
"""Close the HTTP client."""
|
|
490
|
+
await self._client.aclose()
|
|
491
|
+
|
|
492
|
+
async def __aenter__(self) -> "AsyncSnippe":
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
async def __aexit__(self, *args) -> None:
|
|
496
|
+
await self.close()
|
snippe/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Exceptions for Snippe SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SnippeError(Exception):
|
|
5
|
+
"""Base exception for all Snippe errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, code: int = 0, error_code: str = ""):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.code = code
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(SnippeError):
|
|
15
|
+
"""Invalid or missing API key."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationError(SnippeError):
|
|
20
|
+
"""Invalid request parameters."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotFoundError(SnippeError):
|
|
25
|
+
"""Resource not found."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(SnippeError):
|
|
30
|
+
"""Too many requests."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServerError(SnippeError):
|
|
35
|
+
"""Snippe server error."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WebhookVerificationError(SnippeError):
|
|
40
|
+
"""Invalid webhook signature."""
|
|
41
|
+
pass
|
snippe/models.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Data models for Snippe SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .types import Currency, PaymentStatus, PaymentType, WebhookEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Customer:
|
|
12
|
+
"""Customer information."""
|
|
13
|
+
firstname: str
|
|
14
|
+
lastname: str
|
|
15
|
+
email: Optional[str] = None
|
|
16
|
+
address: Optional[str] = None
|
|
17
|
+
city: Optional[str] = None
|
|
18
|
+
state: Optional[str] = None
|
|
19
|
+
postcode: Optional[str] = None
|
|
20
|
+
country: Optional[str] = None
|
|
21
|
+
phone: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
"""Convert to API-compatible dict."""
|
|
25
|
+
data = {"firstname": self.firstname, "lastname": self.lastname}
|
|
26
|
+
if self.email:
|
|
27
|
+
data["email"] = self.email
|
|
28
|
+
if self.address:
|
|
29
|
+
data["address"] = self.address
|
|
30
|
+
if self.city:
|
|
31
|
+
data["city"] = self.city
|
|
32
|
+
if self.state:
|
|
33
|
+
data["state"] = self.state
|
|
34
|
+
if self.postcode:
|
|
35
|
+
data["postcode"] = self.postcode
|
|
36
|
+
if self.country:
|
|
37
|
+
data["country"] = self.country
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PaymentDetails:
|
|
43
|
+
"""Payment amount details."""
|
|
44
|
+
amount: int
|
|
45
|
+
currency: Currency
|
|
46
|
+
callback_url: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
"""Convert to API-compatible dict."""
|
|
50
|
+
data = {"amount": self.amount, "currency": self.currency}
|
|
51
|
+
if self.callback_url:
|
|
52
|
+
data["callback_url"] = self.callback_url
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Payment:
|
|
58
|
+
"""Payment response from API."""
|
|
59
|
+
reference: str
|
|
60
|
+
status: PaymentStatus
|
|
61
|
+
amount: int
|
|
62
|
+
currency: Currency
|
|
63
|
+
payment_type: PaymentType
|
|
64
|
+
expires_at: Optional[str] = None
|
|
65
|
+
payment_url: Optional[str] = None
|
|
66
|
+
qr_code: Optional[str] = None
|
|
67
|
+
payment_qr_code: Optional[str] = None
|
|
68
|
+
payment_token: Optional[str] = None
|
|
69
|
+
id: Optional[str] = None
|
|
70
|
+
psp_reference: Optional[str] = None
|
|
71
|
+
fee_amount: Optional[int] = None
|
|
72
|
+
net_amount: Optional[int] = None
|
|
73
|
+
customer: Optional[dict] = None
|
|
74
|
+
metadata: Optional[dict] = None
|
|
75
|
+
created_at: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> "Payment":
|
|
79
|
+
"""Create Payment from API response dict."""
|
|
80
|
+
return cls(
|
|
81
|
+
reference=data["reference"],
|
|
82
|
+
status=data["status"],
|
|
83
|
+
amount=data["amount"],
|
|
84
|
+
currency=data["currency"],
|
|
85
|
+
payment_type=data["payment_type"],
|
|
86
|
+
expires_at=data.get("expires_at"),
|
|
87
|
+
payment_url=data.get("payment_url"),
|
|
88
|
+
qr_code=data.get("qr_code"),
|
|
89
|
+
payment_qr_code=data.get("payment_qr_code"),
|
|
90
|
+
payment_token=data.get("payment_token"),
|
|
91
|
+
id=data.get("id"),
|
|
92
|
+
psp_reference=data.get("psp_reference"),
|
|
93
|
+
fee_amount=data.get("fee_amount"),
|
|
94
|
+
net_amount=data.get("net_amount"),
|
|
95
|
+
customer=data.get("customer"),
|
|
96
|
+
metadata=data.get("metadata"),
|
|
97
|
+
created_at=data.get("created_at"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class PaymentList:
|
|
103
|
+
"""Paginated list of payments."""
|
|
104
|
+
payments: list[Payment]
|
|
105
|
+
limit: int
|
|
106
|
+
offset: int
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_dict(cls, data: dict) -> "PaymentList":
|
|
110
|
+
"""Create PaymentList from API response dict."""
|
|
111
|
+
return cls(
|
|
112
|
+
payments=[Payment.from_dict(p) for p in data.get("payments", [])],
|
|
113
|
+
limit=data.get("limit", 20),
|
|
114
|
+
offset=data.get("offset", 0),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Balance:
|
|
120
|
+
"""Account balance."""
|
|
121
|
+
available_balance: int
|
|
122
|
+
balance: int
|
|
123
|
+
currency: Currency
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, data: dict) -> "Balance":
|
|
127
|
+
"""Create Balance from API response dict."""
|
|
128
|
+
return cls(
|
|
129
|
+
available_balance=data.get("available_balance", 0),
|
|
130
|
+
balance=data.get("balance", 0),
|
|
131
|
+
currency=data.get("currency", "TZS"),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class WebhookPayload:
|
|
137
|
+
"""Webhook event payload."""
|
|
138
|
+
event: WebhookEvent
|
|
139
|
+
reference: str
|
|
140
|
+
status: PaymentStatus
|
|
141
|
+
amount: dict
|
|
142
|
+
payment_channel: str
|
|
143
|
+
payment_fee: int
|
|
144
|
+
customer: dict
|
|
145
|
+
metadata: dict
|
|
146
|
+
completed_at: Optional[str]
|
|
147
|
+
created_at: str
|
|
148
|
+
timestamp: int
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_dict(cls, data: dict) -> "WebhookPayload":
|
|
152
|
+
"""Create WebhookPayload from webhook data."""
|
|
153
|
+
return cls(
|
|
154
|
+
event=data["event"],
|
|
155
|
+
reference=data["reference"],
|
|
156
|
+
status=data["status"],
|
|
157
|
+
amount=data["amount"],
|
|
158
|
+
payment_channel=data.get("payment_channel", ""),
|
|
159
|
+
payment_fee=data.get("payment_fee", 0),
|
|
160
|
+
customer=data.get("customer", {}),
|
|
161
|
+
metadata=data.get("metadata", {}),
|
|
162
|
+
completed_at=data.get("completed_at"),
|
|
163
|
+
created_at=data["created_at"],
|
|
164
|
+
timestamp=data["timestamp"],
|
|
165
|
+
)
|
snippe/py.typed
ADDED
|
File without changes
|
snippe/types.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Type definitions for Snippe SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
PaymentType = Literal["mobile", "card", "dynamic-qr"]
|
|
6
|
+
PaymentStatus = Literal["pending", "completed", "failed", "expired", "voided"]
|
|
7
|
+
Currency = Literal["TZS", "KES", "UGX"]
|
|
8
|
+
WebhookEvent = Literal[
|
|
9
|
+
"payment.completed",
|
|
10
|
+
"payment.failed",
|
|
11
|
+
"payment.expired",
|
|
12
|
+
"payment.voided"
|
|
13
|
+
]
|
snippe/webhooks.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Webhook verification utilities for Snippe SDK."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .exceptions import WebhookVerificationError
|
|
9
|
+
from .models import WebhookPayload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebhookHandler:
|
|
13
|
+
"""
|
|
14
|
+
Webhook handler for verifying and parsing Snippe webhooks.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
>>> from snippe import WebhookHandler
|
|
18
|
+
>>> handler = WebhookHandler("your_webhook_signing_key")
|
|
19
|
+
>>> payload = handler.verify_and_parse(
|
|
20
|
+
... body=request.body,
|
|
21
|
+
... signature=request.headers["X-Webhook-Signature"],
|
|
22
|
+
... timestamp=request.headers["X-Webhook-Timestamp"]
|
|
23
|
+
... )
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
signing_key: str,
|
|
29
|
+
tolerance: int = 300,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize webhook handler.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
signing_key: Your webhook signing key
|
|
36
|
+
tolerance: Max age in seconds for webhook (default: 5 minutes)
|
|
37
|
+
"""
|
|
38
|
+
self.signing_key = signing_key
|
|
39
|
+
self.tolerance = tolerance
|
|
40
|
+
|
|
41
|
+
def compute_signature(self, payload: str, timestamp: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Compute HMAC-SHA256 signature for payload.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
payload: Raw request body as string
|
|
47
|
+
timestamp: Unix timestamp from X-Webhook-Timestamp header
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Hex-encoded signature
|
|
51
|
+
"""
|
|
52
|
+
message = f"{timestamp}.{payload}"
|
|
53
|
+
signature = hmac.new(
|
|
54
|
+
self.signing_key.encode(),
|
|
55
|
+
message.encode(),
|
|
56
|
+
hashlib.sha256,
|
|
57
|
+
)
|
|
58
|
+
return signature.hexdigest()
|
|
59
|
+
|
|
60
|
+
def verify_signature(
|
|
61
|
+
self,
|
|
62
|
+
payload: str,
|
|
63
|
+
signature: str,
|
|
64
|
+
timestamp: str,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
Verify webhook signature.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
payload: Raw request body as string
|
|
71
|
+
signature: Value from X-Webhook-Signature header
|
|
72
|
+
timestamp: Value from X-Webhook-Timestamp header
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if signature is valid
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
WebhookVerificationError: If signature is invalid or expired
|
|
79
|
+
"""
|
|
80
|
+
# Check timestamp to prevent replay attacks
|
|
81
|
+
try:
|
|
82
|
+
ts = int(timestamp)
|
|
83
|
+
except (ValueError, TypeError):
|
|
84
|
+
raise WebhookVerificationError("Invalid timestamp")
|
|
85
|
+
|
|
86
|
+
if abs(time.time() - ts) > self.tolerance:
|
|
87
|
+
raise WebhookVerificationError("Webhook timestamp expired")
|
|
88
|
+
|
|
89
|
+
# Compute and compare signatures
|
|
90
|
+
expected = self.compute_signature(payload, timestamp)
|
|
91
|
+
if not hmac.compare_digest(expected, signature):
|
|
92
|
+
raise WebhookVerificationError("Invalid signature")
|
|
93
|
+
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def parse(self, data: dict) -> WebhookPayload:
|
|
97
|
+
"""
|
|
98
|
+
Parse webhook payload without verification.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
data: Parsed JSON payload
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
WebhookPayload object
|
|
105
|
+
"""
|
|
106
|
+
return WebhookPayload.from_dict(data)
|
|
107
|
+
|
|
108
|
+
def verify_and_parse(
|
|
109
|
+
self,
|
|
110
|
+
body: str,
|
|
111
|
+
signature: str,
|
|
112
|
+
timestamp: str,
|
|
113
|
+
) -> WebhookPayload:
|
|
114
|
+
"""
|
|
115
|
+
Verify signature and parse webhook payload.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
body: Raw request body as string
|
|
119
|
+
signature: Value from X-Webhook-Signature header
|
|
120
|
+
timestamp: Value from X-Webhook-Timestamp header
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
WebhookPayload object
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
WebhookVerificationError: If signature is invalid or expired
|
|
127
|
+
"""
|
|
128
|
+
import json
|
|
129
|
+
|
|
130
|
+
self.verify_signature(body, signature, timestamp)
|
|
131
|
+
data = json.loads(body)
|
|
132
|
+
return self.parse(data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def verify_webhook(
|
|
136
|
+
body: str,
|
|
137
|
+
signature: str,
|
|
138
|
+
timestamp: str,
|
|
139
|
+
signing_key: str,
|
|
140
|
+
tolerance: int = 300,
|
|
141
|
+
) -> WebhookPayload:
|
|
142
|
+
"""
|
|
143
|
+
Convenience function to verify and parse a webhook.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
body: Raw request body as string
|
|
147
|
+
signature: Value from X-Webhook-Signature header
|
|
148
|
+
timestamp: Value from X-Webhook-Timestamp header
|
|
149
|
+
signing_key: Your webhook signing key
|
|
150
|
+
tolerance: Max age in seconds (default: 5 minutes)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
WebhookPayload object
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
WebhookVerificationError: If signature is invalid or expired
|
|
157
|
+
"""
|
|
158
|
+
handler = WebhookHandler(signing_key, tolerance)
|
|
159
|
+
return handler.verify_and_parse(body, signature, timestamp)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snippe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Snippe Payment API
|
|
5
|
+
Project-URL: Homepage, https://snippe.sh
|
|
6
|
+
Project-URL: Documentation, https://documenter.getpostman.com/view/36488510/2sBXViiWAV#6beb6d54-34a1-4c9c-8a19-acd92a865711
|
|
7
|
+
Project-URL: Repository, https://github.com/Neurotech-HQ/snippe-python-sdk
|
|
8
|
+
Author-email: Nassdaq <mwaijegakelvin@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: Tanzania Payment,africa,fintech,mobile-money,snippe
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.20.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Snippe Python SDK
|
|
30
|
+
|
|
31
|
+
Official Python SDK for [Snippe Payment API](https://snippe.sh) - Accept payments via mobile money, card, and QR code in East Africa.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install snippe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from snippe import Snippe, Customer
|
|
43
|
+
|
|
44
|
+
client = Snippe("your_api_key")
|
|
45
|
+
|
|
46
|
+
# Create a mobile money payment
|
|
47
|
+
payment = client.create_mobile_payment(
|
|
48
|
+
amount=1000,
|
|
49
|
+
currency="TZS",
|
|
50
|
+
phone_number="0788500000",
|
|
51
|
+
customer=Customer(firstname="John", lastname="Doe"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
print(f"Payment reference: {payment.reference}")
|
|
55
|
+
print(f"Status: {payment.status}")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Payment Types
|
|
59
|
+
|
|
60
|
+
### Mobile Money (USSD Push)
|
|
61
|
+
|
|
62
|
+
Customer receives a USSD prompt on their phone to confirm payment.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
payment = client.create_mobile_payment(
|
|
66
|
+
amount=5000,
|
|
67
|
+
currency="TZS",
|
|
68
|
+
phone_number="0712345678",
|
|
69
|
+
customer=Customer(
|
|
70
|
+
firstname="Jane",
|
|
71
|
+
lastname="Doe",
|
|
72
|
+
email="jane@example.com" # optional
|
|
73
|
+
),
|
|
74
|
+
webhook_url="https://yourapp.com/webhooks", # optional
|
|
75
|
+
metadata={"order_id": "ORD-123"}, # optional
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Card Payment
|
|
80
|
+
|
|
81
|
+
Returns a `payment_url` to redirect the customer to complete payment.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
payment = client.create_card_payment(
|
|
85
|
+
amount=50000,
|
|
86
|
+
currency="TZS",
|
|
87
|
+
phone_number="0712345678",
|
|
88
|
+
customer=Customer(
|
|
89
|
+
firstname="John",
|
|
90
|
+
lastname="Doe",
|
|
91
|
+
email="john@example.com",
|
|
92
|
+
address="123 Main Street",
|
|
93
|
+
city="Dar es Salaam",
|
|
94
|
+
state="DSM",
|
|
95
|
+
postcode="14101",
|
|
96
|
+
country="TZ",
|
|
97
|
+
),
|
|
98
|
+
callback_url="https://yourapp.com/callback", # required for card
|
|
99
|
+
webhook_url="https://yourapp.com/webhooks",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Redirect customer to this URL
|
|
103
|
+
print(payment.payment_url)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### QR Code Payment
|
|
107
|
+
|
|
108
|
+
Returns a QR code for the customer to scan.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
payment = client.create_qr_payment(
|
|
112
|
+
amount=25000,
|
|
113
|
+
currency="TZS",
|
|
114
|
+
phone_number="0712345678",
|
|
115
|
+
customer=Customer(firstname="John", lastname="Doe"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Display this QR code to customer
|
|
119
|
+
print(payment.payment_qr_code)
|
|
120
|
+
print(payment.payment_token)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Check Payment Status
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
payment = client.get_payment("payment_reference")
|
|
127
|
+
print(f"Status: {payment.status}") # pending, completed, failed, expired, voided
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## List Payments
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
result = client.list_payments(limit=20, offset=0)
|
|
134
|
+
for payment in result.payments:
|
|
135
|
+
print(f"{payment.reference}: {payment.status}")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Check Balance
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
balance = client.get_balance()
|
|
142
|
+
print(f"Available: {balance.available_balance} {balance.currency}")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Webhooks
|
|
146
|
+
|
|
147
|
+
Verify and parse webhook events from Snippe.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from snippe import verify_webhook, WebhookVerificationError
|
|
151
|
+
|
|
152
|
+
# In your webhook endpoint
|
|
153
|
+
try:
|
|
154
|
+
payload = verify_webhook(
|
|
155
|
+
body=request.body.decode(),
|
|
156
|
+
signature=request.headers["X-Webhook-Signature"],
|
|
157
|
+
timestamp=request.headers["X-Webhook-Timestamp"],
|
|
158
|
+
signing_key="your_webhook_signing_key",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if payload.event == "payment.completed":
|
|
162
|
+
print(f"Payment {payload.reference} completed!")
|
|
163
|
+
# Fulfill the order
|
|
164
|
+
elif payload.event == "payment.failed":
|
|
165
|
+
print(f"Payment {payload.reference} failed")
|
|
166
|
+
# Notify customer
|
|
167
|
+
|
|
168
|
+
except WebhookVerificationError as e:
|
|
169
|
+
print(f"Invalid webhook: {e}")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Webhook Events
|
|
173
|
+
|
|
174
|
+
| Event | Description |
|
|
175
|
+
|-------|-------------|
|
|
176
|
+
| `payment.completed` | Payment successful |
|
|
177
|
+
| `payment.failed` | Payment declined or failed |
|
|
178
|
+
| `payment.expired` | Payment timed out |
|
|
179
|
+
| `payment.voided` | Payment cancelled |
|
|
180
|
+
|
|
181
|
+
## Async Support
|
|
182
|
+
|
|
183
|
+
For async applications (FastAPI, aiohttp, etc.):
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from snippe import AsyncSnippe, Customer
|
|
187
|
+
|
|
188
|
+
async def create_payment():
|
|
189
|
+
async with AsyncSnippe("your_api_key") as client:
|
|
190
|
+
payment = await client.create_mobile_payment(
|
|
191
|
+
amount=1000,
|
|
192
|
+
currency="TZS",
|
|
193
|
+
phone_number="0788500000",
|
|
194
|
+
customer=Customer(firstname="John", lastname="Doe"),
|
|
195
|
+
)
|
|
196
|
+
return payment
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Idempotency
|
|
200
|
+
|
|
201
|
+
Prevent duplicate payments by providing an idempotency key:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
payment = client.create_mobile_payment(
|
|
205
|
+
amount=1000,
|
|
206
|
+
currency="TZS",
|
|
207
|
+
phone_number="0788500000",
|
|
208
|
+
customer=Customer(firstname="John", lastname="Doe"),
|
|
209
|
+
idempotency_key="unique_order_id_123", # Your unique identifier
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Error Handling
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from snippe import (
|
|
217
|
+
Snippe,
|
|
218
|
+
AuthenticationError,
|
|
219
|
+
ValidationError,
|
|
220
|
+
NotFoundError,
|
|
221
|
+
RateLimitError,
|
|
222
|
+
ServerError,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
payment = client.create_mobile_payment(...)
|
|
227
|
+
except AuthenticationError:
|
|
228
|
+
print("Invalid API key")
|
|
229
|
+
except ValidationError as e:
|
|
230
|
+
print(f"Invalid request: {e.message}")
|
|
231
|
+
except NotFoundError:
|
|
232
|
+
print("Payment not found")
|
|
233
|
+
except RateLimitError:
|
|
234
|
+
print("Too many requests, slow down")
|
|
235
|
+
except ServerError:
|
|
236
|
+
print("Snippe server error, try again later")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Supported Currencies
|
|
240
|
+
|
|
241
|
+
| Currency | Country |
|
|
242
|
+
|----------|---------|
|
|
243
|
+
| TZS | Tanzania |
|
|
244
|
+
| KES | Kenya |
|
|
245
|
+
| UGX | Uganda |
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
snippe/__init__.py,sha256=kBzciBjF9_R2aM2Mof5Z6EX9iUUw8VTOTREm3srLa90,1440
|
|
2
|
+
snippe/client.py,sha256=JmWbYm4tzUDea2cfcZygjsJUooQ_Tejtvn0ZsOZZKNk,15535
|
|
3
|
+
snippe/exceptions.py,sha256=N444s_aLPnElQVU9EqSmXskvegB72B221i0E1wuAN-8,807
|
|
4
|
+
snippe/models.py,sha256=cDfUNaFWtqvfsJnlKgwvtHU31h8tZrypC_JVL-YJtq4,4852
|
|
5
|
+
snippe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
snippe/types.py,sha256=RN-Gvr3hGFlhrmjTftcN3nw8G6EBaD9mMZbd_nkKM9Q,358
|
|
7
|
+
snippe/webhooks.py,sha256=OVhEcoWeM3A7arqPQl0JuCshTlgIuADD7cysVghSqR0,4352
|
|
8
|
+
snippe-0.1.0.dist-info/METADATA,sha256=4HPXC8s3hL6zo_1YCi2_HhEFSD0OJ2X-4cbwWV1EsGY,6203
|
|
9
|
+
snippe-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
snippe-0.1.0.dist-info/RECORD,,
|