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.
Files changed (33) hide show
  1. moneybird_cli/__init__.py +1 -0
  2. moneybird_cli/client.py +176 -0
  3. moneybird_cli/commands/__init__.py +0 -0
  4. moneybird_cli/commands/assets.py +87 -0
  5. moneybird_cli/commands/contacts.py +159 -0
  6. moneybird_cli/commands/documents.py +226 -0
  7. moneybird_cli/commands/estimates.py +127 -0
  8. moneybird_cli/commands/external_sales_invoices.py +99 -0
  9. moneybird_cli/commands/financial_accounts.py +24 -0
  10. moneybird_cli/commands/financial_mutations.py +90 -0
  11. moneybird_cli/commands/financial_statements.py +53 -0
  12. moneybird_cli/commands/ledger_accounts.py +82 -0
  13. moneybird_cli/commands/login.py +69 -0
  14. moneybird_cli/commands/payments.py +23 -0
  15. moneybird_cli/commands/products.py +107 -0
  16. moneybird_cli/commands/projects.py +86 -0
  17. moneybird_cli/commands/purchase_transactions.py +65 -0
  18. moneybird_cli/commands/recurring_sales_invoices.py +91 -0
  19. moneybird_cli/commands/reports.py +51 -0
  20. moneybird_cli/commands/sales_invoices.py +201 -0
  21. moneybird_cli/commands/subscriptions.py +88 -0
  22. moneybird_cli/commands/tax_rates.py +34 -0
  23. moneybird_cli/commands/time_entries.py +106 -0
  24. moneybird_cli/commands/webhooks.py +69 -0
  25. moneybird_cli/config.py +121 -0
  26. moneybird_cli/main.py +200 -0
  27. moneybird_cli/output.py +54 -0
  28. moneybird_cli-0.1.0.dist-info/METADATA +71 -0
  29. moneybird_cli-0.1.0.dist-info/RECORD +33 -0
  30. moneybird_cli-0.1.0.dist-info/WHEEL +5 -0
  31. moneybird_cli-0.1.0.dist-info/entry_points.txt +2 -0
  32. moneybird_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  33. 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."""
@@ -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)