fastaar-python 1.0.3__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.
fastaar/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from fastaar.client import FastaarClient
2
+ from fastaar.exceptions import FastaarException
3
+ from fastaar.signature import WebhookSignature
4
+
5
+ __all__ = ["FastaarClient", "FastaarException", "WebhookSignature"]
fastaar/client.py ADDED
@@ -0,0 +1,239 @@
1
+ import json
2
+ import urllib.parse
3
+ import urllib.request
4
+ from urllib.error import HTTPError, URLError
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ from fastaar.exceptions import FastaarException
8
+
9
+ BASE_URL = "https://fastaar.com"
10
+
11
+
12
+ class FastaarClient:
13
+ def __init__(
14
+ self,
15
+ api_key: str,
16
+ timeout_seconds: int = 15,
17
+ ) -> None:
18
+ """
19
+ Initialize the Fastaar client.
20
+
21
+ Args:
22
+ api_key: Your Fastaar API key (e.g. fk_live_... or fk_test_...)
23
+ timeout_seconds: Connection and read timeout in seconds
24
+ """
25
+ self.api_key = api_key
26
+ self.timeout_seconds = timeout_seconds
27
+
28
+ # -------------------------------------------------------------------------
29
+ # Payments
30
+ # -------------------------------------------------------------------------
31
+
32
+ def create_payment(self, params: Dict[str, Any]) -> Dict[str, Any]:
33
+ """
34
+ Create a payment intent.
35
+
36
+ Reusing the same `invoice_number` while a previous payment for it is still
37
+ active (not `failed`/`expired`) raises a FastaarException with error type
38
+ `duplicate_invoice_number` (HTTP 409) instead of creating a duplicate — look
39
+ the existing payment up with `find_by_invoice_number()` rather than retrying
40
+ blindly. Supply `success_url`/`cancel_url` to return the customer to your site
41
+ after checkout; Fastaar appends `payment_id` (and `invoice_number`) to them.
42
+
43
+ Args:
44
+ params: Dictionary containing:
45
+ - amount: int|float|str (required)
46
+ - invoice_number: str (required)
47
+ - customer_id: int (optional) - an existing customer belonging to your merchant account
48
+ - success_url: str (optional)
49
+ - cancel_url: str (optional)
50
+ - metadata: dict (optional)
51
+
52
+ Returns:
53
+ The payment object dictionary, including `id`, `status`, and `checkout_url`.
54
+ """
55
+ result = self._request("POST", "/api/v1/payments", body=params)
56
+ if not isinstance(result, dict):
57
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
58
+ return result
59
+
60
+ def get_payment(self, payment_id: str) -> Dict[str, Any]:
61
+ """
62
+ Retrieve a payment by its reference (the `id` returned at creation).
63
+ """
64
+ encoded_id = urllib.parse.quote(payment_id, safe="")
65
+ result = self._request("GET", f"/api/v1/payments/{encoded_id}")
66
+ if not isinstance(result, dict):
67
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
68
+ return result
69
+
70
+ def list_payments(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
71
+ """
72
+ List payments, newest first.
73
+
74
+ Args:
75
+ params: Dictionary containing optional filters:
76
+ - status: str (optional)
77
+ - invoice_number: str (optional)
78
+ - per_page: int (optional)
79
+ - page: int (optional)
80
+ """
81
+ if params:
82
+ query = "?" + urllib.parse.urlencode(params)
83
+ else:
84
+ query = ""
85
+
86
+ result = self._request("GET", f"/api/v1/payments{query}")
87
+ if not isinstance(result, list):
88
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
89
+ return result
90
+
91
+ def find_by_invoice_number(self, invoice_number: str) -> Optional[Dict[str, Any]]:
92
+ """
93
+ Find the most recent payment for one of your invoice numbers, or None if none.
94
+ """
95
+ payments = self.list_payments({"invoice_number": invoice_number})
96
+ return payments[0] if payments else None
97
+
98
+ def refund_payment(self, payment_id: str, amount: Optional[float] = None) -> Dict[str, Any]:
99
+ """
100
+ Refund a payment, in full or in part. Only payments with status `completed` or
101
+ `partially_refunded` can be refunded. Pass an amount to refund only part of the
102
+ remaining balance; omit it to refund whatever is still refundable.
103
+
104
+ Returns:
105
+ The updated payment object. `status` is `refunded` once fully refunded, or
106
+ `partially_refunded` if some balance remains.
107
+
108
+ Raises:
109
+ FastaarException: if the payment is not in a refundable state, or the amount
110
+ exceeds the remaining refundable balance.
111
+ """
112
+ encoded_id = urllib.parse.quote(payment_id, safe="")
113
+ body = {"amount": amount} if amount is not None else None
114
+ result = self._request("POST", f"/api/v1/payments/{encoded_id}/refund", body=body)
115
+ if not isinstance(result, dict):
116
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
117
+ return result
118
+
119
+ def list_refunds(self, payment_id: str) -> List[Dict[str, Any]]:
120
+ """
121
+ List a payment's refund history, newest first — one entry per refund call, even
122
+ across several partial refunds.
123
+ """
124
+ encoded_id = urllib.parse.quote(payment_id, safe="")
125
+ result = self._request("GET", f"/api/v1/payments/{encoded_id}/refunds")
126
+ if not isinstance(result, list):
127
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
128
+ return result
129
+
130
+ # -------------------------------------------------------------------------
131
+ # Customers
132
+ # -------------------------------------------------------------------------
133
+
134
+ def list_customers(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
135
+ """
136
+ List customers, newest first.
137
+
138
+ Args:
139
+ params: Optional filters — email, phone, per_page, page.
140
+ """
141
+ query = "?" + urllib.parse.urlencode(params) if params else ""
142
+ result = self._request("GET", f"/api/v1/customers{query}")
143
+ if not isinstance(result, list):
144
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
145
+ return result
146
+
147
+ def create_customer(self, params: Dict[str, Any]) -> Dict[str, Any]:
148
+ """
149
+ Create a customer.
150
+
151
+ Args:
152
+ params: name (required), phone (required), email, address, notes.
153
+ """
154
+ result = self._request("POST", "/api/v1/customers", body=params)
155
+ if not isinstance(result, dict):
156
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
157
+ return result
158
+
159
+ def get_customer(self, customer_id: int) -> Dict[str, Any]:
160
+ """Retrieve a customer by ID."""
161
+ result = self._request("GET", f"/api/v1/customers/{customer_id}")
162
+ if not isinstance(result, dict):
163
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
164
+ return result
165
+
166
+ def update_customer(self, customer_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Update a customer (partial — only sent fields are changed)."""
168
+ result = self._request("PATCH", f"/api/v1/customers/{customer_id}", body=params)
169
+ if not isinstance(result, dict):
170
+ raise FastaarException("Fastaar API returned an unexpected response format.", "api_error")
171
+ return result
172
+
173
+ def _request(
174
+ self,
175
+ method: str,
176
+ path: str,
177
+ body: Optional[Dict[str, Any]] = None,
178
+ ) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
179
+ url = BASE_URL + path
180
+
181
+ headers = {
182
+ "Authorization": f"Bearer {self.api_key}",
183
+ "Accept": "application/json",
184
+ }
185
+
186
+ data = None
187
+ if body is not None:
188
+ headers["Content-Type"] = "application/json"
189
+ data = json.dumps(body).encode("utf-8")
190
+
191
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
192
+
193
+ try:
194
+ with urllib.request.urlopen(req, timeout=self.timeout_seconds) as response:
195
+ status_code = response.getcode()
196
+ response_body = response.read().decode("utf-8")
197
+ except HTTPError as e:
198
+ status_code = e.code
199
+ try:
200
+ response_body = e.read().decode("utf-8")
201
+ except Exception:
202
+ response_body = ""
203
+ except (URLError, Exception) as e:
204
+ # Check if reason is present
205
+ reason = getattr(e, "reason", str(e))
206
+ raise FastaarException(
207
+ message=f"Could not reach the Fastaar API: {reason}",
208
+ error_type="connection_error",
209
+ status_code=0,
210
+ )
211
+
212
+ try:
213
+ decoded = json.loads(response_body)
214
+ except json.JSONDecodeError:
215
+ decoded = None
216
+
217
+ if status_code >= 400 or not isinstance(decoded, (dict, list)):
218
+ error_message = None
219
+ error_type = "api_error"
220
+
221
+ if isinstance(decoded, dict):
222
+ error_details = decoded.get("error", decoded)
223
+ if isinstance(error_details, dict):
224
+ error_message = error_details.get("message")
225
+ error_type = error_details.get("type", "api_error")
226
+
227
+ if not error_message:
228
+ error_message = f"Fastaar API returned HTTP {status_code}."
229
+
230
+ raise FastaarException(
231
+ message=error_message,
232
+ error_type=error_type,
233
+ status_code=status_code,
234
+ )
235
+
236
+ # In case Fastaar envelopes data, return decoded['data'] if it exists, otherwise full decoded
237
+ if isinstance(decoded, dict) and "data" in decoded:
238
+ return decoded["data"]
239
+ return decoded
fastaar/exceptions.py ADDED
@@ -0,0 +1,16 @@
1
+ class FastaarException(Exception):
2
+ """
3
+ Exception raised for errors in the Fastaar SDK.
4
+
5
+ Attributes:
6
+ message -- explanation of the error
7
+ error_type -- stable API error code, e.g. "authentication_error",
8
+ "subscription_required", "transaction_limit_reached",
9
+ "connection_error", or "api_error"
10
+ status_code -- HTTP status code returned by the API (0 if connection error)
11
+ """
12
+
13
+ def __init__(self, message: str, error_type: str = "api_error", status_code: int = 0):
14
+ super().__init__(message)
15
+ self.error_type = error_type
16
+ self.status_code = status_code
fastaar/signature.py ADDED
@@ -0,0 +1,53 @@
1
+ import hashlib
2
+ import hmac
3
+ import re
4
+ import time
5
+ from typing import Union
6
+
7
+
8
+ class WebhookSignature:
9
+ @staticmethod
10
+ def verify(
11
+ secret: Union[str, bytes],
12
+ raw_body: Union[str, bytes],
13
+ signature_header: str,
14
+ tolerance_seconds: int = 300,
15
+ ) -> bool:
16
+ """
17
+ Verify the X-Fastaar-Signature header (`t=<ts>,v1=<hmac>`) against
18
+ the raw request body using your merchant webhook secret.
19
+ """
20
+ if not signature_header or not secret:
21
+ return False
22
+
23
+ # Match signature header pattern: t=<timestamp>,v1=<hmac-sha256>
24
+ match = re.match(r"^t=(?P<t>\d+),v1=(?P<v1>[a-f0-9]{64})$", signature_header)
25
+ if not match:
26
+ return False
27
+
28
+ try:
29
+ timestamp = int(match.group("t"))
30
+ except ValueError:
31
+ return False
32
+
33
+ # Check timestamp tolerance
34
+ current_time = int(time.time())
35
+ if abs(current_time - timestamp) > tolerance_seconds:
36
+ return False
37
+
38
+ # Ensure raw_body is a string
39
+ if isinstance(raw_body, bytes):
40
+ raw_body = raw_body.decode("utf-8")
41
+
42
+ # Prepare secret and message payload
43
+ secret_bytes = secret.encode("utf-8") if isinstance(secret, str) else secret
44
+ payload_bytes = f"{timestamp}.{raw_body}".encode("utf-8")
45
+
46
+ # Generate expected signature
47
+ expected = hmac.new(
48
+ secret_bytes,
49
+ payload_bytes,
50
+ hashlib.sha256
51
+ ).hexdigest()
52
+
53
+ return hmac.compare_digest(expected, match.group("v1"))
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastaar-python
3
+ Version: 1.0.3
4
+ Summary: Python SDK for the Fastaar payment gateway — accept bKash & Nagad payments in Bangladesh.
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+
14
+ # Fastaar Python SDK
15
+
16
+ Accept bKash & Nagad payments on any Python website or application via [Fastaar](https://fastaar.com).
17
+
18
+ This is a zero-dependency SDK utilizing Python's standard library.
19
+
20
+ ## Install
21
+
22
+ Install the package via `pip` (or configure it in your `requirements.txt` / `pyproject.toml`):
23
+
24
+ ```bash
25
+ pip install fastaar-python
26
+ ```
27
+
28
+ ## Create a payment & redirect to checkout
29
+
30
+ Here is an example using a generic Python web application:
31
+
32
+ ```python
33
+ import os
34
+ from fastaar import FastaarClient
35
+
36
+ fastaar = FastaarClient(api_key=os.getenv('FASTAAR_API_KEY')) # fk_live_... or fk_test_...
37
+
38
+ # The key must have the `payments:write` ability (and not be expired) or this
39
+ # call returns a 403 `ability_denied` / 401 `authentication_error` error.
40
+ payment = fastaar.create_payment({
41
+ 'amount': 1250,
42
+ 'invoice_number': 'ORDER-42', # required — your order reference
43
+ 'customer_id': customer['id'] if customer else None, # optional — attach an existing customer
44
+ 'success_url': 'https://shop.example.com/thanks', # optional, customer returns here
45
+ 'cancel_url': 'https://shop.example.com/cart', # optional
46
+ })
47
+
48
+ # Redirect the customer to checkout
49
+ checkout_url = payment['checkout_url']
50
+ print(f"Redirecting customer to: {checkout_url}")
51
+ ```
52
+
53
+ `invoice_number` is idempotent: if a payment already exists for it and hasn't reached `failed`
54
+ or `expired`, creating another one raises a `FastaarException` with error type
55
+ `duplicate_invoice_number` (HTTP 409) instead of creating a duplicate — so a dropped connection
56
+ never double-charges. Use `find_by_invoice_number()` to look the existing payment up rather than
57
+ retrying blindly.
58
+
59
+ ## Confirm the order from a webhook
60
+
61
+ Verify the signature header to ensure webhook security:
62
+
63
+ ```python
64
+ import os
65
+ from fastaar import WebhookSignature, FastaarException
66
+
67
+ # Get raw request body as bytes/string and signature header from request
68
+ raw_body = request.body # must be the raw string or bytes of the request body
69
+ signature = request.headers.get('X-Fastaar-Signature', '')
70
+
71
+ secret = os.getenv('FASTAAR_WEBHOOK_SECRET')
72
+
73
+ if not WebhookSignature.verify(secret, raw_body, signature):
74
+ # Signature verification failed
75
+ return "Invalid signature", 400
76
+
77
+ # Parse the event
78
+ event = request.json()
79
+
80
+ if event['event'] == 'payment.completed':
81
+ order_id = event['data']['invoice_number']
82
+ payment_id = event['data']['id']
83
+ # Mark the order as paid idempotently using the payment_id as key
84
+ print(f"Payment completed: {payment_id} for invoice {order_id}")
85
+
86
+ return "OK", 200
87
+ ```
88
+
89
+ ## Other payment calls
90
+
91
+ ```python
92
+ # Retrieve one payment by its ID (e.g. "01jxyz...")
93
+ payment = fastaar.get_payment('01jxyz...')
94
+
95
+ # Look up most recent payment by your reference
96
+ payment = fastaar.find_by_invoice_number('ORDER-42')
97
+
98
+ # List payments
99
+ payments = fastaar.list_payments(params={'status': 'completed'})
100
+
101
+ # Refund a completed (or partially refunded) payment
102
+ payment = fastaar.refund_payment('01jxyz...') # refund the full remaining balance
103
+ payment = fastaar.refund_payment('01jxyz...', 200) # or refund only part of it
104
+ refunds = fastaar.list_refunds('01jxyz...') # full refund history, newest first
105
+ ```
106
+
107
+ ## Customers
108
+
109
+ Store customer records to attach them to payments collected via payment links.
110
+
111
+ ```python
112
+ # Create a customer — name and phone are required
113
+ customer = fastaar.create_customer({
114
+ 'name': 'Rahim Uddin',
115
+ 'phone': '01712345678',
116
+ 'email': 'rahim@example.com', # optional
117
+ 'address': 'Dhaka, Bangladesh', # optional
118
+ 'notes': 'VIP customer', # optional
119
+ })
120
+
121
+ # Retrieve, update, list
122
+ customer = fastaar.get_customer(customer['id'])
123
+ customer = fastaar.update_customer(customer['id'], {'name': 'Rahim Ahmed'})
124
+ customers = fastaar.list_customers({'email': 'rahim@example.com'})
125
+ ```
126
+
127
+ ## Error Handling
128
+
129
+ Errors raise `fastaar.FastaarException` with `error_type` (e.g. `authentication_error`, `subscription_required`, `transaction_limit_reached`, `connection_error`) and `status_code`.
130
+
131
+ ```python
132
+ from fastaar import FastaarException
133
+
134
+ try:
135
+ payment = fastaar.create_payment({'amount': 100, 'invoice_number': 'ORDER-42'})
136
+ except FastaarException as e:
137
+ print(f"API Error: {e}")
138
+ print(f"Type: {e.error_type}")
139
+ print(f"Status Code: {e.status_code}")
140
+ ```
141
+
142
+ ## Test mode
143
+
144
+ Use an `fk_test_` key: payments auto-complete on the checkout page without real money, and webhooks fire exactly like production with `"livemode": false`.
@@ -0,0 +1,8 @@
1
+ fastaar/__init__.py,sha256=BBPMU-Yv3JQ-9e_wvP21MuvFaLMwcxLJI7Fom0CLXX4,205
2
+ fastaar/client.py,sha256=ETxwyibVj3ighJVbLgf6MXlYMqeu9WJnJF2uNeqxqwU,9710
3
+ fastaar/exceptions.py,sha256=uVOXg7oc5ictqRuTJOuvfVY1vQ5DEI6JFWwLBv6mr4g,657
4
+ fastaar/signature.py,sha256=NAnXcjlmaG_jzGUIQnL4TMNUd4L5HwLWYSTw4v6SrFE,1568
5
+ fastaar_python-1.0.3.dist-info/METADATA,sha256=sAMCJpcqhDbfwmCa8m76hCLy3GDHV7C7RdbcbBlnGio,4920
6
+ fastaar_python-1.0.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ fastaar_python-1.0.3.dist-info/top_level.txt,sha256=DsGjqdIYVu85l7X2ZWZ-KbWlmlZ5-fRBWd5ar-yF2rM,8
8
+ fastaar_python-1.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fastaar