fastaar-python 1.0.3__tar.gz

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.
@@ -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,131 @@
1
+ # Fastaar Python SDK
2
+
3
+ Accept bKash & Nagad payments on any Python website or application via [Fastaar](https://fastaar.com).
4
+
5
+ This is a zero-dependency SDK utilizing Python's standard library.
6
+
7
+ ## Install
8
+
9
+ Install the package via `pip` (or configure it in your `requirements.txt` / `pyproject.toml`):
10
+
11
+ ```bash
12
+ pip install fastaar-python
13
+ ```
14
+
15
+ ## Create a payment & redirect to checkout
16
+
17
+ Here is an example using a generic Python web application:
18
+
19
+ ```python
20
+ import os
21
+ from fastaar import FastaarClient
22
+
23
+ fastaar = FastaarClient(api_key=os.getenv('FASTAAR_API_KEY')) # fk_live_... or fk_test_...
24
+
25
+ # The key must have the `payments:write` ability (and not be expired) or this
26
+ # call returns a 403 `ability_denied` / 401 `authentication_error` error.
27
+ payment = fastaar.create_payment({
28
+ 'amount': 1250,
29
+ 'invoice_number': 'ORDER-42', # required — your order reference
30
+ 'customer_id': customer['id'] if customer else None, # optional — attach an existing customer
31
+ 'success_url': 'https://shop.example.com/thanks', # optional, customer returns here
32
+ 'cancel_url': 'https://shop.example.com/cart', # optional
33
+ })
34
+
35
+ # Redirect the customer to checkout
36
+ checkout_url = payment['checkout_url']
37
+ print(f"Redirecting customer to: {checkout_url}")
38
+ ```
39
+
40
+ `invoice_number` is idempotent: if a payment already exists for it and hasn't reached `failed`
41
+ or `expired`, creating another one raises a `FastaarException` with error type
42
+ `duplicate_invoice_number` (HTTP 409) instead of creating a duplicate — so a dropped connection
43
+ never double-charges. Use `find_by_invoice_number()` to look the existing payment up rather than
44
+ retrying blindly.
45
+
46
+ ## Confirm the order from a webhook
47
+
48
+ Verify the signature header to ensure webhook security:
49
+
50
+ ```python
51
+ import os
52
+ from fastaar import WebhookSignature, FastaarException
53
+
54
+ # Get raw request body as bytes/string and signature header from request
55
+ raw_body = request.body # must be the raw string or bytes of the request body
56
+ signature = request.headers.get('X-Fastaar-Signature', '')
57
+
58
+ secret = os.getenv('FASTAAR_WEBHOOK_SECRET')
59
+
60
+ if not WebhookSignature.verify(secret, raw_body, signature):
61
+ # Signature verification failed
62
+ return "Invalid signature", 400
63
+
64
+ # Parse the event
65
+ event = request.json()
66
+
67
+ if event['event'] == 'payment.completed':
68
+ order_id = event['data']['invoice_number']
69
+ payment_id = event['data']['id']
70
+ # Mark the order as paid idempotently using the payment_id as key
71
+ print(f"Payment completed: {payment_id} for invoice {order_id}")
72
+
73
+ return "OK", 200
74
+ ```
75
+
76
+ ## Other payment calls
77
+
78
+ ```python
79
+ # Retrieve one payment by its ID (e.g. "01jxyz...")
80
+ payment = fastaar.get_payment('01jxyz...')
81
+
82
+ # Look up most recent payment by your reference
83
+ payment = fastaar.find_by_invoice_number('ORDER-42')
84
+
85
+ # List payments
86
+ payments = fastaar.list_payments(params={'status': 'completed'})
87
+
88
+ # Refund a completed (or partially refunded) payment
89
+ payment = fastaar.refund_payment('01jxyz...') # refund the full remaining balance
90
+ payment = fastaar.refund_payment('01jxyz...', 200) # or refund only part of it
91
+ refunds = fastaar.list_refunds('01jxyz...') # full refund history, newest first
92
+ ```
93
+
94
+ ## Customers
95
+
96
+ Store customer records to attach them to payments collected via payment links.
97
+
98
+ ```python
99
+ # Create a customer — name and phone are required
100
+ customer = fastaar.create_customer({
101
+ 'name': 'Rahim Uddin',
102
+ 'phone': '01712345678',
103
+ 'email': 'rahim@example.com', # optional
104
+ 'address': 'Dhaka, Bangladesh', # optional
105
+ 'notes': 'VIP customer', # optional
106
+ })
107
+
108
+ # Retrieve, update, list
109
+ customer = fastaar.get_customer(customer['id'])
110
+ customer = fastaar.update_customer(customer['id'], {'name': 'Rahim Ahmed'})
111
+ customers = fastaar.list_customers({'email': 'rahim@example.com'})
112
+ ```
113
+
114
+ ## Error Handling
115
+
116
+ Errors raise `fastaar.FastaarException` with `error_type` (e.g. `authentication_error`, `subscription_required`, `transaction_limit_reached`, `connection_error`) and `status_code`.
117
+
118
+ ```python
119
+ from fastaar import FastaarException
120
+
121
+ try:
122
+ payment = fastaar.create_payment({'amount': 100, 'invoice_number': 'ORDER-42'})
123
+ except FastaarException as e:
124
+ print(f"API Error: {e}")
125
+ print(f"Type: {e.error_type}")
126
+ print(f"Status Code: {e.status_code}")
127
+ ```
128
+
129
+ ## Test mode
130
+
131
+ 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,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"]
@@ -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
@@ -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
@@ -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,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ fastaar/__init__.py
4
+ fastaar/client.py
5
+ fastaar/exceptions.py
6
+ fastaar/signature.py
7
+ fastaar_python.egg-info/PKG-INFO
8
+ fastaar_python.egg-info/SOURCES.txt
9
+ fastaar_python.egg-info/dependency_links.txt
10
+ fastaar_python.egg-info/requires.txt
11
+ fastaar_python.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_signature.py
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastaar-python"
7
+ version = "1.0.3"
8
+ description = "Python SDK for the Fastaar payment gateway — accept bKash & Nagad payments in Bangladesh."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = []
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=7.0"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["."]
24
+ include = ["fastaar*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,164 @@
1
+ import io
2
+ import json
3
+ import unittest
4
+ from urllib.error import HTTPError, URLError
5
+ from unittest.mock import patch, MagicMock
6
+
7
+ from fastaar import FastaarClient, FastaarException
8
+
9
+
10
+ class TestFastaarClient(unittest.TestCase):
11
+ def setUp(self) -> None:
12
+ self.api_key = "fk_test_12345"
13
+ self.client = FastaarClient(api_key=self.api_key)
14
+
15
+ def _mock_response(self, status_code: int, body: str) -> MagicMock:
16
+ mock_resp = MagicMock()
17
+ mock_resp.getcode.return_value = status_code
18
+ mock_resp.read.return_value = body.encode("utf-8")
19
+ mock_resp.__enter__.return_value = mock_resp
20
+ return mock_resp
21
+
22
+ @patch("urllib.request.urlopen")
23
+ def test_create_payment_success(self, mock_urlopen: MagicMock) -> None:
24
+ response_data = {
25
+ "data": {
26
+ "id": "pay_01jxyz",
27
+ "status": "pending",
28
+ "amount": 1250,
29
+ "invoice_number": "ORDER-42",
30
+ "checkout_url": "https://fastaar.test/checkout/01jxyz"
31
+ }
32
+ }
33
+ mock_urlopen.return_value = self._mock_response(201, json.dumps(response_data))
34
+
35
+ params = {
36
+ "amount": 1250,
37
+ "invoice_number": "ORDER-42",
38
+ "success_url": "https://shop.example.com/thanks"
39
+ }
40
+ payment = self.client.create_payment(params)
41
+
42
+ # Verify response matches
43
+ self.assertEqual(payment["id"], "pay_01jxyz")
44
+ self.assertEqual(payment["checkout_url"], "https://fastaar.test/checkout/01jxyz")
45
+
46
+ # Verify request parameters
47
+ args, kwargs = mock_urlopen.call_args
48
+ req = args[0]
49
+ self.assertEqual(req.get_full_url(), "https://fastaar.com/api/v1/payments")
50
+ self.assertEqual(req.method, "POST")
51
+ self.assertEqual(req.headers["Authorization"], f"Bearer {self.api_key}")
52
+ self.assertEqual(req.headers["Content-type"], "application/json")
53
+ self.assertEqual(req.headers["Accept"], "application/json")
54
+ self.assertEqual(json.loads(req.data.decode("utf-8")), params)
55
+
56
+ @patch("urllib.request.urlopen")
57
+ def test_get_payment_success(self, mock_urlopen: MagicMock) -> None:
58
+ response_data = {
59
+ "data": {
60
+ "id": "pay/01jxyz", # contains a slash to test encoding
61
+ "status": "completed"
62
+ }
63
+ }
64
+ mock_urlopen.return_value = self._mock_response(200, json.dumps(response_data))
65
+
66
+ payment = self.client.get_payment("pay/01jxyz")
67
+
68
+ self.assertEqual(payment["id"], "pay/01jxyz")
69
+ self.assertEqual(payment["status"], "completed")
70
+
71
+ args, _ = mock_urlopen.call_args
72
+ req = args[0]
73
+ # pay/01jxyz should be URL encoded as pay%2F01jxyz
74
+ self.assertEqual(req.get_full_url(), "https://fastaar.com/api/v1/payments/pay%2F01jxyz")
75
+ self.assertEqual(req.method, "GET")
76
+
77
+ @patch("urllib.request.urlopen")
78
+ def test_list_payments_success(self, mock_urlopen: MagicMock) -> None:
79
+ response_data = {
80
+ "data": [
81
+ {"id": "1", "status": "completed"},
82
+ {"id": "2", "status": "pending"}
83
+ ]
84
+ }
85
+ mock_urlopen.return_value = self._mock_response(200, json.dumps(response_data))
86
+
87
+ payments = self.client.list_payments({"status": "completed", "page": 2})
88
+
89
+ self.assertEqual(len(payments), 2)
90
+ self.assertEqual(payments[0]["id"], "1")
91
+
92
+ args, _ = mock_urlopen.call_args
93
+ req = args[0]
94
+ # Query parameters should be correctly encoded
95
+ self.assertIn("https://fastaar.com/api/v1/payments?", req.get_full_url())
96
+ self.assertIn("status=completed", req.get_full_url())
97
+ self.assertIn("page=2", req.get_full_url())
98
+ self.assertEqual(req.method, "GET")
99
+
100
+ @patch("urllib.request.urlopen")
101
+ def test_find_by_invoice_number_found(self, mock_urlopen: MagicMock) -> None:
102
+ response_data = {
103
+ "data": [
104
+ {"id": "pay_01jxyz", "invoice_number": "ORDER-42"}
105
+ ]
106
+ }
107
+ mock_urlopen.return_value = self._mock_response(200, json.dumps(response_data))
108
+
109
+ payment = self.client.find_by_invoice_number("ORDER-42")
110
+
111
+ self.assertIsNotNone(payment)
112
+ self.assertEqual(payment["id"], "pay_01jxyz")
113
+
114
+ @patch("urllib.request.urlopen")
115
+ def test_find_by_invoice_number_not_found(self, mock_urlopen: MagicMock) -> None:
116
+ response_data = {"data": []}
117
+ mock_urlopen.return_value = self._mock_response(200, json.dumps(response_data))
118
+
119
+ payment = self.client.find_by_invoice_number("ORDER-42")
120
+
121
+ self.assertIsNull = self.assertIsNone(payment)
122
+
123
+ @patch("urllib.request.urlopen")
124
+ def test_request_error_400(self, mock_urlopen: MagicMock) -> None:
125
+ error_data = {
126
+ "error": {
127
+ "message": "The amount field is required.",
128
+ "type": "validation_error"
129
+ }
130
+ }
131
+ # Simulate HTTP Error in urlopen
132
+ fp = io.BytesIO(json.dumps(error_data).encode("utf-8"))
133
+ mock_urlopen.side_effect = HTTPError("url", 400, "Bad Request", {}, fp)
134
+
135
+ with self.assertRaises(FastaarException) as context:
136
+ self.client.create_payment({})
137
+
138
+ self.assertEqual(context.exception.status_code, 400)
139
+ self.assertEqual(context.exception.error_type, "validation_error")
140
+ self.assertEqual(str(context.exception), "The amount field is required.")
141
+
142
+ @patch("urllib.request.urlopen")
143
+ def test_request_error_generic_500(self, mock_urlopen: MagicMock) -> None:
144
+ # Simulate generic server error without JSON format
145
+ fp = io.BytesIO(b"Internal Server Error")
146
+ mock_urlopen.side_effect = HTTPError("url", 500, "Internal Server Error", {}, fp)
147
+
148
+ with self.assertRaises(FastaarException) as context:
149
+ self.client.create_payment({})
150
+
151
+ self.assertEqual(context.exception.status_code, 500)
152
+ self.assertEqual(context.exception.error_type, "api_error")
153
+ self.assertEqual(str(context.exception), "Fastaar API returned HTTP 500.")
154
+
155
+ @patch("urllib.request.urlopen")
156
+ def test_request_connection_error(self, mock_urlopen: MagicMock) -> None:
157
+ mock_urlopen.side_effect = URLError("connection refused")
158
+
159
+ with self.assertRaises(FastaarException) as context:
160
+ self.client.create_payment({})
161
+
162
+ self.assertEqual(context.exception.status_code, 0)
163
+ self.assertEqual(context.exception.error_type, "connection_error")
164
+ self.assertIn("connection refused", str(context.exception))
@@ -0,0 +1,82 @@
1
+ import hashlib
2
+ import hmac
3
+ import time
4
+ import unittest
5
+
6
+ from fastaar.signature import WebhookSignature
7
+
8
+
9
+ class TestWebhookSignature(unittest.TestCase):
10
+ def setUp(self) -> None:
11
+ self.secret = "super-secret-key"
12
+ self.raw_body = '{"event":"payment.completed","data":{"invoice_number":"ORDER-42"}}'
13
+ self.timestamp = int(time.time())
14
+
15
+ def _generate_signature(self, secret: str, timestamp: int, body: str) -> str:
16
+ payload = f"{timestamp}.{body}".encode("utf-8")
17
+ h = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256)
18
+ return f"t={timestamp},v1={h.hexdigest()}"
19
+
20
+ def test_verify_valid_signature_string(self) -> None:
21
+ sig = self._generate_signature(self.secret, self.timestamp, self.raw_body)
22
+ self.assertTrue(
23
+ WebhookSignature.verify(self.secret, self.raw_body, sig)
24
+ )
25
+
26
+ def test_verify_valid_signature_bytes(self) -> None:
27
+ sig = self._generate_signature(self.secret, self.timestamp, self.raw_body)
28
+ # Verify it works when raw_body is bytes
29
+ self.assertTrue(
30
+ WebhookSignature.verify(self.secret, self.raw_body.encode("utf-8"), sig)
31
+ )
32
+ # Verify it works when secret is bytes
33
+ self.assertTrue(
34
+ WebhookSignature.verify(self.secret.encode("utf-8"), self.raw_body, sig)
35
+ )
36
+
37
+ def test_verify_invalid_secret(self) -> None:
38
+ sig = self._generate_signature(self.secret, self.timestamp, self.raw_body)
39
+ self.assertFalse(
40
+ WebhookSignature.verify("wrong-secret", self.raw_body, sig)
41
+ )
42
+
43
+ def test_verify_invalid_body(self) -> None:
44
+ sig = self._generate_signature(self.secret, self.timestamp, self.raw_body)
45
+ self.assertFalse(
46
+ WebhookSignature.verify(self.secret, "modified-body", sig)
47
+ )
48
+
49
+ def test_verify_expired_timestamp(self) -> None:
50
+ expired_ts = self.timestamp - 301
51
+ sig = self._generate_signature(self.secret, expired_ts, self.raw_body)
52
+ self.assertFalse(
53
+ WebhookSignature.verify(self.secret, self.raw_body, sig)
54
+ )
55
+
56
+ def test_verify_future_timestamp_within_tolerance(self) -> None:
57
+ future_ts = self.timestamp + 100
58
+ sig = self._generate_signature(self.secret, future_ts, self.raw_body)
59
+ self.assertTrue(
60
+ WebhookSignature.verify(self.secret, self.raw_body, sig)
61
+ )
62
+
63
+ def test_verify_future_timestamp_outside_tolerance(self) -> None:
64
+ future_ts = self.timestamp + 301
65
+ sig = self._generate_signature(self.secret, future_ts, self.raw_body)
66
+ self.assertFalse(
67
+ WebhookSignature.verify(self.secret, self.raw_body, sig)
68
+ )
69
+
70
+ def test_verify_invalid_signature_format(self) -> None:
71
+ self.assertFalse(
72
+ WebhookSignature.verify(self.secret, self.raw_body, "invalid-format")
73
+ )
74
+ self.assertFalse(
75
+ WebhookSignature.verify(self.secret, self.raw_body, f"t={self.timestamp}")
76
+ )
77
+ self.assertFalse(
78
+ WebhookSignature.verify(self.secret, self.raw_body, "t=abc,v1=abcdef")
79
+ )
80
+ self.assertFalse(
81
+ WebhookSignature.verify(self.secret, self.raw_body, "")
82
+ )