threecommon 0.1.0__tar.gz → 0.3.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.
- threecommon-0.3.0/CHANGELOG.md +62 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/PKG-INFO +1 -1
- {threecommon-0.1.0 → threecommon-0.3.0}/pyproject.toml +1 -1
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/client.py +7 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/invoices/__init__.py +8 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/invoices/service.py +89 -1
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/invoices/types.py +42 -3
- threecommon-0.3.0/src/threecommon/subscriptions/__init__.py +57 -0
- threecommon-0.3.0/src/threecommon/subscriptions/service.py +376 -0
- threecommon-0.3.0/src/threecommon/subscriptions/types.py +359 -0
- threecommon-0.1.0/CHANGELOG.md +0 -16
- {threecommon-0.1.0 → threecommon-0.3.0}/.gitignore +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/LICENSE +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/README.md +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/headers.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/http_client.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/parse.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/retry.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/telemetry.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_core/url.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_generated/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/_generated/models.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/api_version.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/config.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/errors/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/errors/base.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/errors/classes.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/events/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/events/service.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/events/types.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/filters/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/filters/builder.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/filters/types.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/helpers.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/pagination/__init__.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/pagination/auto_paginator.py +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/py.typed +0 -0
- {threecommon-0.1.0 → threecommon-0.3.0}/src/threecommon/version.py +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
4
|
+
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Invoices: auto_charge, refund_payment, delete_draft methods (sync + async).
|
|
13
|
+
- Invoices: subscription_id filter on list().
|
|
14
|
+
- Invoices: AutoChargeOutcome, AutoChargeResult, DeletedInvoice, RefundBody types.
|
|
15
|
+
|
|
16
|
+
## 0.2.0
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Invoice write operations completing parity with the public REST surface, on
|
|
21
|
+
both the sync and async clients:
|
|
22
|
+
- `auto_charge` — off-session charge the customer's saved card
|
|
23
|
+
(`POST /v1/invoices/{id}/auto_charge`). A decline resolves with
|
|
24
|
+
`outcome="failed"` and a `failure_code` rather than raising; only network /
|
|
25
|
+
processor 5xx errors raise.
|
|
26
|
+
- `refund_payment` — refund all or part of a recorded payment
|
|
27
|
+
(`POST /v1/invoices/{id}/payments/{paymentId}/refunds`). Idempotent on
|
|
28
|
+
`body.idempotency_key`.
|
|
29
|
+
- `delete_draft` — permanently remove a draft invoice
|
|
30
|
+
(`DELETE /v1/invoices/{id}`).
|
|
31
|
+
- New public types on `threecommon.invoices`: `AutoChargeResult`,
|
|
32
|
+
`AutoChargeOutcome`, `RefundBody`, and `DeletedInvoice`.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `InvoiceStatus` now includes `payment_failed` (the state set after a failed
|
|
37
|
+
off-session auto-charge); it was previously missing.
|
|
38
|
+
- Invoice `ListParams` now accepts the `subscription_id` filter the API
|
|
39
|
+
supports; it was previously missing.
|
|
40
|
+
|
|
41
|
+
## 0.1.0
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Subscriptions resource. The new `client.subscriptions` surface covers the
|
|
46
|
+
full subscription lifecycle: `list`, `retrieve`, `create`, `update`
|
|
47
|
+
(mid-cycle change with proration), `activate`, `cancel`,
|
|
48
|
+
`cancel_immediately`, `mark_unpaid`, `bill`, `renew`,
|
|
49
|
+
`preview_upcoming_invoice`, and `list_auto_paginate`. Types and typed
|
|
50
|
+
errors match the events / invoices resources. Both sync and async surfaces.
|
|
51
|
+
|
|
52
|
+
## 0.0.0
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- Initial scaffolding.
|
|
57
|
+
- `ThreeCommon` (sync) and `AsyncThreeCommon` (async) clients.
|
|
58
|
+
- Events resource: `list`, `retrieve`, `update`, `list_auto_paginate`.
|
|
59
|
+
- Invoices resource: `list`, `retrieve`, `create`, `update`, `finalize`, `void`,
|
|
60
|
+
`record_payment`, `list_auto_paginate`. Both sync and async surfaces.
|
|
61
|
+
- Typed exception tree (`AuthError`, `NotFoundError`, `RateLimitError`, …).
|
|
62
|
+
- Conformance harness running shared YAML scenarios against both clients.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threecommon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official Python client for the 3Common Public API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/3-Common/sdk/tree/main/sdk-python
|
|
6
6
|
Project-URL: Issues, https://github.com/3-Common/sdk/issues
|
|
@@ -10,7 +10,7 @@ license = { text = "MIT" }
|
|
|
10
10
|
authors = [{ name = "3Common", email = "support@3common.com" }]
|
|
11
11
|
keywords = ["3common", "sdk", "api-client", "events", "invoices"]
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.3.0"
|
|
14
14
|
dependencies = [
|
|
15
15
|
"httpx>=0.27,<1.0",
|
|
16
16
|
"pydantic>=2.7,<3.0",
|
|
@@ -20,6 +20,7 @@ from threecommon._core.telemetry import Telemetry
|
|
|
20
20
|
from threecommon.config import RetryDelay, resolve_config
|
|
21
21
|
from threecommon.events.service import AsyncEventsService, EventsService
|
|
22
22
|
from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
|
|
23
|
+
from threecommon.subscriptions.service import AsyncSubscriptionsService, SubscriptionsService
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING: # pragma: no cover
|
|
25
26
|
import logging
|
|
@@ -51,6 +52,9 @@ class ThreeCommon:
|
|
|
51
52
|
invoices: InvoicesService
|
|
52
53
|
"""Invoices resource — list, retrieve, create, update, finalize, void, record_payment."""
|
|
53
54
|
|
|
55
|
+
subscriptions: SubscriptionsService
|
|
56
|
+
"""Subscriptions resource — list, retrieve, create, update, activate, cancel, bill, renew."""
|
|
57
|
+
|
|
54
58
|
_http: HTTPClient
|
|
55
59
|
_telemetry: Telemetry
|
|
56
60
|
|
|
@@ -93,6 +97,7 @@ class ThreeCommon:
|
|
|
93
97
|
)
|
|
94
98
|
self.events = EventsService(self._http)
|
|
95
99
|
self.invoices = InvoicesService(self._http)
|
|
100
|
+
self.subscriptions = SubscriptionsService(self._http)
|
|
96
101
|
|
|
97
102
|
def close(self) -> None:
|
|
98
103
|
"""Close the underlying httpx client (no-op if you supplied your own)."""
|
|
@@ -120,6 +125,7 @@ class AsyncThreeCommon:
|
|
|
120
125
|
|
|
121
126
|
events: AsyncEventsService
|
|
122
127
|
invoices: AsyncInvoicesService
|
|
128
|
+
subscriptions: AsyncSubscriptionsService
|
|
123
129
|
|
|
124
130
|
_http: AsyncHTTPClient
|
|
125
131
|
_telemetry: Telemetry
|
|
@@ -163,6 +169,7 @@ class AsyncThreeCommon:
|
|
|
163
169
|
)
|
|
164
170
|
self.events = AsyncEventsService(self._http)
|
|
165
171
|
self.invoices = AsyncInvoicesService(self._http)
|
|
172
|
+
self.subscriptions = AsyncSubscriptionsService(self._http)
|
|
166
173
|
|
|
167
174
|
async def aclose(self) -> None:
|
|
168
175
|
"""Close the underlying async httpx client."""
|
|
@@ -8,7 +8,10 @@ service classes directly is supported for advanced wiring.
|
|
|
8
8
|
|
|
9
9
|
from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
|
|
10
10
|
from threecommon.invoices.types import (
|
|
11
|
+
AutoChargeOutcome,
|
|
12
|
+
AutoChargeResult,
|
|
11
13
|
CreateBody,
|
|
14
|
+
DeletedInvoice,
|
|
12
15
|
Invoice,
|
|
13
16
|
InvoiceCurrency,
|
|
14
17
|
InvoiceLineItem,
|
|
@@ -17,6 +20,7 @@ from threecommon.invoices.types import (
|
|
|
17
20
|
ListInvoicesResponse,
|
|
18
21
|
ListParams,
|
|
19
22
|
PaymentBody,
|
|
23
|
+
RefundBody,
|
|
20
24
|
RetrieveParams,
|
|
21
25
|
UpdateBody,
|
|
22
26
|
VoidBody,
|
|
@@ -24,7 +28,10 @@ from threecommon.invoices.types import (
|
|
|
24
28
|
|
|
25
29
|
__all__ = (
|
|
26
30
|
"AsyncInvoicesService",
|
|
31
|
+
"AutoChargeOutcome",
|
|
32
|
+
"AutoChargeResult",
|
|
27
33
|
"CreateBody",
|
|
34
|
+
"DeletedInvoice",
|
|
28
35
|
"Invoice",
|
|
29
36
|
"InvoiceCurrency",
|
|
30
37
|
"InvoiceLineItem",
|
|
@@ -34,6 +41,7 @@ __all__ = (
|
|
|
34
41
|
"ListInvoicesResponse",
|
|
35
42
|
"ListParams",
|
|
36
43
|
"PaymentBody",
|
|
44
|
+
"RefundBody",
|
|
37
45
|
"RetrieveParams",
|
|
38
46
|
"UpdateBody",
|
|
39
47
|
"VoidBody",
|
|
@@ -6,17 +6,20 @@ difference is which HTTP client they call.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
10
|
from urllib.parse import quote
|
|
11
11
|
|
|
12
12
|
from threecommon._core.http_client import Request
|
|
13
13
|
from threecommon.errors.classes import ValidationError
|
|
14
14
|
from threecommon.invoices.types import (
|
|
15
|
+
AutoChargeResult,
|
|
15
16
|
CreateBody,
|
|
17
|
+
DeletedInvoice,
|
|
16
18
|
Invoice,
|
|
17
19
|
ListInvoicesResponse,
|
|
18
20
|
ListParams,
|
|
19
21
|
PaymentBody,
|
|
22
|
+
RefundBody,
|
|
20
23
|
RetrieveParams,
|
|
21
24
|
UpdateBody,
|
|
22
25
|
VoidBody,
|
|
@@ -56,6 +59,23 @@ def _action_path(invoice_id: str, action: str) -> str:
|
|
|
56
59
|
return f"{_path_for(invoice_id)}/{action}"
|
|
57
60
|
|
|
58
61
|
|
|
62
|
+
def _refund_path(invoice_id: str, payment_id: str) -> str:
|
|
63
|
+
return f"{_path_for(invoice_id)}/payments/{quote(payment_id, safe='')}/refunds"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_auto_charge_result(response: dict[str, Any]) -> AutoChargeResult:
|
|
67
|
+
payload: dict[str, Any] = {"invoice": response["data"], "outcome": response["outcome"]}
|
|
68
|
+
if response.get("failureCode") is not None:
|
|
69
|
+
payload["failure_code"] = response["failureCode"]
|
|
70
|
+
return AutoChargeResult.model_validate(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_payment_id(payment_id: str) -> None:
|
|
74
|
+
if not payment_id:
|
|
75
|
+
msg = "invoices.refund_payment: payment_id must be a non-empty string"
|
|
76
|
+
raise ValidationError(code="missing_payment_id", message=msg)
|
|
77
|
+
|
|
78
|
+
|
|
59
79
|
# ────────────────────────────────────────────────────────────────────────────
|
|
60
80
|
# Sync
|
|
61
81
|
# ────────────────────────────────────────────────────────────────────────────
|
|
@@ -148,6 +168,48 @@ class InvoicesService:
|
|
|
148
168
|
)
|
|
149
169
|
return Invoice.model_validate(response["data"])
|
|
150
170
|
|
|
171
|
+
def auto_charge(self, invoice_id: str) -> AutoChargeResult:
|
|
172
|
+
"""Off-session charge the customer's saved card for an open invoice.
|
|
173
|
+
|
|
174
|
+
A decline is not an error — it resolves with ``outcome="failed"`` and a
|
|
175
|
+
``failure_code``, leaving the invoice in ``payment_failed``. Only
|
|
176
|
+
network / processor 5xx errors raise.
|
|
177
|
+
"""
|
|
178
|
+
_require_id("auto_charge", invoice_id)
|
|
179
|
+
response = self._http.request(
|
|
180
|
+
Request(method="POST", path=_action_path(invoice_id, "auto_charge"), body={})
|
|
181
|
+
)
|
|
182
|
+
return _build_auto_charge_result(response)
|
|
183
|
+
|
|
184
|
+
def refund_payment(self, invoice_id: str, payment_id: str, body: RefundBody) -> Invoice:
|
|
185
|
+
"""Refund all or part of a recorded payment on a paid invoice.
|
|
186
|
+
|
|
187
|
+
Idempotent on ``body.idempotency_key``: replays return the existing
|
|
188
|
+
refund without contacting the processor again.
|
|
189
|
+
"""
|
|
190
|
+
_require_id("refund_payment", invoice_id)
|
|
191
|
+
_require_payment_id(payment_id)
|
|
192
|
+
if body is None:
|
|
193
|
+
raise ValidationError(
|
|
194
|
+
code="missing_body",
|
|
195
|
+
message="invoices.refund_payment: body must be non-None",
|
|
196
|
+
)
|
|
197
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
198
|
+
response = self._http.request(
|
|
199
|
+
Request(method="POST", path=_refund_path(invoice_id, payment_id), body=payload)
|
|
200
|
+
)
|
|
201
|
+
return Invoice.model_validate(response["data"])
|
|
202
|
+
|
|
203
|
+
def delete_draft(self, invoice_id: str) -> DeletedInvoice:
|
|
204
|
+
"""Permanently delete a draft invoice.
|
|
205
|
+
|
|
206
|
+
Only legal while in ``draft`` (no number issued); finalized invoices
|
|
207
|
+
must be voided instead so the audit trail stays intact.
|
|
208
|
+
"""
|
|
209
|
+
_require_id("delete_draft", invoice_id)
|
|
210
|
+
response = self._http.request(Request(method="DELETE", path=_path_for(invoice_id)))
|
|
211
|
+
return DeletedInvoice.model_validate(response["data"])
|
|
212
|
+
|
|
151
213
|
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Invoice]:
|
|
152
214
|
"""Iterate every invoice matching ``params``, paging automatically."""
|
|
153
215
|
start_page = params.page if params is not None and params.page is not None else 0
|
|
@@ -246,6 +308,32 @@ class AsyncInvoicesService:
|
|
|
246
308
|
)
|
|
247
309
|
return Invoice.model_validate(response["data"])
|
|
248
310
|
|
|
311
|
+
async def auto_charge(self, invoice_id: str) -> AutoChargeResult:
|
|
312
|
+
_require_id("auto_charge", invoice_id)
|
|
313
|
+
response = await self._http.request(
|
|
314
|
+
Request(method="POST", path=_action_path(invoice_id, "auto_charge"), body={})
|
|
315
|
+
)
|
|
316
|
+
return _build_auto_charge_result(response)
|
|
317
|
+
|
|
318
|
+
async def refund_payment(self, invoice_id: str, payment_id: str, body: RefundBody) -> Invoice:
|
|
319
|
+
_require_id("refund_payment", invoice_id)
|
|
320
|
+
_require_payment_id(payment_id)
|
|
321
|
+
if body is None:
|
|
322
|
+
raise ValidationError(
|
|
323
|
+
code="missing_body",
|
|
324
|
+
message="invoices.refund_payment: body must be non-None",
|
|
325
|
+
)
|
|
326
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
327
|
+
response = await self._http.request(
|
|
328
|
+
Request(method="POST", path=_refund_path(invoice_id, payment_id), body=payload)
|
|
329
|
+
)
|
|
330
|
+
return Invoice.model_validate(response["data"])
|
|
331
|
+
|
|
332
|
+
async def delete_draft(self, invoice_id: str) -> DeletedInvoice:
|
|
333
|
+
_require_id("delete_draft", invoice_id)
|
|
334
|
+
response = await self._http.request(Request(method="DELETE", path=_path_for(invoice_id)))
|
|
335
|
+
return DeletedInvoice.model_validate(response["data"])
|
|
336
|
+
|
|
249
337
|
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Invoice]:
|
|
250
338
|
"""Async iterate every invoice matching ``params``."""
|
|
251
339
|
start_page = params.page if params is not None and params.page is not None else 0
|
|
@@ -11,13 +11,17 @@ from typing import Literal
|
|
|
11
11
|
|
|
12
12
|
from pydantic import BaseModel, ConfigDict, Field
|
|
13
13
|
|
|
14
|
-
#: Lifecycle status of an invoice.
|
|
15
|
-
#:
|
|
16
|
-
|
|
14
|
+
#: Lifecycle status of an invoice. ``payment_failed`` is set when an off-session
|
|
15
|
+
#: auto-charge attempt is rejected (decline / SCA / no card); the invoice is
|
|
16
|
+
#: still owed and can be retried or paid manually.
|
|
17
|
+
InvoiceStatus = Literal["draft", "open", "payment_failed", "paid", "void"]
|
|
17
18
|
|
|
18
19
|
#: Invoice currency code; all line amounts must match.
|
|
19
20
|
InvoiceCurrency = Literal["USD", "CAD"]
|
|
20
21
|
|
|
22
|
+
#: Outcome of an auto-charge attempt.
|
|
23
|
+
AutoChargeOutcome = Literal["paid", "failed"]
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
class _BaseModel(BaseModel):
|
|
23
27
|
"""Shared config: accept snake_case or camelCase, ignore unknown fields."""
|
|
@@ -130,6 +134,9 @@ class ListParams(_BaseModel):
|
|
|
130
134
|
customer_id: str | None = Field(
|
|
131
135
|
default=None, serialization_alias="customerId", validation_alias="customerId"
|
|
132
136
|
)
|
|
137
|
+
subscription_id: str | None = Field(
|
|
138
|
+
default=None, serialization_alias="subscriptionId", validation_alias="subscriptionId"
|
|
139
|
+
)
|
|
133
140
|
issued_after: str | None = Field(
|
|
134
141
|
default=None, serialization_alias="issuedAfter", validation_alias="issuedAfter"
|
|
135
142
|
)
|
|
@@ -193,3 +200,35 @@ class PaymentBody(_BaseModel):
|
|
|
193
200
|
default=None, serialization_alias="idempotencyKey", validation_alias="idempotencyKey"
|
|
194
201
|
)
|
|
195
202
|
note: str | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class RefundBody(_BaseModel):
|
|
206
|
+
"""Body accepted by ``POST /v1/invoices/{id}/payments/{paymentId}/refunds``."""
|
|
207
|
+
|
|
208
|
+
amount: int
|
|
209
|
+
reason: Literal["duplicate", "fraudulent", "requested_by_customer"] | None = None
|
|
210
|
+
note: str | None = None
|
|
211
|
+
idempotency_key: str | None = Field(
|
|
212
|
+
default=None, serialization_alias="idempotencyKey", validation_alias="idempotencyKey"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AutoChargeResult(_BaseModel):
|
|
217
|
+
"""Successful response shape from ``POST /v1/invoices/{id}/auto_charge``.
|
|
218
|
+
|
|
219
|
+
A card decline is an expected business outcome, not an error: ``outcome`` is
|
|
220
|
+
``"failed"`` with the invoice left in ``payment_failed`` and a
|
|
221
|
+
``failure_code`` set. Only network / processor 5xx errors raise.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
invoice: Invoice
|
|
225
|
+
outcome: AutoChargeOutcome
|
|
226
|
+
failure_code: str | None = Field(
|
|
227
|
+
default=None, serialization_alias="failureCode", validation_alias="failureCode"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class DeletedInvoice(_BaseModel):
|
|
232
|
+
"""Result of ``DELETE /v1/invoices/{id}`` — the id of the removed draft."""
|
|
233
|
+
|
|
234
|
+
id: str
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Subscriptions resource — sync and async clients plus public types.
|
|
2
|
+
|
|
3
|
+
Most callers reach this module through
|
|
4
|
+
[ThreeCommon.subscriptions][threecommon.ThreeCommon] /
|
|
5
|
+
[AsyncThreeCommon.subscriptions][threecommon.AsyncThreeCommon]; importing the
|
|
6
|
+
service classes directly is supported for advanced wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threecommon.subscriptions.service import (
|
|
10
|
+
AsyncSubscriptionsService,
|
|
11
|
+
SubscriptionsService,
|
|
12
|
+
)
|
|
13
|
+
from threecommon.subscriptions.types import (
|
|
14
|
+
BillSubscriptionResult,
|
|
15
|
+
CancelBody,
|
|
16
|
+
CancelImmediatelyBody,
|
|
17
|
+
CreateBody,
|
|
18
|
+
CreateBodyItem,
|
|
19
|
+
ListParams,
|
|
20
|
+
ListSubscriptionsResponse,
|
|
21
|
+
RenewSubscriptionResult,
|
|
22
|
+
RetrieveParams,
|
|
23
|
+
Subscription,
|
|
24
|
+
SubscriptionInvoicePreview,
|
|
25
|
+
SubscriptionInvoicePreviewLineItem,
|
|
26
|
+
SubscriptionInvoiceRef,
|
|
27
|
+
SubscriptionItem,
|
|
28
|
+
SubscriptionProration,
|
|
29
|
+
SubscriptionStatus,
|
|
30
|
+
SubscriptionTaxId,
|
|
31
|
+
UpdateBody,
|
|
32
|
+
UpdateSubscriptionResult,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = (
|
|
36
|
+
"AsyncSubscriptionsService",
|
|
37
|
+
"BillSubscriptionResult",
|
|
38
|
+
"CancelBody",
|
|
39
|
+
"CancelImmediatelyBody",
|
|
40
|
+
"CreateBody",
|
|
41
|
+
"CreateBodyItem",
|
|
42
|
+
"ListParams",
|
|
43
|
+
"ListSubscriptionsResponse",
|
|
44
|
+
"RenewSubscriptionResult",
|
|
45
|
+
"RetrieveParams",
|
|
46
|
+
"Subscription",
|
|
47
|
+
"SubscriptionInvoicePreview",
|
|
48
|
+
"SubscriptionInvoicePreviewLineItem",
|
|
49
|
+
"SubscriptionInvoiceRef",
|
|
50
|
+
"SubscriptionItem",
|
|
51
|
+
"SubscriptionProration",
|
|
52
|
+
"SubscriptionStatus",
|
|
53
|
+
"SubscriptionTaxId",
|
|
54
|
+
"SubscriptionsService",
|
|
55
|
+
"UpdateBody",
|
|
56
|
+
"UpdateSubscriptionResult",
|
|
57
|
+
)
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Sync and async subscriptions services.
|
|
2
|
+
|
|
3
|
+
Both services share the same wire shape and validation logic; the only
|
|
4
|
+
difference is which HTTP client they call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from threecommon._core.http_client import Request
|
|
13
|
+
from threecommon.errors.classes import ValidationError
|
|
14
|
+
from threecommon.pagination import AsyncIter, Iter
|
|
15
|
+
from threecommon.subscriptions.types import (
|
|
16
|
+
BillSubscriptionResult,
|
|
17
|
+
CancelBody,
|
|
18
|
+
CancelImmediatelyBody,
|
|
19
|
+
CreateBody,
|
|
20
|
+
ListParams,
|
|
21
|
+
ListSubscriptionsResponse,
|
|
22
|
+
RenewSubscriptionResult,
|
|
23
|
+
RetrieveParams,
|
|
24
|
+
Subscription,
|
|
25
|
+
SubscriptionInvoicePreview,
|
|
26
|
+
UpdateBody,
|
|
27
|
+
UpdateSubscriptionResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
|
|
35
|
+
if params is None:
|
|
36
|
+
return None
|
|
37
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
38
|
+
if not raw:
|
|
39
|
+
return None
|
|
40
|
+
return {k: str(v) for k, v in raw.items()}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _encode_retrieve_params(params: RetrieveParams | None) -> dict[str, str] | None:
|
|
44
|
+
if params is None or params.fields is None:
|
|
45
|
+
return None
|
|
46
|
+
return {"fields": params.fields}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require_id(method: str, subscription_id: str) -> None:
|
|
50
|
+
if not subscription_id:
|
|
51
|
+
msg = f"subscriptions.{method}: id must be a non-empty string"
|
|
52
|
+
raise ValidationError(code="missing_id", message=msg)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _path_for(subscription_id: str) -> str:
|
|
56
|
+
return f"/subscriptions/{quote(subscription_id, safe='')}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _action_path(subscription_id: str, action: str) -> str:
|
|
60
|
+
return f"{_path_for(subscription_id)}/{action}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_update_result(response: dict[str, Any]) -> UpdateSubscriptionResult:
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"subscription": response["data"],
|
|
66
|
+
"proration": response["proration"],
|
|
67
|
+
}
|
|
68
|
+
if response.get("invoice") is not None:
|
|
69
|
+
payload["invoice"] = response["invoice"]
|
|
70
|
+
return UpdateSubscriptionResult.model_validate(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_bill_result(response: dict[str, Any]) -> BillSubscriptionResult:
|
|
74
|
+
return BillSubscriptionResult.model_validate(
|
|
75
|
+
{"subscription": response["data"], "invoice": response["invoice"]}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_renew_result(response: dict[str, Any]) -> RenewSubscriptionResult:
|
|
80
|
+
payload: dict[str, Any] = {"subscription": response["data"]}
|
|
81
|
+
if response.get("invoice") is not None:
|
|
82
|
+
payload["invoice"] = response["invoice"]
|
|
83
|
+
return RenewSubscriptionResult.model_validate(payload)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
# Sync
|
|
88
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SubscriptionsService:
|
|
92
|
+
"""Sync subscriptions service — bound as ``client.subscriptions`` on [ThreeCommon]."""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("_http",)
|
|
95
|
+
|
|
96
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
97
|
+
self._http = http
|
|
98
|
+
|
|
99
|
+
def list(self, params: ListParams | None = None) -> ListSubscriptionsResponse:
|
|
100
|
+
"""List the host's subscriptions (one page).
|
|
101
|
+
|
|
102
|
+
For full iteration use
|
|
103
|
+
[list_auto_paginate][SubscriptionsService.list_auto_paginate].
|
|
104
|
+
"""
|
|
105
|
+
body = self._http.request(
|
|
106
|
+
Request(method="GET", path="/subscriptions", query=_encode_list_params(params))
|
|
107
|
+
)
|
|
108
|
+
return ListSubscriptionsResponse.model_validate(body)
|
|
109
|
+
|
|
110
|
+
def retrieve(self, subscription_id: str, params: RetrieveParams | None = None) -> Subscription:
|
|
111
|
+
"""Retrieve a single subscription by id."""
|
|
112
|
+
_require_id("retrieve", subscription_id)
|
|
113
|
+
body = self._http.request(
|
|
114
|
+
Request(
|
|
115
|
+
method="GET",
|
|
116
|
+
path=_path_for(subscription_id),
|
|
117
|
+
query=_encode_retrieve_params(params),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return Subscription.model_validate(body["data"])
|
|
121
|
+
|
|
122
|
+
def create(self, body: CreateBody) -> Subscription:
|
|
123
|
+
"""Create a new subscription against an active recurring Price."""
|
|
124
|
+
if body is None:
|
|
125
|
+
raise ValidationError(
|
|
126
|
+
code="missing_body", message="subscriptions.create: body must be non-None"
|
|
127
|
+
)
|
|
128
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
129
|
+
response = self._http.request(Request(method="POST", path="/subscriptions", body=payload))
|
|
130
|
+
return Subscription.model_validate(response["data"])
|
|
131
|
+
|
|
132
|
+
def update(self, subscription_id: str, body: UpdateBody) -> UpdateSubscriptionResult:
|
|
133
|
+
"""Apply a mid-cycle price/quantity change with Stripe-style daily proration."""
|
|
134
|
+
_require_id("update", subscription_id)
|
|
135
|
+
if body is None:
|
|
136
|
+
raise ValidationError(
|
|
137
|
+
code="missing_body", message="subscriptions.update: body must be non-None"
|
|
138
|
+
)
|
|
139
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
140
|
+
response = self._http.request(
|
|
141
|
+
Request(method="PATCH", path=_path_for(subscription_id), body=payload)
|
|
142
|
+
)
|
|
143
|
+
return _build_update_result(response)
|
|
144
|
+
|
|
145
|
+
def activate(self, subscription_id: str) -> Subscription:
|
|
146
|
+
"""Transition an incomplete or trialing subscription to ``active``."""
|
|
147
|
+
_require_id("activate", subscription_id)
|
|
148
|
+
response = self._http.request(
|
|
149
|
+
Request(method="POST", path=_action_path(subscription_id, "activate"), body={})
|
|
150
|
+
)
|
|
151
|
+
return Subscription.model_validate(response["data"])
|
|
152
|
+
|
|
153
|
+
def cancel(self, subscription_id: str, body: CancelBody | None = None) -> Subscription:
|
|
154
|
+
"""Schedule cancellation at the end of the current period. Idempotent."""
|
|
155
|
+
_require_id("cancel", subscription_id)
|
|
156
|
+
payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
|
|
157
|
+
response = self._http.request(
|
|
158
|
+
Request(method="POST", path=_action_path(subscription_id, "cancel"), body=payload)
|
|
159
|
+
)
|
|
160
|
+
return Subscription.model_validate(response["data"])
|
|
161
|
+
|
|
162
|
+
def cancel_immediately(
|
|
163
|
+
self, subscription_id: str, body: CancelImmediatelyBody | None = None
|
|
164
|
+
) -> Subscription:
|
|
165
|
+
"""Admin override — terminate the subscription immediately."""
|
|
166
|
+
_require_id("cancel_immediately", subscription_id)
|
|
167
|
+
payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
|
|
168
|
+
response = self._http.request(
|
|
169
|
+
Request(
|
|
170
|
+
method="POST",
|
|
171
|
+
path=_action_path(subscription_id, "cancel-immediately"),
|
|
172
|
+
body=payload,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return Subscription.model_validate(response["data"])
|
|
176
|
+
|
|
177
|
+
def mark_unpaid(self, subscription_id: str) -> Subscription:
|
|
178
|
+
"""Admin override — mark a subscription ``unpaid`` (terminal)."""
|
|
179
|
+
_require_id("mark_unpaid", subscription_id)
|
|
180
|
+
response = self._http.request(
|
|
181
|
+
Request(method="POST", path=_action_path(subscription_id, "mark-unpaid"), body={})
|
|
182
|
+
)
|
|
183
|
+
return Subscription.model_validate(response["data"])
|
|
184
|
+
|
|
185
|
+
def bill(self, subscription_id: str) -> BillSubscriptionResult:
|
|
186
|
+
"""Generate a draft invoice for the current period without advancing it."""
|
|
187
|
+
_require_id("bill", subscription_id)
|
|
188
|
+
response = self._http.request(
|
|
189
|
+
Request(method="POST", path=_action_path(subscription_id, "bill"), body={})
|
|
190
|
+
)
|
|
191
|
+
return _build_bill_result(response)
|
|
192
|
+
|
|
193
|
+
def renew(self, subscription_id: str) -> RenewSubscriptionResult:
|
|
194
|
+
"""Advance the subscription to its next billing period and generate an invoice."""
|
|
195
|
+
_require_id("renew", subscription_id)
|
|
196
|
+
response = self._http.request(
|
|
197
|
+
Request(method="POST", path=_action_path(subscription_id, "renew"), body={})
|
|
198
|
+
)
|
|
199
|
+
return _build_renew_result(response)
|
|
200
|
+
|
|
201
|
+
def preview_upcoming_invoice(self, subscription_id: str) -> SubscriptionInvoicePreview | None:
|
|
202
|
+
"""Preview the invoice the next renewal will generate.
|
|
203
|
+
|
|
204
|
+
Returns ``None`` when the subscription is set to cancel at period end.
|
|
205
|
+
"""
|
|
206
|
+
_require_id("preview_upcoming_invoice", subscription_id)
|
|
207
|
+
response = self._http.request(
|
|
208
|
+
Request(method="GET", path=_action_path(subscription_id, "upcoming"))
|
|
209
|
+
)
|
|
210
|
+
invoice = response["data"].get("invoice")
|
|
211
|
+
if invoice is None:
|
|
212
|
+
return None
|
|
213
|
+
return SubscriptionInvoicePreview.model_validate(invoice)
|
|
214
|
+
|
|
215
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Subscription]:
|
|
216
|
+
"""Iterate every subscription matching ``params``, paging automatically."""
|
|
217
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
218
|
+
|
|
219
|
+
def fetch(page: int) -> tuple[list[Subscription], bool]:
|
|
220
|
+
page_params = (
|
|
221
|
+
params.model_copy(update={"page": page})
|
|
222
|
+
if params is not None
|
|
223
|
+
else ListParams(page=page)
|
|
224
|
+
)
|
|
225
|
+
body = self._http.request(
|
|
226
|
+
Request(
|
|
227
|
+
method="GET",
|
|
228
|
+
path="/subscriptions",
|
|
229
|
+
query=_encode_list_params(page_params),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
response = ListSubscriptionsResponse.model_validate(body)
|
|
233
|
+
return response.data, response.has_more
|
|
234
|
+
|
|
235
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
# Async
|
|
240
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class AsyncSubscriptionsService:
|
|
244
|
+
"""Async subscriptions service — bound as ``client.subscriptions`` on [AsyncThreeCommon]."""
|
|
245
|
+
|
|
246
|
+
__slots__ = ("_http",)
|
|
247
|
+
|
|
248
|
+
def __init__(self, http: AsyncHTTPClient) -> None:
|
|
249
|
+
self._http = http
|
|
250
|
+
|
|
251
|
+
async def list(self, params: ListParams | None = None) -> ListSubscriptionsResponse:
|
|
252
|
+
body = await self._http.request(
|
|
253
|
+
Request(method="GET", path="/subscriptions", query=_encode_list_params(params))
|
|
254
|
+
)
|
|
255
|
+
return ListSubscriptionsResponse.model_validate(body)
|
|
256
|
+
|
|
257
|
+
async def retrieve(
|
|
258
|
+
self, subscription_id: str, params: RetrieveParams | None = None
|
|
259
|
+
) -> Subscription:
|
|
260
|
+
_require_id("retrieve", subscription_id)
|
|
261
|
+
body = await self._http.request(
|
|
262
|
+
Request(
|
|
263
|
+
method="GET",
|
|
264
|
+
path=_path_for(subscription_id),
|
|
265
|
+
query=_encode_retrieve_params(params),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return Subscription.model_validate(body["data"])
|
|
269
|
+
|
|
270
|
+
async def create(self, body: CreateBody) -> Subscription:
|
|
271
|
+
if body is None:
|
|
272
|
+
raise ValidationError(
|
|
273
|
+
code="missing_body", message="subscriptions.create: body must be non-None"
|
|
274
|
+
)
|
|
275
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
276
|
+
response = await self._http.request(
|
|
277
|
+
Request(method="POST", path="/subscriptions", body=payload)
|
|
278
|
+
)
|
|
279
|
+
return Subscription.model_validate(response["data"])
|
|
280
|
+
|
|
281
|
+
async def update(self, subscription_id: str, body: UpdateBody) -> UpdateSubscriptionResult:
|
|
282
|
+
_require_id("update", subscription_id)
|
|
283
|
+
if body is None:
|
|
284
|
+
raise ValidationError(
|
|
285
|
+
code="missing_body", message="subscriptions.update: body must be non-None"
|
|
286
|
+
)
|
|
287
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
288
|
+
response = await self._http.request(
|
|
289
|
+
Request(method="PATCH", path=_path_for(subscription_id), body=payload)
|
|
290
|
+
)
|
|
291
|
+
return _build_update_result(response)
|
|
292
|
+
|
|
293
|
+
async def activate(self, subscription_id: str) -> Subscription:
|
|
294
|
+
_require_id("activate", subscription_id)
|
|
295
|
+
response = await self._http.request(
|
|
296
|
+
Request(method="POST", path=_action_path(subscription_id, "activate"), body={})
|
|
297
|
+
)
|
|
298
|
+
return Subscription.model_validate(response["data"])
|
|
299
|
+
|
|
300
|
+
async def cancel(self, subscription_id: str, body: CancelBody | None = None) -> Subscription:
|
|
301
|
+
_require_id("cancel", subscription_id)
|
|
302
|
+
payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
|
|
303
|
+
response = await self._http.request(
|
|
304
|
+
Request(method="POST", path=_action_path(subscription_id, "cancel"), body=payload)
|
|
305
|
+
)
|
|
306
|
+
return Subscription.model_validate(response["data"])
|
|
307
|
+
|
|
308
|
+
async def cancel_immediately(
|
|
309
|
+
self, subscription_id: str, body: CancelImmediatelyBody | None = None
|
|
310
|
+
) -> Subscription:
|
|
311
|
+
_require_id("cancel_immediately", subscription_id)
|
|
312
|
+
payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
|
|
313
|
+
response = await self._http.request(
|
|
314
|
+
Request(
|
|
315
|
+
method="POST",
|
|
316
|
+
path=_action_path(subscription_id, "cancel-immediately"),
|
|
317
|
+
body=payload,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return Subscription.model_validate(response["data"])
|
|
321
|
+
|
|
322
|
+
async def mark_unpaid(self, subscription_id: str) -> Subscription:
|
|
323
|
+
_require_id("mark_unpaid", subscription_id)
|
|
324
|
+
response = await self._http.request(
|
|
325
|
+
Request(method="POST", path=_action_path(subscription_id, "mark-unpaid"), body={})
|
|
326
|
+
)
|
|
327
|
+
return Subscription.model_validate(response["data"])
|
|
328
|
+
|
|
329
|
+
async def bill(self, subscription_id: str) -> BillSubscriptionResult:
|
|
330
|
+
_require_id("bill", subscription_id)
|
|
331
|
+
response = await self._http.request(
|
|
332
|
+
Request(method="POST", path=_action_path(subscription_id, "bill"), body={})
|
|
333
|
+
)
|
|
334
|
+
return _build_bill_result(response)
|
|
335
|
+
|
|
336
|
+
async def renew(self, subscription_id: str) -> RenewSubscriptionResult:
|
|
337
|
+
_require_id("renew", subscription_id)
|
|
338
|
+
response = await self._http.request(
|
|
339
|
+
Request(method="POST", path=_action_path(subscription_id, "renew"), body={})
|
|
340
|
+
)
|
|
341
|
+
return _build_renew_result(response)
|
|
342
|
+
|
|
343
|
+
async def preview_upcoming_invoice(
|
|
344
|
+
self, subscription_id: str
|
|
345
|
+
) -> SubscriptionInvoicePreview | None:
|
|
346
|
+
_require_id("preview_upcoming_invoice", subscription_id)
|
|
347
|
+
response = await self._http.request(
|
|
348
|
+
Request(method="GET", path=_action_path(subscription_id, "upcoming"))
|
|
349
|
+
)
|
|
350
|
+
invoice = response["data"].get("invoice")
|
|
351
|
+
if invoice is None:
|
|
352
|
+
return None
|
|
353
|
+
return SubscriptionInvoicePreview.model_validate(invoice)
|
|
354
|
+
|
|
355
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Subscription]:
|
|
356
|
+
"""Async iterate every subscription matching ``params``."""
|
|
357
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
358
|
+
http = self._http
|
|
359
|
+
|
|
360
|
+
async def fetch(page: int) -> tuple[list[Subscription], bool]:
|
|
361
|
+
page_params = (
|
|
362
|
+
params.model_copy(update={"page": page})
|
|
363
|
+
if params is not None
|
|
364
|
+
else ListParams(page=page)
|
|
365
|
+
)
|
|
366
|
+
body = await http.request(
|
|
367
|
+
Request(
|
|
368
|
+
method="GET",
|
|
369
|
+
path="/subscriptions",
|
|
370
|
+
query=_encode_list_params(page_params),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
response = ListSubscriptionsResponse.model_validate(body)
|
|
374
|
+
return response.data, response.has_more
|
|
375
|
+
|
|
376
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Public types for the subscriptions resource.
|
|
2
|
+
|
|
3
|
+
Hand-curated Pydantic models that mirror the wire shape (camelCase aliases
|
|
4
|
+
preserved). All response models use ``extra="ignore"`` so newer server-side
|
|
5
|
+
fields don't break older SDK versions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
#: Lifecycle status of a subscription. Future server-side values will arrive
|
|
15
|
+
#: as raw strings until the SDK is updated.
|
|
16
|
+
SubscriptionStatus = Literal[
|
|
17
|
+
"incomplete",
|
|
18
|
+
"trialing",
|
|
19
|
+
"active",
|
|
20
|
+
"past_due",
|
|
21
|
+
"canceled",
|
|
22
|
+
"unpaid",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _BaseModel(BaseModel):
|
|
27
|
+
"""Shared config: accept snake_case or camelCase, ignore unknown fields."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(
|
|
30
|
+
populate_by_name=True,
|
|
31
|
+
extra="ignore",
|
|
32
|
+
str_strip_whitespace=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubscriptionItem(_BaseModel):
|
|
37
|
+
"""One billed item on a subscription."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
price_id: str = Field(serialization_alias="priceId", validation_alias="priceId")
|
|
41
|
+
quantity: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SubscriptionTaxId(_BaseModel):
|
|
45
|
+
"""Host tax-ID snapshot carried onto each renewal invoice."""
|
|
46
|
+
|
|
47
|
+
type: str
|
|
48
|
+
value: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Subscription(_BaseModel):
|
|
52
|
+
"""One subscription as returned by the API.
|
|
53
|
+
|
|
54
|
+
Optional fields are populated only when the server returned them — list
|
|
55
|
+
responses with a ``fields`` filter omit unrequested values.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
host_id: str | None = Field(
|
|
60
|
+
default=None, serialization_alias="hostId", validation_alias="hostId"
|
|
61
|
+
)
|
|
62
|
+
contact_id: str | None = Field(
|
|
63
|
+
default=None, serialization_alias="contactId", validation_alias="contactId"
|
|
64
|
+
)
|
|
65
|
+
customer_email: str | None = Field(
|
|
66
|
+
default=None, serialization_alias="customerEmail", validation_alias="customerEmail"
|
|
67
|
+
)
|
|
68
|
+
price_id: str | None = Field(
|
|
69
|
+
default=None, serialization_alias="priceId", validation_alias="priceId"
|
|
70
|
+
)
|
|
71
|
+
quantity: int | None = None
|
|
72
|
+
items: list[SubscriptionItem] | None = None
|
|
73
|
+
status: SubscriptionStatus | None = None
|
|
74
|
+
current_period_start: str | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
serialization_alias="currentPeriodStart",
|
|
77
|
+
validation_alias="currentPeriodStart",
|
|
78
|
+
)
|
|
79
|
+
current_period_end: str | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
serialization_alias="currentPeriodEnd",
|
|
82
|
+
validation_alias="currentPeriodEnd",
|
|
83
|
+
)
|
|
84
|
+
trial_start: str | None = Field(
|
|
85
|
+
default=None, serialization_alias="trialStart", validation_alias="trialStart"
|
|
86
|
+
)
|
|
87
|
+
trial_end: str | None = Field(
|
|
88
|
+
default=None, serialization_alias="trialEnd", validation_alias="trialEnd"
|
|
89
|
+
)
|
|
90
|
+
billing_cycle_anchor: str | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
serialization_alias="billingCycleAnchor",
|
|
93
|
+
validation_alias="billingCycleAnchor",
|
|
94
|
+
)
|
|
95
|
+
cancel_at: str | None = Field(
|
|
96
|
+
default=None, serialization_alias="cancelAt", validation_alias="cancelAt"
|
|
97
|
+
)
|
|
98
|
+
cancel_at_period_end: bool | None = Field(
|
|
99
|
+
default=None,
|
|
100
|
+
serialization_alias="cancelAtPeriodEnd",
|
|
101
|
+
validation_alias="cancelAtPeriodEnd",
|
|
102
|
+
)
|
|
103
|
+
canceled_at: str | None = Field(
|
|
104
|
+
default=None, serialization_alias="canceledAt", validation_alias="canceledAt"
|
|
105
|
+
)
|
|
106
|
+
cancel_reason: str | None = Field(
|
|
107
|
+
default=None, serialization_alias="cancelReason", validation_alias="cancelReason"
|
|
108
|
+
)
|
|
109
|
+
ended_at: str | None = Field(
|
|
110
|
+
default=None, serialization_alias="endedAt", validation_alias="endedAt"
|
|
111
|
+
)
|
|
112
|
+
started_at: str | None = Field(
|
|
113
|
+
default=None, serialization_alias="startedAt", validation_alias="startedAt"
|
|
114
|
+
)
|
|
115
|
+
dunning_enabled: bool | None = Field(
|
|
116
|
+
default=None,
|
|
117
|
+
serialization_alias="dunningEnabled",
|
|
118
|
+
validation_alias="dunningEnabled",
|
|
119
|
+
)
|
|
120
|
+
first_failure_at: str | None = Field(
|
|
121
|
+
default=None,
|
|
122
|
+
serialization_alias="firstFailureAt",
|
|
123
|
+
validation_alias="firstFailureAt",
|
|
124
|
+
)
|
|
125
|
+
next_retry_at: str | None = Field(
|
|
126
|
+
default=None,
|
|
127
|
+
serialization_alias="nextRetryAt",
|
|
128
|
+
validation_alias="nextRetryAt",
|
|
129
|
+
)
|
|
130
|
+
retry_count: int | None = Field(
|
|
131
|
+
default=None, serialization_alias="retryCount", validation_alias="retryCount"
|
|
132
|
+
)
|
|
133
|
+
notes: str | None = None
|
|
134
|
+
tax_ids: list[SubscriptionTaxId] | None = Field(
|
|
135
|
+
default=None, serialization_alias="taxIds", validation_alias="taxIds"
|
|
136
|
+
)
|
|
137
|
+
auto_charge: bool | None = Field(
|
|
138
|
+
default=None, serialization_alias="autoCharge", validation_alias="autoCharge"
|
|
139
|
+
)
|
|
140
|
+
payment_due_days: int | None = Field(
|
|
141
|
+
default=None,
|
|
142
|
+
serialization_alias="paymentDueDays",
|
|
143
|
+
validation_alias="paymentDueDays",
|
|
144
|
+
)
|
|
145
|
+
tax_rate: float | None = Field(
|
|
146
|
+
default=None, serialization_alias="taxRate", validation_alias="taxRate"
|
|
147
|
+
)
|
|
148
|
+
metadata: dict[str, str] | None = None
|
|
149
|
+
created_at: str | None = Field(
|
|
150
|
+
default=None, serialization_alias="createdAt", validation_alias="createdAt"
|
|
151
|
+
)
|
|
152
|
+
updated_at: str | None = Field(
|
|
153
|
+
default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SubscriptionInvoiceRef(_BaseModel):
|
|
158
|
+
"""Slim invoice reference returned alongside renew/bill/update responses."""
|
|
159
|
+
|
|
160
|
+
id: str
|
|
161
|
+
status: str
|
|
162
|
+
total: int
|
|
163
|
+
currency: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SubscriptionProration(_BaseModel):
|
|
167
|
+
"""Proration summary returned by ``PATCH /v1/subscriptions/{id}``."""
|
|
168
|
+
|
|
169
|
+
net_amount_minor: int = Field(
|
|
170
|
+
serialization_alias="netAmountMinor", validation_alias="netAmountMinor"
|
|
171
|
+
)
|
|
172
|
+
days_remaining: int = Field(
|
|
173
|
+
serialization_alias="daysRemaining", validation_alias="daysRemaining"
|
|
174
|
+
)
|
|
175
|
+
days_in_cycle: int = Field(serialization_alias="daysInCycle", validation_alias="daysInCycle")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SubscriptionInvoicePreviewLineItem(_BaseModel):
|
|
179
|
+
"""One line item on a subscription invoice preview."""
|
|
180
|
+
|
|
181
|
+
description: str
|
|
182
|
+
quantity: int
|
|
183
|
+
unit_amount: int = Field(serialization_alias="unitAmount", validation_alias="unitAmount")
|
|
184
|
+
product_id: str | None = Field(
|
|
185
|
+
default=None, serialization_alias="productId", validation_alias="productId"
|
|
186
|
+
)
|
|
187
|
+
price_id: str | None = Field(
|
|
188
|
+
default=None, serialization_alias="priceId", validation_alias="priceId"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SubscriptionInvoicePreview(_BaseModel):
|
|
193
|
+
"""Non-persisted projection of the invoice the next renewal will generate."""
|
|
194
|
+
|
|
195
|
+
customer_id: str = Field(serialization_alias="customerId", validation_alias="customerId")
|
|
196
|
+
subscription_id: str = Field(
|
|
197
|
+
serialization_alias="subscriptionId", validation_alias="subscriptionId"
|
|
198
|
+
)
|
|
199
|
+
currency: str
|
|
200
|
+
line_items: list[SubscriptionInvoicePreviewLineItem] = Field(
|
|
201
|
+
serialization_alias="lineItems", validation_alias="lineItems"
|
|
202
|
+
)
|
|
203
|
+
subtotal: int
|
|
204
|
+
total: int
|
|
205
|
+
period_start: str = Field(serialization_alias="periodStart", validation_alias="periodStart")
|
|
206
|
+
period_end: str = Field(serialization_alias="periodEnd", validation_alias="periodEnd")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ListSubscriptionsResponse(_BaseModel):
|
|
210
|
+
"""Successful response shape from ``GET /v1/subscriptions``."""
|
|
211
|
+
|
|
212
|
+
data: list[Subscription]
|
|
213
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class UpdateSubscriptionResult(_BaseModel):
|
|
217
|
+
"""Successful response shape from ``PATCH /v1/subscriptions/{id}``."""
|
|
218
|
+
|
|
219
|
+
subscription: Subscription
|
|
220
|
+
invoice: SubscriptionInvoiceRef | None = None
|
|
221
|
+
proration: SubscriptionProration
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class BillSubscriptionResult(_BaseModel):
|
|
225
|
+
"""Successful response shape from ``POST /v1/subscriptions/{id}/bill``."""
|
|
226
|
+
|
|
227
|
+
subscription: Subscription
|
|
228
|
+
invoice: SubscriptionInvoiceRef
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class RenewSubscriptionResult(_BaseModel):
|
|
232
|
+
"""Successful response shape from ``POST /v1/subscriptions/{id}/renew``."""
|
|
233
|
+
|
|
234
|
+
subscription: Subscription
|
|
235
|
+
invoice: SubscriptionInvoiceRef | None = None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ListParams(_BaseModel):
|
|
239
|
+
"""Query parameters accepted by ``GET /v1/subscriptions``."""
|
|
240
|
+
|
|
241
|
+
page: int | None = None
|
|
242
|
+
page_size: int | None = Field(
|
|
243
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
244
|
+
)
|
|
245
|
+
status: SubscriptionStatus | None = None
|
|
246
|
+
contact_id: str | None = Field(
|
|
247
|
+
default=None, serialization_alias="contactId", validation_alias="contactId"
|
|
248
|
+
)
|
|
249
|
+
price_id: str | None = Field(
|
|
250
|
+
default=None, serialization_alias="priceId", validation_alias="priceId"
|
|
251
|
+
)
|
|
252
|
+
fields: str | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class RetrieveParams(_BaseModel):
|
|
256
|
+
"""Query parameters accepted by ``GET /v1/subscriptions/{id}``."""
|
|
257
|
+
|
|
258
|
+
fields: str | None = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class CreateBodyItem(_BaseModel):
|
|
262
|
+
"""One item on a multi-item subscription create body."""
|
|
263
|
+
|
|
264
|
+
price_id: str = Field(serialization_alias="priceId", validation_alias="priceId")
|
|
265
|
+
quantity: int | None = None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class CreateBody(_BaseModel):
|
|
269
|
+
"""Body accepted by ``POST /v1/subscriptions``."""
|
|
270
|
+
|
|
271
|
+
price_id: str | None = Field(
|
|
272
|
+
default=None, serialization_alias="priceId", validation_alias="priceId"
|
|
273
|
+
)
|
|
274
|
+
quantity: int | None = None
|
|
275
|
+
items: list[CreateBodyItem] | None = None
|
|
276
|
+
contact_id: str | None = Field(
|
|
277
|
+
default=None, serialization_alias="contactId", validation_alias="contactId"
|
|
278
|
+
)
|
|
279
|
+
customer_email: str | None = Field(
|
|
280
|
+
default=None,
|
|
281
|
+
serialization_alias="customerEmail",
|
|
282
|
+
validation_alias="customerEmail",
|
|
283
|
+
)
|
|
284
|
+
trial_days: int | None = Field(
|
|
285
|
+
default=None, serialization_alias="trialDays", validation_alias="trialDays"
|
|
286
|
+
)
|
|
287
|
+
billing_cycle_anchor: str | None = Field(
|
|
288
|
+
default=None,
|
|
289
|
+
serialization_alias="billingCycleAnchor",
|
|
290
|
+
validation_alias="billingCycleAnchor",
|
|
291
|
+
)
|
|
292
|
+
cancel_at: str | None = Field(
|
|
293
|
+
default=None, serialization_alias="cancelAt", validation_alias="cancelAt"
|
|
294
|
+
)
|
|
295
|
+
dunning_enabled: bool | None = Field(
|
|
296
|
+
default=None,
|
|
297
|
+
serialization_alias="dunningEnabled",
|
|
298
|
+
validation_alias="dunningEnabled",
|
|
299
|
+
)
|
|
300
|
+
notes: str | None = None
|
|
301
|
+
tax_ids: list[SubscriptionTaxId] | None = Field(
|
|
302
|
+
default=None, serialization_alias="taxIds", validation_alias="taxIds"
|
|
303
|
+
)
|
|
304
|
+
auto_charge: bool | None = Field(
|
|
305
|
+
default=None, serialization_alias="autoCharge", validation_alias="autoCharge"
|
|
306
|
+
)
|
|
307
|
+
payment_due_days: int | None = Field(
|
|
308
|
+
default=None,
|
|
309
|
+
serialization_alias="paymentDueDays",
|
|
310
|
+
validation_alias="paymentDueDays",
|
|
311
|
+
)
|
|
312
|
+
tax_rate: float | None = Field(
|
|
313
|
+
default=None, serialization_alias="taxRate", validation_alias="taxRate"
|
|
314
|
+
)
|
|
315
|
+
metadata: dict[str, str] | None = None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class UpdateBody(_BaseModel):
|
|
319
|
+
"""Body accepted by ``PATCH /v1/subscriptions/{id}``.
|
|
320
|
+
|
|
321
|
+
Only fields you provide are changed.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
price_id: str | None = Field(
|
|
325
|
+
default=None, serialization_alias="priceId", validation_alias="priceId"
|
|
326
|
+
)
|
|
327
|
+
quantity: int | None = None
|
|
328
|
+
notes: str | None = None
|
|
329
|
+
tax_ids: list[SubscriptionTaxId] | None = Field(
|
|
330
|
+
default=None, serialization_alias="taxIds", validation_alias="taxIds"
|
|
331
|
+
)
|
|
332
|
+
tax_rate: float | None = Field(
|
|
333
|
+
default=None, serialization_alias="taxRate", validation_alias="taxRate"
|
|
334
|
+
)
|
|
335
|
+
auto_charge: bool | None = Field(
|
|
336
|
+
default=None, serialization_alias="autoCharge", validation_alias="autoCharge"
|
|
337
|
+
)
|
|
338
|
+
dunning_enabled: bool | None = Field(
|
|
339
|
+
default=None,
|
|
340
|
+
serialization_alias="dunningEnabled",
|
|
341
|
+
validation_alias="dunningEnabled",
|
|
342
|
+
)
|
|
343
|
+
payment_due_days: int | None = Field(
|
|
344
|
+
default=None,
|
|
345
|
+
serialization_alias="paymentDueDays",
|
|
346
|
+
validation_alias="paymentDueDays",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class CancelBody(_BaseModel):
|
|
351
|
+
"""Body accepted by ``POST /v1/subscriptions/{id}/cancel``."""
|
|
352
|
+
|
|
353
|
+
reason: str | None = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class CancelImmediatelyBody(_BaseModel):
|
|
357
|
+
"""Body accepted by ``POST /v1/subscriptions/{id}/cancel-immediately``."""
|
|
358
|
+
|
|
359
|
+
reason: str | None = None
|
threecommon-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
4
|
-
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
5
|
-
|
|
6
|
-
## [Unreleased]
|
|
7
|
-
|
|
8
|
-
### Added
|
|
9
|
-
|
|
10
|
-
- Initial scaffolding.
|
|
11
|
-
- `ThreeCommon` (sync) and `AsyncThreeCommon` (async) clients.
|
|
12
|
-
- Events resource: `list`, `retrieve`, `update`, `list_auto_paginate`.
|
|
13
|
-
- Invoices resource: `list`, `retrieve`, `create`, `update`, `finalize`, `void`,
|
|
14
|
-
`record_payment`, `list_auto_paginate`. Both sync and async surfaces.
|
|
15
|
-
- Typed exception tree (`AuthError`, `NotFoundError`, `RateLimitError`, …).
|
|
16
|
-
- Conformance harness running shared YAML scenarios against both clients.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|