pofsis-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.
- pofsis_pay-0.1.0/PKG-INFO +37 -0
- pofsis_pay-0.1.0/README.md +23 -0
- pofsis_pay-0.1.0/pyproject.toml +29 -0
- pofsis_pay-0.1.0/setup.cfg +4 -0
- pofsis_pay-0.1.0/src/pofsis_pay.egg-info/PKG-INFO +37 -0
- pofsis_pay-0.1.0/src/pofsis_pay.egg-info/SOURCES.txt +13 -0
- pofsis_pay-0.1.0/src/pofsis_pay.egg-info/dependency_links.txt +1 -0
- pofsis_pay-0.1.0/src/pofsis_pay.egg-info/requires.txt +5 -0
- pofsis_pay-0.1.0/src/pofsis_pay.egg-info/top_level.txt +1 -0
- pofsis_pay-0.1.0/src/pofsis_pofpay/__init__.py +15 -0
- pofsis_pay-0.1.0/src/pofsis_pofpay/callbacks.py +98 -0
- pofsis_pay-0.1.0/src/pofsis_pofpay/client.py +159 -0
- pofsis_pay-0.1.0/src/pofsis_pofpay/errors.py +13 -0
- pofsis_pay-0.1.0/src/pofsis_pofpay/models.py +31 -0
- pofsis_pay-0.1.0/tests/test_sdk.py +81 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pofsis-pay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Internal POFSIS client for IAM-authenticated POFPay integrations
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: boto3<2,>=1.35
|
|
11
|
+
Requires-Dist: httpx<1,>=0.28
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# pofsis-pay
|
|
16
|
+
|
|
17
|
+
Private Python SDK for IAM-authenticated POFPay machine requests and signed callback validation.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pofsis_pofpay import POFPayClient
|
|
21
|
+
|
|
22
|
+
client = POFPayClient(
|
|
23
|
+
api_base_url="https://pofpay.example.com",
|
|
24
|
+
region="ap-southeast-1",
|
|
25
|
+
)
|
|
26
|
+
checkout = client.create_payment_request(snapshot, "tool:order-id")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Lambda uses the standard AWS credential chain, so no access key is stored in application
|
|
30
|
+
configuration. Install the same pinned version locally and in the published Python 3.12 Layer.
|
|
31
|
+
|
|
32
|
+
`buildspec-publish.yml` tests, builds, validates, and publishes an immutable package version to
|
|
33
|
+
PyPI using a protected `PYPI_API_TOKEN`. `buildspec-layer.yml` installs that exact public version
|
|
34
|
+
from `https://pypi.org/simple` and publishes the shared Lambda Layer. Runtime Lambdas need no PyPI
|
|
35
|
+
credentials or package-registry access.
|
|
36
|
+
|
|
37
|
+
The PyPI distribution is `pofsis-pay`; the Python import remains `pofsis_pofpay`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# pofsis-pay
|
|
2
|
+
|
|
3
|
+
Private Python SDK for IAM-authenticated POFPay machine requests and signed callback validation.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from pofsis_pofpay import POFPayClient
|
|
7
|
+
|
|
8
|
+
client = POFPayClient(
|
|
9
|
+
api_base_url="https://pofpay.example.com",
|
|
10
|
+
region="ap-southeast-1",
|
|
11
|
+
)
|
|
12
|
+
checkout = client.create_payment_request(snapshot, "tool:order-id")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Lambda uses the standard AWS credential chain, so no access key is stored in application
|
|
16
|
+
configuration. Install the same pinned version locally and in the published Python 3.12 Layer.
|
|
17
|
+
|
|
18
|
+
`buildspec-publish.yml` tests, builds, validates, and publishes an immutable package version to
|
|
19
|
+
PyPI using a protected `PYPI_API_TOKEN`. `buildspec-layer.yml` installs that exact public version
|
|
20
|
+
from `https://pypi.org/simple` and publishes the shared Lambda Layer. Runtime Lambdas need no PyPI
|
|
21
|
+
credentials or package-registry access.
|
|
22
|
+
|
|
23
|
+
The PyPI distribution is `pofsis-pay`; the Python import remains `pofsis_pofpay`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pofsis-pay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Internal POFSIS client for IAM-authenticated POFPay integrations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"boto3>=1.35,<2",
|
|
18
|
+
"httpx>=0.28,<1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=8,<10"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
pythonpath = ["src"]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pofsis-pay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Internal POFSIS client for IAM-authenticated POFPay integrations
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: boto3<2,>=1.35
|
|
11
|
+
Requires-Dist: httpx<1,>=0.28
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# pofsis-pay
|
|
16
|
+
|
|
17
|
+
Private Python SDK for IAM-authenticated POFPay machine requests and signed callback validation.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pofsis_pofpay import POFPayClient
|
|
21
|
+
|
|
22
|
+
client = POFPayClient(
|
|
23
|
+
api_base_url="https://pofpay.example.com",
|
|
24
|
+
region="ap-southeast-1",
|
|
25
|
+
)
|
|
26
|
+
checkout = client.create_payment_request(snapshot, "tool:order-id")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Lambda uses the standard AWS credential chain, so no access key is stored in application
|
|
30
|
+
configuration. Install the same pinned version locally and in the published Python 3.12 Layer.
|
|
31
|
+
|
|
32
|
+
`buildspec-publish.yml` tests, builds, validates, and publishes an immutable package version to
|
|
33
|
+
PyPI using a protected `PYPI_API_TOKEN`. `buildspec-layer.yml` installs that exact public version
|
|
34
|
+
from `https://pypi.org/simple` and publishes the shared Lambda Layer. Runtime Lambdas need no PyPI
|
|
35
|
+
credentials or package-registry access.
|
|
36
|
+
|
|
37
|
+
The PyPI distribution is `pofsis-pay`; the Python import remains `pofsis_pofpay`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pofsis_pay.egg-info/PKG-INFO
|
|
4
|
+
src/pofsis_pay.egg-info/SOURCES.txt
|
|
5
|
+
src/pofsis_pay.egg-info/dependency_links.txt
|
|
6
|
+
src/pofsis_pay.egg-info/requires.txt
|
|
7
|
+
src/pofsis_pay.egg-info/top_level.txt
|
|
8
|
+
src/pofsis_pofpay/__init__.py
|
|
9
|
+
src/pofsis_pofpay/callbacks.py
|
|
10
|
+
src/pofsis_pofpay/client.py
|
|
11
|
+
src/pofsis_pofpay/errors.py
|
|
12
|
+
src/pofsis_pofpay/models.py
|
|
13
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pofsis_pofpay
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .callbacks import validate_payment_snapshot, verify_callback
|
|
2
|
+
from .client import POFPayClient, derive_idempotency_key
|
|
3
|
+
from .errors import POFPayError
|
|
4
|
+
from .models import CallbackEvent, PaymentRequestCreated, PaymentRequestStatus
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CallbackEvent",
|
|
8
|
+
"POFPayClient",
|
|
9
|
+
"POFPayError",
|
|
10
|
+
"PaymentRequestCreated",
|
|
11
|
+
"PaymentRequestStatus",
|
|
12
|
+
"derive_idempotency_key",
|
|
13
|
+
"validate_payment_snapshot",
|
|
14
|
+
"verify_callback",
|
|
15
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import POFPayError
|
|
9
|
+
from .models import CallbackEvent, PaymentRequestStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_callback(
|
|
13
|
+
raw_body: bytes,
|
|
14
|
+
headers: Mapping[str, str],
|
|
15
|
+
callback_secret: str,
|
|
16
|
+
tolerance_seconds: int = 300,
|
|
17
|
+
*,
|
|
18
|
+
now: int | None = None,
|
|
19
|
+
) -> CallbackEvent:
|
|
20
|
+
if not callback_secret:
|
|
21
|
+
raise POFPayError(
|
|
22
|
+
"POFPay callback verification is not configured",
|
|
23
|
+
status_code=503,
|
|
24
|
+
code="POFPAY_CALLBACK_NOT_CONFIGURED",
|
|
25
|
+
)
|
|
26
|
+
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
|
27
|
+
timestamp = normalized.get("pofpay-timestamp", "")
|
|
28
|
+
signature = normalized.get("pofpay-signature", "")
|
|
29
|
+
event_id = normalized.get("pofpay-event-id", "")
|
|
30
|
+
try:
|
|
31
|
+
issued_at = int(timestamp)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise POFPayError(
|
|
34
|
+
"Invalid callback timestamp",
|
|
35
|
+
status_code=401,
|
|
36
|
+
code="INVALID_CALLBACK_SIGNATURE",
|
|
37
|
+
) from exc
|
|
38
|
+
current_time = int(time.time()) if now is None else now
|
|
39
|
+
if abs(current_time - issued_at) > tolerance_seconds:
|
|
40
|
+
raise POFPayError(
|
|
41
|
+
"Expired callback timestamp",
|
|
42
|
+
status_code=401,
|
|
43
|
+
code="INVALID_CALLBACK_SIGNATURE",
|
|
44
|
+
)
|
|
45
|
+
supplied = signature.removeprefix("v1=")
|
|
46
|
+
expected = hmac.new(
|
|
47
|
+
callback_secret.encode(),
|
|
48
|
+
timestamp.encode() + b"." + raw_body,
|
|
49
|
+
hashlib.sha256,
|
|
50
|
+
).hexdigest()
|
|
51
|
+
if not signature.startswith("v1=") or not hmac.compare_digest(expected, supplied):
|
|
52
|
+
raise POFPayError(
|
|
53
|
+
"Invalid callback signature",
|
|
54
|
+
status_code=401,
|
|
55
|
+
code="INVALID_CALLBACK_SIGNATURE",
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(raw_body)
|
|
59
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
60
|
+
raise POFPayError(
|
|
61
|
+
"Invalid callback body",
|
|
62
|
+
status_code=400,
|
|
63
|
+
code="INVALID_CALLBACK_BODY",
|
|
64
|
+
) from exc
|
|
65
|
+
if (
|
|
66
|
+
not isinstance(payload, dict)
|
|
67
|
+
or not isinstance(payload.get("data"), dict)
|
|
68
|
+
or not event_id
|
|
69
|
+
or payload.get("id") != event_id
|
|
70
|
+
or payload.get("type") != "payment_request.status_changed"
|
|
71
|
+
):
|
|
72
|
+
raise POFPayError(
|
|
73
|
+
"Callback event does not match its headers",
|
|
74
|
+
status_code=400,
|
|
75
|
+
code="INVALID_CALLBACK_BODY",
|
|
76
|
+
)
|
|
77
|
+
return CallbackEvent(event_id=event_id, event_type=payload["type"], data=payload["data"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_payment_snapshot(
|
|
81
|
+
received: PaymentRequestStatus | Mapping[str, Any],
|
|
82
|
+
expected: Mapping[str, Any],
|
|
83
|
+
) -> None:
|
|
84
|
+
values = received.__dict__ if isinstance(received, PaymentRequestStatus) else received
|
|
85
|
+
fields = ("request_ref", "source_tool", "reference_type", "reference_id", "base_amount", "currency")
|
|
86
|
+
for field in fields:
|
|
87
|
+
actual = values.get(field)
|
|
88
|
+
wanted = expected.get(field)
|
|
89
|
+
if field in {"base_amount"} and actual is not None and wanted is not None:
|
|
90
|
+
actual, wanted = int(actual), int(wanted)
|
|
91
|
+
if field == "currency" and actual is not None and wanted is not None:
|
|
92
|
+
actual, wanted = str(actual).upper(), str(wanted).upper()
|
|
93
|
+
if actual != wanted:
|
|
94
|
+
raise POFPayError(
|
|
95
|
+
f"POFPay {field} does not match the local payment snapshot",
|
|
96
|
+
status_code=502,
|
|
97
|
+
code="POFPAY_SNAPSHOT_MISMATCH",
|
|
98
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import httpx
|
|
9
|
+
from botocore.auth import SigV4Auth
|
|
10
|
+
from botocore.awsrequest import AWSRequest
|
|
11
|
+
|
|
12
|
+
from .errors import POFPayError
|
|
13
|
+
from .models import PaymentRequestCreated, PaymentRequestStatus
|
|
14
|
+
|
|
15
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class POFPayClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_base_url: str,
|
|
22
|
+
region: str = "ap-southeast-1",
|
|
23
|
+
timeout: float = 10.0,
|
|
24
|
+
*,
|
|
25
|
+
credential_provider: Any | None = None,
|
|
26
|
+
transport: httpx.BaseTransport | None = None,
|
|
27
|
+
max_attempts: int = 3,
|
|
28
|
+
backoff_seconds: float = 0.2,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.api_base_url = api_base_url.rstrip("/")
|
|
31
|
+
self.region = region
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.credential_provider = credential_provider or boto3.Session()
|
|
34
|
+
self.transport = transport
|
|
35
|
+
self.max_attempts = max(1, max_attempts)
|
|
36
|
+
self.backoff_seconds = max(0.0, backoff_seconds)
|
|
37
|
+
|
|
38
|
+
def _credentials(self):
|
|
39
|
+
credentials = self.credential_provider.get_credentials()
|
|
40
|
+
if credentials is None:
|
|
41
|
+
raise POFPayError(
|
|
42
|
+
"AWS credentials are unavailable",
|
|
43
|
+
status_code=503,
|
|
44
|
+
code="POFPAY_AUTH_UNAVAILABLE",
|
|
45
|
+
)
|
|
46
|
+
return credentials.get_frozen_credentials()
|
|
47
|
+
|
|
48
|
+
def _signed_headers(self, method: str, url: str, body: bytes, headers: dict[str, str]) -> dict[str, str]:
|
|
49
|
+
request = AWSRequest(method=method, url=url, data=body, headers=headers)
|
|
50
|
+
SigV4Auth(self._credentials(), "execute-api", self.region).add_auth(request)
|
|
51
|
+
return dict(request.headers.items())
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _error(response: httpx.Response) -> POFPayError:
|
|
55
|
+
code = "POFPAY_REQUEST_REJECTED"
|
|
56
|
+
message = "POFPay rejected the request"
|
|
57
|
+
request_ref = None
|
|
58
|
+
try:
|
|
59
|
+
payload = response.json()
|
|
60
|
+
detail = payload.get("error") or payload.get("detail") or payload
|
|
61
|
+
if isinstance(detail, dict):
|
|
62
|
+
code = str(detail.get("code", code))
|
|
63
|
+
message = str(detail.get("message", message))
|
|
64
|
+
request_ref = detail.get("request_ref")
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
status = response.status_code if 400 <= response.status_code < 500 else 502
|
|
68
|
+
return POFPayError(message, status_code=status, code=code, request_ref=request_ref)
|
|
69
|
+
|
|
70
|
+
def _request(self, method: str, path: str, *, payload: dict[str, Any] | None = None, headers=None):
|
|
71
|
+
body = (
|
|
72
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
73
|
+
if payload is not None
|
|
74
|
+
else b""
|
|
75
|
+
)
|
|
76
|
+
url = f"{self.api_base_url}{path}"
|
|
77
|
+
base_headers = {"Accept": "application/json", **(headers or {})}
|
|
78
|
+
if payload is not None:
|
|
79
|
+
base_headers["Content-Type"] = "application/json"
|
|
80
|
+
last_error: Exception | None = None
|
|
81
|
+
for attempt in range(self.max_attempts):
|
|
82
|
+
signed_headers = self._signed_headers(method, url, body, base_headers)
|
|
83
|
+
try:
|
|
84
|
+
with httpx.Client(timeout=self.timeout, transport=self.transport) as client:
|
|
85
|
+
response = client.request(method, url, content=body or None, headers=signed_headers)
|
|
86
|
+
except httpx.RequestError as exc:
|
|
87
|
+
last_error = exc
|
|
88
|
+
if attempt + 1 < self.max_attempts:
|
|
89
|
+
time.sleep(self.backoff_seconds * (2**attempt))
|
|
90
|
+
continue
|
|
91
|
+
raise POFPayError(
|
|
92
|
+
"POFPay is temporarily unavailable",
|
|
93
|
+
code="POFPAY_UNAVAILABLE",
|
|
94
|
+
) from exc
|
|
95
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt + 1 < self.max_attempts:
|
|
96
|
+
time.sleep(self.backoff_seconds * (2**attempt))
|
|
97
|
+
continue
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
raise self._error(response)
|
|
100
|
+
try:
|
|
101
|
+
data = response.json()
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise POFPayError(
|
|
104
|
+
"POFPay returned an invalid response",
|
|
105
|
+
code="POFPAY_INVALID_RESPONSE",
|
|
106
|
+
) from exc
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
raise POFPayError("POFPay returned an invalid response", code="POFPAY_INVALID_RESPONSE")
|
|
109
|
+
return data
|
|
110
|
+
raise POFPayError("POFPay is temporarily unavailable", code="POFPAY_UNAVAILABLE") from last_error
|
|
111
|
+
|
|
112
|
+
def create_payment_request(
|
|
113
|
+
self,
|
|
114
|
+
snapshot: dict[str, Any],
|
|
115
|
+
idempotency_key: str,
|
|
116
|
+
) -> PaymentRequestCreated:
|
|
117
|
+
data = self._request(
|
|
118
|
+
"POST",
|
|
119
|
+
"/v1/payment-requests",
|
|
120
|
+
payload=snapshot,
|
|
121
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
return PaymentRequestCreated(
|
|
125
|
+
request_ref=str(data["request_ref"]),
|
|
126
|
+
status=str(data["status"]),
|
|
127
|
+
checkout_url=str(data["checkout_url"]),
|
|
128
|
+
checkout_expires_at=datetime.fromisoformat(
|
|
129
|
+
str(data["checkout_expires_at"]).replace("Z", "+00:00")
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
133
|
+
raise POFPayError("POFPay returned an invalid checkout", code="POFPAY_INVALID_RESPONSE") from exc
|
|
134
|
+
|
|
135
|
+
def get_payment_request(self, request_ref: str) -> PaymentRequestStatus:
|
|
136
|
+
data = self._request("GET", f"/v1/payment-requests/{quote(request_ref, safe='')}")
|
|
137
|
+
try:
|
|
138
|
+
paid_at = data.get("paid_at")
|
|
139
|
+
return PaymentRequestStatus(
|
|
140
|
+
request_ref=str(data["request_ref"]),
|
|
141
|
+
source_tool=str(data["source_tool"]),
|
|
142
|
+
reference_type=str(data["reference_type"]),
|
|
143
|
+
reference_id=str(data["reference_id"]),
|
|
144
|
+
status=str(data["status"]),
|
|
145
|
+
base_amount=int(data["base_amount"]),
|
|
146
|
+
total_amount=int(data["total_amount"]),
|
|
147
|
+
currency=str(data["currency"]),
|
|
148
|
+
paid_at=datetime.fromisoformat(str(paid_at).replace("Z", "+00:00")) if paid_at else None,
|
|
149
|
+
)
|
|
150
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
151
|
+
raise POFPayError("POFPay returned an invalid status", code="POFPAY_INVALID_RESPONSE") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def derive_idempotency_key(source_tool: str, reference_id: str) -> str:
|
|
155
|
+
source = source_tool.strip().replace("_", "-")
|
|
156
|
+
reference = reference_id.strip()
|
|
157
|
+
if not source or not reference:
|
|
158
|
+
raise ValueError("source_tool and reference_id are required")
|
|
159
|
+
return f"{source}:{reference}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class POFPayError(RuntimeError):
|
|
2
|
+
def __init__(
|
|
3
|
+
self,
|
|
4
|
+
message: str,
|
|
5
|
+
*,
|
|
6
|
+
status_code: int = 502,
|
|
7
|
+
code: str = "POFPAY_ERROR",
|
|
8
|
+
request_ref: str | None = None,
|
|
9
|
+
) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.code = code
|
|
13
|
+
self.request_ref = request_ref
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class PaymentRequestCreated:
|
|
8
|
+
request_ref: str
|
|
9
|
+
status: str
|
|
10
|
+
checkout_url: str
|
|
11
|
+
checkout_expires_at: datetime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PaymentRequestStatus:
|
|
16
|
+
request_ref: str
|
|
17
|
+
source_tool: str
|
|
18
|
+
reference_type: str
|
|
19
|
+
reference_id: str
|
|
20
|
+
status: str
|
|
21
|
+
base_amount: int
|
|
22
|
+
total_amount: int
|
|
23
|
+
currency: str
|
|
24
|
+
paid_at: datetime | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CallbackEvent:
|
|
29
|
+
event_id: str
|
|
30
|
+
event_type: str
|
|
31
|
+
data: dict[str, Any]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from botocore.credentials import Credentials
|
|
9
|
+
|
|
10
|
+
from pofsis_pofpay import POFPayClient, POFPayError, verify_callback
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredentialsProvider:
|
|
14
|
+
def get_credentials(self):
|
|
15
|
+
return Credentials("access", "secret", "token")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_client_sigv4_signs_payment_request():
|
|
19
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
20
|
+
assert request.headers["authorization"].startswith("AWS4-HMAC-SHA256 Credential=access/")
|
|
21
|
+
assert request.headers["x-amz-security-token"] == "token"
|
|
22
|
+
assert request.headers["idempotency-key"] == "orbit-store:purchase-1"
|
|
23
|
+
assert json.loads(request.content) == {"base_amount": 49900}
|
|
24
|
+
return httpx.Response(
|
|
25
|
+
201,
|
|
26
|
+
json={
|
|
27
|
+
"request_ref": "payreq_1",
|
|
28
|
+
"status": "OPEN",
|
|
29
|
+
"checkout_url": "https://pay.example.com/pay/token",
|
|
30
|
+
"checkout_expires_at": "2026-10-21T10:00:00Z",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
client = POFPayClient(
|
|
35
|
+
"https://api.example.com",
|
|
36
|
+
credential_provider=CredentialsProvider(),
|
|
37
|
+
transport=httpx.MockTransport(handler),
|
|
38
|
+
backoff_seconds=0,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
result = client.create_payment_request({"base_amount": 49900}, "orbit-store:purchase-1")
|
|
42
|
+
assert result.request_ref == "payreq_1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_callback_verifies_signature_event_and_raw_body():
|
|
46
|
+
body = b'{"id":"event-1","type":"payment_request.status_changed","data":{"status":"PAID"}}'
|
|
47
|
+
timestamp = "1000"
|
|
48
|
+
digest = hmac.new(b"secret", timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
49
|
+
|
|
50
|
+
event = verify_callback(
|
|
51
|
+
body,
|
|
52
|
+
{
|
|
53
|
+
"POFPay-Event-Id": "event-1",
|
|
54
|
+
"POFPay-Timestamp": timestamp,
|
|
55
|
+
"POFPay-Signature": f"v1={digest}",
|
|
56
|
+
},
|
|
57
|
+
"secret",
|
|
58
|
+
now=1000,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert event.event_id == "event-1"
|
|
62
|
+
assert event.data["status"] == "PAID"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_callback_rejects_header_body_event_mismatch():
|
|
66
|
+
body = b'{"id":"event-1","type":"payment_request.status_changed","data":{}}'
|
|
67
|
+
timestamp = "1000"
|
|
68
|
+
digest = hmac.new(b"secret", timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
69
|
+
|
|
70
|
+
with pytest.raises(POFPayError) as error:
|
|
71
|
+
verify_callback(
|
|
72
|
+
body,
|
|
73
|
+
{
|
|
74
|
+
"POFPay-Event-Id": "event-2",
|
|
75
|
+
"POFPay-Timestamp": timestamp,
|
|
76
|
+
"POFPay-Signature": f"v1={digest}",
|
|
77
|
+
},
|
|
78
|
+
"secret",
|
|
79
|
+
now=1000,
|
|
80
|
+
)
|
|
81
|
+
assert error.value.code == "INVALID_CALLBACK_BODY"
|