moneybird-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. moneybird_cli-0.1.0/.gitignore +5 -0
  2. moneybird_cli-0.1.0/LICENSE +21 -0
  3. moneybird_cli-0.1.0/PKG-INFO +71 -0
  4. moneybird_cli-0.1.0/README.md +41 -0
  5. moneybird_cli-0.1.0/pyproject.toml +48 -0
  6. moneybird_cli-0.1.0/setup.cfg +4 -0
  7. moneybird_cli-0.1.0/src/moneybird_cli/__init__.py +1 -0
  8. moneybird_cli-0.1.0/src/moneybird_cli/client.py +176 -0
  9. moneybird_cli-0.1.0/src/moneybird_cli/commands/__init__.py +0 -0
  10. moneybird_cli-0.1.0/src/moneybird_cli/commands/assets.py +87 -0
  11. moneybird_cli-0.1.0/src/moneybird_cli/commands/contacts.py +159 -0
  12. moneybird_cli-0.1.0/src/moneybird_cli/commands/documents.py +226 -0
  13. moneybird_cli-0.1.0/src/moneybird_cli/commands/estimates.py +127 -0
  14. moneybird_cli-0.1.0/src/moneybird_cli/commands/external_sales_invoices.py +99 -0
  15. moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_accounts.py +24 -0
  16. moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_mutations.py +90 -0
  17. moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_statements.py +53 -0
  18. moneybird_cli-0.1.0/src/moneybird_cli/commands/ledger_accounts.py +82 -0
  19. moneybird_cli-0.1.0/src/moneybird_cli/commands/login.py +69 -0
  20. moneybird_cli-0.1.0/src/moneybird_cli/commands/payments.py +23 -0
  21. moneybird_cli-0.1.0/src/moneybird_cli/commands/products.py +107 -0
  22. moneybird_cli-0.1.0/src/moneybird_cli/commands/projects.py +86 -0
  23. moneybird_cli-0.1.0/src/moneybird_cli/commands/purchase_transactions.py +65 -0
  24. moneybird_cli-0.1.0/src/moneybird_cli/commands/recurring_sales_invoices.py +91 -0
  25. moneybird_cli-0.1.0/src/moneybird_cli/commands/reports.py +51 -0
  26. moneybird_cli-0.1.0/src/moneybird_cli/commands/sales_invoices.py +201 -0
  27. moneybird_cli-0.1.0/src/moneybird_cli/commands/subscriptions.py +88 -0
  28. moneybird_cli-0.1.0/src/moneybird_cli/commands/tax_rates.py +34 -0
  29. moneybird_cli-0.1.0/src/moneybird_cli/commands/time_entries.py +106 -0
  30. moneybird_cli-0.1.0/src/moneybird_cli/commands/webhooks.py +69 -0
  31. moneybird_cli-0.1.0/src/moneybird_cli/config.py +121 -0
  32. moneybird_cli-0.1.0/src/moneybird_cli/main.py +200 -0
  33. moneybird_cli-0.1.0/src/moneybird_cli/output.py +54 -0
  34. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/PKG-INFO +71 -0
  35. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/SOURCES.txt +46 -0
  36. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/dependency_links.txt +1 -0
  37. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/entry_points.txt +2 -0
  38. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/requires.txt +7 -0
  39. moneybird_cli-0.1.0/src/moneybird_cli.egg-info/top_level.txt +1 -0
  40. moneybird_cli-0.1.0/tests/__init__.py +0 -0
  41. moneybird_cli-0.1.0/tests/conftest.py +43 -0
  42. moneybird_cli-0.1.0/tests/test_client.py +187 -0
  43. moneybird_cli-0.1.0/tests/test_commands/__init__.py +0 -0
  44. moneybird_cli-0.1.0/tests/test_commands/test_contacts.py +105 -0
  45. moneybird_cli-0.1.0/tests/test_commands/test_login.py +118 -0
  46. moneybird_cli-0.1.0/tests/test_commands/test_sales_invoices.py +99 -0
  47. moneybird_cli-0.1.0/tests/test_config.py +54 -0
  48. moneybird_cli-0.1.0/tests/test_output.py +46 -0
@@ -0,0 +1,5 @@
1
+ **/__pycache__
2
+ *.egg-info
3
+ dist/
4
+ build/
5
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Koert van der Veer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: moneybird-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for the Moneybird API
5
+ Author-email: Koert van der Veer <koert@ondergetekende.nl>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ondergetekende/moneybird-cli
8
+ Project-URL: Repository, https://github.com/ondergetekende/moneybird-cli
9
+ Project-URL: Issues, https://github.com/ondergetekende/moneybird-cli/issues
10
+ Keywords: moneybird,accounting,cli,invoicing,bookkeeping
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: requests>=2.31
25
+ Requires-Dist: tabulate>=0.9
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: responses>=0.23; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # Moneybird CLI
32
+
33
+ A command-line interface for [Moneybird](https://www.moneybird.com/), the online accounting platform. Manage invoices, contacts, expenses, time tracking, and more from your terminal.
34
+
35
+ > **Disclaimer:** This project is not affiliated with, endorsed by, or in any way associated with Moneybird B.V. "Moneybird" is a trademark of Moneybird B.V. This is an independent, open-source tool that uses the public Moneybird API.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ pip install moneybird-cli
41
+ moneybird login --token YOUR_API_TOKEN
42
+ moneybird contacts list
43
+ moneybird sales-invoices list --state open
44
+ ```
45
+
46
+ See [Getting Started](docs/getting-started.md) for full setup instructions, output formats, pagination, and global options.
47
+
48
+ ## Documentation
49
+
50
+ | Guide | What it covers |
51
+ | --- | --- |
52
+ | [Getting Started](docs/getting-started.md) | Installation, authentication, multiple administrations, output formats, pagination, global options |
53
+ | [Contacts](docs/contacts.md) | Customer and supplier management: list, search, filter, create, update, archive |
54
+ | [Invoicing](docs/invoicing.md) | Sales invoices, estimates, recurring invoices, external invoices, payments, credit notes, PDF downloads |
55
+ | [Products & Subscriptions](docs/products-and-subscriptions.md) | Reusable product definitions, subscription lifecycle management |
56
+ | [Financial](docs/financial.md) | Bank accounts, transactions, reconciliation, purchase transactions |
57
+ | [Documents](docs/documents.md) | Purchase invoices, receipts, journal entries, general documents, assets |
58
+ | [Time Tracking](docs/time-tracking.md) | Projects and time entries |
59
+ | [Reports](docs/reports.md) | Profit/loss, balance sheet, cash flow, tax, debtors/creditors, and more |
60
+ | [Settings](docs/settings.md) | Ledger accounts, tax rates, webhooks |
61
+
62
+ ## Tech stack
63
+
64
+ - [Python](https://www.python.org/) >= 3.11
65
+ - [Click](https://click.palletsprojects.com/) for the CLI framework
66
+ - [Requests](https://requests.readthedocs.io/) for HTTP
67
+ - [Tabulate](https://pypi.org/project/tabulate/) for table formatting
68
+
69
+ ## License
70
+
71
+ MIT -- see [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ # Moneybird CLI
2
+
3
+ A command-line interface for [Moneybird](https://www.moneybird.com/), the online accounting platform. Manage invoices, contacts, expenses, time tracking, and more from your terminal.
4
+
5
+ > **Disclaimer:** This project is not affiliated with, endorsed by, or in any way associated with Moneybird B.V. "Moneybird" is a trademark of Moneybird B.V. This is an independent, open-source tool that uses the public Moneybird API.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ pip install moneybird-cli
11
+ moneybird login --token YOUR_API_TOKEN
12
+ moneybird contacts list
13
+ moneybird sales-invoices list --state open
14
+ ```
15
+
16
+ See [Getting Started](docs/getting-started.md) for full setup instructions, output formats, pagination, and global options.
17
+
18
+ ## Documentation
19
+
20
+ | Guide | What it covers |
21
+ | --- | --- |
22
+ | [Getting Started](docs/getting-started.md) | Installation, authentication, multiple administrations, output formats, pagination, global options |
23
+ | [Contacts](docs/contacts.md) | Customer and supplier management: list, search, filter, create, update, archive |
24
+ | [Invoicing](docs/invoicing.md) | Sales invoices, estimates, recurring invoices, external invoices, payments, credit notes, PDF downloads |
25
+ | [Products & Subscriptions](docs/products-and-subscriptions.md) | Reusable product definitions, subscription lifecycle management |
26
+ | [Financial](docs/financial.md) | Bank accounts, transactions, reconciliation, purchase transactions |
27
+ | [Documents](docs/documents.md) | Purchase invoices, receipts, journal entries, general documents, assets |
28
+ | [Time Tracking](docs/time-tracking.md) | Projects and time entries |
29
+ | [Reports](docs/reports.md) | Profit/loss, balance sheet, cash flow, tax, debtors/creditors, and more |
30
+ | [Settings](docs/settings.md) | Ledger accounts, tax rates, webhooks |
31
+
32
+ ## Tech stack
33
+
34
+ - [Python](https://www.python.org/) >= 3.11
35
+ - [Click](https://click.palletsprojects.com/) for the CLI framework
36
+ - [Requests](https://requests.readthedocs.io/) for HTTP
37
+ - [Tabulate](https://pypi.org/project/tabulate/) for table formatting
38
+
39
+ ## License
40
+
41
+ MIT -- see [LICENSE](LICENSE).
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "moneybird-cli"
7
+ version = "0.1.0"
8
+ description = "Command-line interface for the Moneybird API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ keywords = ["moneybird", "accounting", "cli", "invoicing", "bookkeeping"]
13
+ authors = [
14
+ { name = "Koert van der Veer", email = "koert@ondergetekende.nl" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Intended Audience :: System Administrators",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Office/Business :: Financial :: Accounting",
26
+ ]
27
+ dependencies = [
28
+ "click>=8.1",
29
+ "requests>=2.31",
30
+ "tabulate>=0.9",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.0",
36
+ "responses>=0.23",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/ondergetekende/moneybird-cli"
41
+ Repository = "https://github.com/ondergetekende/moneybird-cli"
42
+ Issues = "https://github.com/ondergetekende/moneybird-cli/issues"
43
+
44
+ [project.scripts]
45
+ moneybird = "moneybird_cli.main:cli"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Moneybird CLI - Command-line interface for the Moneybird API."""
@@ -0,0 +1,176 @@
1
+ """Moneybird API client with pagination, rate limiting, and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ import click
10
+ import requests
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ API_BASE = "https://moneybird.com/api/v2"
15
+
16
+
17
+ class MoneybirdApiError(click.ClickException):
18
+ """API error with HTTP status code context."""
19
+
20
+ def __init__(self, status_code: int, message: str) -> None:
21
+ self.status_code = status_code
22
+ super().__init__(f"API error {status_code}: {message}")
23
+
24
+
25
+ class MoneybirdClient:
26
+ """HTTP client for the Moneybird API."""
27
+
28
+ def __init__(
29
+ self,
30
+ token: str,
31
+ administration_id: str,
32
+ dry_run: bool = False,
33
+ verbose: bool = False,
34
+ timezone: str | None = None,
35
+ ) -> None:
36
+ self.token = token
37
+ self.administration_id = administration_id
38
+ self.dry_run = dry_run
39
+ self.verbose = verbose
40
+ self.base_url = f"{API_BASE}/{administration_id}"
41
+ self.session = requests.Session()
42
+ headers = {
43
+ "Authorization": f"Bearer {token}",
44
+ "Content-Type": "application/json",
45
+ "Accept": "application/json",
46
+ }
47
+ if timezone:
48
+ headers["Time-Zone"] = timezone
49
+ self.session.headers.update(headers)
50
+
51
+ def _url(self, path: str) -> str:
52
+ return f"{self.base_url}/{path}.json"
53
+
54
+ def _handle_response(self, response: requests.Response) -> tuple[Any, requests.Response]:
55
+ if response.status_code == 429:
56
+ retry_after = int(response.headers.get("Retry-After", "60"))
57
+ logger.warning("Rate limited. Retrying after %d seconds.", retry_after)
58
+ time.sleep(retry_after)
59
+ return self._handle_response(
60
+ self.session.send(response.request) # type: ignore[arg-type]
61
+ )
62
+
63
+ if response.status_code == 204:
64
+ return None, response
65
+
66
+ if response.status_code >= 400:
67
+ try:
68
+ body = response.json()
69
+ message = body.get("error", body.get("message", response.text))
70
+ except (ValueError, KeyError):
71
+ message = response.text
72
+ raise MoneybirdApiError(response.status_code, message)
73
+
74
+ if not response.content:
75
+ return None, response
76
+ return response.json(), response
77
+
78
+ def _request(
79
+ self,
80
+ method: str,
81
+ path: str,
82
+ params: dict | None = None,
83
+ json: dict | None = None,
84
+ ) -> Any:
85
+ url = self._url(path)
86
+ if self.verbose:
87
+ click.echo(f"{method.upper()} {url}", err=True)
88
+ if json:
89
+ click.echo(f" Body: {json}", err=True)
90
+
91
+ if self.dry_run and method.upper() != "GET":
92
+ click.echo(f"[dry-run] {method.upper()} {url}", err=True)
93
+ return None
94
+
95
+ response = self.session.request(method, url, params=params, json=json)
96
+ data, _ = self._handle_response(response)
97
+ return data
98
+
99
+ def _request_with_headers(
100
+ self,
101
+ method: str,
102
+ path: str,
103
+ params: dict | None = None,
104
+ ) -> tuple[Any, requests.Response]:
105
+ """Like _request, but also returns the response for header inspection."""
106
+ url = self._url(path)
107
+ if self.verbose:
108
+ click.echo(f"{method.upper()} {url}", err=True)
109
+
110
+ response = self.session.request(method, url, params=params)
111
+ return self._handle_response(response)
112
+
113
+ @staticmethod
114
+ def _respect_rate_limit(response: requests.Response, verbose: bool = False) -> None:
115
+ """Sleep if rate limit is nearly exhausted."""
116
+ remaining = response.headers.get("RateLimit-Remaining")
117
+ if remaining is not None and int(remaining) <= 1:
118
+ reset = int(response.headers.get("RateLimit-Reset", "60"))
119
+ if verbose:
120
+ click.echo(f"Rate limit nearly exhausted, waiting {reset}s.", err=True)
121
+ time.sleep(reset)
122
+
123
+ def get(self, path: str, params: dict | None = None) -> Any:
124
+ return self._request("GET", path, params=params)
125
+
126
+ @staticmethod
127
+ def list_administrations(token: str) -> list[dict]:
128
+ """List all administrations accessible with the given token."""
129
+ url = f"{API_BASE}/administrations.json"
130
+ response = requests.get(
131
+ url,
132
+ headers={
133
+ "Authorization": f"Bearer {token}",
134
+ "Accept": "application/json",
135
+ },
136
+ )
137
+ if response.status_code >= 400:
138
+ try:
139
+ body = response.json()
140
+ message = body.get("error", body.get("message", response.text))
141
+ except (ValueError, KeyError):
142
+ message = response.text
143
+ raise MoneybirdApiError(response.status_code, message)
144
+ return response.json()
145
+
146
+ def get_all(
147
+ self, path: str, params: dict | None = None, per_page: int = 100
148
+ ) -> list[dict]:
149
+ """GET with automatic pagination. Returns all results.
150
+
151
+ Respects rate limit headers: pauses when RateLimit-Remaining is low.
152
+ """
153
+ params = dict(params or {})
154
+ params["per_page"] = per_page
155
+ page = 1
156
+ results: list[dict] = []
157
+ while True:
158
+ params["page"] = page
159
+ data, response = self._request_with_headers("GET", path, params=params)
160
+ if not data:
161
+ break
162
+ results.extend(data)
163
+ if len(data) < per_page:
164
+ break
165
+ self._respect_rate_limit(response, verbose=self.verbose)
166
+ page += 1
167
+ return results
168
+
169
+ def post(self, path: str, json: dict | None = None) -> Any:
170
+ return self._request("POST", path, json=json)
171
+
172
+ def patch(self, path: str, json: dict | None = None) -> Any:
173
+ return self._request("PATCH", path, json=json)
174
+
175
+ def delete(self, path: str) -> Any:
176
+ return self._request("DELETE", path)
@@ -0,0 +1,87 @@
1
+ """Assets commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from moneybird_cli.main import pass_context, Context
8
+ from moneybird_cli.output import echo_output
9
+
10
+ LIST_COLUMNS = ["id", "name", "purchase_date", "purchase_value", "current_value"]
11
+
12
+
13
+ @click.group()
14
+ def assets() -> None:
15
+ """Manage assets (company cars, laptops, furniture, etc.)."""
16
+
17
+
18
+ @assets.command("list")
19
+ @click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages (respects rate limits).")
20
+ @click.option("--include-inactive", is_flag=True, help="Include inactive assets.")
21
+ @click.option("--ledger-account-id", help="Filter by ledger account ID.")
22
+ @click.option("--page", type=int, default=None, help="Page number (default: 1).")
23
+ @click.option("--per-page", type=int, default=None, help="Results per page (max 100).")
24
+ @pass_context
25
+ def list_assets(ctx: Context, fetch_all: bool, include_inactive: bool, ledger_account_id: str | None, page: int | None, per_page: int | None) -> None:
26
+ """List assets. Returns the first page by default; use --all to fetch everything."""
27
+ client = ctx.get_client()
28
+ params: dict = {}
29
+ if include_inactive:
30
+ params["active"] = "false"
31
+ if ledger_account_id:
32
+ params["ledger_account_id"] = ledger_account_id
33
+ if fetch_all:
34
+ data = client.get_all("assets", params=params, per_page=per_page or 100)
35
+ else:
36
+ params["page"] = page or 1
37
+ if per_page:
38
+ params["per_page"] = per_page
39
+ data = client.get("assets", params=params)
40
+ echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
41
+
42
+
43
+ @assets.command("get")
44
+ @click.argument("id")
45
+ @pass_context
46
+ def get_asset(ctx: Context, id: str) -> None:
47
+ """Get a single asset by ID."""
48
+ client = ctx.get_client()
49
+ data = client.get(f"assets/{id}")
50
+ echo_output(data, fmt=ctx.output_format)
51
+
52
+
53
+ @assets.command("create")
54
+ @click.option("--name", required=True, help="Asset name.")
55
+ @click.option("--purchase-date", help="Purchase date (YYYY-MM-DD).")
56
+ @click.option("--purchase-value", help="Purchase value.")
57
+ @pass_context
58
+ def create_asset(ctx: Context, **kwargs: str | None) -> None:
59
+ """Create a new asset."""
60
+ client = ctx.get_client()
61
+ asset = {k: v for k, v in kwargs.items() if v is not None}
62
+ data = client.post("assets", json={"asset": asset})
63
+ echo_output(data, fmt=ctx.output_format)
64
+
65
+
66
+ @assets.command("update")
67
+ @click.argument("id")
68
+ @click.option("--name", help="Asset name.")
69
+ @pass_context
70
+ def update_asset(ctx: Context, id: str, **kwargs: str | None) -> None:
71
+ """Update an asset by ID."""
72
+ client = ctx.get_client()
73
+ asset = {k: v for k, v in kwargs.items() if v is not None}
74
+ data = client.patch(f"assets/{id}", json={"asset": asset})
75
+ echo_output(data, fmt=ctx.output_format)
76
+
77
+
78
+ @assets.command("delete")
79
+ @click.argument("id")
80
+ @click.confirmation_option(prompt="Are you sure you want to delete this asset?")
81
+ @pass_context
82
+ def delete_asset(ctx: Context, id: str) -> None:
83
+ """Delete an asset by ID."""
84
+ client = ctx.get_client()
85
+ client.delete(f"assets/{id}")
86
+ if not ctx.quiet:
87
+ click.echo(f"Asset {id} deleted.")
@@ -0,0 +1,159 @@
1
+ """Contacts commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from moneybird_cli.main import pass_context, Context
8
+ from moneybird_cli.output import echo_output
9
+
10
+ LIST_COLUMNS = ["id", "company_name", "firstname", "lastname", "email"]
11
+
12
+
13
+ @click.group()
14
+ def contacts() -> None:
15
+ """Manage contacts."""
16
+
17
+
18
+ @contacts.command("list")
19
+ @click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages (respects rate limits).")
20
+ @click.option("--page", type=int, default=None, help="Page number (default: 1).")
21
+ @click.option("--per-page", type=int, default=None, help="Results per page (max 100).")
22
+ @click.option("--include-archived", is_flag=True, help="Include archived contacts.")
23
+ @pass_context
24
+ def list_contacts(ctx: Context, fetch_all: bool, page: int | None, per_page: int | None, include_archived: bool) -> None:
25
+ """List contacts. Returns the first page by default; use --all to fetch everything."""
26
+ client = ctx.get_client()
27
+ params: dict = {}
28
+ if include_archived:
29
+ params["include_archived"] = "true"
30
+ if fetch_all:
31
+ data = client.get_all("contacts", params=params, per_page=per_page or 100)
32
+ else:
33
+ params["page"] = page or 1
34
+ if per_page:
35
+ params["per_page"] = per_page
36
+ data = client.get("contacts", params=params)
37
+ echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
38
+
39
+
40
+ @contacts.command("get")
41
+ @click.argument("id")
42
+ @pass_context
43
+ def get_contact(ctx: Context, id: str) -> None:
44
+ """Get a single contact by ID."""
45
+ client = ctx.get_client()
46
+ data = client.get(f"contacts/{id}")
47
+ echo_output(data, fmt=ctx.output_format)
48
+
49
+
50
+ @contacts.command("search")
51
+ @click.argument("query")
52
+ @pass_context
53
+ def search_contacts(ctx: Context, query: str) -> None:
54
+ """Search contacts by name or email."""
55
+ client = ctx.get_client()
56
+ data = client.get_all("contacts", params={"query": query})
57
+ echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
58
+
59
+
60
+ @contacts.command("filter")
61
+ @click.option("--created-after", help="Filter by creation date (ISO 8601).")
62
+ @click.option("--updated-after", help="Filter by update date (ISO 8601).")
63
+ @click.option("--first-name", help="Filter by first name.")
64
+ @click.option("--last-name", help="Filter by last name.")
65
+ @pass_context
66
+ def filter_contacts(
67
+ ctx: Context,
68
+ created_after: str | None,
69
+ updated_after: str | None,
70
+ first_name: str | None,
71
+ last_name: str | None,
72
+ ) -> None:
73
+ """Filter contacts by criteria."""
74
+ client = ctx.get_client()
75
+ params: dict = {}
76
+ if created_after:
77
+ params["filter"] = f"created_after:{created_after}"
78
+ if updated_after:
79
+ params["filter"] = f"updated_after:{updated_after}"
80
+ if first_name:
81
+ params["filter"] = f"first_name:{first_name}"
82
+ if last_name:
83
+ params["filter"] = f"last_name:{last_name}"
84
+ data = client.get_all("contacts/filter", params=params)
85
+ echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
86
+
87
+
88
+ @contacts.command("create")
89
+ @click.option("--company-name", help="Company name.")
90
+ @click.option("--firstname", help="First name.")
91
+ @click.option("--lastname", help="Last name.")
92
+ @click.option("--email", help="Email address.")
93
+ @click.option("--phone", help="Phone number.")
94
+ @click.option("--address1", help="Address line 1.")
95
+ @click.option("--address2", help="Address line 2.")
96
+ @click.option("--zipcode", help="Zip code.")
97
+ @click.option("--city", help="City.")
98
+ @click.option("--country", help="Country code (e.g., NL).")
99
+ @click.option("--delivery-method", type=click.Choice(["Email", "Simplerinvoicing", "Post", "Manual"]), help="Invoice delivery method.")
100
+ @pass_context
101
+ def create_contact(ctx: Context, **kwargs: str | None) -> None:
102
+ """Create a new contact."""
103
+ client = ctx.get_client()
104
+ contact = {k.replace("_", ""): v for k, v in kwargs.items() if v is not None}
105
+ # Map back to API field names
106
+ field_map = {
107
+ "companyname": "company_name",
108
+ "address1": "address1",
109
+ "address2": "address2",
110
+ "deliverymethod": "delivery_method",
111
+ }
112
+ api_contact = {}
113
+ for k, v in contact.items():
114
+ api_contact[field_map.get(k, k)] = v
115
+ data = client.post("contacts", json={"contact": api_contact})
116
+ echo_output(data, fmt=ctx.output_format)
117
+
118
+
119
+ @contacts.command("update")
120
+ @click.argument("id")
121
+ @click.option("--company-name", help="Company name.")
122
+ @click.option("--firstname", help="First name.")
123
+ @click.option("--lastname", help="Last name.")
124
+ @click.option("--email", help="Email address.")
125
+ @click.option("--phone", help="Phone number.")
126
+ @pass_context
127
+ def update_contact(ctx: Context, id: str, **kwargs: str | None) -> None:
128
+ """Update a contact by ID."""
129
+ client = ctx.get_client()
130
+ contact = {k: v for k, v in kwargs.items() if v is not None}
131
+ field_map = {"company_name": "company_name"}
132
+ api_contact = {}
133
+ for k, v in contact.items():
134
+ api_contact[field_map.get(k, k)] = v
135
+ data = client.patch(f"contacts/{id}", json={"contact": api_contact})
136
+ echo_output(data, fmt=ctx.output_format)
137
+
138
+
139
+ @contacts.command("delete")
140
+ @click.argument("id")
141
+ @click.confirmation_option(prompt="Are you sure you want to delete this contact?")
142
+ @pass_context
143
+ def delete_contact(ctx: Context, id: str) -> None:
144
+ """Delete a contact by ID."""
145
+ client = ctx.get_client()
146
+ client.delete(f"contacts/{id}")
147
+ if not ctx.quiet:
148
+ click.echo(f"Contact {id} deleted.")
149
+
150
+
151
+ @contacts.command("archive")
152
+ @click.argument("id")
153
+ @pass_context
154
+ def archive_contact(ctx: Context, id: str) -> None:
155
+ """Archive a contact by ID."""
156
+ client = ctx.get_client()
157
+ client.patch(f"contacts/{id}/archive")
158
+ if not ctx.quiet:
159
+ click.echo(f"Contact {id} archived.")