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.
- {akt_cli-0.2.0 → akt_cli-0.3.0}/PKG-INFO +52 -2
- {akt_cli-0.2.0 → akt_cli-0.3.0}/README.md +51 -1
- {akt_cli-0.2.0 → akt_cli-0.3.0}/pyproject.toml +1 -1
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/__init__.py +1 -1
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/cli.py +19 -12
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/client.py +57 -1
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/commands.py +16 -0
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/registry.py +60 -0
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/resources.py +192 -0
- {akt_cli-0.2.0 → akt_cli-0.3.0}/LICENSE +0 -0
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/config.py +0 -0
- {akt_cli-0.2.0 → akt_cli-0.3.0}/src/akt/output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akt-cli
|
|
3
|
-
Version: 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
|
[](https://pypi.org/project/akt-cli/)
|
|
30
30
|
[](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
|
[](https://pypi.org/project/akt-cli/)
|
|
9
9
|
[](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
|
|
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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
|