payhero 0.1.0__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.
payhero-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PayHero and contributors
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.
payhero-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: payhero
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the PayHero Africa API — collect and disburse money across Africa.
5
+ Author: PayHero
6
+ License: MIT
7
+ Project-URL: Homepage, https://payhero.africa
8
+ Project-URL: Documentation, https://docs.payhero.africa
9
+ Project-URL: Repository, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk
10
+ Project-URL: Issues, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/issues
11
+ Project-URL: Changelog, https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/blob/main/CHANGELOG.md
12
+ Keywords: payhero,payments,africa,mpesa,mobile-money,collections,payouts
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.25
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Requires-Dist: responses>=0.23; extra == "dev"
25
+ Requires-Dist: ruff>=0.4; extra == "dev"
26
+ Requires-Dist: build>=1.0; extra == "dev"
27
+ Requires-Dist: twine>=5.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # payhero (Python SDK)
31
+
32
+ Official Python SDK for the **PayHero Africa API** — collect and disburse
33
+ money across Africa over a single unified API (mobile money, bank and card
34
+ rails).
35
+
36
+ ```bash
37
+ pip install payhero
38
+ ```
39
+
40
+ ## Getting started
41
+
42
+ Generate an API key (username + password) on your PayHero dashboard.
43
+
44
+ ```python
45
+ from payhero import (
46
+ Client,
47
+ Customer,
48
+ GlobalPaymentRequest,
49
+ PaymentConfig,
50
+ ProviderConfig,
51
+ REQUEST_TYPE_PAYMENT,
52
+ CHANNEL_MOMO,
53
+ VendorConfig,
54
+ )
55
+
56
+ client = Client(username="your-username", password="your-password")
57
+ # or: client = Client.from_env() # PH_API_USERNAME / PH_API_PASSWORD / PH_BASE_URL
58
+
59
+ # 1. Discover routing details for the customer's country.
60
+ discovery = client.global_.discovery("KE")
61
+ network = discovery["provider_networks"]["m-pesa"][0]
62
+
63
+ # 2. Create a collection (pay-in).
64
+ resp = client.global_.create_payment(GlobalPaymentRequest(
65
+ request_type=REQUEST_TYPE_PAYMENT,
66
+ transaction_channel=CHANNEL_MOMO,
67
+ provider="yellowcard",
68
+ amount=5500,
69
+ currency="KES",
70
+ country="KE",
71
+ customer=Customer(
72
+ first_name="John",
73
+ last_name="Doe",
74
+ email="john.doe@example.com",
75
+ phone="+254712345678",
76
+ country="KE",
77
+ ),
78
+ vendor_config=VendorConfig(vendor_id=63),
79
+ provider_config=ProviderConfig.from_network(network),
80
+ payment_config=PaymentConfig(
81
+ account_number="+254712345678",
82
+ callback_url="https://your-system.com/webhooks/payhero",
83
+ ),
84
+ ))
85
+ print(resp)
86
+ # {"status_code": "200", "merchant_reference": "9FD194041588.iI", ...}
87
+ ```
88
+
89
+ A success response only means the request was **accepted** — the final result
90
+ arrives on your `callback_url`.
91
+
92
+ ## API surface
93
+
94
+ ### `client.global_` — unified `/api/global` payments
95
+
96
+ | Method | Endpoint | Description |
97
+ | ------ | -------- | ----------- |
98
+ | `discovery(country)` | `GET /api/global/discovery/payment-world/country` | Rails, providers & networks per country |
99
+ | `create_payment(request)` | `POST /api/global/payments` | Unified pay-in (`REQUEST_TYPE_PAYMENT`) / pay-out (`REQUEST_TYPE_WITHDRAWAL`) |
100
+ | `transaction_status(request_id)` | `POST /api/global/transaction-status` | Poll a transaction by request ID |
101
+ | `balance(account_id)` | `GET /api/global/accounts/{id}/balance` | Account wallet balance |
102
+ | `convert_currency(from, to)` | `POST /api/currency/convert` | Currency conversion rates |
103
+
104
+ Global payouts use the same request shape as collections with
105
+ `request_type=REQUEST_TYPE_WITHDRAWAL`; the destination goes in
106
+ `payment_config.account_number`. For some corridors (TZ, ZM, NG, …) add the
107
+ funds-owner identity via `Customer(id_type="national_id", id_number="...")`.
108
+ Route part of a payout to savings with `payment_split=PaymentSplit(amount_to_save=100)`.
109
+
110
+ ### `client.kenya` — legacy V1 endpoints
111
+
112
+ | Method | Endpoint | Description |
113
+ | ------ | -------- | ----------- |
114
+ | `create_collection(request)` | `POST /api/v2/payments` | Channel-based (`channel_id`) or wallet-based (`network_code`) collections; `is_offline=True` + `merchant_fee` for offline Paybill collections |
115
+ | `list_payment_channels(page)` | `GET /api/v2/payment_channels` | Your external bank/Till/Paybill/wallet channels |
116
+ | `withdraw(request)` | `POST /api/v2/withdraw` | Payouts to phone (`RECIPIENT_MSISDN`) or Paybill/Till (`RECIPIENT_PAYBILL`) |
117
+
118
+ Kenya collection example:
119
+
120
+ ```python
121
+ col = client.kenya.create_collection(CollectionRequest(
122
+ amount=10,
123
+ phone_number="0712345678",
124
+ provider="m-pesa",
125
+ channel_id=8949, # from list_payment_channels (settle to your channel)
126
+ account_id=63,
127
+ external_reference="test_ext",
128
+ callback_url="https://your-system.com/webhooks/payhero",
129
+ ))
130
+ print(col["CheckoutRequestID"]) # 29119 — offline: use as Paybill account ref
131
+ ```
132
+
133
+ Kenya payout example:
134
+
135
+ ```python
136
+ wd = client.kenya.withdraw(WithdrawRequest(
137
+ account_id=63,
138
+ amount=10,
139
+ phone_number="0712345678",
140
+ account_number="0712345678",
141
+ network_code="63902",
142
+ channel=DESTINATION_PHONE,
143
+ recipient_type=RECIPIENT_MSISDN,
144
+ ))
145
+ ```
146
+
147
+ ### `client.teams` — multi-tenancy
148
+
149
+ Teams are isolated Accounts with their own wallet, payment channels, KYC and
150
+ members — all managed through one organization's API key.
151
+
152
+ | Method | Endpoint | Description |
153
+ | ------ | -------- | ----------- |
154
+ | `user_profile(user_id)` | `GET /api/v2/user_profile/{user_id}` | Discover your Teams, roles & organizations |
155
+ | `create_account(request)` | `POST /api/v2/accounts` | Provision a Team (wallet created automatically) |
156
+ | `get_account(account_id)` | `GET /api/v2/account/{account_id}` | Fetch a Team |
157
+ | `update_account(request)` | `PUT /api/v2/accounts` | Update Team settings |
158
+ | `delete_account(account_id)` | `DELETE /api/v2/accounts/{id}` | Remove a Team (destructive) |
159
+ | `invite_member(request)` | `POST /api/v2/account_invites` | Invite a user into a Team |
160
+ | `remove_member(account_id, user_id)` | `DELETE /api/v2/account_invites` | Remove a member |
161
+ | `submit_kyc(request)` | `POST /api/v2/account_kycs` | Submit Team KYC |
162
+ | `update_kyc(kyc_id, request)` | `PUT /api/v2/account_kycs/{id}` | Amend a KYC submission |
163
+
164
+ ## Errors
165
+
166
+ Non-2xx responses raise `payhero.ApiError`:
167
+
168
+ ```python
169
+ from payhero import ApiError
170
+
171
+ try:
172
+ client.global_.create_payment(req)
173
+ except ApiError as e:
174
+ print(e.status_code, e.error_code, e.error_message)
175
+ ```
176
+
177
+ - `400 invalid_argument` — permanent rejection; fix the input before retrying.
178
+ - `401` — missing or wrong Basic-auth credentials.
179
+ - Network/5xx errors can be retried with backoff using the same
180
+ `external_reference` on your side (pass a custom `requests.Session` with a
181
+ `urllib3.Retry` adapter if you want automatic retries).
182
+
183
+ ## KYC tiers
184
+
185
+ Payouts are unrestricted once KYC is complete. Collections unlock progressively:
186
+ Level 1–2 first-party only (own number), Level 3 third-party Kenya-only,
187
+ Level 4 global collections across all supported countries.
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ python -m venv .venv && source .venv/bin/activate
193
+ pip install -e ".[dev]"
194
+ pytest tests/
195
+ ```
196
+
197
+ The test suite mocks HTTP with [`responses`](https://github.com/getsentry/responses) —
198
+ no network or credentials required.
199
+
200
+ ## License
201
+
202
+ MIT
@@ -0,0 +1,173 @@
1
+ # payhero (Python SDK)
2
+
3
+ Official Python SDK for the **PayHero Africa API** — collect and disburse
4
+ money across Africa over a single unified API (mobile money, bank and card
5
+ rails).
6
+
7
+ ```bash
8
+ pip install payhero
9
+ ```
10
+
11
+ ## Getting started
12
+
13
+ Generate an API key (username + password) on your PayHero dashboard.
14
+
15
+ ```python
16
+ from payhero import (
17
+ Client,
18
+ Customer,
19
+ GlobalPaymentRequest,
20
+ PaymentConfig,
21
+ ProviderConfig,
22
+ REQUEST_TYPE_PAYMENT,
23
+ CHANNEL_MOMO,
24
+ VendorConfig,
25
+ )
26
+
27
+ client = Client(username="your-username", password="your-password")
28
+ # or: client = Client.from_env() # PH_API_USERNAME / PH_API_PASSWORD / PH_BASE_URL
29
+
30
+ # 1. Discover routing details for the customer's country.
31
+ discovery = client.global_.discovery("KE")
32
+ network = discovery["provider_networks"]["m-pesa"][0]
33
+
34
+ # 2. Create a collection (pay-in).
35
+ resp = client.global_.create_payment(GlobalPaymentRequest(
36
+ request_type=REQUEST_TYPE_PAYMENT,
37
+ transaction_channel=CHANNEL_MOMO,
38
+ provider="yellowcard",
39
+ amount=5500,
40
+ currency="KES",
41
+ country="KE",
42
+ customer=Customer(
43
+ first_name="John",
44
+ last_name="Doe",
45
+ email="john.doe@example.com",
46
+ phone="+254712345678",
47
+ country="KE",
48
+ ),
49
+ vendor_config=VendorConfig(vendor_id=63),
50
+ provider_config=ProviderConfig.from_network(network),
51
+ payment_config=PaymentConfig(
52
+ account_number="+254712345678",
53
+ callback_url="https://your-system.com/webhooks/payhero",
54
+ ),
55
+ ))
56
+ print(resp)
57
+ # {"status_code": "200", "merchant_reference": "9FD194041588.iI", ...}
58
+ ```
59
+
60
+ A success response only means the request was **accepted** — the final result
61
+ arrives on your `callback_url`.
62
+
63
+ ## API surface
64
+
65
+ ### `client.global_` — unified `/api/global` payments
66
+
67
+ | Method | Endpoint | Description |
68
+ | ------ | -------- | ----------- |
69
+ | `discovery(country)` | `GET /api/global/discovery/payment-world/country` | Rails, providers & networks per country |
70
+ | `create_payment(request)` | `POST /api/global/payments` | Unified pay-in (`REQUEST_TYPE_PAYMENT`) / pay-out (`REQUEST_TYPE_WITHDRAWAL`) |
71
+ | `transaction_status(request_id)` | `POST /api/global/transaction-status` | Poll a transaction by request ID |
72
+ | `balance(account_id)` | `GET /api/global/accounts/{id}/balance` | Account wallet balance |
73
+ | `convert_currency(from, to)` | `POST /api/currency/convert` | Currency conversion rates |
74
+
75
+ Global payouts use the same request shape as collections with
76
+ `request_type=REQUEST_TYPE_WITHDRAWAL`; the destination goes in
77
+ `payment_config.account_number`. For some corridors (TZ, ZM, NG, …) add the
78
+ funds-owner identity via `Customer(id_type="national_id", id_number="...")`.
79
+ Route part of a payout to savings with `payment_split=PaymentSplit(amount_to_save=100)`.
80
+
81
+ ### `client.kenya` — legacy V1 endpoints
82
+
83
+ | Method | Endpoint | Description |
84
+ | ------ | -------- | ----------- |
85
+ | `create_collection(request)` | `POST /api/v2/payments` | Channel-based (`channel_id`) or wallet-based (`network_code`) collections; `is_offline=True` + `merchant_fee` for offline Paybill collections |
86
+ | `list_payment_channels(page)` | `GET /api/v2/payment_channels` | Your external bank/Till/Paybill/wallet channels |
87
+ | `withdraw(request)` | `POST /api/v2/withdraw` | Payouts to phone (`RECIPIENT_MSISDN`) or Paybill/Till (`RECIPIENT_PAYBILL`) |
88
+
89
+ Kenya collection example:
90
+
91
+ ```python
92
+ col = client.kenya.create_collection(CollectionRequest(
93
+ amount=10,
94
+ phone_number="0712345678",
95
+ provider="m-pesa",
96
+ channel_id=8949, # from list_payment_channels (settle to your channel)
97
+ account_id=63,
98
+ external_reference="test_ext",
99
+ callback_url="https://your-system.com/webhooks/payhero",
100
+ ))
101
+ print(col["CheckoutRequestID"]) # 29119 — offline: use as Paybill account ref
102
+ ```
103
+
104
+ Kenya payout example:
105
+
106
+ ```python
107
+ wd = client.kenya.withdraw(WithdrawRequest(
108
+ account_id=63,
109
+ amount=10,
110
+ phone_number="0712345678",
111
+ account_number="0712345678",
112
+ network_code="63902",
113
+ channel=DESTINATION_PHONE,
114
+ recipient_type=RECIPIENT_MSISDN,
115
+ ))
116
+ ```
117
+
118
+ ### `client.teams` — multi-tenancy
119
+
120
+ Teams are isolated Accounts with their own wallet, payment channels, KYC and
121
+ members — all managed through one organization's API key.
122
+
123
+ | Method | Endpoint | Description |
124
+ | ------ | -------- | ----------- |
125
+ | `user_profile(user_id)` | `GET /api/v2/user_profile/{user_id}` | Discover your Teams, roles & organizations |
126
+ | `create_account(request)` | `POST /api/v2/accounts` | Provision a Team (wallet created automatically) |
127
+ | `get_account(account_id)` | `GET /api/v2/account/{account_id}` | Fetch a Team |
128
+ | `update_account(request)` | `PUT /api/v2/accounts` | Update Team settings |
129
+ | `delete_account(account_id)` | `DELETE /api/v2/accounts/{id}` | Remove a Team (destructive) |
130
+ | `invite_member(request)` | `POST /api/v2/account_invites` | Invite a user into a Team |
131
+ | `remove_member(account_id, user_id)` | `DELETE /api/v2/account_invites` | Remove a member |
132
+ | `submit_kyc(request)` | `POST /api/v2/account_kycs` | Submit Team KYC |
133
+ | `update_kyc(kyc_id, request)` | `PUT /api/v2/account_kycs/{id}` | Amend a KYC submission |
134
+
135
+ ## Errors
136
+
137
+ Non-2xx responses raise `payhero.ApiError`:
138
+
139
+ ```python
140
+ from payhero import ApiError
141
+
142
+ try:
143
+ client.global_.create_payment(req)
144
+ except ApiError as e:
145
+ print(e.status_code, e.error_code, e.error_message)
146
+ ```
147
+
148
+ - `400 invalid_argument` — permanent rejection; fix the input before retrying.
149
+ - `401` — missing or wrong Basic-auth credentials.
150
+ - Network/5xx errors can be retried with backoff using the same
151
+ `external_reference` on your side (pass a custom `requests.Session` with a
152
+ `urllib3.Retry` adapter if you want automatic retries).
153
+
154
+ ## KYC tiers
155
+
156
+ Payouts are unrestricted once KYC is complete. Collections unlock progressively:
157
+ Level 1–2 first-party only (own number), Level 3 third-party Kenya-only,
158
+ Level 4 global collections across all supported countries.
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ python -m venv .venv && source .venv/bin/activate
164
+ pip install -e ".[dev]"
165
+ pytest tests/
166
+ ```
167
+
168
+ The test suite mocks HTTP with [`responses`](https://github.com/getsentry/responses) —
169
+ no network or credentials required.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "payhero"
7
+ dynamic = ["version"]
8
+ description = "Official Python SDK for the PayHero Africa API — collect and disburse money across Africa."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "PayHero" }]
12
+ requires-python = ">=3.8"
13
+ dependencies = ["requests>=2.25"]
14
+ keywords = ["payhero", "payments", "africa", "mpesa", "mobile-money", "collections", "payouts"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Office/Business :: Financial",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://payhero.africa"
25
+ Documentation = "https://docs.payhero.africa"
26
+ Repository = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk"
27
+ Issues = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/issues"
28
+ Changelog = "https://github.com/PAY-HERO-CONSULTING/payhero-python-sdk/blob/main/CHANGELOG.md"
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=7.0",
33
+ "responses>=0.23",
34
+ "ruff>=0.4",
35
+ "build>=1.0",
36
+ "twine>=5.0",
37
+ ]
38
+
39
+ [tool.setuptools.dynamic]
40
+ version = { attr = "payhero.__version__" }
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = "-q"
48
+ pythonpath = ["src"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py38"
53
+
54
+ [tool.ruff.lint]
55
+ # Pinned explicitly so a newer ruff in CI cannot widen the rule set under us.
56
+ select = ["E", "F", "W", "I", "UP", "B", "RUF"]
57
+
58
+ [tool.ruff.lint.isort]
59
+ known-first-party = ["payhero"]
60
+ known-local-folder = ["conftest"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,89 @@
1
+ """payhero — the official Python SDK for the PayHero Africa API.
2
+
3
+ Collect and disburse money across Africa over a single unified API (mobile
4
+ money, bank and card rails). All requests use HTTP Basic auth with the
5
+ username/password pair from your PayHero dashboard API key.
6
+
7
+ Example:
8
+ from payhero import Client, GlobalPaymentRequest, Customer, ...
9
+
10
+ client = Client(username="...", password="...")
11
+ discovery = client.global_.discovery("KE")
12
+ network = discovery["provider_networks"]["m-pesa"][0]
13
+
14
+ resp = client.global_.create_payment(GlobalPaymentRequest(
15
+ request_type=REQUEST_TYPE_PAYMENT,
16
+ transaction_channel=CHANNEL_MOMO,
17
+ provider="yellowcard",
18
+ amount=5500,
19
+ currency="KES",
20
+ country="KE",
21
+ customer=Customer(first_name="John", last_name="Doe", phone="+254712345678"),
22
+ vendor_config=VendorConfig(vendor_id=63),
23
+ provider_config=ProviderConfig.from_network(network),
24
+ payment_config=PaymentConfig(account_number="+254712345678"),
25
+ ))
26
+ """
27
+
28
+ from .client import Client
29
+ from .errors import ApiError, PayHeroError
30
+ from .models import (
31
+ CHANNEL_BANK,
32
+ CHANNEL_CARD,
33
+ CHANNEL_MOMO,
34
+ DESTINATION_PAYBILL,
35
+ DESTINATION_PHONE,
36
+ DESTINATION_TILL,
37
+ RECIPIENT_MSISDN,
38
+ RECIPIENT_PAYBILL,
39
+ REQUEST_TYPE_PAYMENT,
40
+ REQUEST_TYPE_WITHDRAWAL,
41
+ AccountInformation,
42
+ AccountKYCRequest,
43
+ CollectionRequest,
44
+ ContactInformation,
45
+ CreateAccountRequest,
46
+ Customer,
47
+ EntityInformation,
48
+ GlobalPaymentRequest,
49
+ InviteMemberRequest,
50
+ PaymentConfig,
51
+ PaymentSplit,
52
+ ProviderConfig,
53
+ UpdateAccountRequest,
54
+ VendorConfig,
55
+ WithdrawRequest,
56
+ )
57
+
58
+ __version__ = "0.1.0"
59
+
60
+ __all__ = [
61
+ "CHANNEL_BANK",
62
+ "CHANNEL_CARD",
63
+ "CHANNEL_MOMO",
64
+ "DESTINATION_PAYBILL",
65
+ "DESTINATION_PHONE",
66
+ "DESTINATION_TILL",
67
+ "RECIPIENT_MSISDN",
68
+ "RECIPIENT_PAYBILL",
69
+ "REQUEST_TYPE_PAYMENT",
70
+ "REQUEST_TYPE_WITHDRAWAL",
71
+ "AccountInformation",
72
+ "AccountKYCRequest",
73
+ "ApiError",
74
+ "Client",
75
+ "CollectionRequest",
76
+ "ContactInformation",
77
+ "CreateAccountRequest",
78
+ "Customer",
79
+ "EntityInformation",
80
+ "GlobalPaymentRequest",
81
+ "InviteMemberRequest",
82
+ "PayHeroError",
83
+ "PaymentConfig",
84
+ "PaymentSplit",
85
+ "ProviderConfig",
86
+ "UpdateAccountRequest",
87
+ "VendorConfig",
88
+ "WithdrawRequest",
89
+ ]
@@ -0,0 +1,103 @@
1
+ """HTTP transport for the PayHero Africa API (requests + Basic auth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+ from .errors import ApiError
11
+
12
+ DEFAULT_BASE_URL = "https://api.payhero.africa"
13
+ DEFAULT_TIMEOUT = 60.0 # seconds
14
+ DEFAULT_MAX_RETRIES = 0
15
+ RETRY_BASE_DELAY = 0.5 # seconds; doubled per attempt
16
+
17
+
18
+ class Http:
19
+ def __init__(
20
+ self,
21
+ username: str,
22
+ password: str,
23
+ base_url: str = DEFAULT_BASE_URL,
24
+ timeout: float = DEFAULT_TIMEOUT,
25
+ session: requests.Session | None = None,
26
+ max_retries: int = DEFAULT_MAX_RETRIES,
27
+ ) -> None:
28
+ if not username or not password:
29
+ raise ValueError("PayHero requires an API username and password")
30
+ self._auth = (username, password)
31
+ self.base_url = base_url.rstrip("/")
32
+ self.timeout = timeout
33
+ self.session = session or requests.Session()
34
+ self.max_retries = max(0, max_retries)
35
+
36
+ def request(
37
+ self,
38
+ method: str,
39
+ path: str,
40
+ params: dict[str, Any] | None = None,
41
+ json_body: Any = None,
42
+ ) -> Any:
43
+ body = None
44
+ if isinstance(json_body, dict):
45
+ body = {k: v for k, v in json_body.items() if v is not None}
46
+ else:
47
+ to_dict = getattr(json_body, "to_dict", None)
48
+ body = to_dict() if callable(to_dict) else json_body
49
+
50
+ query = {k: v for k, v in (params or {}).items() if v is not None}
51
+ url = self.base_url + path
52
+ last_response: requests.Response | None = None
53
+
54
+ for attempt in range(self.max_retries + 1):
55
+ response = self.session.request(
56
+ method,
57
+ url,
58
+ params=query,
59
+ json=body,
60
+ auth=self._auth,
61
+ timeout=self.timeout,
62
+ headers={"Accept": "application/json"},
63
+ )
64
+ if not self._should_retry(response):
65
+ last_response = response
66
+ break
67
+ last_response = response
68
+ if attempt < self.max_retries:
69
+ time.sleep(RETRY_BASE_DELAY * (2**attempt))
70
+
71
+ assert last_response is not None # loop always runs at least once
72
+ return self._parse(last_response)
73
+
74
+ @staticmethod
75
+ def _should_retry(response: requests.Response) -> bool:
76
+ """Retry network-level failures and transient statuses (429/5xx)."""
77
+ return response.status_code == 429 or response.status_code >= 500
78
+
79
+ @staticmethod
80
+ def _parse(response: requests.Response) -> Any:
81
+ try:
82
+ payload = response.json()
83
+ except ValueError:
84
+ payload = None
85
+
86
+ if not response.ok:
87
+ error_code = ""
88
+ message = response.reason or "request failed"
89
+ status_code = response.status_code
90
+ if isinstance(payload, dict):
91
+ error_code = payload.get("error_code", "") or ""
92
+ message = payload.get("error_message", message) or message
93
+ raw_status = payload.get("status_code")
94
+ if isinstance(raw_status, int):
95
+ status_code = raw_status
96
+ raise ApiError(
97
+ status_code=status_code,
98
+ error_code=error_code,
99
+ error_message=message,
100
+ http_status=response.status_code,
101
+ body=response.text,
102
+ )
103
+ return payload