akt-cli 0.1.0__tar.gz → 0.3.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.
@@ -1,12 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akt-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: akt — a CLI toolbox to fully drive an Akaunting accounting instance
5
5
  Keywords: akaunting,accounting,cli,invoices,bills,api
6
6
  Author: AsyncAlchemist
7
7
  License-Expression: MIT
8
8
  License-File: LICENSE
9
9
  Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
10
13
  Classifier: Environment :: Console
11
14
  Classifier: Topic :: Office/Business :: Financial :: Accounting
12
15
  Requires-Dist: requests>=2.31
@@ -16,20 +19,25 @@ Project-URL: Repository, https://github.com/AsyncAlchemist/akt-cli
16
19
  Project-URL: Issues, https://github.com/AsyncAlchemist/akt-cli/issues
17
20
  Description-Content-Type: text/markdown
18
21
 
22
+ <div align="center">
23
+
19
24
  # akt — Akaunting CLI toolbox
25
+ ### Drive your Akaunting accounting instance entirely from the command line
26
+
27
+ >`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus double-entry journal entries and the chart of accounts, and a `raw` escape hatch for any other endpoint. Built and tested against [Akaunting](https://akaunting.com) **3.1.x**; works with any 3.x deployment that exposes the REST API.
20
28
 
21
- [![PyPI](https://img.shields.io/pypi/v/akt-cli.svg)](https://pypi.org/project/akt-cli/)
22
- [![CI](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
23
- [![codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/gh/AsyncAlchemist/akt-cli)
24
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+ [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
30
+ [![Tests](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/ci.yml?branch=main&label=tests&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
31
+ [![Integration](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/release.yml?label=integration&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
32
+ [![Publish](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/publish.yml?label=publish&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
33
+ [![Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/github/AsyncAlchemist/akt-cli)
25
34
 
26
- `akt` drives an [Akaunting](https://akaunting.com) instance entirely from the
27
- command line: full create / read / update / delete for customers, vendors,
28
- items, invoices, bills, payments, accounts, categories, taxes, currencies and
29
- transfers — plus a `raw` escape hatch for any other API endpoint.
35
+ [![GitHub Release](https://img.shields.io/github/v/release/AsyncAlchemist/akt-cli?style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/releases)
36
+ [![Downloads](https://img.shields.io/pypi/dm/akt-cli.svg?style=flat-square&label=downloads)](https://pypi.org/project/akt-cli/)
37
+ [![Python Version](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat-square)](LICENSE)
30
39
 
31
- Built and tested against Akaunting **3.1.x**; it should work with any 3.x
32
- deployment that exposes the REST API.
40
+ </div>
33
41
 
34
42
  ## Install
35
43
 
@@ -93,8 +101,18 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
93
101
  | `invoice` | `documents` | document of type `invoice` |
94
102
  | `bill` | `documents` | document of type `bill` |
95
103
  | `payment` | `transactions` | income (invoice) or expense (bill) transaction |
104
+ | `journal-entry` | `journal-entry` | double-entry general-ledger entry (module) |
105
+ | `chart-of-account` | `chart-of-accounts` | GL accounts — read via API, CRUD via web |
96
106
  | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
97
107
 
108
+ > `journal-entry` and `chart-of-account` require the **Double-Entry** module
109
+ > installed on the instance. The module publishes chart-of-accounts read-only on
110
+ > the `/api` surface (index/show); its create/update/delete live only on the
111
+ > session/CSRF **web** route. `akt chart-of-account` gives you the full verb set
112
+ > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
113
+ > drive the web CRUD with your admin session (the same mechanism
114
+ > `download-attachment` already uses).
115
+
98
116
  > The `contacts` and `documents` endpoints derive their permission from a
99
117
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
100
118
  > raw without it returns `403 necessary access rights`.
@@ -113,6 +131,17 @@ akt <noun> enable <id> # where applicable
113
131
  akt <noun> disable <id>
114
132
  ```
115
133
 
134
+ Bills, invoices and payments additionally support **attachments** (scanned bills,
135
+ receipts, PDFs):
136
+
137
+ ```
138
+ akt <noun> create ... --attachment ./file.pdf # repeatable; upload on create
139
+ akt <noun> update <id> --attachment ./file.pdf # attach to an existing record
140
+ akt <noun> update <id> --remove-attachment # clear existing attachment(s)
141
+ akt <noun> attachments <id> # list attached files (id, name, size)
142
+ akt <noun> download-attachment <id> [--out DIR] [--media-id ID]
143
+ ```
144
+
116
145
  Output is a table by default; add `--json` (works before or after the verb) for
117
146
  raw JSON suitable for piping into `jq`.
118
147
 
@@ -154,6 +183,37 @@ akt payment create --invoice 34 --amount 750 \
154
183
  akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5'
155
184
  akt payment create --bill 41
156
185
 
186
+ # Attachments: upload the source PDF/scan and fetch it back later
187
+ akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5' \
188
+ --attachment ./supplier-bill.pdf
189
+ akt payment update 57 --attachment ./receipt.pdf # attach to an existing payment
190
+ akt bill attachments 41 # list attached files
191
+ akt bill download-attachment 41 --out ./downloads # save to disk
192
+ akt payment update 57 --remove-attachment # clear attachments
193
+
194
+ # Double-entry general ledger (requires the Double-Entry module)
195
+ akt chart-of-account list # read the chart of accounts
196
+ akt chart-of-account get 12
197
+
198
+ # Build the chart of accounts as code (create/update/delete run via the web
199
+ # session; type-id is the double-entry account-type id — copy it from an
200
+ # existing account's `type_id`)
201
+ akt chart-of-account create --name "Cash on Hand" --code 1010 --type-id 6
202
+ akt chart-of-account create --name "Petty Cash" --code 1011 --type-id 6 --account-id 12
203
+ akt chart-of-account update 12 --code 1000 --description "Operating cash"
204
+ akt chart-of-account delete 12
205
+
206
+ # Post a balanced journal entry (>= 2 lines; debits must equal credits;
207
+ # journal number auto-generated, basis defaults to accrual)
208
+ akt journal-entry create --description "Owner capital contribution" \
209
+ --item 'account_id=10,debit=5000' \
210
+ --item 'account_id=30,credit=5000'
211
+ akt journal-entry list
212
+ akt journal-entry update 4 --description "Corrected memo"
213
+ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
214
+ --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
215
+ --attachment ./invoice.pdf
216
+
157
217
  # Anything else: raw API access
158
218
  akt raw GET reports
159
219
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -177,8 +237,35 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
177
237
  so they aren't lost.
178
238
  * **Nested payment route** — paying a document must POST to
179
239
  `documents/{id}/transactions`; the flat `transactions` endpoint rejects it.
240
+ The same applies to *updating* a document-linked payment (e.g. attaching a
241
+ file to it) — `akt` picks the nested route automatically.
242
+ * **Multipart uploads** — attachments switch the request from JSON to
243
+ `multipart/form-data` with the `attachment[]` field; updates are sent as
244
+ `POST` + `_method=PATCH` because PHP won't populate `$_FILES` on a real `PUT`.
245
+ * **Attachment download isn't on `/api`** — Akaunting only serves attachment
246
+ bytes from the session-authenticated web route `/{company}/uploads/{id}/download`.
247
+ `akt download-attachment` transparently logs in a web session with your admin
248
+ credentials (reused for the process) to fetch the file; metadata (id, name,
249
+ size) comes from the `/api` record itself.
180
250
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
181
251
  `akt` merges your changes onto the current record.
252
+ * **Journal entries must balance** — a `journal-entry` needs >= 2 lines whose
253
+ debits equal its credits; `akt` validates this client-side (clear error)
254
+ before hitting the API. Each line carries both a `debit` and a `credit` key
255
+ (the unused side sent as `0`) because Akaunting validates both as required.
256
+ * **Journal updates re-derive ledgers** — like documents, a journal update
257
+ deletes any ledger line absent from the request. `akt` resends the existing
258
+ lines (with their ledger ids) so an update that only changes a field doesn't
259
+ wipe the entry, and auto-generates the `journal_number` from the module's
260
+ `double-entry.journal.number_*` settings when you don't pass one.
261
+ * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
262
+ accounts read-only on `/api`; create/update/delete exist solely on the
263
+ session/CSRF web route. `akt chart-of-account create|update|delete` logs in a
264
+ web session (reusing your admin credentials, cached for the process), attaches
265
+ the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
266
+ envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
267
+ surfaces as a normal error. Updates resend `name` (required by Akaunting on
268
+ update) from the current record when you don't pass one.
182
269
 
183
270
  ### Invoice creation may be gated by a plan check
184
271
 
@@ -1,17 +1,22 @@
1
+ <div align="center">
2
+
1
3
  # akt — Akaunting CLI toolbox
4
+ ### Drive your Akaunting accounting instance entirely from the command line
5
+
6
+ >`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus double-entry journal entries and the chart of accounts, and a `raw` escape hatch for any other endpoint. Built and tested against [Akaunting](https://akaunting.com) **3.1.x**; works with any 3.x deployment that exposes the REST API.
2
7
 
3
- [![PyPI](https://img.shields.io/pypi/v/akt-cli.svg)](https://pypi.org/project/akt-cli/)
4
- [![CI](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
5
- [![codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/gh/AsyncAlchemist/akt-cli)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
9
+ [![Tests](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/ci.yml?branch=main&label=tests&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/ci.yml)
10
+ [![Integration](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/release.yml?label=integration&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/release.yml)
11
+ [![Publish](https://img.shields.io/github/actions/workflow/status/AsyncAlchemist/akt-cli/publish.yml?label=publish&style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/actions/workflows/publish.yml)
12
+ [![Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli/graph/badge.svg)](https://codecov.io/github/AsyncAlchemist/akt-cli)
7
13
 
8
- `akt` drives an [Akaunting](https://akaunting.com) instance entirely from the
9
- command line: full create / read / update / delete for customers, vendors,
10
- items, invoices, bills, payments, accounts, categories, taxes, currencies and
11
- transfers — plus a `raw` escape hatch for any other API endpoint.
14
+ [![GitHub Release](https://img.shields.io/github/v/release/AsyncAlchemist/akt-cli?style=flat-square)](https://github.com/AsyncAlchemist/akt-cli/releases)
15
+ [![Downloads](https://img.shields.io/pypi/dm/akt-cli.svg?style=flat-square&label=downloads)](https://pypi.org/project/akt-cli/)
16
+ [![Python Version](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
17
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat-square)](LICENSE)
12
18
 
13
- Built and tested against Akaunting **3.1.x**; it should work with any 3.x
14
- deployment that exposes the REST API.
19
+ </div>
15
20
 
16
21
  ## Install
17
22
 
@@ -75,8 +80,18 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
75
80
  | `invoice` | `documents` | document of type `invoice` |
76
81
  | `bill` | `documents` | document of type `bill` |
77
82
  | `payment` | `transactions` | income (invoice) or expense (bill) transaction |
83
+ | `journal-entry` | `journal-entry` | double-entry general-ledger entry (module) |
84
+ | `chart-of-account` | `chart-of-accounts` | GL accounts — read via API, CRUD via web |
78
85
  | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
79
86
 
87
+ > `journal-entry` and `chart-of-account` require the **Double-Entry** module
88
+ > installed on the instance. The module publishes chart-of-accounts read-only on
89
+ > the `/api` surface (index/show); its create/update/delete live only on the
90
+ > session/CSRF **web** route. `akt chart-of-account` gives you the full verb set
91
+ > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
92
+ > drive the web CRUD with your admin session (the same mechanism
93
+ > `download-attachment` already uses).
94
+
80
95
  > The `contacts` and `documents` endpoints derive their permission from a
81
96
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
82
97
  > raw without it returns `403 necessary access rights`.
@@ -95,6 +110,17 @@ akt <noun> enable <id> # where applicable
95
110
  akt <noun> disable <id>
96
111
  ```
97
112
 
113
+ Bills, invoices and payments additionally support **attachments** (scanned bills,
114
+ receipts, PDFs):
115
+
116
+ ```
117
+ akt <noun> create ... --attachment ./file.pdf # repeatable; upload on create
118
+ akt <noun> update <id> --attachment ./file.pdf # attach to an existing record
119
+ akt <noun> update <id> --remove-attachment # clear existing attachment(s)
120
+ akt <noun> attachments <id> # list attached files (id, name, size)
121
+ akt <noun> download-attachment <id> [--out DIR] [--media-id ID]
122
+ ```
123
+
98
124
  Output is a table by default; add `--json` (works before or after the verb) for
99
125
  raw JSON suitable for piping into `jq`.
100
126
 
@@ -136,6 +162,37 @@ akt payment create --invoice 34 --amount 750 \
136
162
  akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5'
137
163
  akt payment create --bill 41
138
164
 
165
+ # Attachments: upload the source PDF/scan and fetch it back later
166
+ akt bill create --contact 13 --item 'name=Paper,price=40,quantity=5' \
167
+ --attachment ./supplier-bill.pdf
168
+ akt payment update 57 --attachment ./receipt.pdf # attach to an existing payment
169
+ akt bill attachments 41 # list attached files
170
+ akt bill download-attachment 41 --out ./downloads # save to disk
171
+ akt payment update 57 --remove-attachment # clear attachments
172
+
173
+ # Double-entry general ledger (requires the Double-Entry module)
174
+ akt chart-of-account list # read the chart of accounts
175
+ akt chart-of-account get 12
176
+
177
+ # Build the chart of accounts as code (create/update/delete run via the web
178
+ # session; type-id is the double-entry account-type id — copy it from an
179
+ # existing account's `type_id`)
180
+ akt chart-of-account create --name "Cash on Hand" --code 1010 --type-id 6
181
+ akt chart-of-account create --name "Petty Cash" --code 1011 --type-id 6 --account-id 12
182
+ akt chart-of-account update 12 --code 1000 --description "Operating cash"
183
+ akt chart-of-account delete 12
184
+
185
+ # Post a balanced journal entry (>= 2 lines; debits must equal credits;
186
+ # journal number auto-generated, basis defaults to accrual)
187
+ akt journal-entry create --description "Owner capital contribution" \
188
+ --item 'account_id=10,debit=5000' \
189
+ --item 'account_id=30,credit=5000'
190
+ akt journal-entry list
191
+ akt journal-entry update 4 --description "Corrected memo"
192
+ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
193
+ --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
194
+ --attachment ./invoice.pdf
195
+
139
196
  # Anything else: raw API access
140
197
  akt raw GET reports
141
198
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -159,8 +216,35 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
159
216
  so they aren't lost.
160
217
  * **Nested payment route** — paying a document must POST to
161
218
  `documents/{id}/transactions`; the flat `transactions` endpoint rejects it.
219
+ The same applies to *updating* a document-linked payment (e.g. attaching a
220
+ file to it) — `akt` picks the nested route automatically.
221
+ * **Multipart uploads** — attachments switch the request from JSON to
222
+ `multipart/form-data` with the `attachment[]` field; updates are sent as
223
+ `POST` + `_method=PATCH` because PHP won't populate `$_FILES` on a real `PUT`.
224
+ * **Attachment download isn't on `/api`** — Akaunting only serves attachment
225
+ bytes from the session-authenticated web route `/{company}/uploads/{id}/download`.
226
+ `akt download-attachment` transparently logs in a web session with your admin
227
+ credentials (reused for the process) to fetch the file; metadata (id, name,
228
+ size) comes from the `/api` record itself.
162
229
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
163
230
  `akt` merges your changes onto the current record.
231
+ * **Journal entries must balance** — a `journal-entry` needs >= 2 lines whose
232
+ debits equal its credits; `akt` validates this client-side (clear error)
233
+ before hitting the API. Each line carries both a `debit` and a `credit` key
234
+ (the unused side sent as `0`) because Akaunting validates both as required.
235
+ * **Journal updates re-derive ledgers** — like documents, a journal update
236
+ deletes any ledger line absent from the request. `akt` resends the existing
237
+ lines (with their ledger ids) so an update that only changes a field doesn't
238
+ wipe the entry, and auto-generates the `journal_number` from the module's
239
+ `double-entry.journal.number_*` settings when you don't pass one.
240
+ * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
241
+ accounts read-only on `/api`; create/update/delete exist solely on the
242
+ session/CSRF web route. `akt chart-of-account create|update|delete` logs in a
243
+ web session (reusing your admin credentials, cached for the process), attaches
244
+ the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
245
+ envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
246
+ surfaces as a normal error. Updates resend `name` (required by Akaunting on
247
+ update) from the current record when you don't pass one.
164
248
 
165
249
  ### Invoice creation may be gated by a plan check
166
250
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "akt — a CLI toolbox to fully drive an Akaunting accounting instance"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -12,6 +12,9 @@ requires-python = ">=3.12"
12
12
  keywords = ["akaunting", "accounting", "cli", "invoices", "bills", "api"]
13
13
  classifiers = [
14
14
  "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
15
18
  "Environment :: Console",
16
19
  "Topic :: Office/Business :: Financial :: Accounting",
17
20
  ]
@@ -3,4 +3,4 @@
3
3
  from .cli import main
4
4
 
5
5
  __all__ = ["main"]
6
- __version__ = "0.1.0"
6
+ __version__ = "0.3.0"
@@ -9,8 +9,10 @@ from typing import Any
9
9
  from .client import ApiError, Client
10
10
  from .config import ConfigError, load_config
11
11
  from .commands import (
12
+ cmd_attachments,
12
13
  cmd_create,
13
14
  cmd_delete,
15
+ cmd_download_attachment,
14
16
  cmd_get,
15
17
  cmd_list,
16
18
  cmd_toggle,
@@ -38,6 +40,18 @@ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bo
38
40
  if res.endpoint == "documents":
39
41
  p.add_argument("--item", action="append", metavar="K=V,...",
40
42
  help="line item, e.g. 'name=Widget,price=10,quantity=2,tax_id=1' (repeatable)")
43
+ elif res.endpoint == "journal-entry":
44
+ p.add_argument("--item", action="append", metavar="K=V,...",
45
+ help="ledger line (>= 2, must balance), e.g. "
46
+ "'account_id=10,debit=100' or 'account_id=20,credit=100' (repeatable)")
47
+ if res.supports_attachments:
48
+ p.add_argument("--attachment", action="append", metavar="PATH",
49
+ help="attach a file (pdf/jpg/png, repeatable); switches the "
50
+ "request to multipart upload")
51
+ if for_update:
52
+ p.add_argument("--remove-attachment", dest="remove_attachment",
53
+ action="store_true",
54
+ help="clear existing attachment(s) on this record")
41
55
  p.add_argument("--set", dest="set_", action="append", metavar="KEY=VALUE",
42
56
  help="set an arbitrary body field (repeatable; value JSON-coerced)")
43
57
  p.add_argument("--data", metavar="JSON|@FILE",
@@ -87,18 +101,35 @@ def _build_parser() -> argparse.ArgumentParser:
87
101
  gp.add_argument("id")
88
102
  gp.set_defaults(_handler=lambda res, c, ns: cmd_get(res, c, ns))
89
103
 
90
- cp = verbs.add_parser("create", parents=[common], help=f"Create a {res.noun}")
91
- _add_field_args(cp, res, for_update=False)
92
- cp.set_defaults(_handler=lambda res, c, ns: cmd_create(res, c, ns))
93
-
94
- up = verbs.add_parser("update", parents=[common], help=f"Update a {res.noun}")
95
- up.add_argument("id")
96
- _add_field_args(up, res, for_update=True)
97
- up.set_defaults(_handler=lambda res, c, ns: cmd_update(res, c, ns))
98
-
99
- dp = verbs.add_parser("delete", parents=[common], help=f"Delete a {res.noun}")
100
- dp.add_argument("id")
101
- dp.set_defaults(_handler=lambda res, c, ns: cmd_delete(res, c, ns))
104
+ # Read-only resources (e.g. chart-of-accounts) expose only list/get; the
105
+ # API has no create/update/delete route for them.
106
+ if not res.read_only:
107
+ cp = verbs.add_parser("create", parents=[common], help=f"Create a {res.noun}")
108
+ _add_field_args(cp, res, for_update=False)
109
+ cp.set_defaults(_handler=lambda res, c, ns: cmd_create(res, c, ns))
110
+
111
+ up = verbs.add_parser("update", parents=[common], help=f"Update a {res.noun}")
112
+ up.add_argument("id")
113
+ _add_field_args(up, res, for_update=True)
114
+ up.set_defaults(_handler=lambda res, c, ns: cmd_update(res, c, ns))
115
+
116
+ dp = verbs.add_parser("delete", parents=[common], help=f"Delete a {res.noun}")
117
+ dp.add_argument("id")
118
+ dp.set_defaults(_handler=lambda res, c, ns: cmd_delete(res, c, ns))
119
+
120
+ if res.supports_attachments:
121
+ ap = verbs.add_parser("attachments", parents=[common],
122
+ help=f"List attachments on a {res.noun}")
123
+ ap.add_argument("id")
124
+ ap.set_defaults(_handler=lambda res, c, ns: cmd_attachments(res, c, ns))
125
+
126
+ dap = verbs.add_parser("download-attachment", parents=[common],
127
+ help=f"Download attachment(s) from a {res.noun}")
128
+ dap.add_argument("id")
129
+ dap.add_argument("--out", metavar="DIR", help="output directory (default .)")
130
+ dap.add_argument("--media-id", metavar="ID",
131
+ help="download only this media id (default: all)")
132
+ dap.set_defaults(_handler=lambda res, c, ns: cmd_download_attachment(res, c, ns))
102
133
 
103
134
  if res.supports_toggle:
104
135
  ep = verbs.add_parser("enable", parents=[common], help=f"Enable a {res.noun}")