threecommon 0.2.0__tar.gz → 0.4.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.4.0/CHANGELOG.md +80 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/PKG-INFO +1 -1
- {threecommon-0.2.0 → threecommon-0.4.0}/pyproject.toml +1 -1
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/client.py +9 -0
- threecommon-0.4.0/src/threecommon/contacts/__init__.py +58 -0
- threecommon-0.4.0/src/threecommon/contacts/service.py +331 -0
- threecommon-0.4.0/src/threecommon/contacts/types.py +310 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/invoices/__init__.py +8 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/invoices/service.py +89 -1
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/invoices/types.py +42 -3
- threecommon-0.2.0/CHANGELOG.md +0 -29
- {threecommon-0.2.0 → threecommon-0.4.0}/.gitignore +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/LICENSE +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/README.md +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/headers.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/http_client.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/parse.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/retry.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/telemetry.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_core/url.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_generated/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/_generated/models.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/api_version.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/config.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/errors/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/errors/base.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/errors/classes.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/events/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/events/service.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/events/types.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/filters/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/filters/builder.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/filters/types.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/helpers.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/pagination/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/pagination/auto_paginator.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/py.typed +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/subscriptions/__init__.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/subscriptions/service.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/subscriptions/types.py +0 -0
- {threecommon-0.2.0 → threecommon-0.4.0}/src/threecommon/version.py +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
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.4.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Contacts resource. The new `client.contacts` surface covers the full
|
|
13
|
+
contact lifecycle: `list`, `count`, `retrieve`, `create`, `update`
|
|
14
|
+
(with optional `merge_with` + `resolution` for absorbing a second
|
|
15
|
+
contact during an email change), `delete`, `bulk_upsert`,
|
|
16
|
+
`list_activity`, and both `list_auto_paginate` +
|
|
17
|
+
`list_activity_auto_paginate` iterators. Both sync and async surfaces.
|
|
18
|
+
- New public types on `threecommon.contacts`: `Contact`,
|
|
19
|
+
`ContactWithOrderDetails`, `ContactActivity`, `ContactProperty`,
|
|
20
|
+
`ContactUpdate`, `CreateBody`, `UpdateBody`, `BulkUpsertBody`,
|
|
21
|
+
`BulkUpsertItem`, `ListParams`, `ActivityListParams`, plus result
|
|
22
|
+
envelopes `ListContactsResponse`, `ListActivityResponse`, `CountResult`,
|
|
23
|
+
`BulkUpsertResult`, `DeleteResult`, and the lifecycle / merge / activity
|
|
24
|
+
literal unions.
|
|
25
|
+
|
|
26
|
+
## 0.3.0
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Invoices: auto_charge, refund_payment, delete_draft methods (sync + async).
|
|
31
|
+
- Invoices: subscription_id filter on list().
|
|
32
|
+
- Invoices: AutoChargeOutcome, AutoChargeResult, DeletedInvoice, RefundBody types.
|
|
33
|
+
|
|
34
|
+
## 0.2.0
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- Invoice write operations completing parity with the public REST surface, on
|
|
39
|
+
both the sync and async clients:
|
|
40
|
+
- `auto_charge` — off-session charge the customer's saved card
|
|
41
|
+
(`POST /v1/invoices/{id}/auto_charge`). A decline resolves with
|
|
42
|
+
`outcome="failed"` and a `failure_code` rather than raising; only network /
|
|
43
|
+
processor 5xx errors raise.
|
|
44
|
+
- `refund_payment` — refund all or part of a recorded payment
|
|
45
|
+
(`POST /v1/invoices/{id}/payments/{paymentId}/refunds`). Idempotent on
|
|
46
|
+
`body.idempotency_key`.
|
|
47
|
+
- `delete_draft` — permanently remove a draft invoice
|
|
48
|
+
(`DELETE /v1/invoices/{id}`).
|
|
49
|
+
- New public types on `threecommon.invoices`: `AutoChargeResult`,
|
|
50
|
+
`AutoChargeOutcome`, `RefundBody`, and `DeletedInvoice`.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- `InvoiceStatus` now includes `payment_failed` (the state set after a failed
|
|
55
|
+
off-session auto-charge); it was previously missing.
|
|
56
|
+
- Invoice `ListParams` now accepts the `subscription_id` filter the API
|
|
57
|
+
supports; it was previously missing.
|
|
58
|
+
|
|
59
|
+
## 0.1.0
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- Subscriptions resource. The new `client.subscriptions` surface covers the
|
|
64
|
+
full subscription lifecycle: `list`, `retrieve`, `create`, `update`
|
|
65
|
+
(mid-cycle change with proration), `activate`, `cancel`,
|
|
66
|
+
`cancel_immediately`, `mark_unpaid`, `bill`, `renew`,
|
|
67
|
+
`preview_upcoming_invoice`, and `list_auto_paginate`. Types and typed
|
|
68
|
+
errors match the events / invoices resources. Both sync and async surfaces.
|
|
69
|
+
|
|
70
|
+
## 0.0.0
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- Initial scaffolding.
|
|
75
|
+
- `ThreeCommon` (sync) and `AsyncThreeCommon` (async) clients.
|
|
76
|
+
- Events resource: `list`, `retrieve`, `update`, `list_auto_paginate`.
|
|
77
|
+
- Invoices resource: `list`, `retrieve`, `create`, `update`, `finalize`, `void`,
|
|
78
|
+
`record_payment`, `list_auto_paginate`. Both sync and async surfaces.
|
|
79
|
+
- Typed exception tree (`AuthError`, `NotFoundError`, `RateLimitError`, …).
|
|
80
|
+
- 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.4.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.4.0"
|
|
14
14
|
dependencies = [
|
|
15
15
|
"httpx>=0.27,<1.0",
|
|
16
16
|
"pydantic>=2.7,<3.0",
|
|
@@ -18,6 +18,7 @@ from threecommon._core.http_client import (
|
|
|
18
18
|
from threecommon._core.retry import RetryPolicy
|
|
19
19
|
from threecommon._core.telemetry import Telemetry
|
|
20
20
|
from threecommon.config import RetryDelay, resolve_config
|
|
21
|
+
from threecommon.contacts.service import AsyncContactsService, ContactsService
|
|
21
22
|
from threecommon.events.service import AsyncEventsService, EventsService
|
|
22
23
|
from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
|
|
23
24
|
from threecommon.subscriptions.service import AsyncSubscriptionsService, SubscriptionsService
|
|
@@ -55,6 +56,11 @@ class ThreeCommon:
|
|
|
55
56
|
subscriptions: SubscriptionsService
|
|
56
57
|
"""Subscriptions resource — list, retrieve, create, update, activate, cancel, bill, renew."""
|
|
57
58
|
|
|
59
|
+
contacts: ContactsService
|
|
60
|
+
"""Contacts resource — ``list``, ``count``, ``retrieve``, ``create``,
|
|
61
|
+
``update``, ``delete``, ``bulk_upsert``, ``list_activity``, plus
|
|
62
|
+
auto-paginators."""
|
|
63
|
+
|
|
58
64
|
_http: HTTPClient
|
|
59
65
|
_telemetry: Telemetry
|
|
60
66
|
|
|
@@ -98,6 +104,7 @@ class ThreeCommon:
|
|
|
98
104
|
self.events = EventsService(self._http)
|
|
99
105
|
self.invoices = InvoicesService(self._http)
|
|
100
106
|
self.subscriptions = SubscriptionsService(self._http)
|
|
107
|
+
self.contacts = ContactsService(self._http)
|
|
101
108
|
|
|
102
109
|
def close(self) -> None:
|
|
103
110
|
"""Close the underlying httpx client (no-op if you supplied your own)."""
|
|
@@ -126,6 +133,7 @@ class AsyncThreeCommon:
|
|
|
126
133
|
events: AsyncEventsService
|
|
127
134
|
invoices: AsyncInvoicesService
|
|
128
135
|
subscriptions: AsyncSubscriptionsService
|
|
136
|
+
contacts: AsyncContactsService
|
|
129
137
|
|
|
130
138
|
_http: AsyncHTTPClient
|
|
131
139
|
_telemetry: Telemetry
|
|
@@ -170,6 +178,7 @@ class AsyncThreeCommon:
|
|
|
170
178
|
self.events = AsyncEventsService(self._http)
|
|
171
179
|
self.invoices = AsyncInvoicesService(self._http)
|
|
172
180
|
self.subscriptions = AsyncSubscriptionsService(self._http)
|
|
181
|
+
self.contacts = AsyncContactsService(self._http)
|
|
173
182
|
|
|
174
183
|
async def aclose(self) -> None:
|
|
175
184
|
"""Close the underlying async httpx client."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Contacts resource — sync and async clients plus public types.
|
|
2
|
+
|
|
3
|
+
Most callers reach this module through
|
|
4
|
+
[ThreeCommon.contacts][threecommon.ThreeCommon] /
|
|
5
|
+
[AsyncThreeCommon.contacts][threecommon.AsyncThreeCommon]; importing the
|
|
6
|
+
service classes directly is supported for advanced wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threecommon.contacts.service import AsyncContactsService, ContactsService
|
|
10
|
+
from threecommon.contacts.types import (
|
|
11
|
+
ActivityListParams,
|
|
12
|
+
BulkUpsertBody,
|
|
13
|
+
BulkUpsertItem,
|
|
14
|
+
BulkUpsertResult,
|
|
15
|
+
CompactContactStatus,
|
|
16
|
+
Contact,
|
|
17
|
+
ContactActivity,
|
|
18
|
+
ContactActivityType,
|
|
19
|
+
ContactMergeResolution,
|
|
20
|
+
ContactProperty,
|
|
21
|
+
ContactQuickFilter,
|
|
22
|
+
ContactStatus,
|
|
23
|
+
ContactUpdate,
|
|
24
|
+
ContactWithOrderDetails,
|
|
25
|
+
CountResult,
|
|
26
|
+
CreateBody,
|
|
27
|
+
DeleteResult,
|
|
28
|
+
ListActivityResponse,
|
|
29
|
+
ListContactsResponse,
|
|
30
|
+
ListParams,
|
|
31
|
+
UpdateBody,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = (
|
|
35
|
+
"ActivityListParams",
|
|
36
|
+
"AsyncContactsService",
|
|
37
|
+
"BulkUpsertBody",
|
|
38
|
+
"BulkUpsertItem",
|
|
39
|
+
"BulkUpsertResult",
|
|
40
|
+
"CompactContactStatus",
|
|
41
|
+
"Contact",
|
|
42
|
+
"ContactActivity",
|
|
43
|
+
"ContactActivityType",
|
|
44
|
+
"ContactMergeResolution",
|
|
45
|
+
"ContactProperty",
|
|
46
|
+
"ContactQuickFilter",
|
|
47
|
+
"ContactStatus",
|
|
48
|
+
"ContactUpdate",
|
|
49
|
+
"ContactWithOrderDetails",
|
|
50
|
+
"ContactsService",
|
|
51
|
+
"CountResult",
|
|
52
|
+
"CreateBody",
|
|
53
|
+
"DeleteResult",
|
|
54
|
+
"ListActivityResponse",
|
|
55
|
+
"ListContactsResponse",
|
|
56
|
+
"ListParams",
|
|
57
|
+
"UpdateBody",
|
|
58
|
+
)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Sync and async contacts 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.contacts.types import (
|
|
14
|
+
ActivityListParams,
|
|
15
|
+
BulkUpsertBody,
|
|
16
|
+
BulkUpsertResult,
|
|
17
|
+
Contact,
|
|
18
|
+
ContactActivity,
|
|
19
|
+
ContactWithOrderDetails,
|
|
20
|
+
CountResult,
|
|
21
|
+
CreateBody,
|
|
22
|
+
DeleteResult,
|
|
23
|
+
ListActivityResponse,
|
|
24
|
+
ListContactsResponse,
|
|
25
|
+
ListParams,
|
|
26
|
+
UpdateBody,
|
|
27
|
+
)
|
|
28
|
+
from threecommon.errors.classes import ValidationError
|
|
29
|
+
from threecommon.pagination import AsyncIter, Iter
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
|
|
36
|
+
if params is None:
|
|
37
|
+
return None
|
|
38
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
39
|
+
if not raw:
|
|
40
|
+
return None
|
|
41
|
+
return {k: str(v) for k, v in raw.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _encode_activity_params(params: ActivityListParams | None) -> dict[str, str] | None:
|
|
45
|
+
if params is None:
|
|
46
|
+
return None
|
|
47
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
48
|
+
if not raw:
|
|
49
|
+
return None
|
|
50
|
+
return {k: str(v) for k, v in raw.items()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_id(method: str, contact_id: str) -> None:
|
|
54
|
+
if not contact_id:
|
|
55
|
+
msg = f"contacts.{method}: id must be a non-empty string"
|
|
56
|
+
raise ValidationError(code="missing_id", message=msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _path_for(contact_id: str) -> str:
|
|
60
|
+
return f"/contacts/{quote(contact_id, safe='')}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _activity_path(contact_id: str) -> str:
|
|
64
|
+
return f"{_path_for(contact_id)}/activity"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------
|
|
68
|
+
# Sync
|
|
69
|
+
# ---------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ContactsService:
|
|
73
|
+
"""Sync contacts service — bound as ``client.contacts`` on [ThreeCommon]."""
|
|
74
|
+
|
|
75
|
+
__slots__ = ("_http",)
|
|
76
|
+
|
|
77
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
78
|
+
self._http = http
|
|
79
|
+
|
|
80
|
+
def list(self, params: ListParams | None = None) -> ListContactsResponse:
|
|
81
|
+
"""List the host's contacts (one page).
|
|
82
|
+
|
|
83
|
+
For full iteration use [list_auto_paginate][ContactsService.list_auto_paginate].
|
|
84
|
+
"""
|
|
85
|
+
body = self._http.request(
|
|
86
|
+
Request(method="GET", path="/contacts", query=_encode_list_params(params))
|
|
87
|
+
)
|
|
88
|
+
return ListContactsResponse.model_validate(body)
|
|
89
|
+
|
|
90
|
+
def count(self) -> CountResult:
|
|
91
|
+
"""Return the total contact count for the host."""
|
|
92
|
+
body = self._http.request(Request(method="GET", path="/contacts/count"))
|
|
93
|
+
return CountResult.model_validate(body["data"])
|
|
94
|
+
|
|
95
|
+
def retrieve(self, contact_id: str) -> Contact:
|
|
96
|
+
"""Retrieve a single contact by id."""
|
|
97
|
+
_require_id("retrieve", contact_id)
|
|
98
|
+
body = self._http.request(Request(method="GET", path=_path_for(contact_id)))
|
|
99
|
+
return Contact.model_validate(body["data"])
|
|
100
|
+
|
|
101
|
+
def create(self, body: CreateBody) -> Contact:
|
|
102
|
+
"""Create a new contact. Raises ``ConflictError`` on duplicate email."""
|
|
103
|
+
if body is None:
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
code="missing_body", message="contacts.create: body must be non-None"
|
|
106
|
+
)
|
|
107
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
108
|
+
response = self._http.request(Request(method="POST", path="/contacts", body=payload))
|
|
109
|
+
return Contact.model_validate(response["data"])
|
|
110
|
+
|
|
111
|
+
def update(self, contact_id: str, body: UpdateBody) -> ContactWithOrderDetails:
|
|
112
|
+
"""Update a contact. Returns the richer order-details projection."""
|
|
113
|
+
_require_id("update", contact_id)
|
|
114
|
+
if body is None:
|
|
115
|
+
raise ValidationError(
|
|
116
|
+
code="missing_body", message="contacts.update: body must be non-None"
|
|
117
|
+
)
|
|
118
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
119
|
+
response = self._http.request(
|
|
120
|
+
Request(method="PATCH", path=_path_for(contact_id), body=payload)
|
|
121
|
+
)
|
|
122
|
+
return ContactWithOrderDetails.model_validate(response["data"])
|
|
123
|
+
|
|
124
|
+
def delete(self, contact_id: str) -> DeleteResult:
|
|
125
|
+
"""Permanently remove a contact. Echoes the removed contact's id."""
|
|
126
|
+
_require_id("delete", contact_id)
|
|
127
|
+
response = self._http.request(Request(method="DELETE", path=_path_for(contact_id)))
|
|
128
|
+
return DeleteResult.model_validate(response["data"])
|
|
129
|
+
|
|
130
|
+
def bulk_upsert(self, body: BulkUpsertBody) -> BulkUpsertResult:
|
|
131
|
+
"""Bulk-upsert up to 10,000 contacts in one round-trip.
|
|
132
|
+
|
|
133
|
+
Deduplicated server-side by email; existing rows are updated rather
|
|
134
|
+
than rejected.
|
|
135
|
+
"""
|
|
136
|
+
if body is None:
|
|
137
|
+
raise ValidationError(
|
|
138
|
+
code="missing_body", message="contacts.bulk_upsert: body must be non-None"
|
|
139
|
+
)
|
|
140
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
141
|
+
response = self._http.request(Request(method="POST", path="/contacts/bulk", body=payload))
|
|
142
|
+
return BulkUpsertResult.model_validate(response["data"])
|
|
143
|
+
|
|
144
|
+
def list_activity(
|
|
145
|
+
self, contact_id: str, params: ActivityListParams | None = None
|
|
146
|
+
) -> ListActivityResponse:
|
|
147
|
+
"""Paginated activity log for a contact."""
|
|
148
|
+
_require_id("list_activity", contact_id)
|
|
149
|
+
body = self._http.request(
|
|
150
|
+
Request(
|
|
151
|
+
method="GET",
|
|
152
|
+
path=_activity_path(contact_id),
|
|
153
|
+
query=_encode_activity_params(params),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return ListActivityResponse.model_validate(body)
|
|
157
|
+
|
|
158
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Contact]:
|
|
159
|
+
"""Iterate every contact matching ``params``, paging automatically."""
|
|
160
|
+
start_page = (
|
|
161
|
+
params.page_number if params is not None and params.page_number is not None else 0
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def fetch(page: int) -> tuple[list[Contact], bool]:
|
|
165
|
+
page_params = (
|
|
166
|
+
params.model_copy(update={"page_number": page})
|
|
167
|
+
if params is not None
|
|
168
|
+
else ListParams(page_number=page)
|
|
169
|
+
)
|
|
170
|
+
body = self._http.request(
|
|
171
|
+
Request(method="GET", path="/contacts", query=_encode_list_params(page_params))
|
|
172
|
+
)
|
|
173
|
+
response = ListContactsResponse.model_validate(body)
|
|
174
|
+
return response.data, response.has_more
|
|
175
|
+
|
|
176
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
177
|
+
|
|
178
|
+
def list_activity_auto_paginate(
|
|
179
|
+
self, contact_id: str, params: ActivityListParams | None = None
|
|
180
|
+
) -> Iter[ContactActivity]:
|
|
181
|
+
"""Iterate every activity record for a contact, paging automatically."""
|
|
182
|
+
_require_id("list_activity_auto_paginate", contact_id)
|
|
183
|
+
start_page = (
|
|
184
|
+
params.page_number if params is not None and params.page_number is not None else 0
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def fetch(page: int) -> tuple[list[ContactActivity], bool]:
|
|
188
|
+
page_params = (
|
|
189
|
+
params.model_copy(update={"page_number": page})
|
|
190
|
+
if params is not None
|
|
191
|
+
else ActivityListParams(page_number=page)
|
|
192
|
+
)
|
|
193
|
+
body = self._http.request(
|
|
194
|
+
Request(
|
|
195
|
+
method="GET",
|
|
196
|
+
path=_activity_path(contact_id),
|
|
197
|
+
query=_encode_activity_params(page_params),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
response = ListActivityResponse.model_validate(body)
|
|
201
|
+
return response.data, response.has_more
|
|
202
|
+
|
|
203
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------
|
|
207
|
+
# Async
|
|
208
|
+
# ---------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AsyncContactsService:
|
|
212
|
+
"""Async contacts service — bound as ``client.contacts`` on [AsyncThreeCommon]."""
|
|
213
|
+
|
|
214
|
+
__slots__ = ("_http",)
|
|
215
|
+
|
|
216
|
+
def __init__(self, http: AsyncHTTPClient) -> None:
|
|
217
|
+
self._http = http
|
|
218
|
+
|
|
219
|
+
async def list(self, params: ListParams | None = None) -> ListContactsResponse:
|
|
220
|
+
body = await self._http.request(
|
|
221
|
+
Request(method="GET", path="/contacts", query=_encode_list_params(params))
|
|
222
|
+
)
|
|
223
|
+
return ListContactsResponse.model_validate(body)
|
|
224
|
+
|
|
225
|
+
async def count(self) -> CountResult:
|
|
226
|
+
body = await self._http.request(Request(method="GET", path="/contacts/count"))
|
|
227
|
+
return CountResult.model_validate(body["data"])
|
|
228
|
+
|
|
229
|
+
async def retrieve(self, contact_id: str) -> Contact:
|
|
230
|
+
_require_id("retrieve", contact_id)
|
|
231
|
+
body = await self._http.request(Request(method="GET", path=_path_for(contact_id)))
|
|
232
|
+
return Contact.model_validate(body["data"])
|
|
233
|
+
|
|
234
|
+
async def create(self, body: CreateBody) -> Contact:
|
|
235
|
+
if body is None:
|
|
236
|
+
raise ValidationError(
|
|
237
|
+
code="missing_body", message="contacts.create: body must be non-None"
|
|
238
|
+
)
|
|
239
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
240
|
+
response = await self._http.request(Request(method="POST", path="/contacts", body=payload))
|
|
241
|
+
return Contact.model_validate(response["data"])
|
|
242
|
+
|
|
243
|
+
async def update(self, contact_id: str, body: UpdateBody) -> ContactWithOrderDetails:
|
|
244
|
+
_require_id("update", contact_id)
|
|
245
|
+
if body is None:
|
|
246
|
+
raise ValidationError(
|
|
247
|
+
code="missing_body", message="contacts.update: body must be non-None"
|
|
248
|
+
)
|
|
249
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
250
|
+
response = await self._http.request(
|
|
251
|
+
Request(method="PATCH", path=_path_for(contact_id), body=payload)
|
|
252
|
+
)
|
|
253
|
+
return ContactWithOrderDetails.model_validate(response["data"])
|
|
254
|
+
|
|
255
|
+
async def delete(self, contact_id: str) -> DeleteResult:
|
|
256
|
+
_require_id("delete", contact_id)
|
|
257
|
+
response = await self._http.request(Request(method="DELETE", path=_path_for(contact_id)))
|
|
258
|
+
return DeleteResult.model_validate(response["data"])
|
|
259
|
+
|
|
260
|
+
async def bulk_upsert(self, body: BulkUpsertBody) -> BulkUpsertResult:
|
|
261
|
+
if body is None:
|
|
262
|
+
raise ValidationError(
|
|
263
|
+
code="missing_body", message="contacts.bulk_upsert: body must be non-None"
|
|
264
|
+
)
|
|
265
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
266
|
+
response = await self._http.request(
|
|
267
|
+
Request(method="POST", path="/contacts/bulk", body=payload)
|
|
268
|
+
)
|
|
269
|
+
return BulkUpsertResult.model_validate(response["data"])
|
|
270
|
+
|
|
271
|
+
async def list_activity(
|
|
272
|
+
self, contact_id: str, params: ActivityListParams | None = None
|
|
273
|
+
) -> ListActivityResponse:
|
|
274
|
+
_require_id("list_activity", contact_id)
|
|
275
|
+
body = await self._http.request(
|
|
276
|
+
Request(
|
|
277
|
+
method="GET",
|
|
278
|
+
path=_activity_path(contact_id),
|
|
279
|
+
query=_encode_activity_params(params),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return ListActivityResponse.model_validate(body)
|
|
283
|
+
|
|
284
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Contact]:
|
|
285
|
+
"""Async iterate every contact matching ``params``."""
|
|
286
|
+
start_page = (
|
|
287
|
+
params.page_number if params is not None and params.page_number is not None else 0
|
|
288
|
+
)
|
|
289
|
+
http = self._http
|
|
290
|
+
|
|
291
|
+
async def fetch(page: int) -> tuple[list[Contact], bool]:
|
|
292
|
+
page_params = (
|
|
293
|
+
params.model_copy(update={"page_number": page})
|
|
294
|
+
if params is not None
|
|
295
|
+
else ListParams(page_number=page)
|
|
296
|
+
)
|
|
297
|
+
body = await http.request(
|
|
298
|
+
Request(method="GET", path="/contacts", query=_encode_list_params(page_params))
|
|
299
|
+
)
|
|
300
|
+
response = ListContactsResponse.model_validate(body)
|
|
301
|
+
return response.data, response.has_more
|
|
302
|
+
|
|
303
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
304
|
+
|
|
305
|
+
def list_activity_auto_paginate(
|
|
306
|
+
self, contact_id: str, params: ActivityListParams | None = None
|
|
307
|
+
) -> AsyncIter[ContactActivity]:
|
|
308
|
+
"""Async iterate every activity record for a contact."""
|
|
309
|
+
_require_id("list_activity_auto_paginate", contact_id)
|
|
310
|
+
start_page = (
|
|
311
|
+
params.page_number if params is not None and params.page_number is not None else 0
|
|
312
|
+
)
|
|
313
|
+
http = self._http
|
|
314
|
+
|
|
315
|
+
async def fetch(page: int) -> tuple[list[ContactActivity], bool]:
|
|
316
|
+
page_params = (
|
|
317
|
+
params.model_copy(update={"page_number": page})
|
|
318
|
+
if params is not None
|
|
319
|
+
else ActivityListParams(page_number=page)
|
|
320
|
+
)
|
|
321
|
+
body = await http.request(
|
|
322
|
+
Request(
|
|
323
|
+
method="GET",
|
|
324
|
+
path=_activity_path(contact_id),
|
|
325
|
+
query=_encode_activity_params(page_params),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
response = ListActivityResponse.model_validate(body)
|
|
329
|
+
return response.data, response.has_more
|
|
330
|
+
|
|
331
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Public types for the contacts 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 Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
#: Lifecycle status of a contact.
|
|
15
|
+
#:
|
|
16
|
+
#: * ``opted-in`` / ``unsubscribed``: explicit consent state
|
|
17
|
+
#: * ``unknown``: never recorded a choice
|
|
18
|
+
#: * ``imported``: created via CSV / bulk-upsert before consent was captured
|
|
19
|
+
#: * ``deleted``: soft-deleted
|
|
20
|
+
ContactStatus = Literal["deleted", "imported", "unsubscribed", "opted-in", "unknown"]
|
|
21
|
+
|
|
22
|
+
#: Subset of statuses surfaced on the compact ``Contact`` projection
|
|
23
|
+
#: returned by ``list``, ``retrieve``, and ``create``.
|
|
24
|
+
CompactContactStatus = Literal["unsubscribed", "opted-in", "unknown"]
|
|
25
|
+
|
|
26
|
+
#: How to resolve field-level conflicts when merging a second contact into
|
|
27
|
+
#: the target during ``update``.
|
|
28
|
+
ContactMergeResolution = Literal["safe-merge", "overwrite-merge"]
|
|
29
|
+
|
|
30
|
+
#: The kind of event recorded against a contact in their activity feed.
|
|
31
|
+
ContactActivityType = Literal[
|
|
32
|
+
"checkout_session_completed",
|
|
33
|
+
"product_set_checkout_session_completed",
|
|
34
|
+
"order_refunded",
|
|
35
|
+
"ticket_scanned",
|
|
36
|
+
"email_sent",
|
|
37
|
+
"invoice_paid",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Quick status filter accepted by ``ListParams.filter``. Case-insensitive
|
|
41
|
+
#: on the wire; the SDK preserves casing.
|
|
42
|
+
ContactQuickFilter = Literal["all", "opted-in", "unknown", "unsubscribed", "imported"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _BaseModel(BaseModel):
|
|
46
|
+
"""Shared config: accept snake_case or camelCase, ignore unknown fields."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(
|
|
49
|
+
populate_by_name=True,
|
|
50
|
+
extra="ignore",
|
|
51
|
+
str_strip_whitespace=False,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Contact(_BaseModel):
|
|
56
|
+
"""A contact in the compact projection returned by ``list``, ``retrieve``,
|
|
57
|
+
and ``create``.
|
|
58
|
+
|
|
59
|
+
Custom-property keys (24-char hex ids) may appear as additional top-level
|
|
60
|
+
fields beyond those declared here — ``extra="ignore"`` on the base config
|
|
61
|
+
means we silently drop them; access via ``model_extra`` if needed.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
id: str
|
|
65
|
+
first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
|
|
66
|
+
last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
|
|
67
|
+
full_name: str = Field(serialization_alias="fullName", validation_alias="fullName")
|
|
68
|
+
email: str
|
|
69
|
+
phone: str | None = None
|
|
70
|
+
vendor_id: str = Field(serialization_alias="vendorId", validation_alias="vendorId")
|
|
71
|
+
order_sum: int = Field(serialization_alias="orderSum", validation_alias="orderSum")
|
|
72
|
+
gross_sum: int = Field(serialization_alias="grossSum", validation_alias="grossSum")
|
|
73
|
+
first_order: int | None = Field(
|
|
74
|
+
default=None, serialization_alias="firstOrder", validation_alias="firstOrder"
|
|
75
|
+
)
|
|
76
|
+
last_order: int | None = Field(
|
|
77
|
+
default=None, serialization_alias="lastOrder", validation_alias="lastOrder"
|
|
78
|
+
)
|
|
79
|
+
created_at: str | None = Field(
|
|
80
|
+
default=None, serialization_alias="createdAt", validation_alias="createdAt"
|
|
81
|
+
)
|
|
82
|
+
status: CompactContactStatus
|
|
83
|
+
events_attended_ids: list[str] = Field(
|
|
84
|
+
default_factory=list,
|
|
85
|
+
serialization_alias="eventsAttended_IDS",
|
|
86
|
+
validation_alias="eventsAttended_IDS",
|
|
87
|
+
)
|
|
88
|
+
items_purchased_ids: list[str] = Field(
|
|
89
|
+
default_factory=list,
|
|
90
|
+
serialization_alias="itemsPurchased_IDS",
|
|
91
|
+
validation_alias="itemsPurchased_IDS",
|
|
92
|
+
)
|
|
93
|
+
products_purchased_ids: list[str] = Field(
|
|
94
|
+
default_factory=list,
|
|
95
|
+
serialization_alias="productsPurchased_IDS",
|
|
96
|
+
validation_alias="productsPurchased_IDS",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ContactProperty(_BaseModel):
|
|
101
|
+
"""One custom-property entry on the richer order-details projection."""
|
|
102
|
+
|
|
103
|
+
property_id: str = Field(serialization_alias="property_id", validation_alias="property_id")
|
|
104
|
+
value: str | list[str] | bool
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ContactWithOrderDetails(_BaseModel):
|
|
108
|
+
"""The richer "order-details" projection returned by ``update``.
|
|
109
|
+
|
|
110
|
+
Includes raw ``events_attended`` / ``items_purchased`` / ``products_purchased``
|
|
111
|
+
arrays and the ``properties`` array, on top of everything in :class:`Contact`.
|
|
112
|
+
The id field on this projection is ``_id`` (Mongo-style), not ``id``.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
id_: str = Field(serialization_alias="_id", validation_alias="_id")
|
|
116
|
+
email: str
|
|
117
|
+
vendor_id: str = Field(serialization_alias="vendorId", validation_alias="vendorId")
|
|
118
|
+
first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
|
|
119
|
+
last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
|
|
120
|
+
full_name: str = Field(serialization_alias="fullName", validation_alias="fullName")
|
|
121
|
+
phone: str | None = None
|
|
122
|
+
status: ContactStatus
|
|
123
|
+
gross_sum: int = Field(serialization_alias="grossSum", validation_alias="grossSum")
|
|
124
|
+
order_sum: int = Field(serialization_alias="orderSum", validation_alias="orderSum")
|
|
125
|
+
least_recent_order: str | None = Field(
|
|
126
|
+
default=None,
|
|
127
|
+
serialization_alias="leastRecentOrder",
|
|
128
|
+
validation_alias="leastRecentOrder",
|
|
129
|
+
)
|
|
130
|
+
most_recent_order: str | None = Field(
|
|
131
|
+
default=None, serialization_alias="mostRecentOrder", validation_alias="mostRecentOrder"
|
|
132
|
+
)
|
|
133
|
+
events_attended: list[str] = Field(default_factory=list)
|
|
134
|
+
items_purchased: list[str] = Field(default_factory=list)
|
|
135
|
+
products_purchased: list[str] = Field(default_factory=list)
|
|
136
|
+
properties: list[ContactProperty] | None = None
|
|
137
|
+
created_at: str | None = Field(
|
|
138
|
+
default=None, serialization_alias="createdAt", validation_alias="createdAt"
|
|
139
|
+
)
|
|
140
|
+
updated_at: str | None = Field(
|
|
141
|
+
default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ContactActivity(_BaseModel):
|
|
146
|
+
"""A single activity record in a contact's activity feed."""
|
|
147
|
+
|
|
148
|
+
id_: str = Field(serialization_alias="_id", validation_alias="_id")
|
|
149
|
+
vendor_id: str = Field(serialization_alias="vendor_id", validation_alias="vendor_id")
|
|
150
|
+
email: str
|
|
151
|
+
contact_id: str | None = Field(
|
|
152
|
+
default=None, serialization_alias="contact_id", validation_alias="contact_id"
|
|
153
|
+
)
|
|
154
|
+
type: ContactActivityType
|
|
155
|
+
data: dict[str, Any]
|
|
156
|
+
created_at: str = Field(serialization_alias="createdAt", validation_alias="createdAt")
|
|
157
|
+
updated_at: str = Field(serialization_alias="updatedAt", validation_alias="updatedAt")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ListContactsResponse(_BaseModel):
|
|
161
|
+
"""Successful response shape from ``GET /v1/contacts``."""
|
|
162
|
+
|
|
163
|
+
data: list[Contact]
|
|
164
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
165
|
+
page_number: int = Field(serialization_alias="pageNumber", validation_alias="pageNumber")
|
|
166
|
+
page_size: int = Field(serialization_alias="pageSize", validation_alias="pageSize")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ListActivityResponse(_BaseModel):
|
|
170
|
+
"""Successful response shape from ``GET /v1/contacts/{id}/activity``."""
|
|
171
|
+
|
|
172
|
+
data: list[ContactActivity]
|
|
173
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
174
|
+
page_number: int = Field(serialization_alias="pageNumber", validation_alias="pageNumber")
|
|
175
|
+
page_size: int = Field(serialization_alias="pageSize", validation_alias="pageSize")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CountResult(_BaseModel):
|
|
179
|
+
"""Result shape returned by ``count``."""
|
|
180
|
+
|
|
181
|
+
count: int
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class BulkUpsertResult(_BaseModel):
|
|
185
|
+
"""Result shape returned by ``bulk_upsert``."""
|
|
186
|
+
|
|
187
|
+
affected: int
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class DeleteResult(_BaseModel):
|
|
191
|
+
"""Result shape returned by ``delete``. Echoes the removed contact id."""
|
|
192
|
+
|
|
193
|
+
id: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ListParams(_BaseModel):
|
|
197
|
+
"""Query parameters accepted by ``GET /v1/contacts``."""
|
|
198
|
+
|
|
199
|
+
page_number: int | None = Field(
|
|
200
|
+
default=None, serialization_alias="pageNumber", validation_alias="pageNumber"
|
|
201
|
+
)
|
|
202
|
+
page_size: int | None = Field(
|
|
203
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
204
|
+
)
|
|
205
|
+
sort_field: str | None = Field(
|
|
206
|
+
default=None, serialization_alias="sortField", validation_alias="sortField"
|
|
207
|
+
)
|
|
208
|
+
sort_direction: Literal["asc", "desc"] | None = Field(
|
|
209
|
+
default=None, serialization_alias="sortDirection", validation_alias="sortDirection"
|
|
210
|
+
)
|
|
211
|
+
filter: ContactQuickFilter | None = None
|
|
212
|
+
filters: str | None = None
|
|
213
|
+
search: str | None = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ActivityListParams(_BaseModel):
|
|
217
|
+
"""Query parameters accepted by ``GET /v1/contacts/{id}/activity``."""
|
|
218
|
+
|
|
219
|
+
page_number: int | None = Field(
|
|
220
|
+
default=None, serialization_alias="pageNumber", validation_alias="pageNumber"
|
|
221
|
+
)
|
|
222
|
+
page_size: int | None = Field(
|
|
223
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
224
|
+
)
|
|
225
|
+
filter: ContactActivityType | None = None
|
|
226
|
+
sort: Literal["oldest"] | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class CreateBody(_BaseModel):
|
|
230
|
+
"""Body accepted by ``POST /v1/contacts``."""
|
|
231
|
+
|
|
232
|
+
email: str
|
|
233
|
+
first_name: str | None = Field(
|
|
234
|
+
default=None, serialization_alias="firstName", validation_alias="firstName"
|
|
235
|
+
)
|
|
236
|
+
last_name: str | None = Field(
|
|
237
|
+
default=None, serialization_alias="lastName", validation_alias="lastName"
|
|
238
|
+
)
|
|
239
|
+
phone: str | None = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ContactUpdate(_BaseModel):
|
|
243
|
+
"""The nested ``contact`` object inside :class:`UpdateBody`."""
|
|
244
|
+
|
|
245
|
+
first_name: str = Field(serialization_alias="firstName", validation_alias="firstName")
|
|
246
|
+
last_name: str = Field(serialization_alias="lastName", validation_alias="lastName")
|
|
247
|
+
email: str
|
|
248
|
+
phone: str | None = None
|
|
249
|
+
status: ContactStatus
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class UpdateBody(_BaseModel):
|
|
253
|
+
"""Body accepted by ``PATCH /v1/contacts/{id}``.
|
|
254
|
+
|
|
255
|
+
The nested ``contact`` object carries the new field values; ``merge_with``
|
|
256
|
+
and ``resolution`` are set together when an email change collides with
|
|
257
|
+
another contact.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
contact: ContactUpdate
|
|
261
|
+
merge_with: str | None = Field(
|
|
262
|
+
default=None, serialization_alias="mergeWith", validation_alias="mergeWith"
|
|
263
|
+
)
|
|
264
|
+
resolution: ContactMergeResolution | None = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class BulkUpsertItem(_BaseModel):
|
|
268
|
+
"""One row in :class:`BulkUpsertBody.contacts`. Wider than :class:`CreateBody`
|
|
269
|
+
to support CSV-import flows that carry status + properties + association
|
|
270
|
+
arrays."""
|
|
271
|
+
|
|
272
|
+
model_config = ConfigDict(
|
|
273
|
+
populate_by_name=True,
|
|
274
|
+
# Unlike other request bodies, the bulk endpoint accepts a `catchall`
|
|
275
|
+
# of 24-char hex custom-property keys at the top level. Keep them.
|
|
276
|
+
extra="allow",
|
|
277
|
+
str_strip_whitespace=False,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
email: str
|
|
281
|
+
first_name: str | None = Field(
|
|
282
|
+
default=None, serialization_alias="firstName", validation_alias="firstName"
|
|
283
|
+
)
|
|
284
|
+
last_name: str | None = Field(
|
|
285
|
+
default=None, serialization_alias="lastName", validation_alias="lastName"
|
|
286
|
+
)
|
|
287
|
+
phone: str | None = None
|
|
288
|
+
status: ContactStatus | None = None
|
|
289
|
+
properties: list[ContactProperty] | None = None
|
|
290
|
+
events_attended_ids: list[str] | None = Field(
|
|
291
|
+
default=None,
|
|
292
|
+
serialization_alias="eventsAttended_IDS",
|
|
293
|
+
validation_alias="eventsAttended_IDS",
|
|
294
|
+
)
|
|
295
|
+
items_purchased_ids: list[str] | None = Field(
|
|
296
|
+
default=None,
|
|
297
|
+
serialization_alias="itemsPurchased_IDS",
|
|
298
|
+
validation_alias="itemsPurchased_IDS",
|
|
299
|
+
)
|
|
300
|
+
products_purchased_ids: list[str] | None = Field(
|
|
301
|
+
default=None,
|
|
302
|
+
serialization_alias="productsPurchased_IDS",
|
|
303
|
+
validation_alias="productsPurchased_IDS",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class BulkUpsertBody(_BaseModel):
|
|
308
|
+
"""Body accepted by ``POST /v1/contacts/bulk``."""
|
|
309
|
+
|
|
310
|
+
contacts: list[BulkUpsertItem]
|
|
@@ -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
|
threecommon-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,29 +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
|
-
## 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
|
-
|
|
21
|
-
### Added
|
|
22
|
-
|
|
23
|
-
- Initial scaffolding.
|
|
24
|
-
- `ThreeCommon` (sync) and `AsyncThreeCommon` (async) clients.
|
|
25
|
-
- Events resource: `list`, `retrieve`, `update`, `list_auto_paginate`.
|
|
26
|
-
- Invoices resource: `list`, `retrieve`, `create`, `update`, `finalize`, `void`,
|
|
27
|
-
`record_payment`, `list_auto_paginate`. Both sync and async surfaces.
|
|
28
|
-
- Typed exception tree (`AuthError`, `NotFoundError`, `RateLimitError`, …).
|
|
29
|
-
- 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|