wealthbox-cli 1.0.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.
Files changed (54) hide show
  1. wealthbox_cli-1.0.0.dist-info/METADATA +230 -0
  2. wealthbox_cli-1.0.0.dist-info/RECORD +54 -0
  3. wealthbox_cli-1.0.0.dist-info/WHEEL +4 -0
  4. wealthbox_cli-1.0.0.dist-info/entry_points.txt +3 -0
  5. wealthbox_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. wealthbox_tools/__init__.py +51 -0
  7. wealthbox_tools/cli/__init__.py +3 -0
  8. wealthbox_tools/cli/_config.py +43 -0
  9. wealthbox_tools/cli/_util.py +315 -0
  10. wealthbox_tools/cli/activity.py +42 -0
  11. wealthbox_tools/cli/categories.py +41 -0
  12. wealthbox_tools/cli/comments.py +51 -0
  13. wealthbox_tools/cli/config.py +47 -0
  14. wealthbox_tools/cli/contacts.py +412 -0
  15. wealthbox_tools/cli/events.py +168 -0
  16. wealthbox_tools/cli/households.py +41 -0
  17. wealthbox_tools/cli/main.py +55 -0
  18. wealthbox_tools/cli/me.py +25 -0
  19. wealthbox_tools/cli/notes.py +97 -0
  20. wealthbox_tools/cli/opportunities.py +198 -0
  21. wealthbox_tools/cli/projects.py +102 -0
  22. wealthbox_tools/cli/tasks.py +180 -0
  23. wealthbox_tools/cli/users.py +24 -0
  24. wealthbox_tools/cli/workflows.py +172 -0
  25. wealthbox_tools/client/__init__.py +45 -0
  26. wealthbox_tools/client/activity.py +13 -0
  27. wealthbox_tools/client/base.py +216 -0
  28. wealthbox_tools/client/categories.py +14 -0
  29. wealthbox_tools/client/comments.py +14 -0
  30. wealthbox_tools/client/contacts.py +44 -0
  31. wealthbox_tools/client/events.py +31 -0
  32. wealthbox_tools/client/households.py +30 -0
  33. wealthbox_tools/client/me.py +11 -0
  34. wealthbox_tools/client/notes.py +28 -0
  35. wealthbox_tools/client/opportunities.py +31 -0
  36. wealthbox_tools/client/projects.py +28 -0
  37. wealthbox_tools/client/tasks.py +31 -0
  38. wealthbox_tools/client/users.py +14 -0
  39. wealthbox_tools/client/workflows.py +44 -0
  40. wealthbox_tools/models/__init__.py +134 -0
  41. wealthbox_tools/models/activity.py +13 -0
  42. wealthbox_tools/models/comments.py +13 -0
  43. wealthbox_tools/models/common.py +123 -0
  44. wealthbox_tools/models/contacts.py +131 -0
  45. wealthbox_tools/models/custom_fields.py +19 -0
  46. wealthbox_tools/models/enums.py +209 -0
  47. wealthbox_tools/models/events.py +63 -0
  48. wealthbox_tools/models/households.py +17 -0
  49. wealthbox_tools/models/notes.py +28 -0
  50. wealthbox_tools/models/opportunities.py +48 -0
  51. wealthbox_tools/models/projects.py +27 -0
  52. wealthbox_tools/models/tasks.py +76 -0
  53. wealthbox_tools/models/workflows.py +50 -0
  54. wealthbox_tools/py.typed +0 -0
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from wealthbox_tools.models import (
8
+ WorkflowCreateInput,
9
+ WorkflowListQuery,
10
+ WorkflowResourceType,
11
+ WorkflowStatus,
12
+ WorkflowStepCompleteInput,
13
+ WorkflowTemplateListQuery,
14
+ )
15
+
16
+ from ._util import OutputFormat, build_linked_to, handle_errors, output_result, parse_more_fields, run_client
17
+
18
+ app = typer.Typer(
19
+ context_settings={"help_option_names": ["-h", "--help"]},
20
+ help="Manage Wealthbox workflows.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ templates_app = typer.Typer(
25
+ context_settings={"help_option_names": ["-h", "--help"]},
26
+ help="List workflow templates.",
27
+ no_args_is_help=True,
28
+ )
29
+ app.add_typer(templates_app, name="templates")
30
+
31
+ _DEFAULT_FIELDS = ["id", "label", "status", "workflow_template", "linked_to", "created_at"]
32
+ _TEMPLATE_DEFAULT_FIELDS = ["id", "name", "description", "status"]
33
+
34
+
35
+ @app.command("list", help="List workflows with optional filters.")
36
+ @handle_errors
37
+ def list_workflows(
38
+ resource_id: int | None = typer.Option(
39
+ None, "--resource-id", help="Filter by linked resource ID (requires --resource-type)"
40
+ ),
41
+ resource_type: WorkflowResourceType | None = typer.Option(
42
+ None, "--resource-type", help="Filter by linked resource type: Contact, Project"
43
+ ),
44
+ status: WorkflowStatus | None = typer.Option(None, "--status", help="active, completed, or scheduled"),
45
+ updated_since: str | None = typer.Option(None, "--updated-since"),
46
+ updated_before: str | None = typer.Option(None, "--updated-before"),
47
+ page: int | None = typer.Option(None),
48
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
49
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
50
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
51
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
52
+ ) -> None:
53
+ query = WorkflowListQuery(
54
+ resource_id=resource_id,
55
+ resource_type=resource_type,
56
+ status=status,
57
+ updated_since=updated_since,
58
+ updated_before=updated_before,
59
+ page=page,
60
+ per_page=per_page,
61
+ )
62
+ output_result(
63
+ run_client(token, lambda c: c.list_workflows(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS
64
+ )
65
+
66
+
67
+ @app.command("get", help="Get a single workflow by ID.")
68
+ @handle_errors
69
+ def get_workflow(
70
+ workflow_id: int = typer.Argument(..., help="Workflow ID"),
71
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
72
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
73
+ ) -> None:
74
+ output_result(run_client(token, lambda c: c.get_workflow(workflow_id)), fmt)
75
+
76
+
77
+ @app.command("add", help="Create a new workflow from a template.")
78
+ @handle_errors
79
+ def add_workflow(
80
+ template: int = typer.Option(..., "--template", help="Workflow template ID — see: wbox workflows templates list"),
81
+ label: str | None = typer.Option(None, "--label", help="Optional label for this workflow instance"),
82
+ contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
83
+ project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
84
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Link to an Opportunity by ID"),
85
+ visible_to: str | None = typer.Option(None, "--visible-to"),
86
+ starts_at: str | None = typer.Option(None, "--starts-at", help="Start date (e.g. 2026-06-01)"),
87
+ more_fields: str | None = typer.Option(
88
+ None, "--more-fields", help="JSON object for additional fields (e.g. workflow_milestones)"
89
+ ),
90
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
91
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
92
+ ) -> None:
93
+ payload: dict[str, Any] = {
94
+ "workflow_template": template,
95
+ "label": label,
96
+ "visible_to": visible_to,
97
+ "starts_at": starts_at,
98
+ "linked_to": build_linked_to(contact, project, opportunity),
99
+ }
100
+
101
+ if more_fields:
102
+ _reserved = {"workflow_template", "label", "visible_to", "starts_at", "linked_to"}
103
+ payload.update(parse_more_fields(more_fields, _reserved))
104
+
105
+ input_model = WorkflowCreateInput(**{k: v for k, v in payload.items() if v is not None})
106
+ output_result(run_client(token, lambda c: c.create_workflow(input_model)), fmt)
107
+
108
+
109
+ @app.command("complete-step", help="Mark a workflow step as complete.")
110
+ @handle_errors
111
+ def complete_workflow_step(
112
+ workflow_id: int = typer.Argument(..., help="Workflow ID"),
113
+ step_id: int = typer.Argument(..., help="Step ID"),
114
+ outcome_id: int | None = typer.Option(
115
+ None, "--outcome-id", help="Workflow outcome ID (if step has multiple outcomes)"
116
+ ),
117
+ due_date: str | None = typer.Option(
118
+ None, "--due-date", help="Due date when restarting a step (requires --due-date-set)"
119
+ ),
120
+ due_date_set: bool = typer.Option(False, "--due-date-set", help="Whether the restarted step has a due date"),
121
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
122
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
123
+ ) -> None:
124
+ data = WorkflowStepCompleteInput(
125
+ workflow_outcome_id=outcome_id,
126
+ due_date_set=due_date_set,
127
+ due_date=due_date,
128
+ )
129
+ output_result(run_client(token, lambda c: c.complete_workflow_step(workflow_id, step_id, data)), fmt)
130
+
131
+
132
+ @app.command("revert-step", help="Revert a completed workflow step.")
133
+ @handle_errors
134
+ def revert_workflow_step(
135
+ workflow_id: int = typer.Argument(..., help="Workflow ID"),
136
+ step_id: int = typer.Argument(..., help="Step ID"),
137
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
138
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
139
+ ) -> None:
140
+ output_result(run_client(token, lambda c: c.revert_workflow_step(workflow_id, step_id)), fmt)
141
+
142
+
143
+ @templates_app.command("list", help="List available workflow templates.")
144
+ @handle_errors
145
+ def list_workflow_templates(
146
+ resource_id: int | None = typer.Option(None, "--resource-id", help="Filter by linked resource ID"),
147
+ resource_type: WorkflowResourceType | None = typer.Option(
148
+ None, "--resource-type", help="Filter by linked resource type: Contact, Project"
149
+ ),
150
+ status: WorkflowStatus | None = typer.Option(None, "--status", help="active, completed, or scheduled"),
151
+ updated_since: str | None = typer.Option(None, "--updated-since"),
152
+ updated_before: str | None = typer.Option(None, "--updated-before"),
153
+ page: int | None = typer.Option(None),
154
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
155
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
156
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
157
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
158
+ ) -> None:
159
+ query = WorkflowTemplateListQuery(
160
+ resource_id=resource_id,
161
+ resource_type=resource_type,
162
+ status=status,
163
+ updated_since=updated_since,
164
+ updated_before=updated_before,
165
+ page=page,
166
+ per_page=per_page,
167
+ )
168
+ output_result(
169
+ run_client(token, lambda c: c.list_workflow_templates(query)),
170
+ fmt,
171
+ fields=None if verbose else _TEMPLATE_DEFAULT_FIELDS,
172
+ )
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from .activity import ActivityMixin
4
+ from .base import WealthboxAPIError, _WealthboxBase
5
+ from .categories import CategoriesMixin
6
+ from .comments import CommentsMixin
7
+ from .contacts import ContactsMixin
8
+ from .events import EventsMixin
9
+ from .households import HouseholdsMixin
10
+ from .me import MeMixin
11
+ from .notes import NotesMixin
12
+ from .opportunities import OpportunitiesMixin
13
+ from .projects import ProjectsMixin
14
+ from .tasks import TasksMixin
15
+ from .users import UsersMixin
16
+ from .workflows import WorkflowsMixin
17
+
18
+
19
+ class WealthboxClient(
20
+ ActivityMixin,
21
+ CategoriesMixin,
22
+ CommentsMixin,
23
+ ContactsMixin,
24
+ EventsMixin,
25
+ HouseholdsMixin,
26
+ MeMixin,
27
+ NotesMixin,
28
+ OpportunitiesMixin,
29
+ ProjectsMixin,
30
+ TasksMixin,
31
+ UsersMixin,
32
+ WorkflowsMixin,
33
+ _WealthboxBase,
34
+ ):
35
+ """Async Wealthbox CRM API client.
36
+
37
+ Usage::
38
+
39
+ async with WealthboxClient() as client:
40
+ me = await client.get_me()
41
+ contacts = await client.list_contacts()
42
+ """
43
+
44
+
45
+ __all__ = ["WealthboxClient", "WealthboxAPIError"]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import ActivityListQuery
6
+
7
+
8
+ class ActivityMixin:
9
+ """Activity Resource"""
10
+ async def list_activity(self, query: ActivityListQuery | None = None) -> dict[str, Any]:
11
+ params = query.model_dump(exclude_none=True) if query else None
12
+ resp = await self._request("GET", "/activity", params=params) # type: ignore[attr-defined]
13
+ return resp.json()
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import collections
5
+ import json
6
+ import logging
7
+ import os
8
+ import pathlib
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ BASE_URL = "https://api.crmworkspace.com/v1"
18
+
19
+
20
+ class WealthboxAPIError(Exception):
21
+ """Raised when the Wealthbox API returns an error response."""
22
+
23
+ def __init__(self, status_code: int, detail: str, response: httpx.Response | httpx.Request):
24
+ self.status_code = status_code
25
+ self.detail = detail
26
+ self.response = response
27
+ super().__init__(f"[{status_code}] {detail}")
28
+
29
+
30
+ class RateLimiter:
31
+ """Async sliding-window rate limiter: 300 requests per 5-minute window."""
32
+
33
+ def __init__(
34
+ self,
35
+ rate: float = 1.0, # kept for backward compat, unused
36
+ window: float = 300.0, # 5-minute window
37
+ limit: int = 300, # 300 req per window = 1/s average
38
+ state_file: pathlib.Path | None = None,
39
+ ):
40
+ self._window = window
41
+ self._limit = limit
42
+ self._timestamps: collections.deque[float] = collections.deque()
43
+ self._lock = asyncio.Lock()
44
+ self._state_file = state_file or pathlib.Path.home() / ".wbox_rate_limit.json"
45
+ self._load_state()
46
+
47
+ def _load_state(self) -> None:
48
+ try:
49
+ raw = json.loads(self._state_file.read_text())
50
+ wall_ts = raw.get("timestamps", [])
51
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
52
+ return
53
+ now_mono, now_wall = time.monotonic(), time.time()
54
+ offset = now_mono - now_wall # wall → monotonic
55
+ cutoff = now_wall - self._window
56
+ for ts in wall_ts:
57
+ if ts > cutoff:
58
+ self._timestamps.append(ts + offset)
59
+
60
+ def _prune_expired(self, now: float) -> None:
61
+ while self._timestamps and (now - self._timestamps[0]) >= self._window:
62
+ self._timestamps.popleft()
63
+
64
+ def _save_state(self) -> None:
65
+ try:
66
+ now_mono, now_wall = time.monotonic(), time.time()
67
+ offset = now_wall - now_mono # monotonic → wall
68
+ cutoff = now_wall - self._window
69
+ wall_ts = [ts + offset for ts in self._timestamps if (ts + offset) > cutoff]
70
+ tmp = self._state_file.with_suffix(".tmp")
71
+ tmp.write_text(json.dumps({"timestamps": wall_ts}))
72
+ os.replace(tmp, self._state_file) # atomic swap
73
+ except OSError:
74
+ pass # never crash CLI over a cache file
75
+
76
+ async def acquire(self) -> None:
77
+ async with self._lock:
78
+ now = time.monotonic()
79
+ self._prune_expired(now)
80
+ if len(self._timestamps) >= self._limit:
81
+ sleep_for = self._window - (now - self._timestamps[0])
82
+ if sleep_for > 0:
83
+ await asyncio.sleep(sleep_for)
84
+ self._prune_expired(time.monotonic())
85
+ self._timestamps.append(time.monotonic())
86
+
87
+
88
+ class _WealthboxBase:
89
+ """Core HTTP client. Resource mixins are combined into WealthboxClient."""
90
+
91
+ def __init__(
92
+ self,
93
+ token: str | None = None,
94
+ base_url: str = BASE_URL,
95
+ rate_limit: bool = True,
96
+ max_429_retries: int = 5,
97
+ ):
98
+ self._token = token or os.environ.get("WEALTHBOX_TOKEN", "")
99
+ if not self._token:
100
+ raise ValueError(
101
+ "Wealthbox token required. Pass token= or set WEALTHBOX_TOKEN env var."
102
+ )
103
+ self._rate_limiter = RateLimiter() if rate_limit else None
104
+ self._max_429_retries = max_429_retries
105
+ self._http = httpx.AsyncClient(
106
+ base_url=base_url,
107
+ headers={"ACCESS_TOKEN": self._token},
108
+ timeout=30.0,
109
+ )
110
+
111
+ async def _request(
112
+ self,
113
+ method: str,
114
+ path: str,
115
+ *,
116
+ params: dict | None = None,
117
+ json: dict | None = None,
118
+ ) -> httpx.Response:
119
+ if self._rate_limiter:
120
+ await self._rate_limiter.acquire()
121
+
122
+ retries = 0
123
+ while True:
124
+ try:
125
+ response = await self._http.request(method, path, params=params, json=json)
126
+ except httpx.RequestError as exc:
127
+ raise WealthboxAPIError(0, f"Request failed: {exc}", exc.request) from exc # type: ignore[arg-type]
128
+
129
+ if response.status_code != 429:
130
+ break
131
+
132
+ retries += 1
133
+ if retries > self._max_429_retries:
134
+ raise WealthboxAPIError(
135
+ 429,
136
+ f"Rate limited after {self._max_429_retries} retries.",
137
+ response,
138
+ )
139
+
140
+ raw_retry = response.headers.get("Retry-After", "5")
141
+ try:
142
+ retry_after = float(raw_retry)
143
+ except ValueError:
144
+ log.warning("Malformed Retry-After header %r, defaulting to 5s", raw_retry)
145
+ retry_after = 5.0
146
+
147
+ await asyncio.sleep(max(retry_after, 0.0))
148
+
149
+ if response.is_error:
150
+ try:
151
+ detail = response.json().get("error", response.text)
152
+ except Exception:
153
+ detail = response.text
154
+ raise WealthboxAPIError(response.status_code, detail, response)
155
+
156
+ return response
157
+
158
+ async def fetch_all_pages(
159
+ self,
160
+ path: str,
161
+ params: dict[str, Any],
162
+ collection_key: str,
163
+ on_progress: Callable[[int, int], None] | None = None,
164
+ ) -> dict[str, Any]:
165
+ """
166
+ Fetch every page from a paginated list endpoint.
167
+
168
+ Calls GET `path` with `params` (plus page/per_page overrides),
169
+ collecting items under `collection_key` until all pages are fetched.
170
+ Calls on_progress(page_num, total_fetched) after each page if provided.
171
+ Returns {"<collection_key>": [...all items...], "meta": {"total_count": N}}.
172
+ """
173
+ all_items: list[Any] = []
174
+ base_params = {**params, "per_page": 100}
175
+ page = 1
176
+
177
+ while True:
178
+ base_params["page"] = page
179
+ resp = await self._request("GET", path, params=base_params)
180
+ try:
181
+ data = resp.json()
182
+ except Exception:
183
+ raise WealthboxAPIError(
184
+ resp.status_code,
185
+ f"Invalid JSON in response from {path} (page {page})",
186
+ resp,
187
+ )
188
+
189
+ items = data.get(collection_key, [])
190
+ all_items.extend(items)
191
+
192
+ if on_progress:
193
+ on_progress(page, len(all_items))
194
+
195
+ total_count = data.get("meta", {}).get("total_count")
196
+ if total_count is not None:
197
+ if page * 100 >= total_count:
198
+ break
199
+ else:
200
+ if len(items) < 100: # fallback if meta missing
201
+ break
202
+
203
+ page += 1
204
+
205
+ return {collection_key: all_items, "meta": {"total_count": len(all_items)}}
206
+
207
+ async def aclose(self) -> None:
208
+ if self._rate_limiter:
209
+ self._rate_limiter._save_state()
210
+ await self._http.aclose()
211
+
212
+ async def __aenter__(self) -> "_WealthboxBase": # type: ignore[return-value]
213
+ return self # type: ignore[return-value]
214
+
215
+ async def __aexit__(self, *args: object) -> None:
216
+ await self.aclose()
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import CategoryListQuery, CategoryType
6
+
7
+
8
+ class CategoriesMixin:
9
+ """Categories Resources"""
10
+
11
+ async def list_categories(self, category: CategoryType, query: CategoryListQuery | None = None) -> dict[str, Any]:
12
+ params = query.model_dump(exclude_none=True) if query else None
13
+ resp = await self._request("GET", f"/categories/{category}", params=params) # type: ignore[attr-defined]
14
+ return resp.json()
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import CommentListQuery
6
+
7
+
8
+ class CommentsMixin:
9
+ """Comments Resource"""
10
+
11
+ async def list_comments(self, query: CommentListQuery | None = None) -> dict[str, Any]:
12
+ params = query.model_dump(exclude_none=True) if query else None
13
+ resp = await self._request("GET", "/comments", params=params) # type: ignore[attr-defined]
14
+ return resp.json()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from wealthbox_tools.models import ContactCreateInput, ContactListQuery, ContactUpdateInput
7
+
8
+
9
+ class ContactsMixin:
10
+ """Contact resource methods. Mixed into WealthboxClient."""
11
+
12
+ async def list_contacts(self, query: ContactListQuery | None = None) -> dict[str, Any]:
13
+ params = query.model_dump(exclude_none=True) if query else None
14
+ resp = await self._request("GET", "/contacts", params=params) # type: ignore[attr-defined]
15
+ return resp.json()
16
+
17
+ async def get_contact(self, contact_id: int) -> dict[str, Any]:
18
+ resp = await self._request("GET", f"/contacts/{contact_id}") # type: ignore[attr-defined]
19
+ return resp.json()
20
+
21
+ async def create_contact(self, data: ContactCreateInput) -> dict[str, Any]:
22
+ payload = data.model_dump(exclude_none=True)
23
+ resp = await self._request("POST", "/contacts", json=payload) # type: ignore[attr-defined]
24
+ return resp.json()
25
+
26
+ async def update_contact(self, contact_id: int, data: ContactUpdateInput) -> dict[str, Any]:
27
+ payload = data.model_dump(exclude_unset=True)
28
+ resp = await self._request("PUT", f"/contacts/{contact_id}", json=payload) # type: ignore[attr-defined]
29
+ return resp.json()
30
+
31
+ async def delete_contact(self, contact_id: int) -> None:
32
+ await self._request("DELETE", f"/contacts/{contact_id}") # type: ignore[attr-defined]
33
+
34
+ async def list_all_contacts(
35
+ self,
36
+ query: ContactListQuery | None = None,
37
+ on_progress: Callable[[int, int], None] | None = None,
38
+ ) -> dict[str, Any]:
39
+ """Fetch all pages of contacts. Use when client-side filtering requires the full dataset."""
40
+ params = query.model_dump(exclude_none=True) if query else {}
41
+ # Remove pagination fields — fetch_all_pages controls these
42
+ params.pop("page", None)
43
+ params.pop("per_page", None)
44
+ return await self.fetch_all_pages("/contacts", params, "contacts", on_progress=on_progress) # type: ignore[attr-defined]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import EventCreateInput, EventListQuery, EventUpdateInput
6
+
7
+
8
+ class EventsMixin:
9
+ """Event resource methods. Mixed into WealthboxClient."""
10
+
11
+ async def list_events(self, query: EventListQuery | None = None) -> dict[str, Any]:
12
+ params = query.model_dump(exclude_none=True) if query else None
13
+ resp = await self._request("GET", "/events", params=params) # type: ignore[attr-defined]
14
+ return resp.json()
15
+
16
+ async def get_event(self, event_id: int) -> dict[str, Any]:
17
+ resp = await self._request("GET", f"/events/{event_id}") # type: ignore[attr-defined]
18
+ return resp.json()
19
+
20
+ async def create_event(self, data: EventCreateInput) -> dict[str, Any]:
21
+ payload = data.model_dump(exclude_none=True)
22
+ resp = await self._request("POST", "/events", json=payload) # type: ignore[attr-defined]
23
+ return resp.json()
24
+
25
+ async def update_event(self, event_id: int, data: EventUpdateInput) -> dict[str, Any]:
26
+ payload = data.model_dump(exclude_unset=True)
27
+ resp = await self._request("PUT", f"/events/{event_id}", json=payload) # type: ignore[attr-defined]
28
+ return resp.json()
29
+
30
+ async def delete_event(self, event_id: int) -> None:
31
+ await self._request("DELETE", f"/events/{event_id}") # type: ignore[attr-defined]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import HouseholdMemberInput
6
+
7
+
8
+ class HouseholdsMixin:
9
+ """Household membership methods."""
10
+
11
+ async def add_household_member(
12
+ self, household_id: int, data: HouseholdMemberInput
13
+ ) -> dict[str, Any]:
14
+ payload = data.model_dump(exclude_none=True)
15
+ resp = await self._request( # type: ignore[attr-defined]
16
+ "POST", f"/households/{household_id}/members", json=payload
17
+ )
18
+ return resp.json()
19
+
20
+ async def remove_household_member(
21
+ self, household_id: int, member_id: int
22
+ ) -> dict[str, Any]:
23
+ resp = await self._request( # type: ignore[attr-defined]
24
+ "DELETE", f"/households/{household_id}/members/{member_id}"
25
+ )
26
+ # Wealthbox typically returns the household body, but may return empty body.
27
+ try:
28
+ return resp.json()
29
+ except ValueError:
30
+ return {"ok": True, "household_id": household_id, "member_id": member_id}
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MeMixin:
7
+ """Me Resource"""
8
+
9
+ async def get_me(self) -> dict[str, Any]:
10
+ resp = await self._request("GET", "/me") # type: ignore[attr-defined]
11
+ return resp.json()
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import NoteCreateInput, NoteListQuery, NoteUpdateInput
6
+
7
+
8
+ class NotesMixin:
9
+ """Note resource methods. Mixed into WealthboxClient. (No delete — API does not support it.)"""
10
+
11
+ async def list_notes(self, query: NoteListQuery | None = None) -> dict[str, Any]:
12
+ params = query.model_dump(exclude_none=True) if query else None
13
+ resp = await self._request("GET", "/notes", params=params) # type: ignore[attr-defined]
14
+ return resp.json()
15
+
16
+ async def get_note(self, note_id: int) -> dict[str, Any]:
17
+ resp = await self._request("GET", f"/notes/{note_id}") # type: ignore[attr-defined]
18
+ return resp.json()
19
+
20
+ async def create_note(self, data: NoteCreateInput) -> dict[str, Any]:
21
+ payload = data.model_dump(exclude_none=True)
22
+ resp = await self._request("POST", "/notes", json=payload) # type: ignore[attr-defined]
23
+ return resp.json()
24
+
25
+ async def update_note(self, note_id: int, data: NoteUpdateInput) -> dict[str, Any]:
26
+ payload = data.model_dump(exclude_unset=True)
27
+ resp = await self._request("PUT", f"/notes/{note_id}", json=payload) # type: ignore[attr-defined]
28
+ return resp.json()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wealthbox_tools.models import OpportunityCreateInput, OpportunityListQuery, OpportunityUpdateInput
6
+
7
+
8
+ class OpportunitiesMixin:
9
+ """Opportunities Resource"""
10
+
11
+ async def list_opportunities(self, query: OpportunityListQuery | None = None) -> dict[str, Any]:
12
+ params = query.model_dump(exclude_none=True) if query else None
13
+ resp = await self._request("GET", "/opportunities", params=params) # type: ignore[attr-defined]
14
+ return resp.json()
15
+
16
+ async def get_opportunity(self, opportunity_id: int) -> dict[str, Any]:
17
+ resp = await self._request("GET", f"/opportunities/{opportunity_id}") # type: ignore[attr-defined]
18
+ return resp.json()
19
+
20
+ async def create_opportunity(self, data: OpportunityCreateInput) -> dict[str, Any]:
21
+ payload = data.model_dump(exclude_none=True)
22
+ resp = await self._request("POST", "/opportunities", json=payload) # type: ignore[attr-defined]
23
+ return resp.json()
24
+
25
+ async def update_opportunity(self, opportunity_id: int, data: OpportunityUpdateInput) -> dict[str, Any]:
26
+ payload = data.model_dump(exclude_unset=True)
27
+ resp = await self._request("PUT", f"/opportunities/{opportunity_id}", json=payload) # type: ignore[attr-defined]
28
+ return resp.json()
29
+
30
+ async def delete_opportunity(self, opportunity_id: int) -> None:
31
+ await self._request("DELETE", f"/opportunities/{opportunity_id}") # type: ignore[attr-defined]