norialabs-pay 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.
@@ -0,0 +1,24 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.log
7
+ .DS_Store
8
+ coverage/
9
+
10
+ sdks/php/vendor/
11
+ sdks/php/composer.lock
12
+ sdks/php/.phpstan-cache/
13
+ sdks/php/.phpunit.cache/
14
+
15
+ sdks/python/.coverage
16
+ sdks/python/.venv/
17
+ sdks/python/.pytest_cache/
18
+ sdks/python/.ruff_cache/
19
+ sdks/python/dist/
20
+ **/__pycache__/
21
+
22
+ apps/console/dist/
23
+ *.tsbuildinfo
24
+ packages/*/test/*.d.ts
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noria Labs
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,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: norialabs-pay
3
+ Version: 0.1.0
4
+ Summary: Python client for Noria Pay: collect, pay out and refund across M-PESA, SasaPay and Paystack through one internal service instead of wiring a provider into every product.
5
+ Project-URL: Homepage, https://github.com/norialabs/pay
6
+ Project-URL: Source, https://github.com/norialabs/pay
7
+ Project-URL: Issues, https://github.com/norialabs/pay/issues
8
+ Author-email: Joseph Gitonga <thekiharani@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: kenya,mpesa,noria,pay,payments,paystack,sasapay
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Office/Business :: Financial
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: httpx>=0.28.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # `norialabs-pay`
23
+
24
+ Python client for **Noria Pay**. Collect, pay out and refund across M-PESA, SasaPay and
25
+ Paystack through one internal service instead of wiring a provider into every product.
26
+
27
+ Sync and async, typed, `httpx` the only dependency.
28
+
29
+ ```bash
30
+ pip install norialabs-pay
31
+ ```
32
+
33
+ ## Collecting
34
+
35
+ An idempotency key is a required argument, not an option you can forget. Use something your
36
+ own system already owns — the invoice id plus the attempt — not a fresh uuid per call, or the
37
+ key protects nothing.
38
+
39
+ ```python
40
+ from noria_pay import Pay
41
+
42
+ pay = Pay(api_key=os.environ["PAY_API_KEY"], base_url="https://pay.noria.co.ke")
43
+
44
+ charge = pay.charges.create(
45
+ {
46
+ "amount_minor": 150_000, # KES 1,500.00
47
+ "channel": "mpesa",
48
+ "reference": invoice.number, # yours; the service never interprets it
49
+ "description": "April rent",
50
+ "payer_phone": customer.phone,
51
+ "metadata": {"invoice_id": str(invoice.id)},
52
+ },
53
+ f"invoice:{invoice.id}:{attempt}",
54
+ )
55
+
56
+ # charge["next_action"]["type"] is "await_payer" (the STK prompt is already sent)
57
+ # or "redirect" (send them to ["url"]).
58
+ ```
59
+
60
+ `AsyncPay` has the same surface with `await`, and both work as context managers.
61
+
62
+ ## The one error that is not like the others
63
+
64
+ A `504` carrying `outcome_unknown` means the provider never answered. The payer **may already
65
+ have been debited**. The client never retries it, and neither should you.
66
+
67
+ ```python
68
+ from noria_pay import PayError
69
+
70
+ try:
71
+ pay.charges.create(charge, key)
72
+ except PayError as error:
73
+ if error.outcome_unknown:
74
+ settled = pay.wait_for_settlement(error.transaction_id)
75
+ # resolve against settled["status"], never by charging again
76
+ raise
77
+ ```
78
+
79
+ ## Receiving webhooks
80
+
81
+ Verify against the **raw body**. A framework that hands you a parsed dict has already lost the
82
+ byte order, and re-serialising it will not match the signature.
83
+
84
+ ```python
85
+ from noria_pay import verify_webhook
86
+
87
+ @app.post("/hooks/pay")
88
+ async def hook(request: Request):
89
+ event = verify_webhook(
90
+ await request.body(),
91
+ request.headers["pay-signature"],
92
+ os.environ["PAY_WEBHOOK_SECRET"],
93
+ )
94
+
95
+ if event["type"] == "succeeded":
96
+ # settled_minor is what actually moved, which is not always amount_minor: a payer can
97
+ # underpay an STK prompt, and Paystack deducts its fee before settlement.
98
+ await settle(event["data"]["reference"], event["data"]["settled_minor"], event["data"]["fee_minor"])
99
+
100
+ return Response(status_code=204)
101
+ ```
102
+
103
+ It raises on a bad signature, a wrong secret, or a timestamp outside the five-minute
104
+ tolerance, so a replay cannot post twice.
105
+
106
+ ## Amounts
107
+
108
+ Always minor units. The service rejects a KES amount that is not a whole number of shillings
109
+ rather than rounding it, because the rails settle whole shillings and a silent round only
110
+ surfaces in reconciliation.
111
+
112
+ ## Surfaces
113
+
114
+ `pay.charges` · `payouts` · `refunds` · `transactions` · `payment_methods` · `payment_links` ·
115
+ `webhooks`
116
+
117
+ ## This is not `noriapay`
118
+
119
+ That package wraps the providers directly and this service uses it internally. This one talks
120
+ to Noria Pay, which owns the persistence, the callbacks and the reconciliation that `noriapay`
121
+ deliberately leaves to you.
122
+
123
+ Events are not delivered in order. Delivery runs in parallel and retries, so a later event can
124
+ arrive first; order on `created_at`, which is when the event happened rather than when the
125
+ attempt went out. Every payload also carries the transaction's status as it stood at delivery,
126
+ so acting on that is safe whatever order they arrive in.
@@ -0,0 +1,105 @@
1
+ # `norialabs-pay`
2
+
3
+ Python client for **Noria Pay**. Collect, pay out and refund across M-PESA, SasaPay and
4
+ Paystack through one internal service instead of wiring a provider into every product.
5
+
6
+ Sync and async, typed, `httpx` the only dependency.
7
+
8
+ ```bash
9
+ pip install norialabs-pay
10
+ ```
11
+
12
+ ## Collecting
13
+
14
+ An idempotency key is a required argument, not an option you can forget. Use something your
15
+ own system already owns — the invoice id plus the attempt — not a fresh uuid per call, or the
16
+ key protects nothing.
17
+
18
+ ```python
19
+ from noria_pay import Pay
20
+
21
+ pay = Pay(api_key=os.environ["PAY_API_KEY"], base_url="https://pay.noria.co.ke")
22
+
23
+ charge = pay.charges.create(
24
+ {
25
+ "amount_minor": 150_000, # KES 1,500.00
26
+ "channel": "mpesa",
27
+ "reference": invoice.number, # yours; the service never interprets it
28
+ "description": "April rent",
29
+ "payer_phone": customer.phone,
30
+ "metadata": {"invoice_id": str(invoice.id)},
31
+ },
32
+ f"invoice:{invoice.id}:{attempt}",
33
+ )
34
+
35
+ # charge["next_action"]["type"] is "await_payer" (the STK prompt is already sent)
36
+ # or "redirect" (send them to ["url"]).
37
+ ```
38
+
39
+ `AsyncPay` has the same surface with `await`, and both work as context managers.
40
+
41
+ ## The one error that is not like the others
42
+
43
+ A `504` carrying `outcome_unknown` means the provider never answered. The payer **may already
44
+ have been debited**. The client never retries it, and neither should you.
45
+
46
+ ```python
47
+ from noria_pay import PayError
48
+
49
+ try:
50
+ pay.charges.create(charge, key)
51
+ except PayError as error:
52
+ if error.outcome_unknown:
53
+ settled = pay.wait_for_settlement(error.transaction_id)
54
+ # resolve against settled["status"], never by charging again
55
+ raise
56
+ ```
57
+
58
+ ## Receiving webhooks
59
+
60
+ Verify against the **raw body**. A framework that hands you a parsed dict has already lost the
61
+ byte order, and re-serialising it will not match the signature.
62
+
63
+ ```python
64
+ from noria_pay import verify_webhook
65
+
66
+ @app.post("/hooks/pay")
67
+ async def hook(request: Request):
68
+ event = verify_webhook(
69
+ await request.body(),
70
+ request.headers["pay-signature"],
71
+ os.environ["PAY_WEBHOOK_SECRET"],
72
+ )
73
+
74
+ if event["type"] == "succeeded":
75
+ # settled_minor is what actually moved, which is not always amount_minor: a payer can
76
+ # underpay an STK prompt, and Paystack deducts its fee before settlement.
77
+ await settle(event["data"]["reference"], event["data"]["settled_minor"], event["data"]["fee_minor"])
78
+
79
+ return Response(status_code=204)
80
+ ```
81
+
82
+ It raises on a bad signature, a wrong secret, or a timestamp outside the five-minute
83
+ tolerance, so a replay cannot post twice.
84
+
85
+ ## Amounts
86
+
87
+ Always minor units. The service rejects a KES amount that is not a whole number of shillings
88
+ rather than rounding it, because the rails settle whole shillings and a silent round only
89
+ surfaces in reconciliation.
90
+
91
+ ## Surfaces
92
+
93
+ `pay.charges` · `payouts` · `refunds` · `transactions` · `payment_methods` · `payment_links` ·
94
+ `webhooks`
95
+
96
+ ## This is not `noriapay`
97
+
98
+ That package wraps the providers directly and this service uses it internally. This one talks
99
+ to Noria Pay, which owns the persistence, the callbacks and the reconciliation that `noriapay`
100
+ deliberately leaves to you.
101
+
102
+ Events are not delivered in order. Delivery runs in parallel and retries, so a later event can
103
+ arrive first; order on `created_at`, which is when the event happened rather than when the
104
+ attempt went out. Every payload also carries the transaction's status as it stood at delivery,
105
+ so acting on that is safe whatever order they arrive in.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "norialabs-pay"
3
+ version = "0.1.0"
4
+ description = "Python client for Noria Pay: collect, pay out and refund across M-PESA, SasaPay and Paystack through one internal service instead of wiring a provider into every product."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Joseph Gitonga", email = "thekiharani@gmail.com" }]
10
+ keywords = ["payments", "mpesa", "sasapay", "paystack", "kenya", "noria", "pay"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: Office/Business :: Financial",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = ["httpx>=0.28.1"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/norialabs/pay"
23
+ Source = "https://github.com/norialabs/pay"
24
+ Issues = "https://github.com/norialabs/pay/issues"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.0.1",
29
+ "pytest-asyncio>=1.3.0",
30
+ "pytest-cov>=7.0.0",
31
+ "ruff>=0.14.5",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src/noria_pay", "README.md", "LICENSE", "pyproject.toml"]
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/noria_pay"]
43
+
44
+ [tool.hatch.build.targets.wheel.force-include]
45
+ "src/noria_pay/py.typed" = "noria_pay/py.typed"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ asyncio_mode = "auto"
50
+ addopts = "-q --tb=short --cov=noria_pay --cov-report=term-missing --cov-fail-under=95"
51
+
52
+ [tool.ruff]
53
+ line-length = 110
54
+ target-version = "py313"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
@@ -0,0 +1,31 @@
1
+ from noria_pay.client import (
2
+ DEFAULT_BASE_URL,
3
+ AsyncPay,
4
+ Channel,
5
+ Charge,
6
+ Direction,
7
+ Pay,
8
+ PayError,
9
+ Payout,
10
+ Provider,
11
+ RecordedCharge,
12
+ Refund,
13
+ Status,
14
+ )
15
+ from noria_pay.webhooks import verify_webhook
16
+
17
+ __all__ = [
18
+ "DEFAULT_BASE_URL",
19
+ "AsyncPay",
20
+ "Channel",
21
+ "Charge",
22
+ "Direction",
23
+ "Pay",
24
+ "PayError",
25
+ "Payout",
26
+ "Provider",
27
+ "RecordedCharge",
28
+ "Refund",
29
+ "Status",
30
+ "verify_webhook",
31
+ ]
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from typing import Any, Literal, NotRequired, TypedDict
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+
11
+ DEFAULT_BASE_URL = "https://pay.noria.co.ke"
12
+ RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
13
+
14
+ # 409 is a conflict everywhere else and not worth another attempt. This one says the same key is
15
+ # still in flight on another request, which resolves by waiting.
16
+ IN_FLIGHT_CODE = "conflict"
17
+
18
+ # 504 is retryable almost everywhere else. Here it means the provider never answered, the
19
+ # charge is in `unknown`, and the money may already have moved, so it is polled and not re-sent.
20
+ NON_RETRYABLE_CODES = frozenset(
21
+ {
22
+ "outcome_unknown",
23
+ "idempotency_mismatch",
24
+ "provider_rejected",
25
+ "provider_not_configured",
26
+ "duplicate_receipt",
27
+ "not_refundable",
28
+ "insufficient_balance",
29
+ }
30
+ )
31
+ TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled", "expired", "reversed", "unknown"})
32
+
33
+ Json = dict[str, Any]
34
+ Provider = Literal["daraja", "sasapay", "paystack", "kcb_buni", "manual"]
35
+ Channel = Literal["mpesa", "airtel", "card", "bank", "wallet", "cash"]
36
+ Direction = Literal["collection", "payout", "refund"]
37
+ Status = Literal[
38
+ "pending", "processing", "succeeded", "failed", "cancelled", "expired", "reversed", "unknown"
39
+ ]
40
+
41
+
42
+ class Charge(TypedDict):
43
+ amount_minor: int
44
+ channel: Channel
45
+ reference: str
46
+ description: str
47
+ currency: NotRequired[str]
48
+ provider: NotRequired[Provider]
49
+ mode: NotRequired[Literal["live", "sandbox"]]
50
+ payer_phone: NotRequired[str]
51
+ payer_email: NotRequired[str]
52
+ payer_name: NotRequired[str]
53
+ return_url: NotRequired[str]
54
+ expires_in_seconds: NotRequired[int]
55
+ metadata: NotRequired[dict[str, str]]
56
+
57
+
58
+ class RecordedCharge(TypedDict):
59
+ amount_minor: int
60
+ channel: Channel
61
+ reference: str
62
+ description: str
63
+ currency: NotRequired[str]
64
+ receipt: NotRequired[str]
65
+ payer_phone: NotRequired[str]
66
+ payer_name: NotRequired[str]
67
+ paid_at: NotRequired[str]
68
+ note: NotRequired[str]
69
+ metadata: NotRequired[dict[str, str]]
70
+
71
+
72
+ class Payout(TypedDict):
73
+ amount_minor: int
74
+ channel: Channel
75
+ reference: str
76
+ description: str
77
+ recipient_name: str
78
+ currency: NotRequired[str]
79
+ provider: NotRequired[Provider]
80
+ recipient_phone: NotRequired[str]
81
+ recipient_account: NotRequired[str]
82
+ recipient_channel_code: NotRequired[str]
83
+ metadata: NotRequired[dict[str, str]]
84
+
85
+
86
+ class Refund(TypedDict):
87
+ transaction_id: str
88
+ reason: str
89
+ amount_minor: NotRequired[int]
90
+ metadata: NotRequired[dict[str, str]]
91
+
92
+
93
+ class PayError(Exception):
94
+ def __init__(
95
+ self,
96
+ code: str,
97
+ status: int,
98
+ message: str,
99
+ details: Any = None,
100
+ request_id: str | None = None,
101
+ ) -> None:
102
+ super().__init__(message)
103
+ self.code = code
104
+ self.status = status
105
+ self.message = message
106
+ self.details = details
107
+ self.request_id = request_id
108
+
109
+ def __repr__(self) -> str:
110
+ return f"PayError(code={self.code!r}, status={self.status}, message={self.message!r})"
111
+
112
+ @classmethod
113
+ def from_response(cls, status: int, body: Any) -> PayError:
114
+ error = body.get("error") if isinstance(body, dict) else None
115
+ error = error if isinstance(error, dict) else {}
116
+ return cls(
117
+ code=error.get("code") or "internal_error",
118
+ status=status,
119
+ message=error.get("message") or f"Request failed with status {status}",
120
+ details=error.get("details"),
121
+ request_id=error.get("request_id"),
122
+ )
123
+
124
+ @property
125
+ def outcome_unknown(self) -> bool:
126
+ """The provider never answered. The payer may already have been debited."""
127
+ return self.code == "outcome_unknown"
128
+
129
+ @property
130
+ def transaction_id(self) -> str | None:
131
+ if isinstance(self.details, dict):
132
+ value = self.details.get("transaction_id")
133
+ return value if isinstance(value, str) else None
134
+ return None
135
+
136
+ @property
137
+ def retryable(self) -> bool:
138
+ if self.code in NON_RETRYABLE_CODES:
139
+ return False
140
+ if self.status == 409:
141
+ return self.code == IN_FLIGHT_CODE
142
+ return self.status == 0 or self.status in RETRYABLE_STATUSES
143
+
144
+
145
+ def _query(**parameters: Any) -> Json:
146
+ return {key: value for key, value in parameters.items() if value not in (None, "")}
147
+
148
+
149
+ def _backoff(attempt: int) -> float:
150
+ return min(2.0, 0.2 * 2 ** (attempt - 1)) * (0.5 + random.random())
151
+
152
+
153
+ def _decode(response: httpx.Response) -> Json:
154
+ if response.status_code == 204 or not response.content:
155
+ return {}
156
+ try:
157
+ return response.json()
158
+ except ValueError:
159
+ # An ingress or proxy answering with HTML, which is what a caller sees exactly when
160
+ # the service is down. It must still arrive as a PayError like everything else.
161
+ if response.is_success:
162
+ raise PayError(
163
+ code="internal_error",
164
+ status=response.status_code,
165
+ message="The response was not JSON",
166
+ ) from None
167
+ return {}
168
+
169
+
170
+ # quote() defaults to safe="/", which leaves slashes alone: an id of "../operator/projects" would
171
+ # resolve to a different endpoint. Nothing that goes in a path segment may keep its slashes.
172
+ def _segment(value: str) -> str:
173
+ return quote(value, safe="")
174
+
175
+
176
+ def _key(idempotency_key: str) -> dict[str, str]:
177
+ return {"idempotency-key": idempotency_key}
178
+
179
+
180
+ class _Namespace:
181
+ def __init__(self, client: Pay | AsyncPay) -> None:
182
+ self._client = client
183
+
184
+
185
+ class Charges(_Namespace):
186
+ def create(self, charge: Charge, idempotency_key: str) -> Any:
187
+ return self._client.request("POST", "/v1/charges", dict(charge), headers=_key(idempotency_key))
188
+
189
+ def record(self, charge: RecordedCharge, idempotency_key: str) -> Any:
190
+ return self._client.request("POST", "/v1/charges/record", dict(charge), headers=_key(idempotency_key))
191
+
192
+ def cancel(self, transaction_id: str) -> Any:
193
+ return self._client.request("POST", f"/v1/charges/{_segment(transaction_id)}/cancel")
194
+
195
+
196
+ class Payouts(_Namespace):
197
+ def create(self, payout: Payout, idempotency_key: str) -> Any:
198
+ return self._client.request("POST", "/v1/payouts", dict(payout), headers=_key(idempotency_key))
199
+
200
+
201
+ class Refunds(_Namespace):
202
+ def create(self, refund: Refund, idempotency_key: str) -> Any:
203
+ return self._client.request("POST", "/v1/refunds", dict(refund), headers=_key(idempotency_key))
204
+
205
+
206
+ class Transactions(_Namespace):
207
+ def get(self, transaction_id: str) -> Any:
208
+ return self._client.request("GET", f"/v1/transactions/{_segment(transaction_id)}")
209
+
210
+ def list(
211
+ self,
212
+ direction: Direction | None = None,
213
+ status: Status | None = None,
214
+ provider: Provider | None = None,
215
+ channel: Channel | None = None,
216
+ reference: str | None = None,
217
+ receipt: str | None = None,
218
+ limit: int | None = None,
219
+ cursor: str | None = None,
220
+ ) -> Any:
221
+ return self._client.request(
222
+ "GET",
223
+ "/v1/transactions",
224
+ params=_query(
225
+ direction=direction,
226
+ status=status,
227
+ provider=provider,
228
+ channel=channel,
229
+ reference=reference,
230
+ receipt=receipt,
231
+ limit=limit,
232
+ cursor=cursor,
233
+ ),
234
+ )
235
+
236
+ def events(self, transaction_id: str, limit: int | None = None) -> Any:
237
+ return self._client.request(
238
+ "GET", f"/v1/transactions/{_segment(transaction_id)}/events", params=_query(limit=limit)
239
+ )
240
+
241
+
242
+ class PaymentMethods(_Namespace):
243
+ def list(self) -> Any:
244
+ return self._client.request("GET", "/v1/payment-methods")
245
+
246
+ def set(self, provider: Provider, method: Json) -> Any:
247
+ return self._client.request("PUT", f"/v1/payment-methods/{_segment(provider)}", method)
248
+
249
+ def verify(self, provider: Provider) -> Any:
250
+ return self._client.request("POST", f"/v1/payment-methods/{_segment(provider)}/verify")
251
+
252
+ def remove(self, provider: Provider) -> Any:
253
+ return self._client.request("DELETE", f"/v1/payment-methods/{_segment(provider)}")
254
+
255
+
256
+ class PaymentLinks(_Namespace):
257
+ def create(self, link: Json) -> Any:
258
+ return self._client.request("POST", "/v1/payment-links", link)
259
+
260
+ def get(self, link_id: str) -> Any:
261
+ return self._client.request("GET", f"/v1/payment-links/{_segment(link_id)}")
262
+
263
+ def list(self, limit: int | None = None, cursor: str | None = None) -> Any:
264
+ return self._client.request("GET", "/v1/payment-links", params=_query(limit=limit, cursor=cursor))
265
+
266
+ def update(self, link_id: str, link: Json) -> Any:
267
+ return self._client.request("PATCH", f"/v1/payment-links/{_segment(link_id)}", link)
268
+
269
+ def close(self, link_id: str) -> Any:
270
+ return self._client.request("DELETE", f"/v1/payment-links/{_segment(link_id)}")
271
+
272
+
273
+ class Statements(_Namespace):
274
+ def import_(self, statement: Json) -> Any:
275
+ """Settle what matches and report the rest; the same rows twice replay the first result."""
276
+ return self._client.request("POST", "/v1/statements", statement)
277
+
278
+ def get(self, import_id: str) -> Any:
279
+ return self._client.request("GET", f"/v1/statements/{_segment(import_id)}")
280
+
281
+ def list(self, limit: int | None = None, cursor: str | None = None) -> Any:
282
+ return self._client.request("GET", "/v1/statements", params=_query(limit=limit, cursor=cursor))
283
+
284
+
285
+ class Webhooks(_Namespace):
286
+ def create(self, url: str, event_types: list[str] | None = None, description: str | None = None) -> Any:
287
+ return self._client.request(
288
+ "POST",
289
+ "/v1/webhook-endpoints",
290
+ {"url": url, "event_types": event_types or [], "description": description},
291
+ )
292
+
293
+ def list(self) -> Any:
294
+ return self._client.request("GET", "/v1/webhook-endpoints")
295
+
296
+ def update(self, endpoint_id: str, changes: Json) -> Any:
297
+ return self._client.request("PATCH", f"/v1/webhook-endpoints/{_segment(endpoint_id)}", changes)
298
+
299
+ def remove(self, endpoint_id: str) -> Any:
300
+ return self._client.request("DELETE", f"/v1/webhook-endpoints/{_segment(endpoint_id)}")
301
+
302
+
303
+ class _BaseClient:
304
+ def __init__(
305
+ self,
306
+ api_key: str,
307
+ base_url: str = DEFAULT_BASE_URL,
308
+ timeout: float = 30.0,
309
+ retries: int = 2,
310
+ ) -> None:
311
+ if not api_key:
312
+ raise PayError("validation_error", 0, "A Noria Pay API key is required")
313
+
314
+ self._api_key = api_key
315
+ self._base_url = base_url.rstrip("/")
316
+ self._timeout = timeout
317
+ self._retries = retries
318
+
319
+ self.charges = Charges(self) # type: ignore[arg-type]
320
+ self.payouts = Payouts(self) # type: ignore[arg-type]
321
+ self.refunds = Refunds(self) # type: ignore[arg-type]
322
+ self.transactions = Transactions(self) # type: ignore[arg-type]
323
+ self.payment_methods = PaymentMethods(self) # type: ignore[arg-type]
324
+ self.payment_links = PaymentLinks(self) # type: ignore[arg-type]
325
+ self.statements = Statements(self) # type: ignore[arg-type]
326
+ self.webhooks = Webhooks(self) # type: ignore[arg-type]
327
+
328
+ @property
329
+ def _headers(self) -> dict[str, str]:
330
+ return {"authorization": f"Bearer {self._api_key}", "accept": "application/json"}
331
+
332
+ # A write that moves money carries an Idempotency-Key, so repeating it after a dropped
333
+ # connection replays rather than charges again. Without one it is not safe to repeat.
334
+ def _may_repeat(self, method: str, headers: dict[str, str] | None) -> bool:
335
+ return method == "GET" or bool(headers and "idempotency-key" in headers)
336
+
337
+
338
+ class Pay(_BaseClient):
339
+ def __init__(self, *args: Any, transport: httpx.BaseTransport | None = None, **kwargs: Any) -> None:
340
+ super().__init__(*args, **kwargs)
341
+ self._http = httpx.Client(timeout=self._timeout, transport=transport)
342
+
343
+ def close(self) -> None:
344
+ self._http.close()
345
+
346
+ def __enter__(self) -> Pay:
347
+ return self
348
+
349
+ def __exit__(self, *_: object) -> None:
350
+ self.close()
351
+
352
+ def request(
353
+ self,
354
+ method: str,
355
+ path: str,
356
+ body: Json | None = None,
357
+ params: Json | None = None,
358
+ headers: dict[str, str] | None = None,
359
+ ) -> Json:
360
+ last: PayError | None = None
361
+
362
+ for attempt in range(1, self._retries + 2):
363
+ if attempt > 1:
364
+ time.sleep(_backoff(attempt - 1))
365
+
366
+ try:
367
+ response = self._http.request(
368
+ method,
369
+ f"{self._base_url}{path}",
370
+ json=body,
371
+ params=params,
372
+ headers={**self._headers, **(headers or {})},
373
+ )
374
+ except httpx.HTTPError as exception:
375
+ last = PayError("network_error", 0, str(exception))
376
+ if not self._may_repeat(method, headers):
377
+ raise last from exception
378
+ continue
379
+
380
+ if response.is_success:
381
+ return _decode(response)
382
+
383
+ last = PayError.from_response(response.status_code, _decode(response))
384
+ if not last.retryable:
385
+ raise last
386
+
387
+ raise last or PayError("network_error", 0, "Request failed")
388
+
389
+ def wait_for_settlement(
390
+ self, transaction_id: str, timeout_seconds: float = 120.0, interval_seconds: float = 3.0
391
+ ) -> Json:
392
+ """Poll a charge the payer is still answering, returning once it is terminal."""
393
+ deadline = time.monotonic() + timeout_seconds
394
+
395
+ while True:
396
+ transaction = self.transactions.get(transaction_id)
397
+ if transaction.get("status") in TERMINAL_STATUSES:
398
+ return transaction
399
+ if time.monotonic() + interval_seconds >= deadline:
400
+ return transaction
401
+ time.sleep(interval_seconds)
402
+
403
+
404
+ class AsyncPay(_BaseClient):
405
+ def __init__(self, *args: Any, transport: httpx.AsyncBaseTransport | None = None, **kwargs: Any) -> None:
406
+ super().__init__(*args, **kwargs)
407
+ self._http = httpx.AsyncClient(timeout=self._timeout, transport=transport)
408
+
409
+ async def aclose(self) -> None:
410
+ await self._http.aclose()
411
+
412
+ async def __aenter__(self) -> AsyncPay:
413
+ return self
414
+
415
+ async def __aexit__(self, *_: object) -> None:
416
+ await self.aclose()
417
+
418
+ async def request(
419
+ self,
420
+ method: str,
421
+ path: str,
422
+ body: Json | None = None,
423
+ params: Json | None = None,
424
+ headers: dict[str, str] | None = None,
425
+ ) -> Json:
426
+ last: PayError | None = None
427
+
428
+ for attempt in range(1, self._retries + 2):
429
+ if attempt > 1:
430
+ await asyncio.sleep(_backoff(attempt - 1))
431
+
432
+ try:
433
+ response = await self._http.request(
434
+ method,
435
+ f"{self._base_url}{path}",
436
+ json=body,
437
+ params=params,
438
+ headers={**self._headers, **(headers or {})},
439
+ )
440
+ except httpx.HTTPError as exception:
441
+ last = PayError("network_error", 0, str(exception))
442
+ if not self._may_repeat(method, headers):
443
+ raise last from exception
444
+ continue
445
+
446
+ if response.is_success:
447
+ return _decode(response)
448
+
449
+ last = PayError.from_response(response.status_code, _decode(response))
450
+ if not last.retryable:
451
+ raise last
452
+
453
+ raise last or PayError("network_error", 0, "Request failed")
454
+
455
+ async def wait_for_settlement(
456
+ self, transaction_id: str, timeout_seconds: float = 120.0, interval_seconds: float = 3.0
457
+ ) -> Json:
458
+ deadline = time.monotonic() + timeout_seconds
459
+
460
+ while True:
461
+ transaction = await self.transactions.get(transaction_id)
462
+ if transaction.get("status") in TERMINAL_STATUSES:
463
+ return transaction
464
+ if time.monotonic() + interval_seconds >= deadline:
465
+ return transaction
466
+ await asyncio.sleep(interval_seconds)
File without changes
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any
8
+
9
+ from noria_pay.client import PayError
10
+
11
+
12
+ def verify_webhook(
13
+ payload: str | bytes,
14
+ signature: str,
15
+ secret: str,
16
+ tolerance_seconds: int = 300,
17
+ now: int | None = None,
18
+ ) -> dict[str, Any]:
19
+ """Verify against the raw request body.
20
+
21
+ A framework that hands you a parsed object has already lost the byte order, and
22
+ re-serialising it will not match the signature.
23
+ """
24
+ timestamp, provided = _parse(signature)
25
+
26
+ if abs((now if now is not None else int(time.time())) - timestamp) > tolerance_seconds:
27
+ raise PayError("validation_error", 400, "Signature timestamp is outside the tolerance window")
28
+
29
+ body = payload.decode() if isinstance(payload, bytes) else payload
30
+ expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
31
+
32
+ if not hmac.compare_digest(expected, provided):
33
+ raise PayError("unauthorized", 401, "Invalid webhook signature")
34
+
35
+ event = json.loads(body)
36
+
37
+ if not isinstance(event, dict):
38
+ raise PayError("validation_error", 400, "Webhook payload is not a JSON object")
39
+
40
+ return event
41
+
42
+
43
+ def _parse(signature: str) -> tuple[int, str]:
44
+ parts: dict[str, str] = {}
45
+
46
+ for pair in signature.split(","):
47
+ key, _, value = pair.strip().partition("=")
48
+ if value:
49
+ parts[key] = value
50
+
51
+ timestamp = parts.get("t", "")
52
+
53
+ if not timestamp.isdigit() or "v1" not in parts:
54
+ raise PayError("validation_error", 400, "Malformed pay-signature header")
55
+
56
+ return int(timestamp), parts["v1"]