akt-cli 0.2.0__tar.gz → 0.4.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.4.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, banks, 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,7 +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
- | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
104
+ | `journal-entry` | `journal-entry` | double-entry general-ledger entry (module) |
105
+ | `account` | `chart-of-accounts` | GL accounts (general ledger) — read via API, CRUD via web |
106
+ | `bank` | `accounts` | bank / cash accounts (the money, not the GL) |
107
+ | `item`, `category`, `tax`, `currency`, `transfer` | as named | |
108
+
109
+ > `journal-entry` and `account` require the **Double-Entry** module
110
+ > installed on the instance. The module publishes chart-of-accounts read-only on
111
+ > the `/api` surface (index/show); its create/update/delete live only on the
112
+ > session/CSRF **web** route. `akt account` gives you the full verb set
113
+ > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
114
+ > drive the web CRUD with your admin session (the same mechanism
115
+ > `download-attachment` already uses).
105
116
 
106
117
  > The `contacts` and `documents` endpoints derive their permission from a
107
118
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
@@ -156,6 +167,10 @@ akt item create --name "Consulting Hour" --sale-price 150 --purchase-price 0
156
167
  akt category create --name "Services" --type income
157
168
  akt tax create --name "Sales Tax" --rate 8.25
158
169
 
170
+ # Bank / cash accounts (the money side — see the COA section below for GL accounts)
171
+ akt bank create --name "Business Checking" --number 1001 --currency-code USD
172
+ akt bank list
173
+
159
174
  # Invoice with line items (totals computed server-side; number auto-generated)
160
175
  akt invoice create --contact 12 \
161
176
  --item 'name=Consulting,price=150,quantity=10,item_id=2' \
@@ -181,6 +196,61 @@ akt bill attachments 41 # list attached files
181
196
  akt bill download-attachment 41 --out ./downloads # save to disk
182
197
  akt payment update 57 --remove-attachment # clear attachments
183
198
 
199
+ # Double-entry general ledger (requires the Double-Entry module)
200
+ akt account list # read the chart of accounts
201
+ akt account get 12
202
+
203
+ # Build the chart of accounts as code (create/update/delete run via the web
204
+ # session; type-id is the double-entry account-type id — copy it from an
205
+ # existing account's `type_id`)
206
+ akt account create --name "Cash on Hand" --code 1010 --type-id 6
207
+ akt account create --name "Petty Cash" --code 1011 --type-id 6 --parent-id 12
208
+ akt account update 12 --code 1000 --description "Operating cash"
209
+ akt account delete 12
210
+
211
+ # Post a balanced journal entry (>= 2 lines; debits must equal credits;
212
+ # journal number auto-generated, basis defaults to accrual)
213
+ akt journal-entry create --description "Owner capital contribution" \
214
+ --item 'account_id=10,debit=5000' \
215
+ --item 'account_id=30,credit=5000'
216
+ akt journal-entry list
217
+ akt journal-entry update 4 --description "Corrected memo"
218
+ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
219
+ --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
220
+ --attachment ./invoice.pdf
221
+
222
+ ## COA config: link categories and accounts (Xero-style)
223
+
224
+ Akaunting keeps *categories* (required on every transaction) separate from the
225
+ double-entry *chart of accounts*. Point akt at a COA config and it keeps them in
226
+ lockstep: one list to maintain (accounts), a 1:1 mirror of categories generated
227
+ from it, and `payment create` coded by `--account` — with the mirror category
228
+ filled in automatically.
229
+
230
+ akt finds the config at `--coa FILE` → `AKT_COA_FILE` → `./coa.toml` →
231
+ `~/.config/akt/coa.toml`. Minimal schema (extra keys are ignored):
232
+
233
+ ```toml
234
+ [[account]]
235
+ code = 400
236
+ name = "API Subscription Revenue"
237
+ type_id = 13 # DoubleEntry account type; income/expense class -> category type
238
+ # optional: category = "Revenue" (override the mirror name)
239
+ # optional: mirror = false (skip mirroring, e.g. bank/AR/AP accounts)
240
+ ```
241
+
242
+ ```bash
243
+ akt coa diff # preview: accounts/categories to create or rename
244
+ akt coa sync # apply (create + rename; idempotent)
245
+ akt coa sync --prune # also DISABLE accounts/categories absent from the config
246
+
247
+ # code a transaction by GL account — akt auto-fills the mirror category:
248
+ akt payment create --type expense --bank 1 --amount 120 --account 628
249
+ ```
250
+
251
+ `--account` takes a GL code or name (the double-entry account; see `--bank` for
252
+ the bank/cash account). An explicit `--set de_account_id=` still wins.
253
+
184
254
  # Anything else: raw API access
185
255
  akt raw GET reports
186
256
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -216,6 +286,23 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
216
286
  size) comes from the `/api` record itself.
217
287
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
218
288
  `akt` merges your changes onto the current record.
289
+ * **Journal entries must balance** — a `journal-entry` needs >= 2 lines whose
290
+ debits equal its credits; `akt` validates this client-side (clear error)
291
+ before hitting the API. Each line carries both a `debit` and a `credit` key
292
+ (the unused side sent as `0`) because Akaunting validates both as required.
293
+ * **Journal updates re-derive ledgers** — like documents, a journal update
294
+ deletes any ledger line absent from the request. `akt` resends the existing
295
+ lines (with their ledger ids) so an update that only changes a field doesn't
296
+ wipe the entry, and auto-generates the `journal_number` from the module's
297
+ `double-entry.journal.number_*` settings when you don't pass one.
298
+ * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
299
+ accounts read-only on `/api`; create/update/delete exist solely on the
300
+ session/CSRF web route. `akt account create|update|delete` logs in a
301
+ web session (reusing your admin credentials, cached for the process), attaches
302
+ the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
303
+ envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
304
+ surfaces as a normal error. Updates resend `name` (required by Akaunting on
305
+ update) from the current record when you don't pass one.
219
306
 
220
307
  ### Invoice creation may be gated by a plan check
221
308
 
@@ -236,75 +323,10 @@ timing out. `akt` retries throttle/WAF responses with backoff, and
236
323
  use `--throttle 1` for bulk work. A durable fix is to whitelist your IP in the
237
324
  host firewall.
238
325
 
239
- ## Testing
240
-
241
- Tests are split in two:
242
-
243
- * **`tests/unit/`** — offline tests for the body builders and arg parsing. No
244
- network. This is what CI runs by default and what gates pull requests.
245
- * **`tests/integration/`** — drive the real `akt` CLI against a live Akaunting
246
- instance, exercising the full surface (contacts, items, bill → payment →
247
- paid, transfers, …). Every record they create is deleted on teardown — even
248
- on failure — so no invoices, bills or payments are left behind.
249
-
250
- ```bash
251
- uv run pytest tests/unit # fast, offline (default)
252
- uv run pytest # integration tests auto-skip without creds
253
-
254
- # Run integration tests against a deployment:
255
- AKT_BASE_URL=https://accounting.example.com \
256
- AKT_EMAIL=admin@example.com \
257
- AKT_PASSWORD=… \
258
- uv run pytest tests/integration -v
259
- ```
260
-
261
- > Invoice creation is `xfail`-ed when the host's plan-limit gate is active (see
262
- > above); the rest of the suite must pass.
263
-
264
- ### CI / CD
265
-
266
- * **CI** (`.github/workflows/ci.yml`) runs on every push and PR: unit tests +
267
- coverage, uploaded to [Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli).
268
- * **Release** (`.github/workflows/release.yml`) runs the live integration suite
269
- on published releases (and via *Run workflow*). Connection details come from
270
- GitHub Actions **secrets** (`AKT_BASE_URL`, `AKT_EMAIL`, `AKT_PASSWORD`) — they
271
- are never committed and are masked in logs.
272
- * **Publish** (`.github/workflows/publish.yml`) builds the sdist + wheel and
273
- uploads them via **Trusted Publishing (OIDC)** — no API token is stored.
274
- *Run workflow* publishes to **TestPyPI**; a published release publishes to
275
- **PyPI**.
276
-
277
- ### Releasing to PyPI
278
-
279
- One-time setup — add a *pending publisher* on each index
280
- (Account → Publishing → *Add a pending publisher*) with:
281
-
282
- | Field | TestPyPI | PyPI |
283
- |-------|----------|------|
284
- | Project Name | `akt-cli` | `akt-cli` |
285
- | Owner | `AsyncAlchemist` | `AsyncAlchemist` |
286
- | Repository name | `akt-cli` | `akt-cli` |
287
- | Workflow name | `publish.yml` | `publish.yml` |
288
- | Environment name | `testpypi` | `pypi` |
289
-
290
- Then:
326
+ ## Contributing
291
327
 
292
- * **Verify** *Actions Publish (PyPI) → Run workflow* uploads the current
293
- version to TestPyPI.
294
- * **Release** — bump `version` in `pyproject.toml`, push, and publish a GitHub
295
- Release. That runs the live integration suite and publishes to PyPI.
296
-
297
- The code is small and declarative:
298
-
299
- | file | purpose |
300
- |-----------------|----------------------------------------------------------|
301
- | `config.py` | credential resolution (flags / env / dotenv) |
302
- | `client.py` | HTTP, auth, company scoping, pagination, retries |
303
- | `resources.py` | field specs + body builders (documents, payments) |
304
- | `registry.py` | the concrete list of resources and their columns |
305
- | `commands.py` | generic list/get/create/update/delete/toggle handlers |
306
- | `cli.py` | argparse wiring and entrypoint |
307
- | `output.py` | JSON / table rendering |
328
+ Developing, testing, or releasing `akt`? See [CONTRIBUTING.md](CONTRIBUTING.md)
329
+ for the test suite, CI/CD, release process, and a map of the source files.
308
330
 
309
331
  ## License
310
332
 
@@ -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, banks, 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,7 +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
- | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
83
+ | `journal-entry` | `journal-entry` | double-entry general-ledger entry (module) |
84
+ | `account` | `chart-of-accounts` | GL accounts (general ledger) — read via API, CRUD via web |
85
+ | `bank` | `accounts` | bank / cash accounts (the money, not the GL) |
86
+ | `item`, `category`, `tax`, `currency`, `transfer` | as named | |
87
+
88
+ > `journal-entry` and `account` require the **Double-Entry** module
89
+ > installed on the instance. The module publishes chart-of-accounts read-only on
90
+ > the `/api` surface (index/show); its create/update/delete live only on the
91
+ > session/CSRF **web** route. `akt account` gives you the full verb set
92
+ > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
93
+ > drive the web CRUD with your admin session (the same mechanism
94
+ > `download-attachment` already uses).
84
95
 
85
96
  > The `contacts` and `documents` endpoints derive their permission from a
86
97
  > `search=type:<x>` query param. `akt` injects this automatically — calling them
@@ -135,6 +146,10 @@ akt item create --name "Consulting Hour" --sale-price 150 --purchase-price 0
135
146
  akt category create --name "Services" --type income
136
147
  akt tax create --name "Sales Tax" --rate 8.25
137
148
 
149
+ # Bank / cash accounts (the money side — see the COA section below for GL accounts)
150
+ akt bank create --name "Business Checking" --number 1001 --currency-code USD
151
+ akt bank list
152
+
138
153
  # Invoice with line items (totals computed server-side; number auto-generated)
139
154
  akt invoice create --contact 12 \
140
155
  --item 'name=Consulting,price=150,quantity=10,item_id=2' \
@@ -160,6 +175,61 @@ akt bill attachments 41 # list attached files
160
175
  akt bill download-attachment 41 --out ./downloads # save to disk
161
176
  akt payment update 57 --remove-attachment # clear attachments
162
177
 
178
+ # Double-entry general ledger (requires the Double-Entry module)
179
+ akt account list # read the chart of accounts
180
+ akt account get 12
181
+
182
+ # Build the chart of accounts as code (create/update/delete run via the web
183
+ # session; type-id is the double-entry account-type id — copy it from an
184
+ # existing account's `type_id`)
185
+ akt account create --name "Cash on Hand" --code 1010 --type-id 6
186
+ akt account create --name "Petty Cash" --code 1011 --type-id 6 --parent-id 12
187
+ akt account update 12 --code 1000 --description "Operating cash"
188
+ akt account delete 12
189
+
190
+ # Post a balanced journal entry (>= 2 lines; debits must equal credits;
191
+ # journal number auto-generated, basis defaults to accrual)
192
+ akt journal-entry create --description "Owner capital contribution" \
193
+ --item 'account_id=10,debit=5000' \
194
+ --item 'account_id=30,credit=5000'
195
+ akt journal-entry list
196
+ akt journal-entry update 4 --description "Corrected memo"
197
+ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
198
+ --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
199
+ --attachment ./invoice.pdf
200
+
201
+ ## COA config: link categories and accounts (Xero-style)
202
+
203
+ Akaunting keeps *categories* (required on every transaction) separate from the
204
+ double-entry *chart of accounts*. Point akt at a COA config and it keeps them in
205
+ lockstep: one list to maintain (accounts), a 1:1 mirror of categories generated
206
+ from it, and `payment create` coded by `--account` — with the mirror category
207
+ filled in automatically.
208
+
209
+ akt finds the config at `--coa FILE` → `AKT_COA_FILE` → `./coa.toml` →
210
+ `~/.config/akt/coa.toml`. Minimal schema (extra keys are ignored):
211
+
212
+ ```toml
213
+ [[account]]
214
+ code = 400
215
+ name = "API Subscription Revenue"
216
+ type_id = 13 # DoubleEntry account type; income/expense class -> category type
217
+ # optional: category = "Revenue" (override the mirror name)
218
+ # optional: mirror = false (skip mirroring, e.g. bank/AR/AP accounts)
219
+ ```
220
+
221
+ ```bash
222
+ akt coa diff # preview: accounts/categories to create or rename
223
+ akt coa sync # apply (create + rename; idempotent)
224
+ akt coa sync --prune # also DISABLE accounts/categories absent from the config
225
+
226
+ # code a transaction by GL account — akt auto-fills the mirror category:
227
+ akt payment create --type expense --bank 1 --amount 120 --account 628
228
+ ```
229
+
230
+ `--account` takes a GL code or name (the double-entry account; see `--bank` for
231
+ the bank/cash account). An explicit `--set de_account_id=` still wins.
232
+
163
233
  # Anything else: raw API access
164
234
  akt raw GET reports
165
235
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -195,6 +265,23 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
195
265
  size) comes from the `/api` record itself.
196
266
  * **Full-replace updates** — Akaunting PUT re-validates required fields, so
197
267
  `akt` merges your changes onto the current record.
268
+ * **Journal entries must balance** — a `journal-entry` needs >= 2 lines whose
269
+ debits equal its credits; `akt` validates this client-side (clear error)
270
+ before hitting the API. Each line carries both a `debit` and a `credit` key
271
+ (the unused side sent as `0`) because Akaunting validates both as required.
272
+ * **Journal updates re-derive ledgers** — like documents, a journal update
273
+ deletes any ledger line absent from the request. `akt` resends the existing
274
+ lines (with their ledger ids) so an update that only changes a field doesn't
275
+ wipe the entry, and auto-generates the `journal_number` from the module's
276
+ `double-entry.journal.number_*` settings when you don't pass one.
277
+ * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
278
+ accounts read-only on `/api`; create/update/delete exist solely on the
279
+ session/CSRF web route. `akt account create|update|delete` logs in a
280
+ web session (reusing your admin credentials, cached for the process), attaches
281
+ the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
282
+ envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
283
+ surfaces as a normal error. Updates resend `name` (required by Akaunting on
284
+ update) from the current record when you don't pass one.
198
285
 
199
286
  ### Invoice creation may be gated by a plan check
200
287
 
@@ -215,75 +302,10 @@ timing out. `akt` retries throttle/WAF responses with backoff, and
215
302
  use `--throttle 1` for bulk work. A durable fix is to whitelist your IP in the
216
303
  host firewall.
217
304
 
218
- ## Testing
219
-
220
- Tests are split in two:
221
-
222
- * **`tests/unit/`** — offline tests for the body builders and arg parsing. No
223
- network. This is what CI runs by default and what gates pull requests.
224
- * **`tests/integration/`** — drive the real `akt` CLI against a live Akaunting
225
- instance, exercising the full surface (contacts, items, bill → payment →
226
- paid, transfers, …). Every record they create is deleted on teardown — even
227
- on failure — so no invoices, bills or payments are left behind.
228
-
229
- ```bash
230
- uv run pytest tests/unit # fast, offline (default)
231
- uv run pytest # integration tests auto-skip without creds
232
-
233
- # Run integration tests against a deployment:
234
- AKT_BASE_URL=https://accounting.example.com \
235
- AKT_EMAIL=admin@example.com \
236
- AKT_PASSWORD=… \
237
- uv run pytest tests/integration -v
238
- ```
239
-
240
- > Invoice creation is `xfail`-ed when the host's plan-limit gate is active (see
241
- > above); the rest of the suite must pass.
242
-
243
- ### CI / CD
244
-
245
- * **CI** (`.github/workflows/ci.yml`) runs on every push and PR: unit tests +
246
- coverage, uploaded to [Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli).
247
- * **Release** (`.github/workflows/release.yml`) runs the live integration suite
248
- on published releases (and via *Run workflow*). Connection details come from
249
- GitHub Actions **secrets** (`AKT_BASE_URL`, `AKT_EMAIL`, `AKT_PASSWORD`) — they
250
- are never committed and are masked in logs.
251
- * **Publish** (`.github/workflows/publish.yml`) builds the sdist + wheel and
252
- uploads them via **Trusted Publishing (OIDC)** — no API token is stored.
253
- *Run workflow* publishes to **TestPyPI**; a published release publishes to
254
- **PyPI**.
255
-
256
- ### Releasing to PyPI
257
-
258
- One-time setup — add a *pending publisher* on each index
259
- (Account → Publishing → *Add a pending publisher*) with:
260
-
261
- | Field | TestPyPI | PyPI |
262
- |-------|----------|------|
263
- | Project Name | `akt-cli` | `akt-cli` |
264
- | Owner | `AsyncAlchemist` | `AsyncAlchemist` |
265
- | Repository name | `akt-cli` | `akt-cli` |
266
- | Workflow name | `publish.yml` | `publish.yml` |
267
- | Environment name | `testpypi` | `pypi` |
268
-
269
- Then:
305
+ ## Contributing
270
306
 
271
- * **Verify** *Actions Publish (PyPI) → Run workflow* uploads the current
272
- version to TestPyPI.
273
- * **Release** — bump `version` in `pyproject.toml`, push, and publish a GitHub
274
- Release. That runs the live integration suite and publishes to PyPI.
275
-
276
- The code is small and declarative:
277
-
278
- | file | purpose |
279
- |-----------------|----------------------------------------------------------|
280
- | `config.py` | credential resolution (flags / env / dotenv) |
281
- | `client.py` | HTTP, auth, company scoping, pagination, retries |
282
- | `resources.py` | field specs + body builders (documents, payments) |
283
- | `registry.py` | the concrete list of resources and their columns |
284
- | `commands.py` | generic list/get/create/update/delete/toggle handlers |
285
- | `cli.py` | argparse wiring and entrypoint |
286
- | `output.py` | JSON / table rendering |
307
+ Developing, testing, or releasing `akt`? See [CONTRIBUTING.md](CONTRIBUTING.md)
308
+ for the test suite, CI/CD, release process, and a map of the source files.
287
309
 
288
310
  ## License
289
311
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.2.0"
3
+ version = "0.4.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"
@@ -21,6 +21,7 @@ from .commands import (
21
21
  from .output import emit
22
22
  from .registry import RESOURCES, BY_NOUN
23
23
  from .resources import Resource, load_data_arg
24
+ from .coa import load_coa, plan_sync, render_plan, apply_plan
24
25
 
25
26
 
26
27
  def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bool) -> None:
@@ -40,6 +41,10 @@ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bo
40
41
  if res.endpoint == "documents":
41
42
  p.add_argument("--item", action="append", metavar="K=V,...",
42
43
  help="line item, e.g. 'name=Widget,price=10,quantity=2,tax_id=1' (repeatable)")
44
+ elif res.endpoint == "journal-entry":
45
+ p.add_argument("--item", action="append", metavar="K=V,...",
46
+ help="ledger line (>= 2, must balance), e.g. "
47
+ "'account_id=10,debit=100' or 'account_id=20,credit=100' (repeatable)")
43
48
  if res.supports_attachments:
44
49
  p.add_argument("--attachment", action="append", metavar="PATH",
45
50
  help="attach a file (pdf/jpg/png, repeatable); switches the "
@@ -80,6 +85,9 @@ def _build_parser() -> argparse.ArgumentParser:
80
85
  metavar="SECONDS",
81
86
  help="Min seconds between API calls (or AKT_THROTTLE). "
82
87
  "Use a value like 1.0 to avoid tripping host bot-protection.")
88
+ parser.add_argument("--coa", dest="coa_file", default=None, metavar="FILE",
89
+ help="COA config for category<->account linking "
90
+ "(or AKT_COA_FILE / ./coa.toml / ~/.config/akt/coa.toml)")
83
91
 
84
92
  sub = parser.add_subparsers(dest="resource", metavar="<resource>")
85
93
 
@@ -97,18 +105,21 @@ def _build_parser() -> argparse.ArgumentParser:
97
105
  gp.add_argument("id")
98
106
  gp.set_defaults(_handler=lambda res, c, ns: cmd_get(res, c, ns))
99
107
 
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))
108
+ # Read-only resources (e.g. chart-of-accounts) expose only list/get; the
109
+ # API has no create/update/delete route for them.
110
+ if not res.read_only:
111
+ cp = verbs.add_parser("create", parents=[common], help=f"Create a {res.noun}")
112
+ _add_field_args(cp, res, for_update=False)
113
+ cp.set_defaults(_handler=lambda res, c, ns: cmd_create(res, c, ns))
103
114
 
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))
115
+ up = verbs.add_parser("update", parents=[common], help=f"Update a {res.noun}")
116
+ up.add_argument("id")
117
+ _add_field_args(up, res, for_update=True)
118
+ up.set_defaults(_handler=lambda res, c, ns: cmd_update(res, c, ns))
108
119
 
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))
120
+ dp = verbs.add_parser("delete", parents=[common], help=f"Delete a {res.noun}")
121
+ dp.add_argument("id")
122
+ dp.set_defaults(_handler=lambda res, c, ns: cmd_delete(res, c, ns))
112
123
 
113
124
  if res.supports_attachments:
114
125
  ap = verbs.add_parser("attachments", parents=[common],
@@ -152,6 +163,19 @@ def _build_parser() -> argparse.ArgumentParser:
152
163
  rp.add_argument("--type-scope", help="search=type:X scope for contacts/documents")
153
164
  rp.set_defaults(_special="raw")
154
165
 
166
+ coap = sub.add_parser("coa", parents=[common],
167
+ help="Sync chart-of-accounts <-> categories from a COA config")
168
+ coav = coap.add_subparsers(dest="coa_verb", metavar="<verb>")
169
+ cdp = coav.add_parser("diff", parents=[common], help="Preview the sync plan (read-only)")
170
+ cdp.add_argument("--prune", action="store_true",
171
+ help="also show accounts/categories that --prune would disable")
172
+ cdp.set_defaults(_special="coa_diff")
173
+ csp = coav.add_parser("sync", parents=[common],
174
+ help="Apply: create/rename accounts + mirror categories")
175
+ csp.add_argument("--prune", action="store_true",
176
+ help="disable accounts/categories absent from the config (never deletes)")
177
+ csp.set_defaults(_special="coa_sync")
178
+
155
179
  return parser
156
180
 
157
181
 
@@ -178,6 +202,30 @@ def _run_special(name: str, client: Client, ns: Any) -> int:
178
202
  json_body=body, type_scope=ns.type_scope)
179
203
  emit(result, as_json=True)
180
204
  return 0
205
+ if name == "coa_diff":
206
+ coa = ns._coa
207
+ if coa is None:
208
+ raise ValueError("no COA config found (use --coa FILE or set AKT_COA_FILE)")
209
+ live_accounts = client.list("chart-of-accounts", all_pages=True)
210
+ live_categories = client.list("categories", all_pages=True)
211
+ plan = plan_sync(coa, live_accounts, live_categories, prune=ns.prune)
212
+ for line in render_plan(plan):
213
+ print(line)
214
+ return 0
215
+ if name == "coa_sync":
216
+ coa = ns._coa
217
+ if coa is None:
218
+ raise ValueError("no COA config found (use --coa FILE or set AKT_COA_FILE)")
219
+ live_accounts = client.list("chart-of-accounts", all_pages=True)
220
+ live_categories = client.list("categories", all_pages=True)
221
+ plan = plan_sync(coa, live_accounts, live_categories, prune=ns.prune)
222
+ for line in render_plan(plan):
223
+ print(line)
224
+ if plan.is_empty:
225
+ return 0
226
+ summary = apply_plan(client, plan, prune=ns.prune)
227
+ print("applied: " + ", ".join(f"{k}={v}" for k, v in summary.items() if v))
228
+ return 0
181
229
  raise ValueError(name)
182
230
 
183
231
 
@@ -209,11 +257,24 @@ def main(argv: list[str] | None = None) -> int:
209
257
  throttle = float(os.environ.get("AKT_THROTTLE", "0") or 0)
210
258
  client = Client(config, throttle=throttle)
211
259
 
260
+ try:
261
+ ns._coa = load_coa(getattr(ns, "coa_file", None))
262
+ except (ValueError, OSError) as e:
263
+ print(f"coa config error: {e}", file=sys.stderr)
264
+ return 2
265
+
212
266
  try:
213
267
  special = getattr(ns, "_special", None)
214
268
  if special:
215
269
  return _run_special(special, client, ns)
216
270
 
271
+ if ns.resource not in BY_NOUN:
272
+ # command group (e.g. "coa") invoked without a verb
273
+ sub = next(a for a in parser._subparsers._actions # type: ignore[attr-defined]
274
+ if a.dest == "resource")
275
+ sub.choices[ns.resource].print_help() # type: ignore[union-attr]
276
+ return 1
277
+
217
278
  res = BY_NOUN[ns.resource]
218
279
  handler = getattr(ns, "_handler", None)
219
280
  if handler is None:
@@ -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
@@ -0,0 +1,289 @@
1
+ """Config-driven chart-of-accounts <-> category linking.
2
+
3
+ akt defines a small COA config schema. When present, it lets `coa sync`
4
+ reconcile the double-entry chart of accounts and a 1:1 mirror of Akaunting
5
+ categories, and lets `payment create` code by --account with the mirror
6
+ category filled in automatically. The feature is inert when no config is found.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ import tomllib
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ # DoubleEntry standard seeds: account type_id -> Akaunting category type.
18
+ # Income classes -> "income", Expense classes -> "expense", everything else
19
+ # (assets/liabilities/equity) -> "other".
20
+ TYPE_ID_CATEGORY_TYPE: dict[int, str] = {
21
+ 13: "income", 14: "income", 15: "income", # revenue, sales, other_income
22
+ 11: "expense", 12: "expense", # direct_costs, expense
23
+ 1: "other", 2: "other", 3: "other", 4: "other", # asset types
24
+ 5: "other", 6: "other", 10: "other", # prepayment, bank_cash, depreciation
25
+ 7: "other", 8: "other", 9: "other", 17: "other", # liability types + tax
26
+ 16: "other", # equity
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CoaAccount:
32
+ code: int
33
+ name: str
34
+ type_id: int
35
+ category: str # mirror category name (== name unless overridden)
36
+ category_type: str # "income" | "expense" | "other"
37
+ mirror: bool # whether to create/keep a mirror category
38
+
39
+
40
+ @dataclass
41
+ class CoaConfig:
42
+ accounts: list[CoaAccount] = field(default_factory=list)
43
+
44
+ def by_code(self, code: int) -> CoaAccount | None:
45
+ for a in self.accounts:
46
+ if a.code == int(code):
47
+ return a
48
+ return None
49
+
50
+ def by_name(self, name: str) -> CoaAccount | None:
51
+ for a in self.accounts:
52
+ if a.name == name:
53
+ return a
54
+ return None
55
+
56
+ @property
57
+ def mirrored(self) -> list[CoaAccount]:
58
+ return [a for a in self.accounts if a.mirror]
59
+
60
+
61
+ def parse_coa(text: str) -> CoaConfig:
62
+ doc = tomllib.loads(text)
63
+ raw_accounts = doc.get("account", [])
64
+ accounts: list[CoaAccount] = []
65
+ seen_codes: set[int] = set()
66
+ seen_categories: set[str] = set()
67
+ for i, row in enumerate(raw_accounts):
68
+ if "code" not in row:
69
+ raise ValueError(f"account #{i + 1}: missing required 'code'")
70
+ if "name" not in row:
71
+ raise ValueError(f"account #{i + 1} (code {row['code']}): missing required 'name'")
72
+ if "type_id" not in row:
73
+ raise ValueError(f"account '{row['name']}': missing required 'type_id'")
74
+ code = int(row["code"])
75
+ name = str(row["name"])
76
+ type_id = int(row["type_id"])
77
+ mirror = bool(row.get("mirror", True))
78
+ category = str(row.get("category", name))
79
+ if code in seen_codes:
80
+ raise ValueError(f"duplicate code {code}")
81
+ seen_codes.add(code)
82
+ if type_id not in TYPE_ID_CATEGORY_TYPE:
83
+ print(f"warning: unknown type_id {type_id} for account '{name}'; "
84
+ f"mirror category type defaulted to 'other'", file=sys.stderr)
85
+ category_type = TYPE_ID_CATEGORY_TYPE.get(type_id, "other")
86
+ if mirror:
87
+ if category in seen_categories:
88
+ raise ValueError(f"duplicate category name {category!r} "
89
+ f"(mirror category names must be unique)")
90
+ seen_categories.add(category)
91
+ accounts.append(CoaAccount(code=code, name=name, type_id=type_id,
92
+ category=category, category_type=category_type,
93
+ mirror=mirror))
94
+ return CoaConfig(accounts=accounts)
95
+
96
+
97
+ def _coa_candidate_files(explicit: str | None) -> list[Path]:
98
+ if explicit:
99
+ return [Path(explicit).expanduser()]
100
+ files: list[Path] = []
101
+ if os.environ.get("AKT_COA_FILE"):
102
+ files.append(Path(os.environ["AKT_COA_FILE"]).expanduser())
103
+ files.append(Path.cwd() / "coa.toml")
104
+ files.append(Path.home() / ".config" / "akt" / "coa.toml")
105
+ return files
106
+
107
+
108
+ def load_coa(path: str | None = None) -> CoaConfig | None:
109
+ """Find and parse the COA config; return None if none is present.
110
+
111
+ An explicitly-passed ``path`` that does not exist is an error (the user
112
+ asked for a specific file); discovered defaults simply fall through."""
113
+ if path and not Path(path).expanduser().is_file():
114
+ raise ValueError(f"COA config not found: {path}")
115
+ for f in _coa_candidate_files(path):
116
+ if f.is_file():
117
+ return parse_coa(f.read_text())
118
+ return None
119
+
120
+
121
+ @dataclass
122
+ class CoaPlan:
123
+ accounts_create: list[CoaAccount] = field(default_factory=list)
124
+ accounts_rename: list[tuple[CoaAccount, dict]] = field(default_factory=list)
125
+ categories_create: list[CoaAccount] = field(default_factory=list)
126
+ categories_rename: list[tuple[CoaAccount, dict]] = field(default_factory=list)
127
+ accounts_disable: list[dict] = field(default_factory=list)
128
+
129
+ @property
130
+ def is_empty(self) -> bool:
131
+ return not any([self.accounts_create, self.accounts_rename,
132
+ self.categories_create, self.categories_rename,
133
+ self.accounts_disable])
134
+
135
+
136
+ def plan_sync(config: CoaConfig, live_accounts: list[dict],
137
+ live_categories: list[dict], *, prune: bool) -> CoaPlan:
138
+ """Diff the config against live Akaunting data. Pure: no I/O."""
139
+ plan = CoaPlan()
140
+ live_by_code = {int(a["code"]): a for a in live_accounts if a.get("code") is not None}
141
+ # (name, type) -> live category, so a same-named category of another type
142
+ # never gets clobbered.
143
+ live_cat = {(c.get("name"), c.get("type")): c for c in live_categories}
144
+
145
+ for acct in config.accounts:
146
+ live = live_by_code.get(acct.code)
147
+ if live is None:
148
+ plan.accounts_create.append(acct)
149
+ elif str(live.get("name")) != acct.name:
150
+ plan.accounts_rename.append((acct, live))
151
+
152
+ # A renamed mirrored account's mirror category currently lives under the account's
153
+ # OLD name; rename it instead of creating a fresh one (which would orphan the old).
154
+ rename_targets = {} # (new category, type) -> (acct, live_old_category)
155
+ for acct, live in plan.accounts_rename:
156
+ if not acct.mirror:
157
+ continue
158
+ old = live_cat.get((str(live.get("name")), acct.category_type))
159
+ if old is not None and old.get("name") != acct.category:
160
+ rename_targets[(acct.category, acct.category_type)] = (acct, old)
161
+
162
+ # Mirror-category names are unique by construction (parse_coa rejects
163
+ # duplicate category names among mirrored accounts), so these loops can
164
+ # never enqueue the same categories_create/categories_rename entry twice.
165
+ for acct in config.mirrored:
166
+ key = (acct.category, acct.category_type)
167
+ if live_cat.get(key) is not None:
168
+ continue # already correct
169
+ if key in rename_targets:
170
+ a, old = rename_targets[key]
171
+ plan.categories_rename.append((a, old)) # rename old -> a.category
172
+ else:
173
+ plan.categories_create.append(acct)
174
+
175
+ if prune:
176
+ config_codes = {a.code for a in config.accounts}
177
+ for live in live_accounts:
178
+ code = live.get("code")
179
+ if code is None:
180
+ continue
181
+ if int(code) not in config_codes and int(live.get("enabled", 1)) == 1:
182
+ plan.accounts_disable.append(live)
183
+ return plan
184
+
185
+
186
+ def render_plan(plan: CoaPlan) -> list[str]:
187
+ lines: list[str] = []
188
+ for a in plan.accounts_create:
189
+ lines.append(f"create account {a.code} {a.name} (type_id {a.type_id})")
190
+ for a, live in plan.accounts_rename:
191
+ lines.append(f"rename account {a.code}: {live.get('name')!r} -> {a.name!r}")
192
+ for a in plan.categories_create:
193
+ lines.append(f"create category {a.category!r} [{a.category_type}]")
194
+ for a, live in plan.categories_rename:
195
+ lines.append(f"rename category {live.get('name')!r} -> {a.category!r}")
196
+ for live in plan.accounts_disable:
197
+ lines.append(f"disable account {live.get('code')} {live.get('name')!r}")
198
+ if not lines:
199
+ lines.append("in sync — nothing to do")
200
+ return lines
201
+
202
+
203
+ def _account_form(acct: CoaAccount) -> list[tuple[str, str]]:
204
+ """The web-CRUD form body for a chart-of-accounts create/update."""
205
+ from .resources import flatten_form # local import: avoids resources<->coa cycle
206
+ return flatten_form({
207
+ "code": acct.code,
208
+ "name": acct.name,
209
+ "type_id": acct.type_id,
210
+ "enabled": 1,
211
+ "is_sub_account": "false",
212
+ })
213
+
214
+
215
+ def apply_plan(client, plan: "CoaPlan", *, prune: bool) -> dict:
216
+ """Execute the plan against Akaunting. Accounts use the web CRUD route
217
+ (chart-of-accounts is read-only on /api); categories use /api."""
218
+ summary = {"accounts_created": 0, "accounts_renamed": 0,
219
+ "categories_created": 0, "categories_renamed": 0,
220
+ "accounts_disabled": 0}
221
+
222
+ for acct in plan.accounts_create:
223
+ client.web_json("POST", "double-entry/chart-of-accounts", _account_form(acct))
224
+ summary["accounts_created"] += 1
225
+
226
+ for acct, live in plan.accounts_rename:
227
+ client.web_json("PATCH", f"double-entry/chart-of-accounts/{live['id']}",
228
+ _account_form(acct))
229
+ summary["accounts_renamed"] += 1
230
+
231
+ for acct in plan.categories_create:
232
+ client.post("categories", {
233
+ "name": acct.category,
234
+ "type": acct.category_type,
235
+ "color": "#00bcd4",
236
+ "enabled": 1,
237
+ })
238
+ summary["categories_created"] += 1
239
+
240
+ for acct, live in plan.categories_rename:
241
+ client.put(f"categories/{live['id']}", {
242
+ "name": acct.category,
243
+ "type": acct.category_type,
244
+ "color": live.get("color") or "#00bcd4",
245
+ "enabled": 1,
246
+ })
247
+ summary["categories_renamed"] += 1
248
+
249
+ if prune:
250
+ for live in plan.accounts_disable:
251
+ client.web_json("GET", f"double-entry/chart-of-accounts/{live['id']}/disable")
252
+ summary["accounts_disabled"] += 1
253
+
254
+ return summary
255
+
256
+
257
+ def _find_account(config: CoaConfig, ref: str) -> CoaAccount:
258
+ acct = config.by_code(int(ref)) if str(ref).lstrip("-").isdigit() else config.by_name(ref)
259
+ if acct is None:
260
+ raise ValueError(f"account {ref!r} is not in the COA config")
261
+ return acct
262
+
263
+
264
+ def resolve_coding(config: CoaConfig, client, *, account_ref: str) -> tuple[int, int]:
265
+ """Resolve --account to live (de_account_id, category_id).
266
+
267
+ The account must already exist in Akaunting (run `coa sync`); category_id
268
+ is auto-derived from its mirror category, which must also already exist."""
269
+ acct = _find_account(config, account_ref)
270
+
271
+ if not acct.mirror:
272
+ raise ValueError(
273
+ f"account {acct.code} ({acct.name}) has mirror=false — it has no mirror "
274
+ f"category, so it can't be coded onto a payment via --account "
275
+ f"(only mirrored income/expense accounts can be)")
276
+
277
+ live_accounts = client.list("chart-of-accounts", all_pages=True)
278
+ de_id = next((a["id"] for a in live_accounts if int(a.get("code", -1)) == acct.code), None)
279
+ if de_id is None:
280
+ raise ValueError(f"account {acct.code} ({acct.name}) is not in Akaunting yet — "
281
+ f"run `akt coa sync` first")
282
+ live_categories = client.list("categories", all_pages=True)
283
+ cat_id = next((c["id"] for c in live_categories
284
+ if c.get("name") == acct.category and c.get("type") == acct.category_type),
285
+ None)
286
+ if cat_id is None:
287
+ raise ValueError(f"mirror category {acct.category!r} [{acct.category_type}] is not in "
288
+ f"Akaunting yet — run `akt coa sync` first")
289
+ return int(de_id), int(cat_id)
@@ -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,
@@ -88,8 +92,8 @@ ITEM = Resource(
88
92
  help="Products and services",
89
93
  )
90
94
 
91
- ACCOUNT = Resource(
92
- noun="account",
95
+ BANK = Resource(
96
+ noun="bank",
93
97
  endpoint="accounts",
94
98
  fields=[
95
99
  f("name", "Account name", required=True),
@@ -216,10 +220,13 @@ PAYMENT = Resource(
216
220
  f("type", "income | expense", choices=["income", "expense"]),
217
221
  f("invoice", "Invoice id to apply an income payment to"),
218
222
  f("bill", "Bill id to apply an expense payment to"),
223
+ f("account", "GL/chart-of-accounts code or name to post to (the double-entry "
224
+ "account). Requires a --coa config; auto-fills the mirrored "
225
+ "category. See --bank for the bank/cash account."),
219
226
  f("document-id", "Linked document id (advanced)"),
220
227
  f("contact-id", "Contact id"),
221
228
  f("amount", "Payment amount"),
222
- f("account-id", "Bank/cash account id"),
229
+ f("bank", "Bank/cash account id", dest="account_id"),
223
230
  f("category-id", "Category id"),
224
231
  f("paid-at", "Payment date/time (YYYY-MM-DD)"),
225
232
  f("currency-code", "Currency code"),
@@ -242,8 +249,8 @@ TRANSFER = Resource(
242
249
  noun="transfer",
243
250
  endpoint="transfers",
244
251
  fields=[
245
- f("from-account-id", "Source account id", required=True),
246
- f("to-account-id", "Destination account id", required=True),
252
+ f("from-bank", "Source bank/cash account id", dest="from_account_id", required=True),
253
+ f("to-bank", "Destination bank/cash account id", dest="to_account_id", required=True),
247
254
  f("amount", "Amount", required=True),
248
255
  f("transferred-at", "Transfer date/time (YYYY-MM-DD)"),
249
256
  f("payment-method", "Payment method code", default="offline-payments.cash.1"),
@@ -260,9 +267,66 @@ TRANSFER = Resource(
260
267
  )
261
268
 
262
269
 
270
+ # Double-entry (general ledger) -------------------------------------------
271
+
272
+ # The DoubleEntry module publishes chart-of-accounts READ-ONLY over the API
273
+ # (index/show only). Create/update/delete exist only on the session/CSRF web
274
+ # CRUD route, so those verbs are driven through client.web_json (web_endpoint)
275
+ # while list/get keep using the /api surface (endpoint). journal-entry is full
276
+ # API CRUD.
277
+
278
+ ACCOUNT = Resource(
279
+ noun="account",
280
+ endpoint="chart-of-accounts",
281
+ web_endpoint="double-entry/chart-of-accounts",
282
+ fields=[
283
+ f("name", "Account name", required=True),
284
+ f("code", "Account code (unique integer per company)"),
285
+ f("type-id", "Double-entry account type id (see an existing account's type_id)"),
286
+ f("parent-id", "Parent account id (makes this a sub-account)", dest="account_id"),
287
+ f("description", "Description"),
288
+ f("enabled", "Enable the record", is_flag=True, default=1),
289
+ ],
290
+ columns=[
291
+ ("ID", "id"), ("Code", "code"), ("Name", "name"), ("Type", "type_id"),
292
+ ("Parent", "account_id"), ("Enabled", "enabled"),
293
+ ],
294
+ supports_toggle=False,
295
+ build_create=build_account_create,
296
+ build_update=build_account_update,
297
+ help="Chart of accounts (general ledger; read via API, create/update/delete via web, "
298
+ "double-entry module)",
299
+ )
300
+
301
+ JOURNAL_ENTRY = Resource(
302
+ noun="journal-entry",
303
+ endpoint="journal-entry",
304
+ fields=[
305
+ f("journal-number", "Journal number (auto-generated if omitted)"),
306
+ f("paid-at", "Entry date (YYYY-MM-DD; defaults to today)"),
307
+ f("description", "Description", required=True),
308
+ f("basis", "cash | accrual", default="accrual", choices=["cash", "accrual"]),
309
+ f("reference", "Reference"),
310
+ f("currency-code", "Currency code", default="USD"),
311
+ f("currency-rate", "Currency rate", default=1),
312
+ ],
313
+ columns=[
314
+ ("ID", "id"), ("Number", "journal_number"), ("Date", "paid_at"),
315
+ ("Amount", "amount_formatted"), ("Basis", "basis"),
316
+ ("Description", "description"),
317
+ ],
318
+ supports_toggle=False,
319
+ supports_attachments=True,
320
+ build_create=build_journal_create,
321
+ build_update=build_journal_update,
322
+ help="Journal entries (double-entry general ledger)",
323
+ )
324
+
325
+
263
326
  RESOURCES: list[Resource] = [
264
- CUSTOMER, VENDOR, ITEM, ACCOUNT, CATEGORY, TAX, CURRENCY,
327
+ CUSTOMER, VENDOR, ITEM, BANK, CATEGORY, TAX, CURRENCY,
265
328
  INVOICE, BILL, PAYMENT, TRANSFER,
329
+ ACCOUNT, JOURNAL_ENTRY,
266
330
  ]
267
331
 
268
332
  BY_NOUN = {r.noun: r for r in RESOURCES}
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
16
16
  from typing import Any, Callable
17
17
 
18
18
  from .client import Client
19
+ from .coa import resolve_coding
19
20
 
20
21
 
21
22
  # --------------------------------------------------------------------------
@@ -53,6 +54,11 @@ class Resource:
53
54
  search_default: str | None = None # always-applied search filter
54
55
  supports_toggle: bool = True # enable/disable verbs
55
56
  supports_attachments: bool = False # --attachment upload + attachments/download verbs
57
+ read_only: bool = False # expose only list/get (no create/update/delete)
58
+ # When set, create/update/delete are driven through the session-authenticated
59
+ # web route (client.web_json) at this path instead of the /api surface — for
60
+ # resources whose CRUD Akaunting exposes only on the web (e.g. chart-of-accounts).
61
+ web_endpoint: str | None = None
56
62
  help: str = ""
57
63
 
58
64
  # hooks (override for documents/payments)
@@ -431,6 +437,18 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
431
437
  contact_id = getattr(ns, "contact_id", None)
432
438
  category_id = getattr(ns, "category_id", None)
433
439
 
440
+ coa = getattr(ns, "_coa", None)
441
+ account_ref = getattr(ns, "account", None)
442
+ de_account_id = None
443
+ if account_ref:
444
+ if coa is None:
445
+ raise ValueError(
446
+ "--account requires a COA config (pass --coa FILE or set AKT_COA_FILE)")
447
+ coa_de_account_id, coa_category_id = resolve_coding(coa, client, account_ref=account_ref)
448
+ de_account_id = coa_de_account_id
449
+ if category_id is None: # explicit --category-id wins
450
+ category_id = coa_category_id
451
+
434
452
  if invoice_id:
435
453
  document = client.show("documents", invoice_id, type_scope="invoice")
436
454
  ptype = ptype or "income"
@@ -487,6 +505,8 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
487
505
  body["reference"] = ns.reference
488
506
  if getattr(ns, "description", None):
489
507
  body["description"] = ns.description
508
+ if de_account_id is not None:
509
+ body["de_account_id"] = de_account_id
490
510
  body.update(parse_set(getattr(ns, "set_", None)))
491
511
  body.update(load_data_arg(getattr(ns, "data", None)))
492
512
 
@@ -551,3 +571,190 @@ def _next_transaction_number(client: Client) -> str:
551
571
  if tail:
552
572
  maxn = max(maxn, int(tail))
553
573
  return f"{pre}{maxn + 1:05d}"
574
+
575
+
576
+ # --------------------------------------------------------------------------
577
+ # journal entry (double-entry) body builder
578
+ # --------------------------------------------------------------------------
579
+
580
+ def parse_journal_item(spec: str) -> dict:
581
+ """Parse one journal line: ``account_id=10,debit=100`` or
582
+ ``account_id=20,credit=100`` (optional ``id=`` on update to target an
583
+ existing ledger, optional ``notes=``). A line carries a debit *or* a
584
+ credit; the omitted side defaults to 0."""
585
+ item: dict[str, Any] = {}
586
+ for part in spec.split(","):
587
+ if not part.strip():
588
+ continue
589
+ if "=" not in part:
590
+ raise ValueError(f"--item field must be key=value, got {part!r}")
591
+ k, _, v = part.partition("=")
592
+ item[k.strip()] = _coerce(v.strip())
593
+ if "account_id" not in item:
594
+ raise ValueError(f"--item requires an account_id=… field: {spec!r}")
595
+ if "debit" not in item and "credit" not in item:
596
+ raise ValueError(f"--item requires a debit=… or credit=… field: {spec!r}")
597
+ return item
598
+
599
+
600
+ def _normalize_journal_items(items: list[dict]) -> list[dict]:
601
+ """Coerce account_id/id to int and ensure both debit & credit keys exist
602
+ (Akaunting validates ``items.*.debit`` and ``items.*.credit`` as required)."""
603
+ for it in items:
604
+ if it.get("account_id") is not None:
605
+ it["account_id"] = int(it["account_id"])
606
+ if it.get("id") is not None:
607
+ it["id"] = int(it["id"])
608
+ it.setdefault("debit", 0)
609
+ it.setdefault("credit", 0)
610
+ return items
611
+
612
+
613
+ def _require_balanced(items: list[dict]) -> None:
614
+ """A journal entry needs >= 2 lines whose debits equal its credits."""
615
+ if len(items) < 2:
616
+ raise ValueError("a journal entry needs at least 2 line items")
617
+ debit = sum(float(it.get("debit") or 0) for it in items)
618
+ credit = sum(float(it.get("credit") or 0) for it in items)
619
+ if abs(debit - credit) > 1e-4:
620
+ raise ValueError(
621
+ f"journal entry does not balance: debits {debit:.2f} != credits {credit:.2f}"
622
+ )
623
+
624
+
625
+ def _next_journal_number(client: Client) -> str:
626
+ pre = client.setting("double-entry.journal.number_prefix") or "MJE-"
627
+ digit = client.setting("double-entry.journal.number_digit")
628
+ try:
629
+ width = int(digit)
630
+ except (TypeError, ValueError):
631
+ width = 5
632
+ existing = client.list("journal-entry", all_pages=True)
633
+ maxn = 0
634
+ for row in existing:
635
+ tail = "".join(ch for ch in str(row.get("journal_number", "")) if ch.isdigit())
636
+ if tail:
637
+ maxn = max(maxn, int(tail))
638
+ return f"{pre}{maxn + 1:0{width}d}"
639
+
640
+
641
+ def build_journal_create(res: Resource, client: Client, ns: Any) -> dict:
642
+ extra = load_data_arg(getattr(ns, "data", None))
643
+ items = [parse_journal_item(s) for s in (getattr(ns, "item", None) or [])]
644
+ if not items and "items" not in extra:
645
+ raise ValueError(
646
+ "at least two --item 'account_id=…,debit=…|credit=…' lines are "
647
+ "required (or supply --data with items)"
648
+ )
649
+
650
+ description = getattr(ns, "description", None)
651
+ if not description:
652
+ raise ValueError("--description is required to create a journal-entry")
653
+
654
+ body: dict[str, Any] = {
655
+ "paid_at": _normalize_date(getattr(ns, "paid_at", None) or today_dt()),
656
+ "journal_number": getattr(ns, "journal_number", None),
657
+ "description": description,
658
+ "basis": getattr(ns, "basis", None) or "accrual",
659
+ "currency_code": getattr(ns, "currency_code", None) or "USD",
660
+ "currency_rate": getattr(ns, "currency_rate", None) or 1,
661
+ # Akaunting recomputes the entry amount from the ledger debits; send 0.
662
+ "amount": 0,
663
+ "items": items,
664
+ }
665
+ if getattr(ns, "reference", None):
666
+ body["reference"] = ns.reference
667
+ body.update(parse_set(getattr(ns, "set_", None)))
668
+ body.update(extra)
669
+ if isinstance(body.get("items"), list):
670
+ _normalize_journal_items(body["items"])
671
+ _require_balanced(body["items"])
672
+ # Assign the journal number last — after client-side validation — so an
673
+ # invalid entry never burns a lookup (and the imbalance error surfaces
674
+ # without a network round-trip).
675
+ if not body.get("journal_number"):
676
+ body["journal_number"] = _next_journal_number(client)
677
+ return body
678
+
679
+
680
+ def _journal_items_from_current(current: dict) -> list[dict]:
681
+ """Rebuild request items from a fetched entry's ledgers so an update that
682
+ doesn't touch lines doesn't delete them (UpdateJournalEntry deletes any
683
+ ledger whose id is absent from the request). Each line carries its ledger
684
+ ``id`` so the existing row is updated in place rather than recreated."""
685
+ out: list[dict] = []
686
+ for led in current.get("ledgers", {}).get("data", []):
687
+ row = {
688
+ "account_id": led.get("account_id"),
689
+ "debit": float(led.get("debit") or 0),
690
+ "credit": float(led.get("credit") or 0),
691
+ }
692
+ if led.get("id"):
693
+ row["id"] = int(led["id"])
694
+ out.append(row)
695
+ return out
696
+
697
+
698
+ def build_journal_update(res: Resource, client: Client, ns: Any, current: dict) -> dict:
699
+ """Full update: Akaunting re-derives ledgers & amount from the request, so
700
+ resend the whole entry, overlaying any provided fields."""
701
+ body: dict[str, Any] = {
702
+ "paid_at": _normalize_dt_field(current.get("paid_at") or today_dt()),
703
+ "journal_number": current.get("journal_number"),
704
+ "description": current.get("description"),
705
+ "basis": current.get("basis") or "accrual",
706
+ "currency_code": current.get("currency_code"),
707
+ "currency_rate": current.get("currency_rate", 1),
708
+ "amount": 0,
709
+ "items": _journal_items_from_current(current),
710
+ }
711
+ if current.get("reference"):
712
+ body["reference"] = current["reference"]
713
+
714
+ for attr, key in [
715
+ ("paid_at", "paid_at"), ("journal_number", "journal_number"),
716
+ ("description", "description"), ("basis", "basis"),
717
+ ("currency_code", "currency_code"), ("currency_rate", "currency_rate"),
718
+ ("reference", "reference"),
719
+ ]:
720
+ v = getattr(ns, attr, None)
721
+ if v is not None:
722
+ body[key] = _normalize_date(v) if key == "paid_at" else v
723
+
724
+ items_specs = getattr(ns, "item", None) or []
725
+ if items_specs:
726
+ body["items"] = [parse_journal_item(s) for s in items_specs]
727
+
728
+ body.update(parse_set(getattr(ns, "set_", None)))
729
+ body.update(load_data_arg(getattr(ns, "data", None)))
730
+ if isinstance(body.get("items"), list):
731
+ _normalize_journal_items(body["items"])
732
+ _require_balanced(body["items"])
733
+ return body
734
+
735
+
736
+ # --------------------------------------------------------------------------
737
+ # chart-of-accounts (double-entry) — web-surface CRUD body builder
738
+ # --------------------------------------------------------------------------
739
+
740
+ def _apply_sub_account(body: dict) -> None:
741
+ """The web CRUD form carries an ``is_sub_account`` toggle; a parent
742
+ ``account_id`` is only honored when it's ``"true"`` and is cleared when
743
+ ``"false"``. Derive it from whether a parent was supplied."""
744
+ body["is_sub_account"] = "true" if body.get("account_id") else "false"
745
+
746
+
747
+ def build_account_create(res: Resource, client: Client, ns: Any) -> dict:
748
+ body = body_from_fields(res, ns, for_update=False)
749
+ if not body.get("name"):
750
+ raise ValueError("--name is required to create an account")
751
+ _apply_sub_account(body)
752
+ return body
753
+
754
+
755
+ def build_account_update(res: Resource, client: Client, ns: Any, current: dict) -> dict:
756
+ # A full replace: Akaunting's Account request re-validates `name` on update,
757
+ # so backfill unspecified fields from the current record.
758
+ body = body_from_fields(res, ns, for_update=True, current=current)
759
+ _apply_sub_account(body)
760
+ return body
File without changes
File without changes
File without changes