akt-cli 0.2.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akt-cli
3
- Version: 0.2.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
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
  # akt — Akaunting CLI toolbox
25
25
  ### Drive your Akaunting accounting instance entirely from the command line
26
26
 
27
- >`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus 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.
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.
28
28
 
29
29
  [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
30
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)
@@ -101,8 +101,18 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
101
101
  | `invoice` | `documents` | document of type `invoice` |
102
102
  | `bill` | `documents` | document of type `bill` |
103
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 |
104
106
  | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
105
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
+
106
116
  > The `contacts` and `documents` endpoints derive their permission from a
107
117
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
108
118
  > raw without it returns `403 necessary access rights`.
@@ -181,6 +191,29 @@ akt bill attachments 41 # list attached files
181
191
  akt bill download-attachment 41 --out ./downloads # save to disk
182
192
  akt payment update 57 --remove-attachment # clear attachments
183
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
+
184
217
  # Anything else: raw API access
185
218
  akt raw GET reports
186
219
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -216,6 +249,23 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
216
249
  size) comes from the `/api` record itself.
217
250
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
218
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.
219
269
 
220
270
  ### Invoice creation may be gated by a plan check
221
271
 
@@ -3,7 +3,7 @@
3
3
  # akt — Akaunting CLI toolbox
4
4
  ### Drive your Akaunting accounting instance entirely from the command line
5
5
 
6
- >`akt` gives you full create / read / update / delete for customers, vendors, items, invoices, bills, payments, accounts, categories, taxes, currencies and transfers — plus 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.
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.
7
7
 
8
8
  [![PyPI Version](https://img.shields.io/pypi/v/akt-cli.svg?style=flat-square)](https://pypi.org/project/akt-cli/)
9
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)
@@ -80,8 +80,18 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
80
80
  | `invoice` | `documents` | document of type `invoice` |
81
81
  | `bill` | `documents` | document of type `bill` |
82
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 |
83
85
  | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
84
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
+
85
95
  > The `contacts` and `documents` endpoints derive their permission from a
86
96
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
87
97
  > raw without it returns `403 necessary access rights`.
@@ -160,6 +170,29 @@ akt bill attachments 41 # list attached files
160
170
  akt bill download-attachment 41 --out ./downloads # save to disk
161
171
  akt payment update 57 --remove-attachment # clear attachments
162
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
+
163
196
  # Anything else: raw API access
164
197
  akt raw GET reports
165
198
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -195,6 +228,23 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
195
228
  size) comes from the `/api` record itself.
196
229
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
197
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.
198
248
 
199
249
  ### Invoice creation may be gated by a plan check
200
250
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.2.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"
@@ -3,4 +3,4 @@
3
3
  from .cli import main
4
4
 
5
5
  __all__ = ["main"]
6
- __version__ = "0.2.0"
6
+ __version__ = "0.3.0"
@@ -40,6 +40,10 @@ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bo
40
40
  if res.endpoint == "documents":
41
41
  p.add_argument("--item", action="append", metavar="K=V,...",
42
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)")
43
47
  if res.supports_attachments:
44
48
  p.add_argument("--attachment", action="append", metavar="PATH",
45
49
  help="attach a file (pdf/jpg/png, repeatable); switches the "
@@ -97,18 +101,21 @@ def _build_parser() -> argparse.ArgumentParser:
97
101
  gp.add_argument("id")
98
102
  gp.set_defaults(_handler=lambda res, c, ns: cmd_get(res, c, ns))
99
103
 
100
- cp = verbs.add_parser("create", parents=[common], help=f"Create a {res.noun}")
101
- _add_field_args(cp, res, for_update=False)
102
- cp.set_defaults(_handler=lambda res, c, ns: cmd_create(res, c, ns))
103
-
104
- up = verbs.add_parser("update", parents=[common], help=f"Update a {res.noun}")
105
- up.add_argument("id")
106
- _add_field_args(up, res, for_update=True)
107
- up.set_defaults(_handler=lambda res, c, ns: cmd_update(res, c, ns))
108
-
109
- dp = verbs.add_parser("delete", parents=[common], help=f"Delete a {res.noun}")
110
- dp.add_argument("id")
111
- 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))
112
119
 
113
120
  if res.supports_attachments:
114
121
  ap = verbs.add_parser("attachments", parents=[common],
@@ -76,7 +76,7 @@ class Client:
76
76
  {
77
77
  "Accept": "application/json",
78
78
  "Content-Type": "application/json",
79
- "User-Agent": "akt/0.2 (+akaunting-cli)",
79
+ "User-Agent": "akt/0.3 (+akaunting-cli)",
80
80
  }
81
81
  )
82
82
 
@@ -277,6 +277,62 @@ class Client:
277
277
  filename = self._disposition_filename(resp.headers.get("Content-Disposition", ""))
278
278
  return filename or f"attachment-{media_id}", resp.content
279
279
 
280
+ def _xsrf_header(self) -> dict:
281
+ """CSRF header for a session web request.
282
+
283
+ Laravel accepts the *encrypted* ``XSRF-TOKEN`` cookie value echoed back in
284
+ the ``X-XSRF-TOKEN`` header (it decrypts that to the session token) — this
285
+ is exactly what Akaunting's axios frontend does, and is far more robust
286
+ than scraping a per-page ``_token`` (Akaunting emits no ``csrf-token``
287
+ meta tag). ``requests`` already stores the cookie; we just surface it as a
288
+ header."""
289
+ cookie = self._session.cookies.get("XSRF-TOKEN")
290
+ return {"X-XSRF-TOKEN": unquote(cookie)} if cookie else {}
291
+
292
+ def web_json(self, method: str, path: str,
293
+ form: "list[tuple[str, str]] | None" = None) -> Any:
294
+ """Call a session-authenticated web (non-``/api``) route that answers JSON.
295
+
296
+ Some module features are exposed only on the CSRF-protected web surface,
297
+ not the Basic-auth ``/api`` one — notably the Double-Entry
298
+ chart-of-accounts CRUD (``apiResource`` publishes it read-only). This logs
299
+ in a web session (cached for the process), attaches the CSRF token plus the
300
+ ``X-Requested-With``/``Accept`` headers Laravel needs to reply with JSON
301
+ instead of an HTML redirect, and unwraps Akaunting's
302
+ ``{success, error, data, message}`` AJAX envelope. ``path`` is relative to
303
+ ``{web_root}/{company_id}/``; use a real ``PATCH``/``DELETE`` method (the
304
+ resource controller and its FormRequest honor it directly)."""
305
+ self._web_login()
306
+ url = f"{self.config.web_root}/{self.config.company_id}/{path.lstrip('/')}"
307
+ headers = {
308
+ # Drop the JSON content-type so requests url-encodes the form itself.
309
+ "Content-Type": None,
310
+ "Accept": "application/json",
311
+ "X-Requested-With": "XMLHttpRequest",
312
+ **self._xsrf_header(),
313
+ }
314
+ resp = self._session.request(
315
+ method.upper(), url, data=list(form or []), headers=headers,
316
+ allow_redirects=False, timeout=self.timeout,
317
+ )
318
+ if self._waf_blocked(resp):
319
+ raise ApiError(resp.status_code, "Blocked by bot-protection on web route.")
320
+ if resp.status_code in (301, 302, 303, 307, 308):
321
+ raise ApiError(
322
+ resp.status_code,
323
+ "Web route redirected instead of returning JSON — the session "
324
+ "isn't authenticated or the CSRF token was rejected. Check the "
325
+ "admin credentials.",
326
+ )
327
+ payload = self._handle(resp) # raises ApiError (with validation errors) on non-2xx
328
+ # Akaunting's AJAX envelope: a 200 can still carry a business-rule failure
329
+ # (e.g. deleting an account that has ledgers) as {success:false, error:true}.
330
+ if isinstance(payload, dict) and payload.get("error"):
331
+ raise ApiError(400, payload.get("message") or "Request failed")
332
+ if isinstance(payload, dict) and "data" in payload and "success" in payload:
333
+ return payload["data"]
334
+ return payload
335
+
280
336
  @staticmethod
281
337
  def _disposition_filename(disposition: str) -> str | None:
282
338
  name = None
@@ -43,6 +43,12 @@ def cmd_create(res: Resource, client: Client, ns: Any) -> int:
43
43
  # document goes to documents/{id}/transactions).
44
44
  endpoint = body.pop("__endpoint__", res.endpoint)
45
45
  type_scope = body.pop("__type_scope__", res.type_scope)
46
+ # Resources whose CRUD lives only on the session/CSRF web surface (e.g.
47
+ # chart-of-accounts) POST through client.web_json instead of /api.
48
+ if res.web_endpoint:
49
+ data = client.web_json("POST", res.web_endpoint, flatten_form(body))
50
+ emit(data, as_json=True)
51
+ return 0
46
52
  files = load_attachments(getattr(ns, "attachment", None))
47
53
  if files:
48
54
  payload = client.post_multipart(endpoint, flatten_form(body), files,
@@ -63,6 +69,12 @@ def cmd_update(res: Resource, client: Client, ns: Any) -> int:
63
69
  # record to satisfy required-field validation.
64
70
  body = body_from_fields(res, ns, for_update=True, current=current)
65
71
 
72
+ # Web-surface CRUD resources (chart-of-accounts) update via the session route.
73
+ if res.web_endpoint:
74
+ data = client.web_json("PATCH", f"{res.web_endpoint}/{ns.id}", flatten_form(body))
75
+ emit(data, as_json=True)
76
+ return 0
77
+
66
78
  # A resolver may redirect to a nested route (a document-linked payment must
67
79
  # update via documents/{doc}/transactions/{id}; the flat route 400s on it).
68
80
  if res.update_resolver:
@@ -97,6 +109,10 @@ def cmd_update(res: Resource, client: Client, ns: Any) -> int:
97
109
 
98
110
 
99
111
  def cmd_delete(res: Resource, client: Client, ns: Any) -> int:
112
+ if res.web_endpoint:
113
+ client.web_json("DELETE", f"{res.web_endpoint}/{ns.id}")
114
+ print(f"deleted {res.noun} {ns.id}")
115
+ return 0
100
116
  if res.delete_resolver:
101
117
  path, type_scope = res.delete_resolver(res, client, str(ns.id))
102
118
  else:
@@ -5,8 +5,12 @@ from __future__ import annotations
5
5
  from .resources import (
6
6
  Resource,
7
7
  f,
8
+ build_account_create,
9
+ build_account_update,
8
10
  build_document_create,
9
11
  build_document_update,
12
+ build_journal_create,
13
+ build_journal_update,
10
14
  build_payment_create,
11
15
  build_transfer_create,
12
16
  resolve_payment_delete,
@@ -260,9 +264,65 @@ TRANSFER = Resource(
260
264
  )
261
265
 
262
266
 
267
+ # Double-entry (general ledger) -------------------------------------------
268
+
269
+ # The DoubleEntry module publishes chart-of-accounts READ-ONLY over the API
270
+ # (index/show only). Create/update/delete exist only on the session/CSRF web
271
+ # CRUD route, so those verbs are driven through client.web_json (web_endpoint)
272
+ # while list/get keep using the /api surface (endpoint). journal-entry is full
273
+ # API CRUD.
274
+
275
+ CHART_OF_ACCOUNT = Resource(
276
+ noun="chart-of-account",
277
+ endpoint="chart-of-accounts",
278
+ web_endpoint="double-entry/chart-of-accounts",
279
+ fields=[
280
+ f("name", "Account name", required=True),
281
+ f("code", "Account code (unique integer per company)"),
282
+ f("type-id", "Double-entry account type id (see an existing account's type_id)"),
283
+ f("account-id", "Parent account id (makes this a sub-account)"),
284
+ f("description", "Description"),
285
+ f("enabled", "Enable the record", is_flag=True, default=1),
286
+ ],
287
+ columns=[
288
+ ("ID", "id"), ("Code", "code"), ("Name", "name"), ("Type", "type_id"),
289
+ ("Parent", "account_id"), ("Enabled", "enabled"),
290
+ ],
291
+ supports_toggle=False,
292
+ build_create=build_account_create,
293
+ build_update=build_account_update,
294
+ help="Chart of accounts (read via API; create/update/delete via web, double-entry module)",
295
+ )
296
+
297
+ JOURNAL_ENTRY = Resource(
298
+ noun="journal-entry",
299
+ endpoint="journal-entry",
300
+ fields=[
301
+ f("journal-number", "Journal number (auto-generated if omitted)"),
302
+ f("paid-at", "Entry date (YYYY-MM-DD; defaults to today)"),
303
+ f("description", "Description", required=True),
304
+ f("basis", "cash | accrual", default="accrual", choices=["cash", "accrual"]),
305
+ f("reference", "Reference"),
306
+ f("currency-code", "Currency code", default="USD"),
307
+ f("currency-rate", "Currency rate", default=1),
308
+ ],
309
+ columns=[
310
+ ("ID", "id"), ("Number", "journal_number"), ("Date", "paid_at"),
311
+ ("Amount", "amount_formatted"), ("Basis", "basis"),
312
+ ("Description", "description"),
313
+ ],
314
+ supports_toggle=False,
315
+ supports_attachments=True,
316
+ build_create=build_journal_create,
317
+ build_update=build_journal_update,
318
+ help="Journal entries (double-entry general ledger)",
319
+ )
320
+
321
+
263
322
  RESOURCES: list[Resource] = [
264
323
  CUSTOMER, VENDOR, ITEM, ACCOUNT, CATEGORY, TAX, CURRENCY,
265
324
  INVOICE, BILL, PAYMENT, TRANSFER,
325
+ CHART_OF_ACCOUNT, JOURNAL_ENTRY,
266
326
  ]
267
327
 
268
328
  BY_NOUN = {r.noun: r for r in RESOURCES}
@@ -53,6 +53,11 @@ class Resource:
53
53
  search_default: str | None = None # always-applied search filter
54
54
  supports_toggle: bool = True # enable/disable verbs
55
55
  supports_attachments: bool = False # --attachment upload + attachments/download verbs
56
+ read_only: bool = False # expose only list/get (no create/update/delete)
57
+ # When set, create/update/delete are driven through the session-authenticated
58
+ # web route (client.web_json) at this path instead of the /api surface — for
59
+ # resources whose CRUD Akaunting exposes only on the web (e.g. chart-of-accounts).
60
+ web_endpoint: str | None = None
56
61
  help: str = ""
57
62
 
58
63
  # hooks (override for documents/payments)
@@ -551,3 +556,190 @@ def _next_transaction_number(client: Client) -> str:
551
556
  if tail:
552
557
  maxn = max(maxn, int(tail))
553
558
  return f"{pre}{maxn + 1:05d}"
559
+
560
+
561
+ # --------------------------------------------------------------------------
562
+ # journal entry (double-entry) body builder
563
+ # --------------------------------------------------------------------------
564
+
565
+ def parse_journal_item(spec: str) -> dict:
566
+ """Parse one journal line: ``account_id=10,debit=100`` or
567
+ ``account_id=20,credit=100`` (optional ``id=`` on update to target an
568
+ existing ledger, optional ``notes=``). A line carries a debit *or* a
569
+ credit; the omitted side defaults to 0."""
570
+ item: dict[str, Any] = {}
571
+ for part in spec.split(","):
572
+ if not part.strip():
573
+ continue
574
+ if "=" not in part:
575
+ raise ValueError(f"--item field must be key=value, got {part!r}")
576
+ k, _, v = part.partition("=")
577
+ item[k.strip()] = _coerce(v.strip())
578
+ if "account_id" not in item:
579
+ raise ValueError(f"--item requires an account_id=… field: {spec!r}")
580
+ if "debit" not in item and "credit" not in item:
581
+ raise ValueError(f"--item requires a debit=… or credit=… field: {spec!r}")
582
+ return item
583
+
584
+
585
+ def _normalize_journal_items(items: list[dict]) -> list[dict]:
586
+ """Coerce account_id/id to int and ensure both debit & credit keys exist
587
+ (Akaunting validates ``items.*.debit`` and ``items.*.credit`` as required)."""
588
+ for it in items:
589
+ if it.get("account_id") is not None:
590
+ it["account_id"] = int(it["account_id"])
591
+ if it.get("id") is not None:
592
+ it["id"] = int(it["id"])
593
+ it.setdefault("debit", 0)
594
+ it.setdefault("credit", 0)
595
+ return items
596
+
597
+
598
+ def _require_balanced(items: list[dict]) -> None:
599
+ """A journal entry needs >= 2 lines whose debits equal its credits."""
600
+ if len(items) < 2:
601
+ raise ValueError("a journal entry needs at least 2 line items")
602
+ debit = sum(float(it.get("debit") or 0) for it in items)
603
+ credit = sum(float(it.get("credit") or 0) for it in items)
604
+ if abs(debit - credit) > 1e-4:
605
+ raise ValueError(
606
+ f"journal entry does not balance: debits {debit:.2f} != credits {credit:.2f}"
607
+ )
608
+
609
+
610
+ def _next_journal_number(client: Client) -> str:
611
+ pre = client.setting("double-entry.journal.number_prefix") or "MJE-"
612
+ digit = client.setting("double-entry.journal.number_digit")
613
+ try:
614
+ width = int(digit)
615
+ except (TypeError, ValueError):
616
+ width = 5
617
+ existing = client.list("journal-entry", all_pages=True)
618
+ maxn = 0
619
+ for row in existing:
620
+ tail = "".join(ch for ch in str(row.get("journal_number", "")) if ch.isdigit())
621
+ if tail:
622
+ maxn = max(maxn, int(tail))
623
+ return f"{pre}{maxn + 1:0{width}d}"
624
+
625
+
626
+ def build_journal_create(res: Resource, client: Client, ns: Any) -> dict:
627
+ extra = load_data_arg(getattr(ns, "data", None))
628
+ items = [parse_journal_item(s) for s in (getattr(ns, "item", None) or [])]
629
+ if not items and "items" not in extra:
630
+ raise ValueError(
631
+ "at least two --item 'account_id=…,debit=…|credit=…' lines are "
632
+ "required (or supply --data with items)"
633
+ )
634
+
635
+ description = getattr(ns, "description", None)
636
+ if not description:
637
+ raise ValueError("--description is required to create a journal-entry")
638
+
639
+ body: dict[str, Any] = {
640
+ "paid_at": _normalize_date(getattr(ns, "paid_at", None) or today_dt()),
641
+ "journal_number": getattr(ns, "journal_number", None),
642
+ "description": description,
643
+ "basis": getattr(ns, "basis", None) or "accrual",
644
+ "currency_code": getattr(ns, "currency_code", None) or "USD",
645
+ "currency_rate": getattr(ns, "currency_rate", None) or 1,
646
+ # Akaunting recomputes the entry amount from the ledger debits; send 0.
647
+ "amount": 0,
648
+ "items": items,
649
+ }
650
+ if getattr(ns, "reference", None):
651
+ body["reference"] = ns.reference
652
+ body.update(parse_set(getattr(ns, "set_", None)))
653
+ body.update(extra)
654
+ if isinstance(body.get("items"), list):
655
+ _normalize_journal_items(body["items"])
656
+ _require_balanced(body["items"])
657
+ # Assign the journal number last — after client-side validation — so an
658
+ # invalid entry never burns a lookup (and the imbalance error surfaces
659
+ # without a network round-trip).
660
+ if not body.get("journal_number"):
661
+ body["journal_number"] = _next_journal_number(client)
662
+ return body
663
+
664
+
665
+ def _journal_items_from_current(current: dict) -> list[dict]:
666
+ """Rebuild request items from a fetched entry's ledgers so an update that
667
+ doesn't touch lines doesn't delete them (UpdateJournalEntry deletes any
668
+ ledger whose id is absent from the request). Each line carries its ledger
669
+ ``id`` so the existing row is updated in place rather than recreated."""
670
+ out: list[dict] = []
671
+ for led in current.get("ledgers", {}).get("data", []):
672
+ row = {
673
+ "account_id": led.get("account_id"),
674
+ "debit": float(led.get("debit") or 0),
675
+ "credit": float(led.get("credit") or 0),
676
+ }
677
+ if led.get("id"):
678
+ row["id"] = int(led["id"])
679
+ out.append(row)
680
+ return out
681
+
682
+
683
+ def build_journal_update(res: Resource, client: Client, ns: Any, current: dict) -> dict:
684
+ """Full update: Akaunting re-derives ledgers & amount from the request, so
685
+ resend the whole entry, overlaying any provided fields."""
686
+ body: dict[str, Any] = {
687
+ "paid_at": _normalize_dt_field(current.get("paid_at") or today_dt()),
688
+ "journal_number": current.get("journal_number"),
689
+ "description": current.get("description"),
690
+ "basis": current.get("basis") or "accrual",
691
+ "currency_code": current.get("currency_code"),
692
+ "currency_rate": current.get("currency_rate", 1),
693
+ "amount": 0,
694
+ "items": _journal_items_from_current(current),
695
+ }
696
+ if current.get("reference"):
697
+ body["reference"] = current["reference"]
698
+
699
+ for attr, key in [
700
+ ("paid_at", "paid_at"), ("journal_number", "journal_number"),
701
+ ("description", "description"), ("basis", "basis"),
702
+ ("currency_code", "currency_code"), ("currency_rate", "currency_rate"),
703
+ ("reference", "reference"),
704
+ ]:
705
+ v = getattr(ns, attr, None)
706
+ if v is not None:
707
+ body[key] = _normalize_date(v) if key == "paid_at" else v
708
+
709
+ items_specs = getattr(ns, "item", None) or []
710
+ if items_specs:
711
+ body["items"] = [parse_journal_item(s) for s in items_specs]
712
+
713
+ body.update(parse_set(getattr(ns, "set_", None)))
714
+ body.update(load_data_arg(getattr(ns, "data", None)))
715
+ if isinstance(body.get("items"), list):
716
+ _normalize_journal_items(body["items"])
717
+ _require_balanced(body["items"])
718
+ return body
719
+
720
+
721
+ # --------------------------------------------------------------------------
722
+ # chart-of-accounts (double-entry) — web-surface CRUD body builder
723
+ # --------------------------------------------------------------------------
724
+
725
+ def _apply_sub_account(body: dict) -> None:
726
+ """The web CRUD form carries an ``is_sub_account`` toggle; a parent
727
+ ``account_id`` is only honored when it's ``"true"`` and is cleared when
728
+ ``"false"``. Derive it from whether a parent was supplied."""
729
+ body["is_sub_account"] = "true" if body.get("account_id") else "false"
730
+
731
+
732
+ def build_account_create(res: Resource, client: Client, ns: Any) -> dict:
733
+ body = body_from_fields(res, ns, for_update=False)
734
+ if not body.get("name"):
735
+ raise ValueError("--name is required to create a chart-of-account")
736
+ _apply_sub_account(body)
737
+ return body
738
+
739
+
740
+ def build_account_update(res: Resource, client: Client, ns: Any, current: dict) -> dict:
741
+ # A full replace: Akaunting's Account request re-validates `name` on update,
742
+ # so backfill unspecified fields from the current record.
743
+ body = body_from_fields(res, ns, for_update=True, current=current)
744
+ _apply_sub_account(body)
745
+ return body
File without changes
File without changes
File without changes