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.
- moneybird_cli-0.1.0/.gitignore +5 -0
- moneybird_cli-0.1.0/LICENSE +21 -0
- moneybird_cli-0.1.0/PKG-INFO +71 -0
- moneybird_cli-0.1.0/README.md +41 -0
- moneybird_cli-0.1.0/pyproject.toml +48 -0
- moneybird_cli-0.1.0/setup.cfg +4 -0
- moneybird_cli-0.1.0/src/moneybird_cli/__init__.py +1 -0
- moneybird_cli-0.1.0/src/moneybird_cli/client.py +176 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/__init__.py +0 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/assets.py +87 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/contacts.py +159 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/documents.py +226 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/estimates.py +127 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/external_sales_invoices.py +99 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_accounts.py +24 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_mutations.py +90 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/financial_statements.py +53 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/ledger_accounts.py +82 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/login.py +69 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/payments.py +23 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/products.py +107 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/projects.py +86 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/purchase_transactions.py +65 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/recurring_sales_invoices.py +91 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/reports.py +51 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/sales_invoices.py +201 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/subscriptions.py +88 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/tax_rates.py +34 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/time_entries.py +106 -0
- moneybird_cli-0.1.0/src/moneybird_cli/commands/webhooks.py +69 -0
- moneybird_cli-0.1.0/src/moneybird_cli/config.py +121 -0
- moneybird_cli-0.1.0/src/moneybird_cli/main.py +200 -0
- moneybird_cli-0.1.0/src/moneybird_cli/output.py +54 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/PKG-INFO +71 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/SOURCES.txt +46 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/dependency_links.txt +1 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/entry_points.txt +2 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/requires.txt +7 -0
- moneybird_cli-0.1.0/src/moneybird_cli.egg-info/top_level.txt +1 -0
- moneybird_cli-0.1.0/tests/__init__.py +0 -0
- moneybird_cli-0.1.0/tests/conftest.py +43 -0
- moneybird_cli-0.1.0/tests/test_client.py +187 -0
- moneybird_cli-0.1.0/tests/test_commands/__init__.py +0 -0
- moneybird_cli-0.1.0/tests/test_commands/test_contacts.py +105 -0
- moneybird_cli-0.1.0/tests/test_commands/test_login.py +118 -0
- moneybird_cli-0.1.0/tests/test_commands/test_sales_invoices.py +99 -0
- moneybird_cli-0.1.0/tests/test_config.py +54 -0
- moneybird_cli-0.1.0/tests/test_output.py +46 -0
|
@@ -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 @@
|
|
|
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)
|
|
File without changes
|
|
@@ -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.")
|