reevit 0.9.1__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.
- reevit/__init__.py +11 -0
- reevit/_version.py +6 -0
- reevit/client.py +92 -0
- reevit/services/__init__.py +2 -0
- reevit/services/checkout_sessions.py +10 -0
- reevit/services/connections.py +55 -0
- reevit/services/customers.py +39 -0
- reevit/services/fraud.py +11 -0
- reevit/services/invoices.py +30 -0
- reevit/services/payment_links.py +42 -0
- reevit/services/payments.py +54 -0
- reevit/services/routing_rules.py +30 -0
- reevit/services/subscriptions.py +27 -0
- reevit/services/webhooks.py +43 -0
- reevit/webhooks.py +53 -0
- reevit-0.9.1.dist-info/METADATA +452 -0
- reevit-0.9.1.dist-info/RECORD +20 -0
- reevit-0.9.1.dist-info/WHEEL +5 -0
- reevit-0.9.1.dist-info/licenses/LICENSE +21 -0
- reevit-0.9.1.dist-info/top_level.txt +1 -0
reevit/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import Reevit, ReevitAPIError
|
|
3
|
+
from .webhooks import sign_webhook_payload, verify_webhook_signature
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Reevit",
|
|
7
|
+
"ReevitAPIError",
|
|
8
|
+
"verify_webhook_signature",
|
|
9
|
+
"sign_webhook_payload",
|
|
10
|
+
"__version__",
|
|
11
|
+
]
|
reevit/_version.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Single source of truth for the SDK version.
|
|
2
|
+
# Read by setup.py (via exec, without importing the package) and by
|
|
3
|
+
# client.py for the X-Reevit-Client-Version header. Keep this file
|
|
4
|
+
# free of imports so it stays loadable in build environments where
|
|
5
|
+
# runtime dependencies are not installed.
|
|
6
|
+
__version__ = "0.9.1"
|
reevit/client.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .services.payments import PaymentsService
|
|
6
|
+
from .services.connections import ConnectionsService
|
|
7
|
+
from .services.subscriptions import SubscriptionsService
|
|
8
|
+
from .services.fraud import FraudService
|
|
9
|
+
from .services.customers import CustomersService
|
|
10
|
+
from .services.payment_links import PaymentLinksService
|
|
11
|
+
from .services.checkout_sessions import CheckoutSessionsService
|
|
12
|
+
from .services.webhooks import WebhooksService
|
|
13
|
+
from .services.routing_rules import RoutingRulesService
|
|
14
|
+
from .services.invoices import InvoicesService
|
|
15
|
+
|
|
16
|
+
API_BASE_URL_PRODUCTION = 'https://api.reevit.io'
|
|
17
|
+
DEFAULT_TIMEOUT = 30
|
|
18
|
+
|
|
19
|
+
class ReevitAPIError(Exception):
|
|
20
|
+
def __init__(self, status_code: int, message: str, code: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.code = code or "api_error"
|
|
23
|
+
self.details = details or {}
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
def is_sandbox_key(api_key: str) -> bool:
|
|
27
|
+
return api_key.startswith('pfk_test_')
|
|
28
|
+
|
|
29
|
+
class Reevit:
|
|
30
|
+
def __init__(self, api_key: str, org_id: Optional[str] = None, base_url: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT):
|
|
31
|
+
if base_url is None:
|
|
32
|
+
base_url = API_BASE_URL_PRODUCTION
|
|
33
|
+
self.session = requests.Session()
|
|
34
|
+
self.session.headers.update({
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"User-Agent": "@reevit/python",
|
|
37
|
+
"X-Reevit-Key": api_key,
|
|
38
|
+
"X-Reevit-Client": "@reevit/python",
|
|
39
|
+
"X-Reevit-Client-Version": __version__,
|
|
40
|
+
})
|
|
41
|
+
if org_id:
|
|
42
|
+
self.session.headers["X-Org-Id"] = org_id
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.org_id = org_id
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
|
|
47
|
+
self.payments = PaymentsService(self)
|
|
48
|
+
self.connections = ConnectionsService(self)
|
|
49
|
+
self.subscriptions = SubscriptionsService(self)
|
|
50
|
+
self.fraud = FraudService(self)
|
|
51
|
+
self.customers = CustomersService(self)
|
|
52
|
+
self.payment_links = PaymentLinksService(self)
|
|
53
|
+
self.checkout_sessions = CheckoutSessionsService(self)
|
|
54
|
+
self.webhooks = WebhooksService(self)
|
|
55
|
+
self.routing_rules = RoutingRulesService(self)
|
|
56
|
+
self.invoices = InvoicesService(self)
|
|
57
|
+
|
|
58
|
+
def request(self, method: str, path: str, **kwargs) -> Any:
|
|
59
|
+
if not path.startswith("/v1/pay/") and not self.org_id:
|
|
60
|
+
warnings.warn(
|
|
61
|
+
"org_id should be provided for authenticated Reevit API requests; org-less usage is deprecated.",
|
|
62
|
+
DeprecationWarning,
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
url = f"{self.base_url}{path}"
|
|
66
|
+
if 'timeout' not in kwargs:
|
|
67
|
+
kwargs['timeout'] = self.timeout
|
|
68
|
+
headers = kwargs.pop('headers', None)
|
|
69
|
+
if headers:
|
|
70
|
+
merged_headers = dict(self.session.headers)
|
|
71
|
+
merged_headers.update(headers)
|
|
72
|
+
kwargs['headers'] = merged_headers
|
|
73
|
+
response = self.session.request(method, url, **kwargs)
|
|
74
|
+
if response.status_code == 204:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if not response.ok:
|
|
78
|
+
try:
|
|
79
|
+
payload = response.json()
|
|
80
|
+
except ValueError:
|
|
81
|
+
payload = {}
|
|
82
|
+
raise ReevitAPIError(
|
|
83
|
+
response.status_code,
|
|
84
|
+
payload.get("message") or response.text or "request failed",
|
|
85
|
+
payload.get("code"),
|
|
86
|
+
payload.get("details"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return response.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CheckoutSessionsService:
|
|
5
|
+
def __init__(self, client):
|
|
6
|
+
self.client = client
|
|
7
|
+
|
|
8
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
9
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
10
|
+
return self.client.request("POST", "/v1/checkout/sessions", json=data, headers=headers)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class ConnectionsService:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
8
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
9
|
+
return self.client.request("POST", "/v1/connections", json=data, headers=headers)
|
|
10
|
+
|
|
11
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
12
|
+
response = self.client.request("GET", "/v1/connections", params=params)
|
|
13
|
+
if isinstance(response, dict):
|
|
14
|
+
return response.get("connections", [])
|
|
15
|
+
return response
|
|
16
|
+
|
|
17
|
+
def get(self, connection_id: str) -> Dict[str, Any]:
|
|
18
|
+
return self.client.request("GET", f"/v1/connections/{connection_id}")
|
|
19
|
+
|
|
20
|
+
def delete(self, connection_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
21
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
22
|
+
self.client.request("DELETE", f"/v1/connections/{connection_id}", headers=headers)
|
|
23
|
+
|
|
24
|
+
def validate(self, connection_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
26
|
+
return self.client.request("POST", f"/v1/connections/{connection_id}/validate", headers=headers)
|
|
27
|
+
|
|
28
|
+
def list_audit(self, connection_id: str, **params: Any) -> List[Dict[str, Any]]:
|
|
29
|
+
response = self.client.request("GET", f"/v1/connections/{connection_id}/audit", params=params)
|
|
30
|
+
if isinstance(response, dict):
|
|
31
|
+
return response.get("audit", [])
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
def update_labels(self, connection_id: str, labels: List[str], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
35
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
36
|
+
return self.client.request(
|
|
37
|
+
"PATCH",
|
|
38
|
+
f"/v1/connections/{connection_id}/labels",
|
|
39
|
+
json={"labels": labels},
|
|
40
|
+
headers=headers,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def update_status(self, connection_id: str, status: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
44
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
45
|
+
return self.client.request(
|
|
46
|
+
"PATCH",
|
|
47
|
+
f"/v1/connections/{connection_id}/status",
|
|
48
|
+
json={"status": status},
|
|
49
|
+
headers=headers,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> bool:
|
|
53
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
54
|
+
resp = self.client.request("POST", "/v1/connections/test", json=data, headers=headers)
|
|
55
|
+
return resp.get("success", False)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
|
|
5
|
+
if isinstance(payload, dict):
|
|
6
|
+
return payload.get(key, [])
|
|
7
|
+
return payload or []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CustomersService:
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
15
|
+
return _extract_list(self.client.request("GET", "/v1/customers", params=params), "customers")
|
|
16
|
+
|
|
17
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
19
|
+
return self.client.request("POST", "/v1/customers", json=data, headers=headers)
|
|
20
|
+
|
|
21
|
+
def get(self, customer_id: str) -> Dict[str, Any]:
|
|
22
|
+
return self.client.request("GET", f"/v1/customers/{customer_id}")
|
|
23
|
+
|
|
24
|
+
def update(self, customer_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
26
|
+
return self.client.request("PATCH", f"/v1/customers/{customer_id}", json=data, headers=headers)
|
|
27
|
+
|
|
28
|
+
def delete(self, customer_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
29
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
30
|
+
self.client.request("DELETE", f"/v1/customers/{customer_id}", headers=headers)
|
|
31
|
+
|
|
32
|
+
def lookup(self, external_id: str) -> Dict[str, Any]:
|
|
33
|
+
return self.client.request("GET", "/v1/customers/lookup", params={"external_id": external_id})
|
|
34
|
+
|
|
35
|
+
def top(self, **params: Any) -> List[Dict[str, Any]]:
|
|
36
|
+
return _extract_list(self.client.request("GET", "/v1/customers/top", params=params), "customers")
|
|
37
|
+
|
|
38
|
+
def payment_history(self, customer_id: str, **params: Any) -> List[Dict[str, Any]]:
|
|
39
|
+
return _extract_list(self.client.request("GET", f"/v1/customers/{customer_id}/payments", params=params), "payments")
|
reevit/services/fraud.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
class FraudService:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def get(self) -> Dict[str, Any]:
|
|
8
|
+
return self.client.request("GET", "/v1/policies/fraud")
|
|
9
|
+
|
|
10
|
+
def update(self, policy: Dict[str, Any]) -> Dict[str, Any]:
|
|
11
|
+
return self.client.request("POST", "/v1/policies/fraud", json=policy)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
|
|
5
|
+
if isinstance(payload, dict):
|
|
6
|
+
return payload.get(key, [])
|
|
7
|
+
return payload or []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvoicesService:
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
15
|
+
return _extract_list(self.client.request("GET", "/v1/invoices", params=params), "invoices")
|
|
16
|
+
|
|
17
|
+
def get(self, invoice_id: str) -> Dict[str, Any]:
|
|
18
|
+
return self.client.request("GET", f"/v1/invoices/{invoice_id}")
|
|
19
|
+
|
|
20
|
+
def update(self, invoice_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
21
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
22
|
+
return self.client.request("PATCH", f"/v1/invoices/{invoice_id}", json=data, headers=headers)
|
|
23
|
+
|
|
24
|
+
def cancel(self, invoice_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
26
|
+
return self.client.request("POST", f"/v1/invoices/{invoice_id}/cancel", json={}, headers=headers)
|
|
27
|
+
|
|
28
|
+
def retry(self, invoice_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
29
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
30
|
+
return self.client.request("POST", f"/v1/invoices/{invoice_id}/retry", json={}, headers=headers)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
|
|
5
|
+
if isinstance(payload, dict):
|
|
6
|
+
return payload.get(key, [])
|
|
7
|
+
return payload or []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PaymentLinksService:
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
15
|
+
return _extract_list(self.client.request("GET", "/v1/payment-links", params=params), "payment_links")
|
|
16
|
+
|
|
17
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
19
|
+
return self.client.request("POST", "/v1/payment-links", json=data, headers=headers)
|
|
20
|
+
|
|
21
|
+
def get(self, payment_link_id: str) -> Dict[str, Any]:
|
|
22
|
+
return self.client.request("GET", f"/v1/payment-links/{payment_link_id}")
|
|
23
|
+
|
|
24
|
+
def update(self, payment_link_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
26
|
+
return self.client.request("PATCH", f"/v1/payment-links/{payment_link_id}", json=data, headers=headers)
|
|
27
|
+
|
|
28
|
+
def delete(self, payment_link_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
29
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
30
|
+
self.client.request("DELETE", f"/v1/payment-links/{payment_link_id}", headers=headers)
|
|
31
|
+
|
|
32
|
+
def get_stats(self, payment_link_id: str) -> Dict[str, Any]:
|
|
33
|
+
return self.client.request("GET", f"/v1/payment-links/{payment_link_id}/stats")
|
|
34
|
+
|
|
35
|
+
def list_payments(self, payment_link_id: str, **params: Any) -> List[Dict[str, Any]]:
|
|
36
|
+
return _extract_list(
|
|
37
|
+
self.client.request("GET", f"/v1/payment-links/{payment_link_id}/payments", params=params),
|
|
38
|
+
"payments",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def get_by_code(self, code: str) -> Dict[str, Any]:
|
|
42
|
+
return self.client.request("GET", f"/v1/pay/{code}")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class PaymentsService:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def create_intent(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
8
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
9
|
+
return self.client.request("POST", "/v1/payments/intents", json=data, headers=headers)
|
|
10
|
+
|
|
11
|
+
def list(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
|
12
|
+
params = {"limit": limit, "offset": offset}
|
|
13
|
+
return self.client.request("GET", "/v1/payments", params=params)
|
|
14
|
+
|
|
15
|
+
def get(self, payment_id: str) -> Dict[str, Any]:
|
|
16
|
+
return self.client.request("GET", f"/v1/payments/{payment_id}")
|
|
17
|
+
|
|
18
|
+
def update_intent(self, payment_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
19
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
20
|
+
return self.client.request("PATCH", f"/v1/payments/intents/{payment_id}", json=data, headers=headers)
|
|
21
|
+
|
|
22
|
+
def confirm(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
23
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
24
|
+
return self.client.request("POST", f"/v1/payments/{payment_id}/confirm", json={}, headers=headers)
|
|
25
|
+
|
|
26
|
+
def confirm_intent(self, payment_id: str, client_secret: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
27
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
28
|
+
return self.client.request(
|
|
29
|
+
"POST",
|
|
30
|
+
f"/v1/payments/{payment_id}/confirm-intent",
|
|
31
|
+
params={"client_secret": client_secret},
|
|
32
|
+
json={},
|
|
33
|
+
headers=headers,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def cancel(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
37
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
38
|
+
return self.client.request("POST", f"/v1/payments/{payment_id}/cancel", json={}, headers=headers)
|
|
39
|
+
|
|
40
|
+
def retry(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
41
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
42
|
+
return self.client.request("POST", f"/v1/payments/{payment_id}/retry", json={}, headers=headers)
|
|
43
|
+
|
|
44
|
+
def refund(self, payment_id: str, amount: Optional[int] = None, reason: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
45
|
+
data = {}
|
|
46
|
+
if amount is not None:
|
|
47
|
+
data["amount"] = amount
|
|
48
|
+
if reason is not None:
|
|
49
|
+
data["reason"] = reason
|
|
50
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
51
|
+
return self.client.request("POST", f"/v1/payments/{payment_id}/refund", json=data, headers=headers)
|
|
52
|
+
|
|
53
|
+
def stats(self, **params: Any) -> Dict[str, Any]:
|
|
54
|
+
return self.client.request("GET", "/v1/payments/stats", params=params)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
|
|
5
|
+
if isinstance(payload, dict):
|
|
6
|
+
return payload.get(key, [])
|
|
7
|
+
return payload or []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RoutingRulesService:
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
15
|
+
return _extract_list(self.client.request("GET", "/v1/routing-rules"), "rules")
|
|
16
|
+
|
|
17
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
19
|
+
return self.client.request("POST", "/v1/routing-rules", json=data, headers=headers)
|
|
20
|
+
|
|
21
|
+
def get(self, rule_id: str) -> Dict[str, Any]:
|
|
22
|
+
return self.client.request("GET", f"/v1/routing-rules/{rule_id}")
|
|
23
|
+
|
|
24
|
+
def update(self, rule_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
25
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
26
|
+
return self.client.request("PATCH", f"/v1/routing-rules/{rule_id}", json=data, headers=headers)
|
|
27
|
+
|
|
28
|
+
def delete(self, rule_id: str, idempotency_key: Optional[str] = None) -> None:
|
|
29
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
30
|
+
self.client.request("DELETE", f"/v1/routing-rules/{rule_id}", headers=headers)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
class SubscriptionsService:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
8
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
9
|
+
return self.client.request("POST", "/v1/subscriptions", json=data, headers=headers)
|
|
10
|
+
|
|
11
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
12
|
+
return self.client.request("GET", "/v1/subscriptions", params=params)
|
|
13
|
+
|
|
14
|
+
def get(self, subscription_id: str) -> Dict[str, Any]:
|
|
15
|
+
return self.client.request("GET", f"/v1/subscriptions/{subscription_id}")
|
|
16
|
+
|
|
17
|
+
def update(self, subscription_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
19
|
+
return self.client.request("PATCH", f"/v1/subscriptions/{subscription_id}", json=data, headers=headers)
|
|
20
|
+
|
|
21
|
+
def cancel(self, subscription_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
22
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
23
|
+
return self.client.request("POST", f"/v1/subscriptions/{subscription_id}/cancel", json={}, headers=headers)
|
|
24
|
+
|
|
25
|
+
def resume(self, subscription_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
26
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
27
|
+
return self.client.request("POST", f"/v1/subscriptions/{subscription_id}/resume", json={}, headers=headers)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
|
|
5
|
+
if isinstance(payload, dict):
|
|
6
|
+
return payload.get(key, [])
|
|
7
|
+
return payload or []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebhooksService:
|
|
11
|
+
def __init__(self, client):
|
|
12
|
+
self.client = client
|
|
13
|
+
|
|
14
|
+
def get_config(self) -> Dict[str, Any]:
|
|
15
|
+
return self.client.request("GET", "/v1/webhooks/config")
|
|
16
|
+
|
|
17
|
+
def upsert_config(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
19
|
+
return self.client.request("POST", "/v1/webhooks/config", json=data, headers=headers)
|
|
20
|
+
|
|
21
|
+
def delete_config(self, idempotency_key: Optional[str] = None) -> None:
|
|
22
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
23
|
+
self.client.request("DELETE", "/v1/webhooks/config", headers=headers)
|
|
24
|
+
|
|
25
|
+
def send_test(self, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
26
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
27
|
+
return self.client.request("POST", "/v1/webhooks/test", json={}, headers=headers)
|
|
28
|
+
|
|
29
|
+
def list_events(self, **params: Any) -> List[Dict[str, Any]]:
|
|
30
|
+
return _extract_list(self.client.request("GET", "/v1/webhooks/events", params=params), "events")
|
|
31
|
+
|
|
32
|
+
def get_event(self, event_id: str) -> Dict[str, Any]:
|
|
33
|
+
return self.client.request("GET", f"/v1/webhooks/events/{event_id}")
|
|
34
|
+
|
|
35
|
+
def replay_event(self, event_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
36
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
37
|
+
return self.client.request("POST", f"/v1/webhooks/events/{event_id}/replay", json={}, headers=headers)
|
|
38
|
+
|
|
39
|
+
def list_outbound(self, **params: Any) -> List[Dict[str, Any]]:
|
|
40
|
+
return _extract_list(self.client.request("GET", "/v1/webhooks/outbound", params=params), "outbound")
|
|
41
|
+
|
|
42
|
+
def get_outbound(self, outbound_id: str) -> Dict[str, Any]:
|
|
43
|
+
return self.client.request("GET", f"/v1/webhooks/outbound/{outbound_id}")
|
reevit/webhooks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Webhook signature verification for Reevit outbound webhooks.
|
|
2
|
+
|
|
3
|
+
Reevit signs every outbound webhook with HMAC-SHA256 over the **raw request
|
|
4
|
+
body** and sends the result in the ``X-Reevit-Signature`` header as
|
|
5
|
+
``sha256=<hex>``. The signed body includes a ``signature_timestamp`` field, so
|
|
6
|
+
the signature also covers the timestamp (tamper protection). To additionally
|
|
7
|
+
guard against replay of a captured-but-recent delivery, check that
|
|
8
|
+
``signature_timestamp`` is recent after the signature verifies.
|
|
9
|
+
|
|
10
|
+
IMPORTANT: verify against the exact bytes you received. Do not ``json.loads``
|
|
11
|
+
and re-serialize the body first -- key order and whitespace must match what
|
|
12
|
+
Reevit signed, or the signature will not match.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
from typing import Optional, Union
|
|
18
|
+
|
|
19
|
+
_SIGNATURE_PREFIX = "sha256="
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _to_bytes(payload: Union[str, bytes]) -> bytes:
|
|
23
|
+
return payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sign_webhook_payload(payload: Union[str, bytes], secret: str) -> str:
|
|
27
|
+
"""Compute the ``X-Reevit-Signature`` header value for a raw webhook body.
|
|
28
|
+
|
|
29
|
+
Returns ``sha256=<hex HMAC-SHA256 of the body>``. Primarily useful for tests.
|
|
30
|
+
"""
|
|
31
|
+
digest = hmac.new(secret.encode("utf-8"), _to_bytes(payload), hashlib.sha256).hexdigest()
|
|
32
|
+
return _SIGNATURE_PREFIX + digest
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_webhook_signature(
|
|
36
|
+
payload: Union[str, bytes],
|
|
37
|
+
signature: Optional[str],
|
|
38
|
+
secret: str,
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Verify a Reevit webhook signature in constant time.
|
|
41
|
+
|
|
42
|
+
:param payload: Raw request body, exactly as received (str or bytes).
|
|
43
|
+
:param signature: The ``X-Reevit-Signature`` header value (``sha256=...``).
|
|
44
|
+
:param secret: The signing secret for the webhook endpoint.
|
|
45
|
+
:returns: ``True`` only if the signature is present and valid.
|
|
46
|
+
"""
|
|
47
|
+
if not signature or not secret:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
expected = sign_webhook_payload(payload, secret)
|
|
51
|
+
|
|
52
|
+
# compare_digest is constant-time and length-safe.
|
|
53
|
+
return hmac.compare_digest(expected, signature)
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reevit
|
|
3
|
+
Version: 0.9.1
|
|
4
|
+
Summary: Reevit Python SDK
|
|
5
|
+
Home-page: https://github.com/Reevit-Platform/python-sdk
|
|
6
|
+
Author: Reevit
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Documentation, https://docs.reevit.io
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.6
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.25.0
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
Dynamic: project-url
|
|
24
|
+
Dynamic: requires-dist
|
|
25
|
+
Dynamic: requires-python
|
|
26
|
+
Dynamic: summary
|
|
27
|
+
|
|
28
|
+
# Reevit Python SDK
|
|
29
|
+
|
|
30
|
+
The official Python SDK for [Reevit](https://reevit.io) — a unified payment orchestration platform for Africa.
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/reevit/)
|
|
33
|
+
[](https://pypi.org/project/reevit/)
|
|
34
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install reevit==0.9.1
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from reevit import Reevit
|
|
46
|
+
|
|
47
|
+
client = Reevit(api_key="pfk_live_xxx", org_id="org_123")
|
|
48
|
+
|
|
49
|
+
# Create a payment
|
|
50
|
+
try:
|
|
51
|
+
payment = client.payments.create_intent({
|
|
52
|
+
"amount": 5000, # 50.00 GHS
|
|
53
|
+
"currency": "GHS",
|
|
54
|
+
"method": "momo",
|
|
55
|
+
"country": "GH",
|
|
56
|
+
"customer_id": "cust_123",
|
|
57
|
+
"metadata": {
|
|
58
|
+
"order_id": "12345"
|
|
59
|
+
}
|
|
60
|
+
}, idempotency_key="order_12345")
|
|
61
|
+
print(f"Payment created: {payment['id']}")
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"Error: {e}")
|
|
64
|
+
|
|
65
|
+
# List payments
|
|
66
|
+
payments = client.payments.list()
|
|
67
|
+
print(payments)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Server-created checkout sessions
|
|
71
|
+
|
|
72
|
+
Create checkout sessions on your server, then pass `session["session_secret"]` to a browser SDK — [`@reevit/react`](https://www.npmjs.com/package/@reevit/react), [`@reevit/vue`](https://www.npmjs.com/package/@reevit/vue), or [`@reevit/svelte`](https://www.npmjs.com/package/@reevit/svelte) — to render the checkout UI.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
session = client.checkout_sessions.create(
|
|
76
|
+
{
|
|
77
|
+
"amount": 5000,
|
|
78
|
+
"currency": "GHS",
|
|
79
|
+
"method": "mobile_money",
|
|
80
|
+
"country": "GH",
|
|
81
|
+
},
|
|
82
|
+
idempotency_key="order_12345",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Idempotency
|
|
87
|
+
|
|
88
|
+
Pass `idempotency_key` to safely retry intent creation without duplicates.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
payment = client.payments.create_intent(
|
|
92
|
+
{
|
|
93
|
+
"amount": 5000,
|
|
94
|
+
"currency": "GHS",
|
|
95
|
+
"method": "momo",
|
|
96
|
+
"country": "GH",
|
|
97
|
+
},
|
|
98
|
+
idempotency_key="order_12345",
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Features
|
|
103
|
+
|
|
104
|
+
- **Payments**: Create intents, update intents, confirm, confirm intent, cancel, retry, refund, stats
|
|
105
|
+
- **Connections**: Manage PSP integrations, validation, labels, status, audit
|
|
106
|
+
- **Subscriptions**: Manage recurring billing lifecycle
|
|
107
|
+
- **Fraud**: Configure fraud rules
|
|
108
|
+
- **Customers / Payment Links / Checkout Sessions / Webhooks / Routing Rules / Invoices**: Additional backend services
|
|
109
|
+
|
|
110
|
+
`org_id` is supported directly on the client. Omitting it for authenticated requests still works for backward compatibility, but that mode is deprecated.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Webhook Verification
|
|
115
|
+
|
|
116
|
+
Reevit sends webhooks to notify your application of payment events. Always verify webhook signatures.
|
|
117
|
+
|
|
118
|
+
### Understanding Webhooks
|
|
119
|
+
|
|
120
|
+
There are **two types of webhooks** in Reevit:
|
|
121
|
+
|
|
122
|
+
1. **Inbound Webhooks (PSP → Reevit)**: Webhooks from payment providers (Paystack, Flutterwave, etc.) to Reevit. Configure these in the PSP dashboard. Reevit handles them automatically.
|
|
123
|
+
|
|
124
|
+
2. **Outbound Webhooks (Reevit → Your App)**: Webhooks from Reevit to your application. Configure in Reevit Dashboard and create a handler in your app.
|
|
125
|
+
|
|
126
|
+
### Signature Format
|
|
127
|
+
|
|
128
|
+
- **Header**: `X-Reevit-Signature: sha256=<hex-signature>`
|
|
129
|
+
- **Signature**: `HMAC-SHA256(request_body, signing_secret)`
|
|
130
|
+
|
|
131
|
+
### Getting Your Signing Secret
|
|
132
|
+
|
|
133
|
+
1. Go to **Reevit Dashboard > Developers > Webhooks**
|
|
134
|
+
2. Configure your webhook endpoint URL
|
|
135
|
+
3. Copy the signing secret (starts with `whsec_`)
|
|
136
|
+
4. Set environment variable: `REEVIT_WEBHOOK_SECRET=whsec_xxx...`
|
|
137
|
+
|
|
138
|
+
### Flask Webhook Handler
|
|
139
|
+
|
|
140
|
+
The SDK ships a constant-time verifier — `verify_webhook_signature(payload, signature, secret)` — so you do not have to reimplement HMAC. Pass the **raw** request body (not parsed-and-reserialized JSON), the `X-Reevit-Signature` header, and your signing secret.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import os
|
|
144
|
+
import logging
|
|
145
|
+
from dataclasses import dataclass
|
|
146
|
+
from typing import Optional, Dict, Any
|
|
147
|
+
from flask import Flask, request, jsonify
|
|
148
|
+
from reevit import verify_webhook_signature
|
|
149
|
+
|
|
150
|
+
app = Flask(__name__)
|
|
151
|
+
logging.basicConfig(level=logging.INFO)
|
|
152
|
+
logger = logging.getLogger(__name__)
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class PaymentData:
|
|
156
|
+
id: str
|
|
157
|
+
status: str
|
|
158
|
+
amount: int
|
|
159
|
+
currency: str
|
|
160
|
+
provider: str
|
|
161
|
+
customer_id: Optional[str] = None
|
|
162
|
+
metadata: Optional[Dict[str, str]] = None
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class SubscriptionData:
|
|
166
|
+
id: str
|
|
167
|
+
customer_id: str
|
|
168
|
+
plan_id: str
|
|
169
|
+
status: str
|
|
170
|
+
amount: int
|
|
171
|
+
currency: str
|
|
172
|
+
interval: str
|
|
173
|
+
next_renewal_at: Optional[str] = None
|
|
174
|
+
|
|
175
|
+
@app.route('/webhooks/reevit', methods=['POST'])
|
|
176
|
+
def webhook():
|
|
177
|
+
payload = request.get_data() # raw bytes — do not re-serialize
|
|
178
|
+
signature = request.headers.get('X-Reevit-Signature', '')
|
|
179
|
+
secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
|
|
180
|
+
|
|
181
|
+
# Verify signature (required in production)
|
|
182
|
+
if not verify_webhook_signature(payload, signature, secret):
|
|
183
|
+
logger.warning('[Webhook] Invalid signature')
|
|
184
|
+
return jsonify({'error': 'Invalid signature'}), 401
|
|
185
|
+
|
|
186
|
+
event = request.get_json()
|
|
187
|
+
event_type = event.get('type')
|
|
188
|
+
event_id = event.get('id')
|
|
189
|
+
|
|
190
|
+
logger.info(f'[Webhook] Received: {event_type} ({event_id})')
|
|
191
|
+
|
|
192
|
+
# Handle different event types
|
|
193
|
+
if event_type == 'reevit.webhook.test':
|
|
194
|
+
logger.info(f'[Webhook] Test received: {event.get("message")}')
|
|
195
|
+
|
|
196
|
+
# Payment events
|
|
197
|
+
elif event_type == 'payment.succeeded':
|
|
198
|
+
data = PaymentData(**event.get('data', {}))
|
|
199
|
+
handle_payment_succeeded(data)
|
|
200
|
+
|
|
201
|
+
elif event_type == 'payment.failed':
|
|
202
|
+
data = PaymentData(**event.get('data', {}))
|
|
203
|
+
handle_payment_failed(data)
|
|
204
|
+
|
|
205
|
+
elif event_type == 'payment.refunded':
|
|
206
|
+
data = PaymentData(**event.get('data', {}))
|
|
207
|
+
handle_payment_refunded(data)
|
|
208
|
+
|
|
209
|
+
elif event_type == 'payment.pending':
|
|
210
|
+
data = PaymentData(**event.get('data', {}))
|
|
211
|
+
logger.info(f'[Webhook] Payment pending: {data.id}')
|
|
212
|
+
|
|
213
|
+
# Subscription events
|
|
214
|
+
elif event_type == 'subscription.created':
|
|
215
|
+
data = SubscriptionData(**event.get('data', {}))
|
|
216
|
+
handle_subscription_created(data)
|
|
217
|
+
|
|
218
|
+
elif event_type == 'subscription.renewed':
|
|
219
|
+
data = SubscriptionData(**event.get('data', {}))
|
|
220
|
+
handle_subscription_renewed(data)
|
|
221
|
+
|
|
222
|
+
elif event_type == 'subscription.canceled':
|
|
223
|
+
data = SubscriptionData(**event.get('data', {}))
|
|
224
|
+
handle_subscription_canceled(data)
|
|
225
|
+
|
|
226
|
+
else:
|
|
227
|
+
logger.info(f'[Webhook] Unhandled event: {event_type}')
|
|
228
|
+
|
|
229
|
+
return jsonify({'received': True})
|
|
230
|
+
|
|
231
|
+
# Payment handlers
|
|
232
|
+
def handle_payment_succeeded(data: PaymentData):
|
|
233
|
+
order_id = data.metadata.get('order_id') if data.metadata else None
|
|
234
|
+
logger.info(f'[Webhook] Payment succeeded: {data.id} for order {order_id}')
|
|
235
|
+
|
|
236
|
+
# TODO: Implement your business logic
|
|
237
|
+
# - Update order status to "paid"
|
|
238
|
+
# - Send confirmation email to customer
|
|
239
|
+
# - Trigger fulfillment process
|
|
240
|
+
|
|
241
|
+
def handle_payment_failed(data: PaymentData):
|
|
242
|
+
logger.info(f'[Webhook] Payment failed: {data.id}')
|
|
243
|
+
|
|
244
|
+
# TODO: Implement your business logic
|
|
245
|
+
# - Update order status to "payment_failed"
|
|
246
|
+
# - Send notification to customer
|
|
247
|
+
# - Allow retry
|
|
248
|
+
|
|
249
|
+
def handle_payment_refunded(data: PaymentData):
|
|
250
|
+
order_id = data.metadata.get('order_id') if data.metadata else None
|
|
251
|
+
logger.info(f'[Webhook] Payment refunded: {data.id} for order {order_id}')
|
|
252
|
+
|
|
253
|
+
# TODO: Implement your business logic
|
|
254
|
+
# - Update order status to "refunded"
|
|
255
|
+
# - Restore inventory if applicable
|
|
256
|
+
|
|
257
|
+
# Subscription handlers
|
|
258
|
+
def handle_subscription_created(data: SubscriptionData):
|
|
259
|
+
logger.info(f'[Webhook] Subscription created: {data.id} for customer {data.customer_id}')
|
|
260
|
+
|
|
261
|
+
# TODO: Implement your business logic
|
|
262
|
+
# - Grant access to subscription features
|
|
263
|
+
# - Send welcome email
|
|
264
|
+
|
|
265
|
+
def handle_subscription_renewed(data: SubscriptionData):
|
|
266
|
+
logger.info(f'[Webhook] Subscription renewed: {data.id}')
|
|
267
|
+
|
|
268
|
+
# TODO: Implement your business logic
|
|
269
|
+
# - Extend access period
|
|
270
|
+
# - Send renewal confirmation
|
|
271
|
+
|
|
272
|
+
def handle_subscription_canceled(data: SubscriptionData):
|
|
273
|
+
logger.info(f'[Webhook] Subscription canceled: {data.id}')
|
|
274
|
+
|
|
275
|
+
# TODO: Implement your business logic
|
|
276
|
+
# - Revoke access at end of billing period
|
|
277
|
+
# - Send cancellation confirmation
|
|
278
|
+
|
|
279
|
+
if __name__ == '__main__':
|
|
280
|
+
app.run(port=8080)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Django Webhook Handler
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
# views.py
|
|
287
|
+
import json
|
|
288
|
+
import os
|
|
289
|
+
import logging
|
|
290
|
+
from django.http import JsonResponse
|
|
291
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
292
|
+
from django.views.decorators.http import require_POST
|
|
293
|
+
from reevit import verify_webhook_signature
|
|
294
|
+
|
|
295
|
+
logger = logging.getLogger(__name__)
|
|
296
|
+
|
|
297
|
+
@csrf_exempt
|
|
298
|
+
@require_POST
|
|
299
|
+
def reevit_webhook(request):
|
|
300
|
+
payload = request.body # raw bytes — do not re-serialize
|
|
301
|
+
signature = request.headers.get('X-Reevit-Signature', '')
|
|
302
|
+
secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
|
|
303
|
+
|
|
304
|
+
if not verify_webhook_signature(payload, signature, secret):
|
|
305
|
+
return JsonResponse({'error': 'Invalid signature'}, status=401)
|
|
306
|
+
|
|
307
|
+
event = json.loads(payload)
|
|
308
|
+
event_type = event.get('type')
|
|
309
|
+
|
|
310
|
+
logger.info(f'[Webhook] Received: {event_type}')
|
|
311
|
+
|
|
312
|
+
if event_type == 'payment.succeeded':
|
|
313
|
+
data = event.get('data', {})
|
|
314
|
+
order_id = data.get('metadata', {}).get('order_id')
|
|
315
|
+
# Fulfill order, send confirmation email
|
|
316
|
+
logger.info(f'Payment succeeded for order {order_id}')
|
|
317
|
+
|
|
318
|
+
elif event_type == 'payment.failed':
|
|
319
|
+
# Notify customer, allow retry
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
elif event_type == 'subscription.renewed':
|
|
323
|
+
# Extend access
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
elif event_type == 'subscription.canceled':
|
|
327
|
+
# Revoke access
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
return JsonResponse({'received': True})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### FastAPI Webhook Handler
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
337
|
+
from pydantic import BaseModel
|
|
338
|
+
from typing import Optional, Dict
|
|
339
|
+
import os
|
|
340
|
+
import logging
|
|
341
|
+
from reevit import verify_webhook_signature
|
|
342
|
+
|
|
343
|
+
app = FastAPI()
|
|
344
|
+
logger = logging.getLogger(__name__)
|
|
345
|
+
|
|
346
|
+
class PaymentData(BaseModel):
|
|
347
|
+
id: str
|
|
348
|
+
status: str
|
|
349
|
+
amount: int
|
|
350
|
+
currency: str
|
|
351
|
+
provider: str
|
|
352
|
+
customer_id: Optional[str] = None
|
|
353
|
+
metadata: Optional[Dict[str, str]] = None
|
|
354
|
+
|
|
355
|
+
class SubscriptionData(BaseModel):
|
|
356
|
+
id: str
|
|
357
|
+
customer_id: str
|
|
358
|
+
plan_id: str
|
|
359
|
+
status: str
|
|
360
|
+
amount: int
|
|
361
|
+
currency: str
|
|
362
|
+
interval: str
|
|
363
|
+
next_renewal_at: Optional[str] = None
|
|
364
|
+
|
|
365
|
+
@app.post('/webhooks/reevit')
|
|
366
|
+
async def webhook(request: Request):
|
|
367
|
+
payload = await request.body() # raw bytes — do not re-serialize
|
|
368
|
+
signature = request.headers.get('X-Reevit-Signature', '')
|
|
369
|
+
secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
|
|
370
|
+
|
|
371
|
+
if not verify_webhook_signature(payload, signature, secret):
|
|
372
|
+
raise HTTPException(status_code=401, detail='Invalid signature')
|
|
373
|
+
|
|
374
|
+
event = await request.json()
|
|
375
|
+
event_type = event.get('type')
|
|
376
|
+
|
|
377
|
+
logger.info(f'[Webhook] Received: {event_type}')
|
|
378
|
+
|
|
379
|
+
# Payment events
|
|
380
|
+
if event_type == 'payment.succeeded':
|
|
381
|
+
data = PaymentData(**event.get('data', {}))
|
|
382
|
+
order_id = data.metadata.get('order_id') if data.metadata else None
|
|
383
|
+
logger.info(f'Payment succeeded: {data.id} for order {order_id}')
|
|
384
|
+
# Fulfill order, send confirmation email
|
|
385
|
+
|
|
386
|
+
elif event_type == 'payment.failed':
|
|
387
|
+
# Notify customer, allow retry
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# Subscription events
|
|
391
|
+
elif event_type == 'subscription.renewed':
|
|
392
|
+
data = SubscriptionData(**event.get('data', {}))
|
|
393
|
+
logger.info(f'Subscription renewed: {data.id}')
|
|
394
|
+
# Extend access
|
|
395
|
+
|
|
396
|
+
elif event_type == 'subscription.canceled':
|
|
397
|
+
data = SubscriptionData(**event.get('data', {}))
|
|
398
|
+
logger.info(f'Subscription canceled: {data.id}')
|
|
399
|
+
# Revoke access
|
|
400
|
+
|
|
401
|
+
return {'received': True}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Supported PSPs
|
|
405
|
+
|
|
406
|
+
| Provider | Countries | Payment Methods |
|
|
407
|
+
|----------|-----------|-----------------|
|
|
408
|
+
| Paystack | NG, GH, ZA, KE | Card, Mobile Money, Bank Transfer |
|
|
409
|
+
| Flutterwave | NG, GH, KE, ZA + | Card, Mobile Money, Bank Transfer |
|
|
410
|
+
| Hubtel | GH | Mobile Money |
|
|
411
|
+
| Stripe | Global (50+) | Card, Apple Pay, Google Pay |
|
|
412
|
+
| Monnify | NG | Card, Bank Transfer, USSD |
|
|
413
|
+
| M-Pesa | KE, TZ | Mobile Money (STK Push) |
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Release Notes
|
|
418
|
+
|
|
419
|
+
### v0.9.1
|
|
420
|
+
|
|
421
|
+
- Added `verify_webhook_signature` / `sign_webhook_payload` helpers (constant-time HMAC-SHA256 verification of the `X-Reevit-Signature` header)
|
|
422
|
+
- Version is now sourced from `reevit._version` and sent as the `X-Reevit-Client-Version` header
|
|
423
|
+
|
|
424
|
+
### v0.9.0
|
|
425
|
+
|
|
426
|
+
- Added server-created checkout sessions
|
|
427
|
+
- Version alignment across all Reevit SDKs
|
|
428
|
+
- Updated documentation and webhook examples
|
|
429
|
+
- Added support for Apple Pay and Google Pay
|
|
430
|
+
- Updated supported PSPs and payment methods documentation
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Environment Variables
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
export REEVIT_API_KEY=pfk_live_xxx
|
|
438
|
+
export REEVIT_ORG_ID=org_xxx
|
|
439
|
+
export REEVIT_WEBHOOK_SECRET=whsec_xxx # Get from Dashboard > Developers > Webhooks
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## Support
|
|
445
|
+
|
|
446
|
+
- **Documentation**: [https://docs.reevit.io](https://docs.reevit.io)
|
|
447
|
+
- **GitHub Issues**: [https://github.com/Reevit-Platform/backend/issues](https://github.com/Reevit-Platform/backend/issues)
|
|
448
|
+
- **Email**: support@reevit.io
|
|
449
|
+
|
|
450
|
+
## License
|
|
451
|
+
|
|
452
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
reevit/__init__.py,sha256=prpmjhIWFfsweJo7XyFwjkOzfCw4o9zEWPKp2nu7Jwc,276
|
|
2
|
+
reevit/_version.py,sha256=VfQ_lu-jIwCa8kx_s31JSCAL4SGFr08ExXCvvbz7Ejs,312
|
|
3
|
+
reevit/client.py,sha256=Ygzd4KBI6pRSVfvQo7__p88JV2_Hk6O1U386UTR_HwY,3617
|
|
4
|
+
reevit/webhooks.py,sha256=q8uID1jmpqYGOiYQEt16dxCvbnpYrx4-MT-kHvbbQks,1999
|
|
5
|
+
reevit/services/__init__.py,sha256=p1afTZx1O_Sxog_xOa9yOi8s9zcXyBW5jyYoFujbhwc,155
|
|
6
|
+
reevit/services/checkout_sessions.py,sha256=X52wXJcyBHO7dRtdePhQF3sm4IOZBzChsGpYmtuKRrs,415
|
|
7
|
+
reevit/services/connections.py,sha256=x3ohIlL5hsi3Dy-MFm2wiHm_0y3UinnPSQcEPNHwL1A,2753
|
|
8
|
+
reevit/services/customers.py,sha256=1DRHki6gyLPhDK7MKl37c6mqsvM82x063h1n7uD3Tno,1970
|
|
9
|
+
reevit/services/fraud.py,sha256=WqoMt-d2pEviZkME27ROGbmRsSWqdrzCWTva7xCPZco,356
|
|
10
|
+
reevit/services/invoices.py,sha256=elJWEkGO59hqRJXNfRmQCO3ydazuRFZQz1V4RUXqHxg,1459
|
|
11
|
+
reevit/services/payment_links.py,sha256=o04oe2-OvKABN8KyNbJZ7FvQSLyQVDInboFPbH1oeVA,2011
|
|
12
|
+
reevit/services/payments.py,sha256=A1TjWdW00USwcWgfhoXlW5JbEpW2t-hcT_XQt15seIU,3000
|
|
13
|
+
reevit/services/routing_rules.py,sha256=vkKyyqatDuVnEDSgPLjFDXI8OiNH7gei2Iyc267ebto,1394
|
|
14
|
+
reevit/services/subscriptions.py,sha256=66gtMwBuGSPUWA_Hybg82kMx2mSUR5bIaJwju_AgKpM,1617
|
|
15
|
+
reevit/services/webhooks.py,sha256=Bgx2Z-Ll9fvq9AB24rODbqsa3nGHY2zl9qOFJeWECug,2117
|
|
16
|
+
reevit-0.9.1.dist-info/licenses/LICENSE,sha256=UDLWW01GC5P5RdsVrq3mq2xaIol__OoHOXNb_iSTh9M,1063
|
|
17
|
+
reevit-0.9.1.dist-info/METADATA,sha256=ar707ijBl-qxnXc_KiqR0ma_5_O-jcqKtCv2_AwkWiw,13904
|
|
18
|
+
reevit-0.9.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
19
|
+
reevit-0.9.1.dist-info/top_level.txt,sha256=5fiQTE6CazGDmrY12b129nI2f-W7GVrCMMOuU6AVb3U,7
|
|
20
|
+
reevit-0.9.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Reevit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
reevit
|