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.
- norialabs_pay-0.1.0/.gitignore +24 -0
- norialabs_pay-0.1.0/LICENSE +21 -0
- norialabs_pay-0.1.0/PKG-INFO +126 -0
- norialabs_pay-0.1.0/README.md +105 -0
- norialabs_pay-0.1.0/pyproject.toml +57 -0
- norialabs_pay-0.1.0/src/noria_pay/__init__.py +31 -0
- norialabs_pay-0.1.0/src/noria_pay/client.py +466 -0
- norialabs_pay-0.1.0/src/noria_pay/py.typed +0 -0
- norialabs_pay-0.1.0/src/noria_pay/webhooks.py +56 -0
|
@@ -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"]
|