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.
Files changed (70) hide show
  1. solvapay_python-0.7.1/CHANGELOG.md +53 -0
  2. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/PKG-INFO +5 -2
  3. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/pyproject.toml +5 -2
  4. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/__init__.py +23 -2
  5. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/_async_client.py +34 -6
  6. solvapay_python-0.7.1/src/solvapay/_http.py +220 -0
  7. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/client.py +29 -5
  8. solvapay_python-0.7.1/src/solvapay/exceptions.py +86 -0
  9. solvapay_python-0.7.1/src/solvapay/idempotency.py +16 -0
  10. solvapay_python-0.7.1/tests/__init__.py +0 -0
  11. solvapay_python-0.7.1/tests/test_errors.py +113 -0
  12. solvapay_python-0.7.1/tests/test_idempotency.py +62 -0
  13. solvapay_python-0.7.1/tests/test_packaging.py +9 -0
  14. solvapay_python-0.7.1/tests/test_redaction.py +57 -0
  15. solvapay_python-0.7.0/CHANGELOG.md +0 -45
  16. solvapay_python-0.7.0/src/solvapay/_http.py +0 -107
  17. solvapay_python-0.7.0/src/solvapay/exceptions.py +0 -16
  18. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.github/workflows/ci.yml +0 -0
  19. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.github/workflows/publish.yml +0 -0
  20. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.gitignore +0 -0
  21. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/.python-version +0 -0
  22. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/LICENSE +0 -0
  23. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/README.md +0 -0
  24. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/.env.example +0 -0
  25. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/.gitignore +0 -0
  26. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/README.md +0 -0
  27. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/claim.py +0 -0
  28. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/pyproject.toml +0 -0
  29. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/server.py +0 -0
  30. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/fastmcp-paywall/uv.lock +0 -0
  31. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/.env.example +0 -0
  32. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/.gitignore +0 -0
  33. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/README.md +0 -0
  34. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/agent.py +0 -0
  35. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/langchain-paywall/pyproject.toml +0 -0
  36. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/.env.example +0 -0
  37. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/.streamlit/config.toml +0 -0
  38. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/PLAN.md +0 -0
  39. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/README.md +0 -0
  40. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/agents.py +0 -0
  41. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/app.py +0 -0
  42. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/demo_customers.py +0 -0
  43. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/requirements.txt +0 -0
  44. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/sdk_gateway.py +0 -0
  45. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/examples/marketplace/ui_components.py +0 -0
  46. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/_config.py +0 -0
  47. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/events.py +0 -0
  48. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/fastapi.py +0 -0
  49. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/langchain.py +0 -0
  50. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/models.py +0 -0
  51. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/paywall.py +0 -0
  52. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/paywall_state.py +0 -0
  53. /solvapay_python-0.7.0/tests/__init__.py → /solvapay_python-0.7.1/src/solvapay/py.typed +0 -0
  54. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/src/solvapay/webhooks.py +0 -0
  55. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/conftest.py +0 -0
  56. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_admin.py +0 -0
  57. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_async_client.py +0 -0
  58. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_checkout.py +0 -0
  59. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_config.py +0 -0
  60. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_customer.py +0 -0
  61. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_http.py +0 -0
  62. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_invariants.py +0 -0
  63. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_langchain.py +0 -0
  64. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_lifecycle.py +0 -0
  65. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_limits.py +0 -0
  66. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_paywall.py +0 -0
  67. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_paywall_state.py +0 -0
  68. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_webhook_events.py +0 -0
  69. {solvapay_python-0.7.0 → solvapay_python-0.7.1}/tests/test_webhooks.py +0 -0
  70. {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.0
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 :: 3 - Alpha
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.0"
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 :: 3 - Alpha",
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 SolvaPayAPIError, SolvaPayError
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.0"
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", "/v1/sdk/customers", json=req.model_dump(by_alias=True, exclude_none=True)
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, purchase_ref: str, *, reason: str | None = None
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(self, purchase_ref: str) -> dict[str, Any]:
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("POST", f"/v1/sdk/purchases/{purchase_ref}/reactivate")
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(self, *, name: str, type: str, default_currency: str) -> 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(self, product_ref: str, *, new_name: str) -> 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(self, purchase_ref: str, *, reason: str | None = None) -> dict[str, Any]:
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(self, purchase_ref: str) -> dict[str, Any]:
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("POST", f"/v1/sdk/purchases/{purchase_ref}/reactivate")
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(self, *, name: str, type: str, default_currency: str) -> 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(self, product_ref: str, *, new_name: str) -> 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