moneybird-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moneybird_cli/__init__.py +1 -0
- moneybird_cli/client.py +176 -0
- moneybird_cli/commands/__init__.py +0 -0
- moneybird_cli/commands/assets.py +87 -0
- moneybird_cli/commands/contacts.py +159 -0
- moneybird_cli/commands/documents.py +226 -0
- moneybird_cli/commands/estimates.py +127 -0
- moneybird_cli/commands/external_sales_invoices.py +99 -0
- moneybird_cli/commands/financial_accounts.py +24 -0
- moneybird_cli/commands/financial_mutations.py +90 -0
- moneybird_cli/commands/financial_statements.py +53 -0
- moneybird_cli/commands/ledger_accounts.py +82 -0
- moneybird_cli/commands/login.py +69 -0
- moneybird_cli/commands/payments.py +23 -0
- moneybird_cli/commands/products.py +107 -0
- moneybird_cli/commands/projects.py +86 -0
- moneybird_cli/commands/purchase_transactions.py +65 -0
- moneybird_cli/commands/recurring_sales_invoices.py +91 -0
- moneybird_cli/commands/reports.py +51 -0
- moneybird_cli/commands/sales_invoices.py +201 -0
- moneybird_cli/commands/subscriptions.py +88 -0
- moneybird_cli/commands/tax_rates.py +34 -0
- moneybird_cli/commands/time_entries.py +106 -0
- moneybird_cli/commands/webhooks.py +69 -0
- moneybird_cli/config.py +121 -0
- moneybird_cli/main.py +200 -0
- moneybird_cli/output.py +54 -0
- moneybird_cli-0.1.0.dist-info/METADATA +71 -0
- moneybird_cli-0.1.0.dist-info/RECORD +33 -0
- moneybird_cli-0.1.0.dist-info/WHEEL +5 -0
- moneybird_cli-0.1.0.dist-info/entry_points.txt +2 -0
- moneybird_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- moneybird_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Moneybird CLI - Command-line interface for the Moneybird API."""
|
moneybird_cli/client.py
ADDED
|
@@ -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.")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Document commands (general, journal, purchase invoices, receipts, typeless)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from moneybird_cli.main import pass_context, Context
|
|
10
|
+
from moneybird_cli.output import echo_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _list_cmd(api_path: str, list_columns: list[str]) -> Callable:
|
|
14
|
+
@click.command("list")
|
|
15
|
+
@click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages (respects rate limits).")
|
|
16
|
+
@click.option("--period", help="Filter by period (e.g., this_month, prev_year, 202401..202412).")
|
|
17
|
+
@click.option("--page", type=int, default=None, help="Page number (default: 1).")
|
|
18
|
+
@click.option("--per-page", type=int, default=None, help="Results per page (max 100).")
|
|
19
|
+
@pass_context
|
|
20
|
+
def cmd(ctx: Context, fetch_all: bool, period: str | None, page: int | None, per_page: int | None) -> None:
|
|
21
|
+
"""List documents. Returns the first page by default; use --all to fetch everything."""
|
|
22
|
+
client = ctx.get_client()
|
|
23
|
+
params: dict = {}
|
|
24
|
+
if period:
|
|
25
|
+
params["filter"] = f"period:{period}"
|
|
26
|
+
if fetch_all:
|
|
27
|
+
data = client.get_all(api_path, params=params, per_page=per_page or 100)
|
|
28
|
+
else:
|
|
29
|
+
params["page"] = page or 1
|
|
30
|
+
if per_page:
|
|
31
|
+
params["per_page"] = per_page
|
|
32
|
+
data = client.get(api_path, params=params)
|
|
33
|
+
echo_output(data, fmt=ctx.output_format, columns=list_columns)
|
|
34
|
+
return cmd
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_cmd(api_path: str) -> Callable:
|
|
38
|
+
@click.command("get")
|
|
39
|
+
@click.argument("id")
|
|
40
|
+
@pass_context
|
|
41
|
+
def cmd(ctx: Context, id: str) -> None:
|
|
42
|
+
"""Get a single document by ID."""
|
|
43
|
+
client = ctx.get_client()
|
|
44
|
+
data = client.get(f"{api_path}/{id}")
|
|
45
|
+
echo_output(data, fmt=ctx.output_format)
|
|
46
|
+
return cmd
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _delete_cmd(api_path: str) -> Callable:
|
|
50
|
+
@click.command("delete")
|
|
51
|
+
@click.argument("id")
|
|
52
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this document?")
|
|
53
|
+
@pass_context
|
|
54
|
+
def cmd(ctx: Context, id: str) -> None:
|
|
55
|
+
"""Delete a document by ID."""
|
|
56
|
+
client = ctx.get_client()
|
|
57
|
+
client.delete(f"{api_path}/{id}")
|
|
58
|
+
if not ctx.quiet:
|
|
59
|
+
click.echo(f"Document {id} deleted.")
|
|
60
|
+
return cmd
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- General Documents ---
|
|
64
|
+
|
|
65
|
+
@click.group(name="general-documents")
|
|
66
|
+
def general_documents() -> None:
|
|
67
|
+
"""Manage general documents."""
|
|
68
|
+
|
|
69
|
+
general_documents.add_command(_list_cmd("documents/general_documents", ["id", "contact_id", "reference", "date", "state"]))
|
|
70
|
+
general_documents.add_command(_get_cmd("documents/general_documents"))
|
|
71
|
+
general_documents.add_command(_delete_cmd("documents/general_documents"))
|
|
72
|
+
|
|
73
|
+
@general_documents.command("create")
|
|
74
|
+
@click.option("--contact-id", required=True, help="Contact ID.")
|
|
75
|
+
@click.option("--reference", help="Document reference.")
|
|
76
|
+
@click.option("--date", help="Document date (YYYY-MM-DD).")
|
|
77
|
+
@click.option("--due-date", help="Due date (YYYY-MM-DD).")
|
|
78
|
+
@pass_context
|
|
79
|
+
def create_general_document(ctx: Context, **kwargs: str | None) -> None:
|
|
80
|
+
"""Create a new general document."""
|
|
81
|
+
client = ctx.get_client()
|
|
82
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
83
|
+
data = client.post("documents/general_documents", json={"general_document": doc})
|
|
84
|
+
echo_output(data, fmt=ctx.output_format)
|
|
85
|
+
|
|
86
|
+
@general_documents.command("update")
|
|
87
|
+
@click.argument("id")
|
|
88
|
+
@click.option("--reference", help="Document reference.")
|
|
89
|
+
@click.option("--date", help="Document date (YYYY-MM-DD).")
|
|
90
|
+
@click.option("--due-date", help="Due date (YYYY-MM-DD).")
|
|
91
|
+
@pass_context
|
|
92
|
+
def update_general_document(ctx: Context, id: str, **kwargs: str | None) -> None:
|
|
93
|
+
"""Update a general document by ID."""
|
|
94
|
+
client = ctx.get_client()
|
|
95
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
96
|
+
data = client.patch(f"documents/general_documents/{id}", json={"general_document": doc})
|
|
97
|
+
echo_output(data, fmt=ctx.output_format)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- General Journal Documents ---
|
|
101
|
+
|
|
102
|
+
@click.group(name="journal-documents")
|
|
103
|
+
def general_journal_documents() -> None:
|
|
104
|
+
"""Manage general journal documents."""
|
|
105
|
+
|
|
106
|
+
general_journal_documents.add_command(_list_cmd("documents/general_journal_documents", ["id", "reference", "date", "journal_type"]))
|
|
107
|
+
general_journal_documents.add_command(_get_cmd("documents/general_journal_documents"))
|
|
108
|
+
general_journal_documents.add_command(_delete_cmd("documents/general_journal_documents"))
|
|
109
|
+
|
|
110
|
+
@general_journal_documents.command("create")
|
|
111
|
+
@click.option("--reference", help="Document reference.")
|
|
112
|
+
@click.option("--date", help="Document date (YYYY-MM-DD).")
|
|
113
|
+
@pass_context
|
|
114
|
+
def create_journal_document(ctx: Context, **kwargs: str | None) -> None:
|
|
115
|
+
"""Create a new general journal document."""
|
|
116
|
+
client = ctx.get_client()
|
|
117
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
118
|
+
data = client.post("documents/general_journal_documents", json={"general_journal_document": doc})
|
|
119
|
+
echo_output(data, fmt=ctx.output_format)
|
|
120
|
+
|
|
121
|
+
@general_journal_documents.command("update")
|
|
122
|
+
@click.argument("id")
|
|
123
|
+
@click.option("--reference", help="Document reference.")
|
|
124
|
+
@click.option("--date", help="Document date (YYYY-MM-DD).")
|
|
125
|
+
@pass_context
|
|
126
|
+
def update_journal_document(ctx: Context, id: str, **kwargs: str | None) -> None:
|
|
127
|
+
"""Update a general journal document by ID."""
|
|
128
|
+
client = ctx.get_client()
|
|
129
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
130
|
+
data = client.patch(f"documents/general_journal_documents/{id}", json={"general_journal_document": doc})
|
|
131
|
+
echo_output(data, fmt=ctx.output_format)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- Purchase Invoices ---
|
|
135
|
+
|
|
136
|
+
@click.group(name="purchase-invoices")
|
|
137
|
+
def purchase_invoices() -> None:
|
|
138
|
+
"""Manage purchase invoices."""
|
|
139
|
+
|
|
140
|
+
purchase_invoices.add_command(_list_cmd("documents/purchase_invoices", ["id", "contact_id", "reference", "date", "state", "total_price_incl_tax"]))
|
|
141
|
+
purchase_invoices.add_command(_get_cmd("documents/purchase_invoices"))
|
|
142
|
+
purchase_invoices.add_command(_delete_cmd("documents/purchase_invoices"))
|
|
143
|
+
|
|
144
|
+
@purchase_invoices.command("create")
|
|
145
|
+
@click.option("--contact-id", required=True, help="Contact ID.")
|
|
146
|
+
@click.option("--reference", help="Invoice reference.")
|
|
147
|
+
@click.option("--date", help="Invoice date (YYYY-MM-DD).")
|
|
148
|
+
@click.option("--due-date", help="Due date (YYYY-MM-DD).")
|
|
149
|
+
@click.option("--currency", help="Currency code (default: EUR).")
|
|
150
|
+
@pass_context
|
|
151
|
+
def create_purchase_invoice(ctx: Context, **kwargs: str | None) -> None:
|
|
152
|
+
"""Create a new purchase invoice."""
|
|
153
|
+
client = ctx.get_client()
|
|
154
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
155
|
+
data = client.post("documents/purchase_invoices", json={"purchase_invoice": doc})
|
|
156
|
+
echo_output(data, fmt=ctx.output_format)
|
|
157
|
+
|
|
158
|
+
@purchase_invoices.command("update")
|
|
159
|
+
@click.argument("id")
|
|
160
|
+
@click.option("--reference", help="Invoice reference.")
|
|
161
|
+
@click.option("--date", help="Invoice date (YYYY-MM-DD).")
|
|
162
|
+
@click.option("--due-date", help="Due date (YYYY-MM-DD).")
|
|
163
|
+
@pass_context
|
|
164
|
+
def update_purchase_invoice(ctx: Context, id: str, **kwargs: str | None) -> None:
|
|
165
|
+
"""Update a purchase invoice by ID."""
|
|
166
|
+
client = ctx.get_client()
|
|
167
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
168
|
+
data = client.patch(f"documents/purchase_invoices/{id}", json={"purchase_invoice": doc})
|
|
169
|
+
echo_output(data, fmt=ctx.output_format)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- Receipts ---
|
|
173
|
+
|
|
174
|
+
@click.group(name="receipts")
|
|
175
|
+
def receipts() -> None:
|
|
176
|
+
"""Manage receipts."""
|
|
177
|
+
|
|
178
|
+
receipts.add_command(_list_cmd("documents/receipts", ["id", "contact_id", "reference", "date", "state", "total_price_incl_tax"]))
|
|
179
|
+
receipts.add_command(_get_cmd("documents/receipts"))
|
|
180
|
+
receipts.add_command(_delete_cmd("documents/receipts"))
|
|
181
|
+
|
|
182
|
+
@receipts.command("create")
|
|
183
|
+
@click.option("--contact-id", required=True, help="Contact ID.")
|
|
184
|
+
@click.option("--reference", help="Receipt reference.")
|
|
185
|
+
@click.option("--date", help="Receipt date (YYYY-MM-DD).")
|
|
186
|
+
@pass_context
|
|
187
|
+
def create_receipt(ctx: Context, **kwargs: str | None) -> None:
|
|
188
|
+
"""Create a new receipt."""
|
|
189
|
+
client = ctx.get_client()
|
|
190
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
191
|
+
data = client.post("documents/receipts", json={"receipt": doc})
|
|
192
|
+
echo_output(data, fmt=ctx.output_format)
|
|
193
|
+
|
|
194
|
+
@receipts.command("update")
|
|
195
|
+
@click.argument("id")
|
|
196
|
+
@click.option("--reference", help="Receipt reference.")
|
|
197
|
+
@click.option("--date", help="Receipt date (YYYY-MM-DD).")
|
|
198
|
+
@pass_context
|
|
199
|
+
def update_receipt(ctx: Context, id: str, **kwargs: str | None) -> None:
|
|
200
|
+
"""Update a receipt by ID."""
|
|
201
|
+
client = ctx.get_client()
|
|
202
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
203
|
+
data = client.patch(f"documents/receipts/{id}", json={"receipt": doc})
|
|
204
|
+
echo_output(data, fmt=ctx.output_format)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- Typeless Documents ---
|
|
208
|
+
|
|
209
|
+
@click.group(name="typeless-documents")
|
|
210
|
+
def typeless_documents() -> None:
|
|
211
|
+
"""Manage typeless documents (unclassified uploads)."""
|
|
212
|
+
|
|
213
|
+
typeless_documents.add_command(_list_cmd("documents/typeless_documents", ["id", "contact_id", "reference", "date", "state"]))
|
|
214
|
+
typeless_documents.add_command(_get_cmd("documents/typeless_documents"))
|
|
215
|
+
typeless_documents.add_command(_delete_cmd("documents/typeless_documents"))
|
|
216
|
+
|
|
217
|
+
@typeless_documents.command("create")
|
|
218
|
+
@click.option("--contact-id", help="Contact ID.")
|
|
219
|
+
@click.option("--reference", help="Document reference.")
|
|
220
|
+
@pass_context
|
|
221
|
+
def create_typeless_document(ctx: Context, **kwargs: str | None) -> None:
|
|
222
|
+
"""Create a new typeless document."""
|
|
223
|
+
client = ctx.get_client()
|
|
224
|
+
doc = {k: v for k, v in kwargs.items() if v is not None}
|
|
225
|
+
data = client.post("documents/typeless_documents", json={"typeless_document": doc})
|
|
226
|
+
echo_output(data, fmt=ctx.output_format)
|