threecommon 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,266 @@
1
+ """Sync and async invoices 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
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.invoices.types import (
15
+ CreateBody,
16
+ Invoice,
17
+ ListInvoicesResponse,
18
+ ListParams,
19
+ PaymentBody,
20
+ RetrieveParams,
21
+ UpdateBody,
22
+ VoidBody,
23
+ )
24
+ from threecommon.pagination import AsyncIter, Iter
25
+
26
+ if TYPE_CHECKING:
27
+ from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
28
+
29
+
30
+ def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
31
+ if params is None:
32
+ return None
33
+ raw = params.model_dump(by_alias=True, exclude_none=True)
34
+ if not raw:
35
+ return None
36
+ return {k: str(v) for k, v in raw.items()}
37
+
38
+
39
+ def _encode_retrieve_params(params: RetrieveParams | None) -> dict[str, str] | None:
40
+ if params is None or params.fields is None:
41
+ return None
42
+ return {"fields": params.fields}
43
+
44
+
45
+ def _require_id(method: str, invoice_id: str) -> None:
46
+ if not invoice_id:
47
+ msg = f"invoices.{method}: id must be a non-empty string"
48
+ raise ValidationError(code="missing_id", message=msg)
49
+
50
+
51
+ def _path_for(invoice_id: str) -> str:
52
+ return f"/invoices/{quote(invoice_id, safe='')}"
53
+
54
+
55
+ def _action_path(invoice_id: str, action: str) -> str:
56
+ return f"{_path_for(invoice_id)}/{action}"
57
+
58
+
59
+ # ────────────────────────────────────────────────────────────────────────────
60
+ # Sync
61
+ # ────────────────────────────────────────────────────────────────────────────
62
+
63
+
64
+ class InvoicesService:
65
+ """Sync invoices service — bound as ``client.invoices`` on [ThreeCommon]."""
66
+
67
+ __slots__ = ("_http",)
68
+
69
+ def __init__(self, http: HTTPClient) -> None:
70
+ self._http = http
71
+
72
+ def list(self, params: ListParams | None = None) -> ListInvoicesResponse:
73
+ """List the host's invoices (one page).
74
+
75
+ For full iteration use [list_auto_paginate][InvoicesService.list_auto_paginate].
76
+ """
77
+ body = self._http.request(
78
+ Request(method="GET", path="/invoices", query=_encode_list_params(params))
79
+ )
80
+ return ListInvoicesResponse.model_validate(body)
81
+
82
+ def retrieve(self, invoice_id: str, params: RetrieveParams | None = None) -> Invoice:
83
+ """Retrieve a single invoice by id."""
84
+ _require_id("retrieve", invoice_id)
85
+ body = self._http.request(
86
+ Request(
87
+ method="GET",
88
+ path=_path_for(invoice_id),
89
+ query=_encode_retrieve_params(params),
90
+ )
91
+ )
92
+ return Invoice.model_validate(body["data"])
93
+
94
+ def create(self, body: CreateBody) -> Invoice:
95
+ """Create a draft invoice. Totals are computed server-side from line items."""
96
+ if body is None:
97
+ raise ValidationError(
98
+ code="missing_body", message="invoices.create: body must be non-None"
99
+ )
100
+ payload = body.model_dump(by_alias=True, exclude_none=True)
101
+ response = self._http.request(Request(method="POST", path="/invoices", body=payload))
102
+ return Invoice.model_validate(response["data"])
103
+
104
+ def update(self, invoice_id: str, body: UpdateBody) -> Invoice:
105
+ """Revise a draft invoice. Only legal while in draft."""
106
+ _require_id("update", invoice_id)
107
+ if body is None:
108
+ raise ValidationError(
109
+ code="missing_body", message="invoices.update: body must be non-None"
110
+ )
111
+ payload = body.model_dump(by_alias=True, exclude_none=True)
112
+ response = self._http.request(
113
+ Request(method="PATCH", path=_path_for(invoice_id), body=payload)
114
+ )
115
+ return Invoice.model_validate(response["data"])
116
+
117
+ def finalize(self, invoice_id: str) -> Invoice:
118
+ """Finalize a draft invoice: assign a number, stamp ``issuedAt``, set status ``open``."""
119
+ _require_id("finalize", invoice_id)
120
+ response = self._http.request(
121
+ Request(method="POST", path=_action_path(invoice_id, "finalize"), body={})
122
+ )
123
+ return Invoice.model_validate(response["data"])
124
+
125
+ def void(self, invoice_id: str, body: VoidBody | None = None) -> Invoice:
126
+ """Void an invoice. Permitted from ``draft`` or ``open``; paid invoices cannot be voided."""
127
+ _require_id("void", invoice_id)
128
+ payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
129
+ response = self._http.request(
130
+ Request(method="POST", path=_action_path(invoice_id, "void"), body=payload)
131
+ )
132
+ return Invoice.model_validate(response["data"])
133
+
134
+ def record_payment(self, invoice_id: str, body: PaymentBody) -> Invoice:
135
+ """Record a manual payment against an open invoice.
136
+
137
+ Cumulative payments meeting the total transition the invoice to ``paid``.
138
+ """
139
+ _require_id("record_payment", invoice_id)
140
+ if body is None:
141
+ raise ValidationError(
142
+ code="missing_body",
143
+ message="invoices.record_payment: body must be non-None",
144
+ )
145
+ payload = body.model_dump(by_alias=True, exclude_none=True)
146
+ response = self._http.request(
147
+ Request(method="POST", path=_action_path(invoice_id, "payments"), body=payload)
148
+ )
149
+ return Invoice.model_validate(response["data"])
150
+
151
+ def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Invoice]:
152
+ """Iterate every invoice matching ``params``, paging automatically."""
153
+ start_page = params.page if params is not None and params.page is not None else 0
154
+
155
+ def fetch(page: int) -> tuple[list[Invoice], bool]:
156
+ page_params = (
157
+ params.model_copy(update={"page": page})
158
+ if params is not None
159
+ else ListParams(page=page)
160
+ )
161
+ body = self._http.request(
162
+ Request(method="GET", path="/invoices", query=_encode_list_params(page_params))
163
+ )
164
+ response = ListInvoicesResponse.model_validate(body)
165
+ return response.data, response.has_more
166
+
167
+ return Iter(fetch_page=fetch, start_page=start_page)
168
+
169
+
170
+ # ────────────────────────────────────────────────────────────────────────────
171
+ # Async
172
+ # ────────────────────────────────────────────────────────────────────────────
173
+
174
+
175
+ class AsyncInvoicesService:
176
+ """Async invoices service — bound as ``client.invoices`` on [AsyncThreeCommon]."""
177
+
178
+ __slots__ = ("_http",)
179
+
180
+ def __init__(self, http: AsyncHTTPClient) -> None:
181
+ self._http = http
182
+
183
+ async def list(self, params: ListParams | None = None) -> ListInvoicesResponse:
184
+ body = await self._http.request(
185
+ Request(method="GET", path="/invoices", query=_encode_list_params(params))
186
+ )
187
+ return ListInvoicesResponse.model_validate(body)
188
+
189
+ async def retrieve(self, invoice_id: str, params: RetrieveParams | None = None) -> Invoice:
190
+ _require_id("retrieve", invoice_id)
191
+ body = await self._http.request(
192
+ Request(
193
+ method="GET",
194
+ path=_path_for(invoice_id),
195
+ query=_encode_retrieve_params(params),
196
+ )
197
+ )
198
+ return Invoice.model_validate(body["data"])
199
+
200
+ async def create(self, body: CreateBody) -> Invoice:
201
+ if body is None:
202
+ raise ValidationError(
203
+ code="missing_body", message="invoices.create: body must be non-None"
204
+ )
205
+ payload = body.model_dump(by_alias=True, exclude_none=True)
206
+ response = await self._http.request(Request(method="POST", path="/invoices", body=payload))
207
+ return Invoice.model_validate(response["data"])
208
+
209
+ async def update(self, invoice_id: str, body: UpdateBody) -> Invoice:
210
+ _require_id("update", invoice_id)
211
+ if body is None:
212
+ raise ValidationError(
213
+ code="missing_body", message="invoices.update: body must be non-None"
214
+ )
215
+ payload = body.model_dump(by_alias=True, exclude_none=True)
216
+ response = await self._http.request(
217
+ Request(method="PATCH", path=_path_for(invoice_id), body=payload)
218
+ )
219
+ return Invoice.model_validate(response["data"])
220
+
221
+ async def finalize(self, invoice_id: str) -> Invoice:
222
+ _require_id("finalize", invoice_id)
223
+ response = await self._http.request(
224
+ Request(method="POST", path=_action_path(invoice_id, "finalize"), body={})
225
+ )
226
+ return Invoice.model_validate(response["data"])
227
+
228
+ async def void(self, invoice_id: str, body: VoidBody | None = None) -> Invoice:
229
+ _require_id("void", invoice_id)
230
+ payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else {}
231
+ response = await self._http.request(
232
+ Request(method="POST", path=_action_path(invoice_id, "void"), body=payload)
233
+ )
234
+ return Invoice.model_validate(response["data"])
235
+
236
+ async def record_payment(self, invoice_id: str, body: PaymentBody) -> Invoice:
237
+ _require_id("record_payment", invoice_id)
238
+ if body is None:
239
+ raise ValidationError(
240
+ code="missing_body",
241
+ message="invoices.record_payment: body must be non-None",
242
+ )
243
+ payload = body.model_dump(by_alias=True, exclude_none=True)
244
+ response = await self._http.request(
245
+ Request(method="POST", path=_action_path(invoice_id, "payments"), body=payload)
246
+ )
247
+ return Invoice.model_validate(response["data"])
248
+
249
+ def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Invoice]:
250
+ """Async iterate every invoice matching ``params``."""
251
+ start_page = params.page if params is not None and params.page is not None else 0
252
+ http = self._http
253
+
254
+ async def fetch(page: int) -> tuple[list[Invoice], bool]:
255
+ page_params = (
256
+ params.model_copy(update={"page": page})
257
+ if params is not None
258
+ else ListParams(page=page)
259
+ )
260
+ body = await http.request(
261
+ Request(method="GET", path="/invoices", query=_encode_list_params(page_params))
262
+ )
263
+ response = ListInvoicesResponse.model_validate(body)
264
+ return response.data, response.has_more
265
+
266
+ return AsyncIter(fetch_page=fetch, start_page=start_page)
@@ -0,0 +1,195 @@
1
+ """Public types for the invoices 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 an invoice. Future server-side values will arrive as
15
+ #: raw strings until the SDK is updated.
16
+ InvoiceStatus = Literal["draft", "open", "paid", "void"]
17
+
18
+ #: Invoice currency code; all line amounts must match.
19
+ InvoiceCurrency = Literal["USD", "CAD"]
20
+
21
+
22
+ class _BaseModel(BaseModel):
23
+ """Shared config: accept snake_case or camelCase, ignore unknown fields."""
24
+
25
+ model_config = ConfigDict(
26
+ populate_by_name=True,
27
+ extra="ignore",
28
+ str_strip_whitespace=False,
29
+ )
30
+
31
+
32
+ class InvoiceLineItem(_BaseModel):
33
+ """One line on an invoice."""
34
+
35
+ description: str
36
+ quantity: int
37
+ unit_amount: int = Field(serialization_alias="unitAmount", validation_alias="unitAmount")
38
+ product_id: str | None = Field(
39
+ default=None, serialization_alias="productId", validation_alias="productId"
40
+ )
41
+ tax_amount: int | None = Field(
42
+ default=None, serialization_alias="taxAmount", validation_alias="taxAmount"
43
+ )
44
+
45
+
46
+ class InvoicePayment(_BaseModel):
47
+ """One recorded payment against an invoice."""
48
+
49
+ id: str
50
+ amount: int
51
+ paid_at: str = Field(serialization_alias="paidAt", validation_alias="paidAt")
52
+ idempotency_key: str | None = Field(
53
+ default=None, serialization_alias="idempotencyKey", validation_alias="idempotencyKey"
54
+ )
55
+ note: str | None = None
56
+
57
+
58
+ class Invoice(_BaseModel):
59
+ """One invoice as returned by the API.
60
+
61
+ Optional fields are populated only when the server returned them — list
62
+ responses with a ``fields`` filter omit unrequested values.
63
+ """
64
+
65
+ id: str
66
+ host_id: str | None = Field(
67
+ default=None, serialization_alias="hostId", validation_alias="hostId"
68
+ )
69
+ customer_id: str | None = Field(
70
+ default=None, serialization_alias="customerId", validation_alias="customerId"
71
+ )
72
+ number: str | None = None
73
+ currency: InvoiceCurrency | None = None
74
+ line_items: list[InvoiceLineItem] | None = Field(
75
+ default=None, serialization_alias="lineItems", validation_alias="lineItems"
76
+ )
77
+ payments: list[InvoicePayment] | None = None
78
+ subtotal: int | None = None
79
+ tax_total: int | None = Field(
80
+ default=None, serialization_alias="taxTotal", validation_alias="taxTotal"
81
+ )
82
+ total: int | None = None
83
+ amount_paid: int | None = Field(
84
+ default=None, serialization_alias="amountPaid", validation_alias="amountPaid"
85
+ )
86
+ amount_due: int | None = Field(
87
+ default=None, serialization_alias="amountDue", validation_alias="amountDue"
88
+ )
89
+ status: InvoiceStatus | None = None
90
+ notes: str | None = None
91
+ issued_at: str | None = Field(
92
+ default=None, serialization_alias="issuedAt", validation_alias="issuedAt"
93
+ )
94
+ due_at: str | None = Field(default=None, serialization_alias="dueAt", validation_alias="dueAt")
95
+ paid_at: str | None = Field(
96
+ default=None, serialization_alias="paidAt", validation_alias="paidAt"
97
+ )
98
+ voided_at: str | None = Field(
99
+ default=None, serialization_alias="voidedAt", validation_alias="voidedAt"
100
+ )
101
+ subscription_id: str | None = Field(
102
+ default=None, serialization_alias="subscriptionId", validation_alias="subscriptionId"
103
+ )
104
+ quote_id: str | None = Field(
105
+ default=None, serialization_alias="quoteId", validation_alias="quoteId"
106
+ )
107
+ created_at: str | None = Field(
108
+ default=None, serialization_alias="createdAt", validation_alias="createdAt"
109
+ )
110
+ updated_at: str | None = Field(
111
+ default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
112
+ )
113
+
114
+
115
+ class ListInvoicesResponse(_BaseModel):
116
+ """Successful response shape from ``GET /v1/invoices``."""
117
+
118
+ data: list[Invoice]
119
+ has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
120
+
121
+
122
+ class ListParams(_BaseModel):
123
+ """Query parameters accepted by ``GET /v1/invoices``."""
124
+
125
+ page: int | None = None
126
+ page_size: int | None = Field(
127
+ default=None, serialization_alias="pageSize", validation_alias="pageSize"
128
+ )
129
+ status: InvoiceStatus | None = None
130
+ customer_id: str | None = Field(
131
+ default=None, serialization_alias="customerId", validation_alias="customerId"
132
+ )
133
+ issued_after: str | None = Field(
134
+ default=None, serialization_alias="issuedAfter", validation_alias="issuedAfter"
135
+ )
136
+ issued_before: str | None = Field(
137
+ default=None, serialization_alias="issuedBefore", validation_alias="issuedBefore"
138
+ )
139
+ fields: str | None = None
140
+
141
+
142
+ class RetrieveParams(_BaseModel):
143
+ """Query parameters accepted by ``GET /v1/invoices/{id}``."""
144
+
145
+ fields: str | None = None
146
+
147
+
148
+ class CreateBody(_BaseModel):
149
+ """Body accepted by ``POST /v1/invoices``."""
150
+
151
+ customer_id: str = Field(serialization_alias="customerId", validation_alias="customerId")
152
+ currency: InvoiceCurrency
153
+ line_items: list[InvoiceLineItem] = Field(
154
+ serialization_alias="lineItems", validation_alias="lineItems"
155
+ )
156
+ notes: str | None = None
157
+ due_at: str | None = Field(default=None, serialization_alias="dueAt", validation_alias="dueAt")
158
+ subscription_id: str | None = Field(
159
+ default=None, serialization_alias="subscriptionId", validation_alias="subscriptionId"
160
+ )
161
+ quote_id: str | None = Field(
162
+ default=None, serialization_alias="quoteId", validation_alias="quoteId"
163
+ )
164
+
165
+
166
+ class UpdateBody(_BaseModel):
167
+ """Body accepted by ``PATCH /v1/invoices/{id}``.
168
+
169
+ Only fields you provide are changed.
170
+ """
171
+
172
+ customer_id: str | None = Field(
173
+ default=None, serialization_alias="customerId", validation_alias="customerId"
174
+ )
175
+ line_items: list[InvoiceLineItem] | None = Field(
176
+ default=None, serialization_alias="lineItems", validation_alias="lineItems"
177
+ )
178
+ notes: str | None = None
179
+ due_at: str | None = Field(default=None, serialization_alias="dueAt", validation_alias="dueAt")
180
+
181
+
182
+ class VoidBody(_BaseModel):
183
+ """Body accepted by ``POST /v1/invoices/{id}/void``."""
184
+
185
+ reason: str | None = None
186
+
187
+
188
+ class PaymentBody(_BaseModel):
189
+ """Body accepted by ``POST /v1/invoices/{id}/payments``."""
190
+
191
+ payment: int
192
+ idempotency_key: str | None = Field(
193
+ default=None, serialization_alias="idempotencyKey", validation_alias="idempotencyKey"
194
+ )
195
+ note: str | None = None
@@ -0,0 +1,12 @@
1
+ """Pagination module — exports the sync and async auto-paginating iterators.
2
+
3
+ User usually receive these from resource methods (e.g.
4
+ ``client.events.list_auto_paginate(...)``) rather than constructing them
5
+ directly. Importing the submodule directly is supported::
6
+
7
+ from threecommon.pagination import Iter, AsyncIter
8
+ """
9
+
10
+ from threecommon.pagination.auto_paginator import AsyncIter, Iter
11
+
12
+ __all__ = ("AsyncIter", "Iter")
@@ -0,0 +1,98 @@
1
+ """auto-paginating iterators returned by every list endpoint.
2
+
3
+ Two flavors:
4
+
5
+ * [Iter][threecommon.pagination.Iter] — sync iterator. Use ``for ev in iter:``.
6
+ * [AsyncIter][threecommon.pagination.AsyncIter] — async iterator.
7
+ Use ``async for ev in iter:``.
8
+
9
+ Both walk pages lazily — one HTTP call per page, only when the user
10
+ drains the previous page's buffer.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
16
+ from typing import Generic, TypeVar
17
+
18
+ T = TypeVar("T")
19
+
20
+ #: Synchronous page-fetch callback. Returns ``(page_items, has_more)``.
21
+ SyncFetchPage = Callable[[int], tuple[list[T], bool]]
22
+
23
+ #: Asynchronous page-fetch callback. Returns ``(page_items, has_more)``.
24
+ AsyncFetchPage = Callable[[int], Awaitable[tuple[list[T], bool]]]
25
+
26
+
27
+ class Iter(Generic[T]):
28
+ """Sync auto-paginating iterator. ``for ev in iter:`` drives the pages."""
29
+
30
+ __slots__ = ("_buffer", "_fetch_page", "_has_more", "_index", "_page")
31
+
32
+ def __init__(self, *, fetch_page: SyncFetchPage[T], start_page: int = 0) -> None:
33
+ self._fetch_page = fetch_page
34
+ self._page = start_page
35
+ self._buffer: list[T] = []
36
+ self._index = 0
37
+ self._has_more = True
38
+
39
+ def __iter__(self) -> Iterator[T]:
40
+ return self
41
+
42
+ def __next__(self) -> T:
43
+ if self._index < len(self._buffer):
44
+ value = self._buffer[self._index]
45
+ self._index += 1
46
+ return value
47
+ if not self._has_more:
48
+ raise StopIteration
49
+
50
+ data, has_more = self._fetch_page(self._page)
51
+ self._buffer = data
52
+ self._index = 0
53
+ self._has_more = has_more
54
+ self._page += 1
55
+
56
+ if not self._buffer:
57
+ raise StopIteration
58
+
59
+ value = self._buffer[self._index]
60
+ self._index += 1
61
+ return value
62
+
63
+
64
+ class AsyncIter(Generic[T]):
65
+ """Async auto-paginating iterator. ``async for ev in iter:`` drives the pages."""
66
+
67
+ __slots__ = ("_buffer", "_fetch_page", "_has_more", "_index", "_page")
68
+
69
+ def __init__(self, *, fetch_page: AsyncFetchPage[T], start_page: int = 0) -> None:
70
+ self._fetch_page = fetch_page
71
+ self._page = start_page
72
+ self._buffer: list[T] = []
73
+ self._index = 0
74
+ self._has_more = True
75
+
76
+ def __aiter__(self) -> AsyncIterator[T]:
77
+ return self
78
+
79
+ async def __anext__(self) -> T:
80
+ if self._index < len(self._buffer):
81
+ value = self._buffer[self._index]
82
+ self._index += 1
83
+ return value
84
+ if not self._has_more:
85
+ raise StopAsyncIteration
86
+
87
+ data, has_more = await self._fetch_page(self._page)
88
+ self._buffer = data
89
+ self._index = 0
90
+ self._has_more = has_more
91
+ self._page += 1
92
+
93
+ if not self._buffer:
94
+ raise StopAsyncIteration
95
+
96
+ value = self._buffer[self._index]
97
+ self._index += 1
98
+ return value
threecommon/py.typed ADDED
File without changes
threecommon/version.py ADDED
@@ -0,0 +1,10 @@
1
+ """SDK package version.
2
+
3
+ Replaced by the release pipeline at publish time (currently static; will
4
+ become dynamic via hatch-vcs once the path-prefixed-tag wiring lands — see
5
+ the comment in pyproject.toml).
6
+ """
7
+
8
+ __version__ = "0.0.0.dev0"
9
+
10
+ VERSION = __version__