solvapay-python 0.7.0__tar.gz → 0.7.1__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.
- solvapay_python-0.7.1/CHANGELOG.md +53 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/PKG-INFO +5 -2
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/pyproject.toml +5 -2
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/__init__.py +23 -2
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/_async_client.py +34 -6
- solvapay_python-0.7.1/src/solvapay/_http.py +220 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/client.py +29 -5
- solvapay_python-0.7.1/src/solvapay/exceptions.py +86 -0
- solvapay_python-0.7.1/src/solvapay/idempotency.py +16 -0
- solvapay_python-0.7.1/tests/__init__.py +0 -0
- solvapay_python-0.7.1/tests/test_errors.py +113 -0
- solvapay_python-0.7.1/tests/test_idempotency.py +62 -0
- solvapay_python-0.7.1/tests/test_packaging.py +9 -0
- solvapay_python-0.7.1/tests/test_redaction.py +57 -0
- solvapay_python-0.7.0/CHANGELOG.md +0 -45
- solvapay_python-0.7.0/src/solvapay/_http.py +0 -107
- solvapay_python-0.7.0/src/solvapay/exceptions.py +0 -16
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.github/workflows/ci.yml +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.github/workflows/publish.yml +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.gitignore +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.python-version +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/LICENSE +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/README.md +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/.env.example +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/.gitignore +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/README.md +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/claim.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/pyproject.toml +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/server.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/uv.lock +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/.env.example +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/.gitignore +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/README.md +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/agent.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/pyproject.toml +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/.env.example +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/.streamlit/config.toml +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/PLAN.md +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/README.md +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/agents.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/app.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/demo_customers.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/requirements.txt +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/sdk_gateway.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/ui_components.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/_config.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/events.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/fastapi.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/langchain.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/models.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/paywall.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/paywall_state.py +0 -0
- /solvapay_python-0.7.0/tests/__init__.py → /solvapay_python-0.7.1/src/solvapay/py.typed +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/webhooks.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/conftest.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_admin.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_async_client.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_checkout.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_config.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_customer.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_http.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_invariants.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_langchain.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_lifecycle.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_limits.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_paywall.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_paywall_state.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_webhook_events.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_webhooks.py +0 -0
- {solvapay_python-0.7.0 → solvapay_python-0.7.1}/uv.lock +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.1 — 2026-05-17
|
|
4
|
+
|
|
5
|
+
Payments-grade hardening: structured errors, idempotency keys, py.typed, structured logging.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Structured error hierarchy** (`exceptions.py`): `APIError` base with `status_code`, `request_id`, `error_code`, `error_message`. Subclasses: `AuthenticationError` (401), `PermissionError` (403), `NotFoundError` (404), `RateLimitError` (429, adds `.retry_after`), `InvalidRequestError` (4xx), `APIServerError` (5xx), `APIConnectionError`, `APITimeoutError`. `SolvaPayAPIError` aliased to `APIError` for back-compat.
|
|
9
|
+
- **Idempotency keys** on all mutating ops: `create_checkout_session`, `ensure_customer`, `track_usage`, `cancel_purchase`, `reactivate_purchase`, `create_product`, `clone_product`, `create_plan` all accept `idempotency_key: str | None = None`. Header casing: `Idempotency-Key` (matches TS SDK).
|
|
10
|
+
- **`solvapay.idempotency.from_payload(*parts)`** — SHA256 of stable-serialized payload parts → 32-hex-char deterministic key.
|
|
11
|
+
- **PEP 561 `py.typed` marker** — downstream mypy users no longer see `Any` on `solvapay.*` imports.
|
|
12
|
+
- **Structured logging** at `solvapay.http` logger: INFO on success (method, path, status, request_id, duration_ms), WARNING on 4xx/5xx (adds body_excerpt ≤200 chars). Never calls `logging.basicConfig`. Optional `logger=` injection on `SolvaPay`/`AsyncSolvaPay` constructors for `loguru`/`structlog`.
|
|
13
|
+
- **Secret redaction**: `Authorization` header never appears in logs. Verified by `tests/test_redaction.py`.
|
|
14
|
+
- **pyproject classifiers**: `Development Status :: 4 - Beta`, `Framework :: AsyncIO`, `Topic :: Office/Business :: Financial`, `Typing :: Typed`.
|
|
15
|
+
|
|
16
|
+
### Internal
|
|
17
|
+
- User-Agent bumped to `solvapay-python/0.7.1`
|
|
18
|
+
- 142 tests (up from 125), `mypy --strict` clean, `ruff` clean
|
|
19
|
+
|
|
20
|
+
## 0.7.0 — 2026-05-17
|
|
21
|
+
|
|
22
|
+
Real-API alignment after testing against the SolvaPay sandbox revealed wire-format mismatches.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **`Customer.customer_ref`** now accepts `reference` (real API) in addition to `customerRef` via `validation_alias=AliasChoices(...)`.
|
|
26
|
+
- **`ensure_customer()`** reads `reference` from API response; falls back to `customerRef`. Raises if neither present.
|
|
27
|
+
- **`BalanceResponse`** rewritten: `credits`, `display_currency`, `credits_per_minor_unit`, `display_exchange_rate`; `balance` and `currency` kept as computed properties.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`paywall_state.gate()`** — one-call enrichment helper (limits + checkout URL + plan via `get_customer`).
|
|
31
|
+
- **`paywall.require` / `require_async`**: auto-mints checkout URL when `LimitResponse.checkout_url is None`.
|
|
32
|
+
- **`examples/marketplace/`** — Streamlit demo with 4 paywalled AI agents (Google Gemini) against real SolvaPay sandbox.
|
|
33
|
+
|
|
34
|
+
### Internal
|
|
35
|
+
- User-Agent `solvapay-python/0.7.0`. 125 tests (up from 121). `mypy --strict` clean.
|
|
36
|
+
|
|
37
|
+
## 0.6.0
|
|
38
|
+
- Admin endpoints (products, plans, merchant config). GitHub Actions trusted-publish to PyPI.
|
|
39
|
+
|
|
40
|
+
## 0.5.0
|
|
41
|
+
- `paywall_state` classifier (ACTIVATION_REQUIRED / TOPUP_REQUIRED / UPGRADE_REQUIRED). LangChain `monetize_tool`.
|
|
42
|
+
|
|
43
|
+
## 0.4.0
|
|
44
|
+
- Full async client. 5 lifecycle operations. 13 typed webhook event classes.
|
|
45
|
+
|
|
46
|
+
## 0.3.0
|
|
47
|
+
- FastMCP example: AI agent with two paywalled tools.
|
|
48
|
+
|
|
49
|
+
## 0.2.0
|
|
50
|
+
- `@paywall.require` decorator. FastAPI webhook router.
|
|
51
|
+
|
|
52
|
+
## 0.1.0
|
|
53
|
+
- Sync client, HMAC-SHA256 webhook verification, Pydantic v2 models, CI on 3 Python versions.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solvapay-python
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Community Python SDK for SolvaPay (agent-native payment rails)
|
|
5
5
|
Project-URL: Homepage, https://github.com/dhruv-sanan/solvapay-python
|
|
6
6
|
Project-URL: Issues, https://github.com/dhruv-sanan/solvapay-python/issues
|
|
@@ -9,14 +9,17 @@ Author: Dhruv Sanan
|
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Keywords: agents,fintech,mcp,payments,solvapay
|
|
12
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
13
14
|
Classifier: Intended Audience :: Developers
|
|
14
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
15
16
|
Classifier: Programming Language :: Python :: 3
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
19
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
20
23
|
Requires-Python: >=3.10
|
|
21
24
|
Requires-Dist: httpx>=0.27
|
|
22
25
|
Requires-Dist: pydantic>=2.5
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "solvapay-python"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "Community Python SDK for SolvaPay (agent-native payment rails)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -12,14 +12,17 @@ authors = [{ name = "Dhruv Sanan" }]
|
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
keywords = ["solvapay", "payments", "agents", "mcp", "fintech"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
16
17
|
"Intended Audience :: Developers",
|
|
17
18
|
"License :: OSI Approved :: MIT License",
|
|
18
19
|
"Programming Language :: Python :: 3",
|
|
19
20
|
"Programming Language :: Python :: 3.10",
|
|
20
21
|
"Programming Language :: Python :: 3.11",
|
|
21
22
|
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Office/Business :: Financial",
|
|
22
24
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
23
26
|
]
|
|
24
27
|
dependencies = [
|
|
25
28
|
"httpx>=0.27",
|
|
@@ -21,24 +21,44 @@ from solvapay.events import (
|
|
|
21
21
|
PurchaseUpdated,
|
|
22
22
|
WebhookEvent,
|
|
23
23
|
)
|
|
24
|
-
from solvapay.exceptions import
|
|
24
|
+
from solvapay.exceptions import (
|
|
25
|
+
APIConnectionError,
|
|
26
|
+
APIError,
|
|
27
|
+
APIServerError,
|
|
28
|
+
APITimeoutError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
InvalidRequestError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
PermissionError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
SolvaPayAPIError,
|
|
35
|
+
SolvaPayError,
|
|
36
|
+
)
|
|
25
37
|
from solvapay.models import BalanceResponse, Merchant, Plan, PlatformConfig, Product
|
|
26
38
|
from solvapay.paywall import PaywallRequired
|
|
27
39
|
from solvapay.webhooks import verify_webhook
|
|
28
40
|
|
|
29
41
|
__all__ = [
|
|
42
|
+
"APIConnectionError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"APIServerError",
|
|
45
|
+
"APITimeoutError",
|
|
30
46
|
"AsyncSolvaPay",
|
|
47
|
+
"AuthenticationError",
|
|
31
48
|
"BalanceResponse",
|
|
32
49
|
"CheckoutSessionCreated",
|
|
33
50
|
"CustomerCreated",
|
|
34
51
|
"CustomerDeleted",
|
|
35
52
|
"CustomerUpdated",
|
|
53
|
+
"InvalidRequestError",
|
|
36
54
|
"Merchant",
|
|
55
|
+
"NotFoundError",
|
|
37
56
|
"PaymentFailed",
|
|
38
57
|
"PaymentRefundFailed",
|
|
39
58
|
"PaymentRefunded",
|
|
40
59
|
"PaymentSucceeded",
|
|
41
60
|
"PaywallRequired",
|
|
61
|
+
"PermissionError",
|
|
42
62
|
"Plan",
|
|
43
63
|
"PlatformConfig",
|
|
44
64
|
"Product",
|
|
@@ -47,6 +67,7 @@ __all__ = [
|
|
|
47
67
|
"PurchaseExpired",
|
|
48
68
|
"PurchaseSuspended",
|
|
49
69
|
"PurchaseUpdated",
|
|
70
|
+
"RateLimitError",
|
|
50
71
|
"SolvaPay",
|
|
51
72
|
"SolvaPayAPIError",
|
|
52
73
|
"SolvaPayError",
|
|
@@ -54,4 +75,4 @@ __all__ = [
|
|
|
54
75
|
"paywall",
|
|
55
76
|
"verify_webhook",
|
|
56
77
|
]
|
|
57
|
-
__version__ = "0.7.
|
|
78
|
+
__version__ = "0.7.1"
|
|
@@ -6,6 +6,7 @@ Use `async with AsyncSolvaPay() as sv: ...` for proper teardown.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import time
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -56,11 +57,13 @@ class AsyncSolvaPay:
|
|
|
56
57
|
*,
|
|
57
58
|
base_url: str | None = None,
|
|
58
59
|
timeout: float = 30.0,
|
|
60
|
+
logger: logging.Logger | None = None,
|
|
59
61
|
) -> None:
|
|
60
62
|
self._http = AsyncHttpClient(
|
|
61
63
|
api_key=resolve_api_key(api_key),
|
|
62
64
|
base_url=resolve_base_url(base_url),
|
|
63
65
|
timeout=timeout,
|
|
66
|
+
logger=logger,
|
|
64
67
|
)
|
|
65
68
|
|
|
66
69
|
async def aclose(self) -> None:
|
|
@@ -79,6 +82,7 @@ class AsyncSolvaPay:
|
|
|
79
82
|
product_ref: str,
|
|
80
83
|
plan_ref: str | None = None,
|
|
81
84
|
return_url: str | None = None,
|
|
85
|
+
idempotency_key: str | None = None,
|
|
82
86
|
) -> CheckoutSession:
|
|
83
87
|
req = CheckoutSessionRequest(
|
|
84
88
|
customer_ref=customer_ref,
|
|
@@ -91,6 +95,7 @@ class AsyncSolvaPay:
|
|
|
91
95
|
"POST",
|
|
92
96
|
"/v1/sdk/checkout-sessions",
|
|
93
97
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
98
|
+
idempotency_key=idempotency_key,
|
|
94
99
|
)
|
|
95
100
|
)
|
|
96
101
|
return CheckoutSession.model_validate(data)
|
|
@@ -102,6 +107,7 @@ class AsyncSolvaPay:
|
|
|
102
107
|
*,
|
|
103
108
|
email: str | None = None,
|
|
104
109
|
name: str | None = None,
|
|
110
|
+
idempotency_key: str | None = None,
|
|
105
111
|
) -> str:
|
|
106
112
|
lookup_ref = external_ref or customer_ref
|
|
107
113
|
try:
|
|
@@ -122,7 +128,10 @@ class AsyncSolvaPay:
|
|
|
122
128
|
)
|
|
123
129
|
created = await self._http.send(
|
|
124
130
|
_RequestSpec(
|
|
125
|
-
"POST",
|
|
131
|
+
"POST",
|
|
132
|
+
"/v1/sdk/customers",
|
|
133
|
+
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
134
|
+
idempotency_key=idempotency_key,
|
|
126
135
|
)
|
|
127
136
|
)
|
|
128
137
|
ref = created.get("reference") or created.get("customerRef")
|
|
@@ -226,7 +235,11 @@ class AsyncSolvaPay:
|
|
|
226
235
|
return BalanceResponse.model_validate(data)
|
|
227
236
|
|
|
228
237
|
async def cancel_purchase(
|
|
229
|
-
self,
|
|
238
|
+
self,
|
|
239
|
+
purchase_ref: str,
|
|
240
|
+
*,
|
|
241
|
+
reason: str | None = None,
|
|
242
|
+
idempotency_key: str | None = None,
|
|
230
243
|
) -> dict[str, Any]:
|
|
231
244
|
"""Cancel a purchase. Maps to POST /v1/sdk/purchases/{ref}/cancel."""
|
|
232
245
|
req = CancelPurchaseRequest(reason=reason)
|
|
@@ -235,13 +248,20 @@ class AsyncSolvaPay:
|
|
|
235
248
|
"POST",
|
|
236
249
|
f"/v1/sdk/purchases/{purchase_ref}/cancel",
|
|
237
250
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
251
|
+
idempotency_key=idempotency_key,
|
|
238
252
|
)
|
|
239
253
|
)
|
|
240
254
|
|
|
241
|
-
async def reactivate_purchase(
|
|
255
|
+
async def reactivate_purchase(
|
|
256
|
+
self, purchase_ref: str, *, idempotency_key: str | None = None
|
|
257
|
+
) -> dict[str, Any]:
|
|
242
258
|
"""Reactivate a cancelled purchase. Maps to POST /v1/sdk/purchases/{ref}/reactivate."""
|
|
243
259
|
return await self._http.send(
|
|
244
|
-
_RequestSpec(
|
|
260
|
+
_RequestSpec(
|
|
261
|
+
"POST",
|
|
262
|
+
f"/v1/sdk/purchases/{purchase_ref}/reactivate",
|
|
263
|
+
idempotency_key=idempotency_key,
|
|
264
|
+
)
|
|
245
265
|
)
|
|
246
266
|
|
|
247
267
|
# --- Admin: Products ---
|
|
@@ -257,7 +277,9 @@ class AsyncSolvaPay:
|
|
|
257
277
|
data = await self._http.send(_RequestSpec("GET", f"/v1/sdk/products/{product_ref}"))
|
|
258
278
|
return Product.model_validate(data)
|
|
259
279
|
|
|
260
|
-
async def create_product(
|
|
280
|
+
async def create_product(
|
|
281
|
+
self, *, name: str, type: str, default_currency: str, idempotency_key: str | None = None
|
|
282
|
+
) -> Product:
|
|
261
283
|
"""Create a product. Maps to POST /v1/sdk/products."""
|
|
262
284
|
req = CreateProductRequest(name=name, type=type, default_currency=default_currency)
|
|
263
285
|
data = await self._http.send(
|
|
@@ -265,6 +287,7 @@ class AsyncSolvaPay:
|
|
|
265
287
|
"POST",
|
|
266
288
|
"/v1/sdk/products",
|
|
267
289
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
290
|
+
idempotency_key=idempotency_key,
|
|
268
291
|
)
|
|
269
292
|
)
|
|
270
293
|
return Product.model_validate(data)
|
|
@@ -273,7 +296,9 @@ class AsyncSolvaPay:
|
|
|
273
296
|
"""Delete a product. Maps to DELETE /v1/sdk/products/{ref}."""
|
|
274
297
|
return await self._http.send(_RequestSpec("DELETE", f"/v1/sdk/products/{product_ref}"))
|
|
275
298
|
|
|
276
|
-
async def clone_product(
|
|
299
|
+
async def clone_product(
|
|
300
|
+
self, product_ref: str, *, new_name: str, idempotency_key: str | None = None
|
|
301
|
+
) -> Product:
|
|
277
302
|
"""Clone a product with a new name. Maps to POST /v1/sdk/products/{ref}/clone."""
|
|
278
303
|
req = CloneProductRequest(new_name=new_name)
|
|
279
304
|
data = await self._http.send(
|
|
@@ -281,6 +306,7 @@ class AsyncSolvaPay:
|
|
|
281
306
|
"POST",
|
|
282
307
|
f"/v1/sdk/products/{product_ref}/clone",
|
|
283
308
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
309
|
+
idempotency_key=idempotency_key,
|
|
284
310
|
)
|
|
285
311
|
)
|
|
286
312
|
return Product.model_validate(data)
|
|
@@ -302,6 +328,7 @@ class AsyncSolvaPay:
|
|
|
302
328
|
price: float | None = None,
|
|
303
329
|
currency: str | None = None,
|
|
304
330
|
interval: str | None = None,
|
|
331
|
+
idempotency_key: str | None = None,
|
|
305
332
|
) -> Plan:
|
|
306
333
|
"""Create a plan for a product. Maps to POST /v1/sdk/products/{ref}/plans."""
|
|
307
334
|
req = CreatePlanRequest(
|
|
@@ -312,6 +339,7 @@ class AsyncSolvaPay:
|
|
|
312
339
|
"POST",
|
|
313
340
|
f"/v1/sdk/products/{product_ref}/plans",
|
|
314
341
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
342
|
+
idempotency_key=idempotency_key,
|
|
315
343
|
)
|
|
316
344
|
)
|
|
317
345
|
return Plan.model_validate(data)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Internal HTTP transport. Not part of public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from solvapay.exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIError,
|
|
15
|
+
APIServerError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InvalidRequestError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_logger = logging.getLogger("solvapay.http")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _RequestSpec:
|
|
29
|
+
method: str
|
|
30
|
+
path: str
|
|
31
|
+
json: dict[str, Any] | None = None
|
|
32
|
+
params: dict[str, Any] | None = None
|
|
33
|
+
idempotency_key: str | None = None
|
|
34
|
+
|
|
35
|
+
def headers(self) -> dict[str, str]:
|
|
36
|
+
return {"Idempotency-Key": self.idempotency_key} if self.idempotency_key else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_error(response: httpx.Response) -> APIError:
|
|
40
|
+
request_id = response.headers.get("x-request-id") or response.headers.get("x-correlation-id")
|
|
41
|
+
error_code: str | None = None
|
|
42
|
+
error_message: str | None = None
|
|
43
|
+
try:
|
|
44
|
+
payload = response.json()
|
|
45
|
+
if isinstance(payload, dict):
|
|
46
|
+
err = payload.get("error", payload)
|
|
47
|
+
if isinstance(err, dict):
|
|
48
|
+
code = err.get("code")
|
|
49
|
+
msg = err.get("message")
|
|
50
|
+
if isinstance(code, str):
|
|
51
|
+
error_code = code
|
|
52
|
+
if isinstance(msg, str):
|
|
53
|
+
error_message = msg
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
status = response.status_code
|
|
58
|
+
body = response.text
|
|
59
|
+
|
|
60
|
+
if status == 401:
|
|
61
|
+
return AuthenticationError(
|
|
62
|
+
status, body, request_id=request_id, error_code=error_code, error_message=error_message
|
|
63
|
+
)
|
|
64
|
+
if status == 403:
|
|
65
|
+
return PermissionError(
|
|
66
|
+
status, body, request_id=request_id, error_code=error_code, error_message=error_message
|
|
67
|
+
)
|
|
68
|
+
if status == 404:
|
|
69
|
+
return NotFoundError(
|
|
70
|
+
status, body, request_id=request_id, error_code=error_code, error_message=error_message
|
|
71
|
+
)
|
|
72
|
+
if status == 429:
|
|
73
|
+
return RateLimitError(
|
|
74
|
+
status,
|
|
75
|
+
body,
|
|
76
|
+
request_id=request_id,
|
|
77
|
+
error_code=error_code,
|
|
78
|
+
error_message=error_message,
|
|
79
|
+
retry_after=response.headers.get("Retry-After"),
|
|
80
|
+
)
|
|
81
|
+
if 400 <= status < 500:
|
|
82
|
+
return InvalidRequestError(
|
|
83
|
+
status, body, request_id=request_id, error_code=error_code, error_message=error_message
|
|
84
|
+
)
|
|
85
|
+
return APIServerError(
|
|
86
|
+
status, body, request_id=request_id, error_code=error_code, error_message=error_message
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _handle(response: httpx.Response) -> dict[str, Any]:
|
|
91
|
+
if not response.is_success:
|
|
92
|
+
raise _parse_error(response)
|
|
93
|
+
if response.status_code == 204 or not response.content:
|
|
94
|
+
return {}
|
|
95
|
+
return response.json() # type: ignore[no-any-return]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _log_response(
|
|
99
|
+
logger: logging.Logger,
|
|
100
|
+
spec: _RequestSpec,
|
|
101
|
+
response: httpx.Response,
|
|
102
|
+
duration_ms: int,
|
|
103
|
+
) -> None:
|
|
104
|
+
request_id = response.headers.get("x-request-id") or response.headers.get("x-correlation-id")
|
|
105
|
+
extra: dict[str, object] = {"request_id": request_id, "duration_ms": duration_ms}
|
|
106
|
+
if response.is_success:
|
|
107
|
+
logger.info(
|
|
108
|
+
"%s %s → %d (%dms)",
|
|
109
|
+
spec.method,
|
|
110
|
+
spec.path,
|
|
111
|
+
response.status_code,
|
|
112
|
+
duration_ms,
|
|
113
|
+
extra=extra,
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
logger.warning(
|
|
117
|
+
"%s %s → %d (%dms)",
|
|
118
|
+
spec.method,
|
|
119
|
+
spec.path,
|
|
120
|
+
response.status_code,
|
|
121
|
+
duration_ms,
|
|
122
|
+
extra={**extra, "body_excerpt": response.text[:200]},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HttpClient:
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
api_key: str,
|
|
131
|
+
base_url: str,
|
|
132
|
+
timeout: float = 30.0,
|
|
133
|
+
logger: logging.Logger | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._logger = logger or _logger
|
|
136
|
+
self._client = httpx.Client(
|
|
137
|
+
base_url=base_url,
|
|
138
|
+
headers={
|
|
139
|
+
"Authorization": f"Bearer {api_key}",
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"User-Agent": "solvapay-python/0.7.1",
|
|
142
|
+
},
|
|
143
|
+
timeout=timeout,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def close(self) -> None:
|
|
147
|
+
self._client.close()
|
|
148
|
+
|
|
149
|
+
def __enter__(self) -> HttpClient:
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def __exit__(self, *_: object) -> None:
|
|
153
|
+
self.close()
|
|
154
|
+
|
|
155
|
+
def send(self, spec: _RequestSpec) -> dict[str, Any]:
|
|
156
|
+
t0 = time.perf_counter()
|
|
157
|
+
try:
|
|
158
|
+
response = self._client.request(
|
|
159
|
+
spec.method, spec.path, json=spec.json, params=spec.params, headers=spec.headers()
|
|
160
|
+
)
|
|
161
|
+
except httpx.TimeoutException as exc:
|
|
162
|
+
raise APITimeoutError(str(exc)) from exc
|
|
163
|
+
except (httpx.ConnectError, httpx.ReadError) as exc:
|
|
164
|
+
raise APIConnectionError(str(exc)) from exc
|
|
165
|
+
_log_response(self._logger, spec, response, int((time.perf_counter() - t0) * 1000))
|
|
166
|
+
return _handle(response)
|
|
167
|
+
|
|
168
|
+
def request(
|
|
169
|
+
self,
|
|
170
|
+
method: str,
|
|
171
|
+
path: str,
|
|
172
|
+
*,
|
|
173
|
+
json: dict[str, Any] | None = None,
|
|
174
|
+
params: dict[str, Any] | None = None,
|
|
175
|
+
idempotency_key: str | None = None,
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
return self.send(_RequestSpec(method, path, json, params, idempotency_key))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AsyncHttpClient:
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
api_key: str,
|
|
185
|
+
base_url: str,
|
|
186
|
+
timeout: float = 30.0,
|
|
187
|
+
logger: logging.Logger | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
self._logger = logger or _logger
|
|
190
|
+
self._client = httpx.AsyncClient(
|
|
191
|
+
base_url=base_url,
|
|
192
|
+
headers={
|
|
193
|
+
"Authorization": f"Bearer {api_key}",
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"User-Agent": "solvapay-python/0.7.1",
|
|
196
|
+
},
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def aclose(self) -> None:
|
|
201
|
+
await self._client.aclose()
|
|
202
|
+
|
|
203
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
async def __aexit__(self, *_: object) -> None:
|
|
207
|
+
await self.aclose()
|
|
208
|
+
|
|
209
|
+
async def send(self, spec: _RequestSpec) -> dict[str, Any]:
|
|
210
|
+
t0 = time.perf_counter()
|
|
211
|
+
try:
|
|
212
|
+
response = await self._client.request(
|
|
213
|
+
spec.method, spec.path, json=spec.json, params=spec.params, headers=spec.headers()
|
|
214
|
+
)
|
|
215
|
+
except httpx.TimeoutException as exc:
|
|
216
|
+
raise APITimeoutError(str(exc)) from exc
|
|
217
|
+
except (httpx.ConnectError, httpx.ReadError) as exc:
|
|
218
|
+
raise APIConnectionError(str(exc)) from exc
|
|
219
|
+
_log_response(self._logger, spec, response, int((time.perf_counter() - t0) * 1000))
|
|
220
|
+
return _handle(response)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import time
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -54,11 +55,13 @@ class SolvaPay:
|
|
|
54
55
|
*,
|
|
55
56
|
base_url: str | None = None,
|
|
56
57
|
timeout: float = 30.0,
|
|
58
|
+
logger: logging.Logger | None = None,
|
|
57
59
|
) -> None:
|
|
58
60
|
self._http = HttpClient(
|
|
59
61
|
api_key=resolve_api_key(api_key),
|
|
60
62
|
base_url=resolve_base_url(base_url),
|
|
61
63
|
timeout=timeout,
|
|
64
|
+
logger=logger,
|
|
62
65
|
)
|
|
63
66
|
|
|
64
67
|
def close(self) -> None:
|
|
@@ -77,6 +80,7 @@ class SolvaPay:
|
|
|
77
80
|
product_ref: str,
|
|
78
81
|
plan_ref: str | None = None,
|
|
79
82
|
return_url: str | None = None,
|
|
83
|
+
idempotency_key: str | None = None,
|
|
80
84
|
) -> CheckoutSession:
|
|
81
85
|
"""Create a hosted checkout session.
|
|
82
86
|
|
|
@@ -101,6 +105,7 @@ class SolvaPay:
|
|
|
101
105
|
"POST",
|
|
102
106
|
"/v1/sdk/checkout-sessions",
|
|
103
107
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
108
|
+
idempotency_key=idempotency_key,
|
|
104
109
|
)
|
|
105
110
|
return CheckoutSession.model_validate(data)
|
|
106
111
|
|
|
@@ -111,6 +116,7 @@ class SolvaPay:
|
|
|
111
116
|
*,
|
|
112
117
|
email: str | None = None,
|
|
113
118
|
name: str | None = None,
|
|
119
|
+
idempotency_key: str | None = None,
|
|
114
120
|
) -> str:
|
|
115
121
|
"""Idempotently create or look up a customer.
|
|
116
122
|
|
|
@@ -140,6 +146,7 @@ class SolvaPay:
|
|
|
140
146
|
"POST",
|
|
141
147
|
"/v1/sdk/customers",
|
|
142
148
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
149
|
+
idempotency_key=idempotency_key,
|
|
143
150
|
)
|
|
144
151
|
ref = created.get("reference") or created.get("customerRef")
|
|
145
152
|
if not ref:
|
|
@@ -238,18 +245,27 @@ class SolvaPay:
|
|
|
238
245
|
data = self._http.request("GET", f"/v1/sdk/customers/{customer_ref}/balance")
|
|
239
246
|
return BalanceResponse.model_validate(data)
|
|
240
247
|
|
|
241
|
-
def cancel_purchase(
|
|
248
|
+
def cancel_purchase(
|
|
249
|
+
self, purchase_ref: str, *, reason: str | None = None, idempotency_key: str | None = None
|
|
250
|
+
) -> dict[str, Any]:
|
|
242
251
|
"""Cancel a purchase. Maps to POST /v1/sdk/purchases/{ref}/cancel."""
|
|
243
252
|
req = CancelPurchaseRequest(reason=reason)
|
|
244
253
|
return self._http.request(
|
|
245
254
|
"POST",
|
|
246
255
|
f"/v1/sdk/purchases/{purchase_ref}/cancel",
|
|
247
256
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
257
|
+
idempotency_key=idempotency_key,
|
|
248
258
|
)
|
|
249
259
|
|
|
250
|
-
def reactivate_purchase(
|
|
260
|
+
def reactivate_purchase(
|
|
261
|
+
self, purchase_ref: str, *, idempotency_key: str | None = None
|
|
262
|
+
) -> dict[str, Any]:
|
|
251
263
|
"""Reactivate a cancelled purchase. Maps to POST /v1/sdk/purchases/{ref}/reactivate."""
|
|
252
|
-
return self._http.request(
|
|
264
|
+
return self._http.request(
|
|
265
|
+
"POST",
|
|
266
|
+
f"/v1/sdk/purchases/{purchase_ref}/reactivate",
|
|
267
|
+
idempotency_key=idempotency_key,
|
|
268
|
+
)
|
|
253
269
|
|
|
254
270
|
# --- Admin: Products ---
|
|
255
271
|
|
|
@@ -264,13 +280,16 @@ class SolvaPay:
|
|
|
264
280
|
data = self._http.request("GET", f"/v1/sdk/products/{product_ref}")
|
|
265
281
|
return Product.model_validate(data)
|
|
266
282
|
|
|
267
|
-
def create_product(
|
|
283
|
+
def create_product(
|
|
284
|
+
self, *, name: str, type: str, default_currency: str, idempotency_key: str | None = None
|
|
285
|
+
) -> Product:
|
|
268
286
|
"""Create a product. Maps to POST /v1/sdk/products."""
|
|
269
287
|
req = CreateProductRequest(name=name, type=type, default_currency=default_currency)
|
|
270
288
|
data = self._http.request(
|
|
271
289
|
"POST",
|
|
272
290
|
"/v1/sdk/products",
|
|
273
291
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
292
|
+
idempotency_key=idempotency_key,
|
|
274
293
|
)
|
|
275
294
|
return Product.model_validate(data)
|
|
276
295
|
|
|
@@ -278,13 +297,16 @@ class SolvaPay:
|
|
|
278
297
|
"""Delete a product. Maps to DELETE /v1/sdk/products/{ref}."""
|
|
279
298
|
return self._http.request("DELETE", f"/v1/sdk/products/{product_ref}")
|
|
280
299
|
|
|
281
|
-
def clone_product(
|
|
300
|
+
def clone_product(
|
|
301
|
+
self, product_ref: str, *, new_name: str, idempotency_key: str | None = None
|
|
302
|
+
) -> Product:
|
|
282
303
|
"""Clone a product with a new name. Maps to POST /v1/sdk/products/{ref}/clone."""
|
|
283
304
|
req = CloneProductRequest(new_name=new_name)
|
|
284
305
|
data = self._http.request(
|
|
285
306
|
"POST",
|
|
286
307
|
f"/v1/sdk/products/{product_ref}/clone",
|
|
287
308
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
309
|
+
idempotency_key=idempotency_key,
|
|
288
310
|
)
|
|
289
311
|
return Product.model_validate(data)
|
|
290
312
|
|
|
@@ -305,6 +327,7 @@ class SolvaPay:
|
|
|
305
327
|
price: float | None = None,
|
|
306
328
|
currency: str | None = None,
|
|
307
329
|
interval: str | None = None,
|
|
330
|
+
idempotency_key: str | None = None,
|
|
308
331
|
) -> Plan:
|
|
309
332
|
"""Create a plan for a product. Maps to POST /v1/sdk/products/{ref}/plans."""
|
|
310
333
|
req = CreatePlanRequest(
|
|
@@ -314,6 +337,7 @@ class SolvaPay:
|
|
|
314
337
|
"POST",
|
|
315
338
|
f"/v1/sdk/products/{product_ref}/plans",
|
|
316
339
|
json=req.model_dump(by_alias=True, exclude_none=True),
|
|
340
|
+
idempotency_key=idempotency_key,
|
|
317
341
|
)
|
|
318
342
|
return Plan.model_validate(data)
|
|
319
343
|
|