threecommon 0.1.0__tar.gz → 0.2.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.
Files changed (39) hide show
  1. {threecommon-0.1.0 → threecommon-0.2.0}/CHANGELOG.md +13 -0
  2. {threecommon-0.1.0 → threecommon-0.2.0}/PKG-INFO +1 -1
  3. {threecommon-0.1.0 → threecommon-0.2.0}/pyproject.toml +1 -1
  4. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/client.py +7 -0
  5. threecommon-0.2.0/src/threecommon/subscriptions/__init__.py +57 -0
  6. threecommon-0.2.0/src/threecommon/subscriptions/service.py +376 -0
  7. threecommon-0.2.0/src/threecommon/subscriptions/types.py +359 -0
  8. {threecommon-0.1.0 → threecommon-0.2.0}/.gitignore +0 -0
  9. {threecommon-0.1.0 → threecommon-0.2.0}/LICENSE +0 -0
  10. {threecommon-0.1.0 → threecommon-0.2.0}/README.md +0 -0
  11. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/__init__.py +0 -0
  12. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/__init__.py +0 -0
  13. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/headers.py +0 -0
  14. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/http_client.py +0 -0
  15. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/parse.py +0 -0
  16. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/retry.py +0 -0
  17. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/telemetry.py +0 -0
  18. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_core/url.py +0 -0
  19. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_generated/__init__.py +0 -0
  20. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/_generated/models.py +0 -0
  21. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/api_version.py +0 -0
  22. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/config.py +0 -0
  23. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/errors/__init__.py +0 -0
  24. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/errors/base.py +0 -0
  25. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/errors/classes.py +0 -0
  26. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/events/__init__.py +0 -0
  27. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/events/service.py +0 -0
  28. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/events/types.py +0 -0
  29. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/filters/__init__.py +0 -0
  30. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/filters/builder.py +0 -0
  31. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/filters/types.py +0 -0
  32. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/helpers.py +0 -0
  33. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/invoices/__init__.py +0 -0
  34. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/invoices/service.py +0 -0
  35. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/invoices/types.py +0 -0
  36. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/pagination/__init__.py +0 -0
  37. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/pagination/auto_paginator.py +0 -0
  38. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/py.typed +0 -0
  39. {threecommon-0.1.0 → threecommon-0.2.0}/src/threecommon/version.py +0 -0
@@ -5,6 +5,19 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## 0.1.0
9
+
10
+ ### Added
11
+
12
+ - Subscriptions resource. The new `client.subscriptions` surface covers the
13
+ full subscription lifecycle: `list`, `retrieve`, `create`, `update`
14
+ (mid-cycle change with proration), `activate`, `cancel`,
15
+ `cancel_immediately`, `mark_unpaid`, `bill`, `renew`,
16
+ `preview_upcoming_invoice`, and `list_auto_paginate`. Types and typed
17
+ errors match the events / invoices resources. Both sync and async surfaces.
18
+
19
+ ## 0.0.0
20
+
8
21
  ### Added
9
22
 
10
23
  - Initial scaffolding.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threecommon
3
- Version: 0.1.0
3
+ Version: 0.2.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.1.0"
13
+ version = "0.2.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."""
@@ -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
File without changes
File without changes
File without changes