smartpaystack 0.1.2__tar.gz → 0.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartpaystack
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A smart, strategy-based, multi-currency Paystack SDK for Python.
5
5
  Author-email: Fidelis Chukwunyere <fidelchukwunyere@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "smartpaystack"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "A smart, strategy-based, multi-currency Paystack SDK for Python."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Fidelis Chukwunyere", email = "fidelchukwunyere@gmail.com" }]
@@ -26,5 +26,6 @@ classifiers = [
26
26
  [project.urls]
27
27
  Homepage = "https://github.com/fidel-c/smartpaystack"
28
28
 
29
- [tool.setuptools]
30
- py-modules = ["calculator", "client", "config", "enums", "exceptions", "webhooks"]
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["smartpaystack*"]
@@ -0,0 +1,18 @@
1
+ from .client import SmartPaystack
2
+ from .webhooks import WebhookVerifier
3
+ from .enums import ChargeStrategy, Currency, RecipientType, TransferSource
4
+ from .exceptions import PaystackError, PaystackAPIError, WebhookVerificationError
5
+
6
+ __version__ = "0.1.3"
7
+
8
+ __all__ = [
9
+ "SmartPaystack",
10
+ "WebhookVerifier",
11
+ "ChargeStrategy",
12
+ "Currency",
13
+ "RecipientType",
14
+ "TransferSource",
15
+ "PaystackError",
16
+ "PaystackAPIError",
17
+ "WebhookVerificationError"
18
+ ]
@@ -0,0 +1,37 @@
1
+ from decimal import Decimal, ROUND_HALF_UP
2
+ from .enums import Currency
3
+ from .config import CURRENCY_RULES
4
+
5
+ class PaystackFeeCalculator:
6
+ """Calculates Paystack transaction fees accurately to avoid floating-point errors."""
7
+
8
+ @classmethod
9
+ def calculate_fee(cls, amount: Decimal, currency: Currency = Currency.NGN) -> Decimal:
10
+ rule = CURRENCY_RULES.get(currency, CURRENCY_RULES[Currency.NGN])
11
+ amount = Decimal(str(amount))
12
+
13
+ fee = amount * rule["percentage"]
14
+
15
+ if rule["flat_fee"] > 0 and amount >= rule["threshold"]:
16
+ fee += rule["flat_fee"]
17
+
18
+ if rule["cap"] is not None:
19
+ fee = min(fee, rule["cap"])
20
+
21
+ return fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
22
+
23
+ @classmethod
24
+ def gross_up_amount(cls, desired_amount: Decimal, currency: Currency = Currency.NGN) -> Decimal:
25
+ """Calculates the exact amount to charge a customer so the merchant receives the `desired_amount`."""
26
+ rule = CURRENCY_RULES.get(currency, CURRENCY_RULES[Currency.NGN])
27
+ desired_amount = Decimal(str(desired_amount))
28
+
29
+ gross = desired_amount / (Decimal("1") - rule["percentage"])
30
+
31
+ if rule["flat_fee"] > 0 and gross >= rule["threshold"]:
32
+ gross = (desired_amount + rule["flat_fee"]) / (Decimal("1") - rule["percentage"])
33
+
34
+ if rule["cap"] is not None and (gross - desired_amount) > rule["cap"]:
35
+ gross = desired_amount + rule["cap"]
36
+
37
+ return gross.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
@@ -0,0 +1,127 @@
1
+ import os
2
+ import uuid
3
+ import requests
4
+ from decimal import Decimal
5
+ from typing import Optional, Dict, Any, Union
6
+
7
+ from .calculator import PaystackFeeCalculator
8
+ from .enums import ChargeStrategy, Currency, RecipientType, TransferSource
9
+ from .exceptions import PaystackAPIError
10
+
11
+ class SmartPaystack:
12
+ """The main client for interacting with the Paystack API."""
13
+ BASE_URL = "https://api.paystack.co"
14
+
15
+ def __init__(self, secret_key: Optional[str] = None) -> None:
16
+ self.secret_key = secret_key or os.environ.get("PAYSTACK_SECRET_KEY")
17
+
18
+ if not self.secret_key:
19
+ raise ValueError(
20
+ "Paystack secret key missing. Pass it to the client "
21
+ "or set the 'PAYSTACK_SECRET_KEY' environment variable."
22
+ )
23
+
24
+ self.headers = {
25
+ "Authorization": f"Bearer {self.secret_key}",
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+ def _request(self, method: str, endpoint: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Dict[str, Any]:
30
+ url = f"{self.BASE_URL}{endpoint}"
31
+ try:
32
+ response = requests.request(
33
+ method, url, json=data, params=params, headers=self.headers, timeout=30
34
+ )
35
+ result = response.json()
36
+ except requests.RequestException as e:
37
+ raise PaystackAPIError(f"Network error: {str(e)}")
38
+ except ValueError:
39
+ raise PaystackAPIError("Invalid JSON response from Paystack.")
40
+
41
+ if not response.ok or not result.get("status"):
42
+ raise PaystackAPIError(result.get("message", "API Request Failed"))
43
+
44
+ return result.get("data", result)
45
+
46
+ def create_charge(
47
+ self,
48
+ email: str,
49
+ amount: Union[int, float, Decimal],
50
+ currency: Currency = Currency.NGN,
51
+ charge_strategy: ChargeStrategy = ChargeStrategy.ABSORB,
52
+ split_ratio: float = 0.5,
53
+ metadata: Optional[Dict[str, Any]] = None,
54
+ **kwargs: Any
55
+ ) -> Dict[str, Any]:
56
+ """Initializes a transaction applying the correct fee strategy automatically."""
57
+ amount = Decimal(str(amount))
58
+ fee = PaystackFeeCalculator.calculate_fee(amount, currency)
59
+
60
+ if charge_strategy == ChargeStrategy.PASS:
61
+ customer_amount = PaystackFeeCalculator.gross_up_amount(amount, currency)
62
+ elif charge_strategy == ChargeStrategy.SPLIT:
63
+ customer_amount = amount + (fee * Decimal(str(split_ratio)))
64
+ else:
65
+ customer_amount = amount
66
+
67
+ payload = {
68
+ "email": email,
69
+ "amount": int(customer_amount * 100), # Convert to lowest denomination (kobo/cents)
70
+ "currency": currency.value,
71
+ "reference": kwargs.pop("reference", str(uuid.uuid4())),
72
+ "metadata": {
73
+ "smartpaystack_strategy": charge_strategy.value,
74
+ "merchant_expected": float(amount),
75
+ "customer_amount": float(customer_amount),
76
+ **(metadata or {}),
77
+ },
78
+ **kwargs
79
+ }
80
+ return self._request("POST", "/transaction/initialize", data=payload)
81
+
82
+ def resolve_account_number(self, account_number: str, bank_code: str) -> Dict[str, Any]:
83
+ """Verifies an account number and bank code before creating a recipient."""
84
+ params = {"account_number": account_number, "bank_code": bank_code}
85
+ return self._request("GET", "/bank/resolve", params=params)
86
+
87
+ def create_transfer_recipient(
88
+ self,
89
+ name: str,
90
+ account_number: str,
91
+ bank_code: str,
92
+ recipient_type: RecipientType = RecipientType.NUBAN,
93
+ currency: Currency = Currency.NGN,
94
+ description: str = "",
95
+ metadata: Optional[Dict[str, Any]] = None
96
+ ) -> Dict[str, Any]:
97
+ """Creates a recipient code needed to initiate a transfer."""
98
+ payload = {
99
+ "type": recipient_type.value,
100
+ "name": name,
101
+ "account_number": account_number,
102
+ "bank_code": bank_code,
103
+ "currency": currency.value,
104
+ "description": description,
105
+ "metadata": metadata or {}
106
+ }
107
+ return self._request("POST", "/transferrecipient", data=payload)
108
+
109
+ def initiate_transfer(
110
+ self,
111
+ amount: Union[int, float, Decimal],
112
+ recipient_code: str,
113
+ currency: Currency = Currency.NGN,
114
+ source: TransferSource = TransferSource.BALANCE,
115
+ reason: str = "",
116
+ reference: Optional[str] = None
117
+ ) -> Dict[str, Any]:
118
+ """Initiates a transfer from your Paystack balance to the recipient."""
119
+ payload = {
120
+ "source": source.value,
121
+ "amount": int(Decimal(str(amount)) * 100), # Convert to kobo/cents
122
+ "currency": currency.value,
123
+ "recipient": recipient_code,
124
+ "reason": reason,
125
+ "reference": reference or str(uuid.uuid4())
126
+ }
127
+ return self._request("POST", "/transfer", data=payload)
@@ -0,0 +1,30 @@
1
+ from decimal import Decimal
2
+ from .enums import Currency
3
+
4
+ # Official Paystack fee structures per currency
5
+ CURRENCY_RULES = {
6
+ Currency.NGN: {
7
+ "percentage": Decimal("0.015"),
8
+ "flat_fee": Decimal("100"),
9
+ "threshold": Decimal("2500"),
10
+ "cap": Decimal("2000"),
11
+ },
12
+ Currency.GHS: {
13
+ "percentage": Decimal("0.0195"),
14
+ "flat_fee": Decimal("0"),
15
+ "threshold": Decimal("0"),
16
+ "cap": None,
17
+ },
18
+ Currency.ZAR: {
19
+ "percentage": Decimal("0.029"),
20
+ "flat_fee": Decimal("1"),
21
+ "threshold": Decimal("0"),
22
+ "cap": None,
23
+ },
24
+ Currency.KES: {
25
+ "percentage": Decimal("0.029"),
26
+ "flat_fee": Decimal("0"),
27
+ "threshold": Decimal("0"),
28
+ "cap": None,
29
+ },
30
+ }
@@ -0,0 +1,21 @@
1
+ from enum import Enum
2
+
3
+ class ChargeStrategy(str, Enum):
4
+ ABSORB = "absorb"
5
+ PASS = "pass"
6
+ SPLIT = "split"
7
+
8
+ class Currency(str, Enum):
9
+ NGN = "NGN"
10
+ GHS = "GHS"
11
+ ZAR = "ZAR"
12
+ KES = "KES"
13
+ USD = "USD"
14
+
15
+ class RecipientType(str, Enum):
16
+ NUBAN = "nuban"
17
+ MOBILE_MONEY = "mobile_money"
18
+ BASA = "basa"
19
+
20
+ class TransferSource(str, Enum):
21
+ BALANCE = "balance"
@@ -0,0 +1,11 @@
1
+ class PaystackError(Exception):
2
+ """Base exception for all SmartPaystack errors."""
3
+ pass
4
+
5
+ class PaystackAPIError(PaystackError):
6
+ """Raised when the Paystack API returns an error or fails."""
7
+ pass
8
+
9
+ class WebhookVerificationError(PaystackError):
10
+ """Raised when a webhook signature fails validation."""
11
+ pass
@@ -0,0 +1,26 @@
1
+ import hmac
2
+ import hashlib
3
+ import json
4
+ from typing import Dict, Any
5
+ from .exceptions import WebhookVerificationError
6
+
7
+ class WebhookVerifier:
8
+ """A framework-agnostic utility for validating Paystack webhook signatures."""
9
+
10
+ def __init__(self, secret_key: str):
11
+ self.secret_key = secret_key.encode("utf-8")
12
+
13
+ def verify_and_parse(self, payload: bytes, signature: str) -> Dict[str, Any]:
14
+ if not signature:
15
+ raise WebhookVerificationError("Missing Paystack signature header")
16
+
17
+ # Paystack uses HMAC SHA512 to sign webhooks
18
+ hash_obj = hmac.new(self.secret_key, msg=payload, digestmod=hashlib.sha512)
19
+
20
+ if not hmac.compare_digest(hash_obj.hexdigest(), signature):
21
+ raise WebhookVerificationError("Invalid Paystack webhook signature")
22
+
23
+ try:
24
+ return json.loads(payload)
25
+ except json.JSONDecodeError:
26
+ raise WebhookVerificationError("Invalid JSON payload")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartpaystack
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A smart, strategy-based, multi-currency Paystack SDK for Python.
5
5
  Author-email: Fidelis Chukwunyere <fidelchukwunyere@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ smartpaystack/__init__.py
4
+ smartpaystack/calculator.py
5
+ smartpaystack/client.py
6
+ smartpaystack/config.py
7
+ smartpaystack/enums.py
8
+ smartpaystack/exceptions.py
9
+ smartpaystack/webhooks.py
10
+ smartpaystack.egg-info/PKG-INFO
11
+ smartpaystack.egg-info/SOURCES.txt
12
+ smartpaystack.egg-info/dependency_links.txt
13
+ smartpaystack.egg-info/requires.txt
14
+ smartpaystack.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ smartpaystack
@@ -1,7 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- smartpaystack.egg-info/PKG-INFO
4
- smartpaystack.egg-info/SOURCES.txt
5
- smartpaystack.egg-info/dependency_links.txt
6
- smartpaystack.egg-info/requires.txt
7
- smartpaystack.egg-info/top_level.txt
@@ -1,6 +0,0 @@
1
- calculator
2
- client
3
- config
4
- enums
5
- exceptions
6
- webhooks
File without changes
File without changes