akt-cli 0.3.0__tar.gz → 0.4.1__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.3.0
3
+ Version: 0.4.1
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 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.
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)
@@ -102,13 +102,14 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
102
102
  | `bill` | `documents` | document of type `bill` |
103
103
  | `payment` | `transactions` | income (invoice) or expense (bill) transaction |
104
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 |
106
- | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
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 | |
107
108
 
108
- > `journal-entry` and `chart-of-account` require the **Double-Entry** module
109
+ > `journal-entry` and `account` require the **Double-Entry** module
109
110
  > installed on the instance. The module publishes chart-of-accounts read-only on
110
111
  > 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
+ > session/CSRF **web** route. `akt account` gives you the full verb set
112
113
  > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
113
114
  > drive the web CRUD with your admin session (the same mechanism
114
115
  > `download-attachment` already uses).
@@ -166,6 +167,10 @@ akt item create --name "Consulting Hour" --sale-price 150 --purchase-price 0
166
167
  akt category create --name "Services" --type income
167
168
  akt tax create --name "Sales Tax" --rate 8.25
168
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
+
169
174
  # Invoice with line items (totals computed server-side; number auto-generated)
170
175
  akt invoice create --contact 12 \
171
176
  --item 'name=Consulting,price=150,quantity=10,item_id=2' \
@@ -192,16 +197,16 @@ akt bill download-attachment 41 --out ./downloads # save to disk
192
197
  akt payment update 57 --remove-attachment # clear attachments
193
198
 
194
199
  # 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
200
+ akt account list # read the chart of accounts
201
+ akt account get 12
197
202
 
198
203
  # Build the chart of accounts as code (create/update/delete run via the web
199
204
  # session; type-id is the double-entry account-type id — copy it from an
200
205
  # 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
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
205
210
 
206
211
  # Post a balanced journal entry (>= 2 lines; debits must equal credits;
207
212
  # journal number auto-generated, basis defaults to accrual)
@@ -214,6 +219,38 @@ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
214
219
  --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
215
220
  --attachment ./invoice.pdf
216
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
+
217
254
  # Anything else: raw API access
218
255
  akt raw GET reports
219
256
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -260,7 +297,7 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
260
297
  `double-entry.journal.number_*` settings when you don't pass one.
261
298
  * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
262
299
  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
300
+ session/CSRF web route. `akt account create|update|delete` logs in a
264
301
  web session (reusing your admin credentials, cached for the process), attaches
265
302
  the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
266
303
  envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
@@ -286,75 +323,10 @@ timing out. `akt` retries throttle/WAF responses with backoff, and
286
323
  use `--throttle 1` for bulk work. A durable fix is to whitelist your IP in the
287
324
  host firewall.
288
325
 
289
- ## Testing
290
-
291
- Tests are split in two:
292
-
293
- * **`tests/unit/`** — offline tests for the body builders and arg parsing. No
294
- network. This is what CI runs by default and what gates pull requests.
295
- * **`tests/integration/`** — drive the real `akt` CLI against a live Akaunting
296
- instance, exercising the full surface (contacts, items, bill → payment →
297
- paid, transfers, …). Every record they create is deleted on teardown — even
298
- on failure — so no invoices, bills or payments are left behind.
299
-
300
- ```bash
301
- uv run pytest tests/unit # fast, offline (default)
302
- uv run pytest # integration tests auto-skip without creds
303
-
304
- # Run integration tests against a deployment:
305
- AKT_BASE_URL=https://accounting.example.com \
306
- AKT_EMAIL=admin@example.com \
307
- AKT_PASSWORD=… \
308
- uv run pytest tests/integration -v
309
- ```
310
-
311
- > Invoice creation is `xfail`-ed when the host's plan-limit gate is active (see
312
- > above); the rest of the suite must pass.
313
-
314
- ### CI / CD
315
-
316
- * **CI** (`.github/workflows/ci.yml`) runs on every push and PR: unit tests +
317
- coverage, uploaded to [Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli).
318
- * **Release** (`.github/workflows/release.yml`) runs the live integration suite
319
- on published releases (and via *Run workflow*). Connection details come from
320
- GitHub Actions **secrets** (`AKT_BASE_URL`, `AKT_EMAIL`, `AKT_PASSWORD`) — they
321
- are never committed and are masked in logs.
322
- * **Publish** (`.github/workflows/publish.yml`) builds the sdist + wheel and
323
- uploads them via **Trusted Publishing (OIDC)** — no API token is stored.
324
- *Run workflow* publishes to **TestPyPI**; a published release publishes to
325
- **PyPI**.
326
-
327
- ### Releasing to PyPI
328
-
329
- One-time setup — add a *pending publisher* on each index
330
- (Account → Publishing → *Add a pending publisher*) with:
331
-
332
- | Field | TestPyPI | PyPI |
333
- |-------|----------|------|
334
- | Project Name | `akt-cli` | `akt-cli` |
335
- | Owner | `AsyncAlchemist` | `AsyncAlchemist` |
336
- | Repository name | `akt-cli` | `akt-cli` |
337
- | Workflow name | `publish.yml` | `publish.yml` |
338
- | Environment name | `testpypi` | `pypi` |
339
-
340
- Then:
326
+ ## Contributing
341
327
 
342
- * **Verify** *Actions Publish (PyPI) → Run workflow* uploads the current
343
- version to TestPyPI.
344
- * **Release** — bump `version` in `pyproject.toml`, push, and publish a GitHub
345
- Release. That runs the live integration suite and publishes to PyPI.
346
-
347
- The code is small and declarative:
348
-
349
- | file | purpose |
350
- |-----------------|----------------------------------------------------------|
351
- | `config.py` | credential resolution (flags / env / dotenv) |
352
- | `client.py` | HTTP, auth, company scoping, pagination, retries |
353
- | `resources.py` | field specs + body builders (documents, payments) |
354
- | `registry.py` | the concrete list of resources and their columns |
355
- | `commands.py` | generic list/get/create/update/delete/toggle handlers |
356
- | `cli.py` | argparse wiring and entrypoint |
357
- | `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.
358
330
 
359
331
  ## License
360
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 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.
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)
@@ -81,13 +81,14 @@ Akaunting folds several nouns onto shared endpoints; `akt` hides that:
81
81
  | `bill` | `documents` | document of type `bill` |
82
82
  | `payment` | `transactions` | income (invoice) or expense (bill) transaction |
83
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 |
85
- | `item`, `account`, `category`, `tax`, `currency`, `transfer` | as named | |
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 | |
86
87
 
87
- > `journal-entry` and `chart-of-account` require the **Double-Entry** module
88
+ > `journal-entry` and `account` require the **Double-Entry** module
88
89
  > installed on the instance. The module publishes chart-of-accounts read-only on
89
90
  > 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
+ > session/CSRF **web** route. `akt account` gives you the full verb set
91
92
  > anyway — `list`/`get` hit `/api`, while `create`/`update`/`delete` transparently
92
93
  > drive the web CRUD with your admin session (the same mechanism
93
94
  > `download-attachment` already uses).
@@ -145,6 +146,10 @@ akt item create --name "Consulting Hour" --sale-price 150 --purchase-price 0
145
146
  akt category create --name "Services" --type income
146
147
  akt tax create --name "Sales Tax" --rate 8.25
147
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
+
148
153
  # Invoice with line items (totals computed server-side; number auto-generated)
149
154
  akt invoice create --contact 12 \
150
155
  --item 'name=Consulting,price=150,quantity=10,item_id=2' \
@@ -171,16 +176,16 @@ akt bill download-attachment 41 --out ./downloads # save to disk
171
176
  akt payment update 57 --remove-attachment # clear attachments
172
177
 
173
178
  # 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
179
+ akt account list # read the chart of accounts
180
+ akt account get 12
176
181
 
177
182
  # Build the chart of accounts as code (create/update/delete run via the web
178
183
  # session; type-id is the double-entry account-type id — copy it from an
179
184
  # 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
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
184
189
 
185
190
  # Post a balanced journal entry (>= 2 lines; debits must equal credits;
186
191
  # journal number auto-generated, basis defaults to accrual)
@@ -193,6 +198,38 @@ akt journal-entry create --description "Vendor bill accrual" --basis accrual \
193
198
  --item 'account_id=60,debit=250' --item 'account_id=21,credit=250' \
194
199
  --attachment ./invoice.pdf
195
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
+
196
233
  # Anything else: raw API access
197
234
  akt raw GET reports
198
235
  akt raw POST items --data '{"name":"Ad-hoc","type":"service","sale_price":99}'
@@ -239,7 +276,7 @@ Driving Akaunting's API directly has sharp edges; `akt` papers over these:
239
276
  `double-entry.journal.number_*` settings when you don't pass one.
240
277
  * **Chart-of-accounts CRUD is web-only** — the Double-Entry module exposes
241
278
  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
279
+ session/CSRF web route. `akt account create|update|delete` logs in a
243
280
  web session (reusing your admin credentials, cached for the process), attaches
244
281
  the CSRF token, and unwraps Akaunting's `{success, error, data, message}` AJAX
245
282
  envelope — so a server-side block (e.g. *deleting an account that has ledgers*)
@@ -265,75 +302,10 @@ timing out. `akt` retries throttle/WAF responses with backoff, and
265
302
  use `--throttle 1` for bulk work. A durable fix is to whitelist your IP in the
266
303
  host firewall.
267
304
 
268
- ## Testing
269
-
270
- Tests are split in two:
271
-
272
- * **`tests/unit/`** — offline tests for the body builders and arg parsing. No
273
- network. This is what CI runs by default and what gates pull requests.
274
- * **`tests/integration/`** — drive the real `akt` CLI against a live Akaunting
275
- instance, exercising the full surface (contacts, items, bill → payment →
276
- paid, transfers, …). Every record they create is deleted on teardown — even
277
- on failure — so no invoices, bills or payments are left behind.
278
-
279
- ```bash
280
- uv run pytest tests/unit # fast, offline (default)
281
- uv run pytest # integration tests auto-skip without creds
282
-
283
- # Run integration tests against a deployment:
284
- AKT_BASE_URL=https://accounting.example.com \
285
- AKT_EMAIL=admin@example.com \
286
- AKT_PASSWORD=… \
287
- uv run pytest tests/integration -v
288
- ```
289
-
290
- > Invoice creation is `xfail`-ed when the host's plan-limit gate is active (see
291
- > above); the rest of the suite must pass.
292
-
293
- ### CI / CD
294
-
295
- * **CI** (`.github/workflows/ci.yml`) runs on every push and PR: unit tests +
296
- coverage, uploaded to [Codecov](https://codecov.io/gh/AsyncAlchemist/akt-cli).
297
- * **Release** (`.github/workflows/release.yml`) runs the live integration suite
298
- on published releases (and via *Run workflow*). Connection details come from
299
- GitHub Actions **secrets** (`AKT_BASE_URL`, `AKT_EMAIL`, `AKT_PASSWORD`) — they
300
- are never committed and are masked in logs.
301
- * **Publish** (`.github/workflows/publish.yml`) builds the sdist + wheel and
302
- uploads them via **Trusted Publishing (OIDC)** — no API token is stored.
303
- *Run workflow* publishes to **TestPyPI**; a published release publishes to
304
- **PyPI**.
305
-
306
- ### Releasing to PyPI
307
-
308
- One-time setup — add a *pending publisher* on each index
309
- (Account → Publishing → *Add a pending publisher*) with:
310
-
311
- | Field | TestPyPI | PyPI |
312
- |-------|----------|------|
313
- | Project Name | `akt-cli` | `akt-cli` |
314
- | Owner | `AsyncAlchemist` | `AsyncAlchemist` |
315
- | Repository name | `akt-cli` | `akt-cli` |
316
- | Workflow name | `publish.yml` | `publish.yml` |
317
- | Environment name | `testpypi` | `pypi` |
318
-
319
- Then:
305
+ ## Contributing
320
306
 
321
- * **Verify** *Actions Publish (PyPI) → Run workflow* uploads the current
322
- version to TestPyPI.
323
- * **Release** — bump `version` in `pyproject.toml`, push, and publish a GitHub
324
- Release. That runs the live integration suite and publishes to PyPI.
325
-
326
- The code is small and declarative:
327
-
328
- | file | purpose |
329
- |-----------------|----------------------------------------------------------|
330
- | `config.py` | credential resolution (flags / env / dotenv) |
331
- | `client.py` | HTTP, auth, company scoping, pagination, retries |
332
- | `resources.py` | field specs + body builders (documents, payments) |
333
- | `registry.py` | the concrete list of resources and their columns |
334
- | `commands.py` | generic list/get/create/update/delete/toggle handlers |
335
- | `cli.py` | argparse wiring and entrypoint |
336
- | `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.
337
309
 
338
310
  ## License
339
311
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "akt — a CLI toolbox to fully drive an Akaunting accounting instance"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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:
@@ -84,6 +85,9 @@ def _build_parser() -> argparse.ArgumentParser:
84
85
  metavar="SECONDS",
85
86
  help="Min seconds between API calls (or AKT_THROTTLE). "
86
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)")
87
91
 
88
92
  sub = parser.add_subparsers(dest="resource", metavar="<resource>")
89
93
 
@@ -159,6 +163,19 @@ def _build_parser() -> argparse.ArgumentParser:
159
163
  rp.add_argument("--type-scope", help="search=type:X scope for contacts/documents")
160
164
  rp.set_defaults(_special="raw")
161
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
+
162
179
  return parser
163
180
 
164
181
 
@@ -185,6 +202,30 @@ def _run_special(name: str, client: Client, ns: Any) -> int:
185
202
  json_body=body, type_scope=ns.type_scope)
186
203
  emit(result, as_json=True)
187
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
188
229
  raise ValueError(name)
189
230
 
190
231
 
@@ -216,11 +257,24 @@ def main(argv: list[str] | None = None) -> int:
216
257
  throttle = float(os.environ.get("AKT_THROTTLE", "0") or 0)
217
258
  client = Client(config, throttle=throttle)
218
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
+
219
266
  try:
220
267
  special = getattr(ns, "_special", None)
221
268
  if special:
222
269
  return _run_special(special, client, ns)
223
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
+
224
278
  res = BY_NOUN[ns.resource]
225
279
  handler = getattr(ns, "_handler", None)
226
280
  if handler is None:
@@ -0,0 +1,293 @@
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 = row.get("mirror", True)
78
+ if not isinstance(mirror, bool):
79
+ raise ValueError(
80
+ f"account '{name}': 'mirror' must be a boolean (true/false), "
81
+ f"got {mirror!r} — did you quote it?")
82
+ category = str(row.get("category", name))
83
+ if code in seen_codes:
84
+ raise ValueError(f"duplicate code {code}")
85
+ seen_codes.add(code)
86
+ if type_id not in TYPE_ID_CATEGORY_TYPE:
87
+ print(f"warning: unknown type_id {type_id} for account '{name}'; "
88
+ f"mirror category type defaulted to 'other'", file=sys.stderr)
89
+ category_type = TYPE_ID_CATEGORY_TYPE.get(type_id, "other")
90
+ if mirror:
91
+ if category in seen_categories:
92
+ raise ValueError(f"duplicate category name {category!r} "
93
+ f"(mirror category names must be unique)")
94
+ seen_categories.add(category)
95
+ accounts.append(CoaAccount(code=code, name=name, type_id=type_id,
96
+ category=category, category_type=category_type,
97
+ mirror=mirror))
98
+ return CoaConfig(accounts=accounts)
99
+
100
+
101
+ def _coa_candidate_files(explicit: str | None) -> list[Path]:
102
+ if explicit:
103
+ return [Path(explicit).expanduser()]
104
+ files: list[Path] = []
105
+ if os.environ.get("AKT_COA_FILE"):
106
+ files.append(Path(os.environ["AKT_COA_FILE"]).expanduser())
107
+ files.append(Path.cwd() / "coa.toml")
108
+ files.append(Path.home() / ".config" / "akt" / "coa.toml")
109
+ return files
110
+
111
+
112
+ def load_coa(path: str | None = None) -> CoaConfig | None:
113
+ """Find and parse the COA config; return None if none is present.
114
+
115
+ An explicitly-passed ``path`` that does not exist is an error (the user
116
+ asked for a specific file); discovered defaults simply fall through."""
117
+ if path and not Path(path).expanduser().is_file():
118
+ raise ValueError(f"COA config not found: {path}")
119
+ for f in _coa_candidate_files(path):
120
+ if f.is_file():
121
+ return parse_coa(f.read_text())
122
+ return None
123
+
124
+
125
+ @dataclass
126
+ class CoaPlan:
127
+ accounts_create: list[CoaAccount] = field(default_factory=list)
128
+ accounts_rename: list[tuple[CoaAccount, dict]] = field(default_factory=list)
129
+ categories_create: list[CoaAccount] = field(default_factory=list)
130
+ categories_rename: list[tuple[CoaAccount, dict]] = field(default_factory=list)
131
+ accounts_disable: list[dict] = field(default_factory=list)
132
+
133
+ @property
134
+ def is_empty(self) -> bool:
135
+ return not any([self.accounts_create, self.accounts_rename,
136
+ self.categories_create, self.categories_rename,
137
+ self.accounts_disable])
138
+
139
+
140
+ def plan_sync(config: CoaConfig, live_accounts: list[dict],
141
+ live_categories: list[dict], *, prune: bool) -> CoaPlan:
142
+ """Diff the config against live Akaunting data. Pure: no I/O."""
143
+ plan = CoaPlan()
144
+ live_by_code = {int(a["code"]): a for a in live_accounts if a.get("code") is not None}
145
+ # (name, type) -> live category, so a same-named category of another type
146
+ # never gets clobbered.
147
+ live_cat = {(c.get("name"), c.get("type")): c for c in live_categories}
148
+
149
+ for acct in config.accounts:
150
+ live = live_by_code.get(acct.code)
151
+ if live is None:
152
+ plan.accounts_create.append(acct)
153
+ elif str(live.get("name")) != acct.name:
154
+ plan.accounts_rename.append((acct, live))
155
+
156
+ # A renamed mirrored account's mirror category currently lives under the account's
157
+ # OLD name; rename it instead of creating a fresh one (which would orphan the old).
158
+ rename_targets = {} # (new category, type) -> (acct, live_old_category)
159
+ for acct, live in plan.accounts_rename:
160
+ if not acct.mirror:
161
+ continue
162
+ old = live_cat.get((str(live.get("name")), acct.category_type))
163
+ if old is not None and old.get("name") != acct.category:
164
+ rename_targets[(acct.category, acct.category_type)] = (acct, old)
165
+
166
+ # Mirror-category names are unique by construction (parse_coa rejects
167
+ # duplicate category names among mirrored accounts), so these loops can
168
+ # never enqueue the same categories_create/categories_rename entry twice.
169
+ for acct in config.mirrored:
170
+ key = (acct.category, acct.category_type)
171
+ if live_cat.get(key) is not None:
172
+ continue # already correct
173
+ if key in rename_targets:
174
+ a, old = rename_targets[key]
175
+ plan.categories_rename.append((a, old)) # rename old -> a.category
176
+ else:
177
+ plan.categories_create.append(acct)
178
+
179
+ if prune:
180
+ config_codes = {a.code for a in config.accounts}
181
+ for live in live_accounts:
182
+ code = live.get("code")
183
+ if code is None:
184
+ continue
185
+ if int(code) not in config_codes and int(live.get("enabled", 1)) == 1:
186
+ plan.accounts_disable.append(live)
187
+ return plan
188
+
189
+
190
+ def render_plan(plan: CoaPlan) -> list[str]:
191
+ lines: list[str] = []
192
+ for a in plan.accounts_create:
193
+ lines.append(f"create account {a.code} {a.name} (type_id {a.type_id})")
194
+ for a, live in plan.accounts_rename:
195
+ lines.append(f"rename account {a.code}: {live.get('name')!r} -> {a.name!r}")
196
+ for a in plan.categories_create:
197
+ lines.append(f"create category {a.category!r} [{a.category_type}]")
198
+ for a, live in plan.categories_rename:
199
+ lines.append(f"rename category {live.get('name')!r} -> {a.category!r}")
200
+ for live in plan.accounts_disable:
201
+ lines.append(f"disable account {live.get('code')} {live.get('name')!r}")
202
+ if not lines:
203
+ lines.append("in sync — nothing to do")
204
+ return lines
205
+
206
+
207
+ def _account_form(acct: CoaAccount) -> list[tuple[str, str]]:
208
+ """The web-CRUD form body for a chart-of-accounts create/update."""
209
+ from .resources import flatten_form # local import: avoids resources<->coa cycle
210
+ return flatten_form({
211
+ "code": acct.code,
212
+ "name": acct.name,
213
+ "type_id": acct.type_id,
214
+ "enabled": 1,
215
+ "is_sub_account": "false",
216
+ })
217
+
218
+
219
+ def apply_plan(client, plan: "CoaPlan", *, prune: bool) -> dict:
220
+ """Execute the plan against Akaunting. Accounts use the web CRUD route
221
+ (chart-of-accounts is read-only on /api); categories use /api."""
222
+ summary = {"accounts_created": 0, "accounts_renamed": 0,
223
+ "categories_created": 0, "categories_renamed": 0,
224
+ "accounts_disabled": 0}
225
+
226
+ for acct in plan.accounts_create:
227
+ client.web_json("POST", "double-entry/chart-of-accounts", _account_form(acct))
228
+ summary["accounts_created"] += 1
229
+
230
+ for acct, live in plan.accounts_rename:
231
+ client.web_json("PATCH", f"double-entry/chart-of-accounts/{live['id']}",
232
+ _account_form(acct))
233
+ summary["accounts_renamed"] += 1
234
+
235
+ for acct in plan.categories_create:
236
+ client.post("categories", {
237
+ "name": acct.category,
238
+ "type": acct.category_type,
239
+ "color": "#00bcd4",
240
+ "enabled": 1,
241
+ })
242
+ summary["categories_created"] += 1
243
+
244
+ for acct, live in plan.categories_rename:
245
+ client.put(f"categories/{live['id']}", {
246
+ "name": acct.category,
247
+ "type": acct.category_type,
248
+ "color": live.get("color") or "#00bcd4",
249
+ "enabled": 1,
250
+ })
251
+ summary["categories_renamed"] += 1
252
+
253
+ if prune:
254
+ for live in plan.accounts_disable:
255
+ client.web_json("GET", f"double-entry/chart-of-accounts/{live['id']}/disable")
256
+ summary["accounts_disabled"] += 1
257
+
258
+ return summary
259
+
260
+
261
+ def _find_account(config: CoaConfig, ref: str) -> CoaAccount:
262
+ acct = config.by_code(int(ref)) if str(ref).lstrip("-").isdigit() else config.by_name(ref)
263
+ if acct is None:
264
+ raise ValueError(f"account {ref!r} is not in the COA config")
265
+ return acct
266
+
267
+
268
+ def resolve_coding(config: CoaConfig, client, *, account_ref: str) -> tuple[int, int]:
269
+ """Resolve --account to live (de_account_id, category_id).
270
+
271
+ The account must already exist in Akaunting (run `coa sync`); category_id
272
+ is auto-derived from its mirror category, which must also already exist."""
273
+ acct = _find_account(config, account_ref)
274
+
275
+ if not acct.mirror:
276
+ raise ValueError(
277
+ f"account {acct.code} ({acct.name}) has mirror=false — it has no mirror "
278
+ f"category, so it can't be coded onto a payment via --account "
279
+ f"(only mirrored income/expense accounts can be)")
280
+
281
+ live_accounts = client.list("chart-of-accounts", all_pages=True)
282
+ de_id = next((a["id"] for a in live_accounts if int(a.get("code", -1)) == acct.code), None)
283
+ if de_id is None:
284
+ raise ValueError(f"account {acct.code} ({acct.name}) is not in Akaunting yet — "
285
+ f"run `akt coa sync` first")
286
+ live_categories = client.list("categories", all_pages=True)
287
+ cat_id = next((c["id"] for c in live_categories
288
+ if c.get("name") == acct.category and c.get("type") == acct.category_type),
289
+ None)
290
+ if cat_id is None:
291
+ raise ValueError(f"mirror category {acct.category!r} [{acct.category_type}] is not in "
292
+ f"Akaunting yet — run `akt coa sync` first")
293
+ return int(de_id), int(cat_id)
@@ -92,8 +92,8 @@ ITEM = Resource(
92
92
  help="Products and services",
93
93
  )
94
94
 
95
- ACCOUNT = Resource(
96
- noun="account",
95
+ BANK = Resource(
96
+ noun="bank",
97
97
  endpoint="accounts",
98
98
  fields=[
99
99
  f("name", "Account name", required=True),
@@ -220,10 +220,13 @@ PAYMENT = Resource(
220
220
  f("type", "income | expense", choices=["income", "expense"]),
221
221
  f("invoice", "Invoice id to apply an income payment to"),
222
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."),
223
226
  f("document-id", "Linked document id (advanced)"),
224
227
  f("contact-id", "Contact id"),
225
228
  f("amount", "Payment amount"),
226
- f("account-id", "Bank/cash account id"),
229
+ f("bank", "Bank/cash account id", dest="account_id"),
227
230
  f("category-id", "Category id"),
228
231
  f("paid-at", "Payment date/time (YYYY-MM-DD)"),
229
232
  f("currency-code", "Currency code"),
@@ -246,8 +249,8 @@ TRANSFER = Resource(
246
249
  noun="transfer",
247
250
  endpoint="transfers",
248
251
  fields=[
249
- f("from-account-id", "Source account id", required=True),
250
- 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),
251
254
  f("amount", "Amount", required=True),
252
255
  f("transferred-at", "Transfer date/time (YYYY-MM-DD)"),
253
256
  f("payment-method", "Payment method code", default="offline-payments.cash.1"),
@@ -272,15 +275,15 @@ TRANSFER = Resource(
272
275
  # while list/get keep using the /api surface (endpoint). journal-entry is full
273
276
  # API CRUD.
274
277
 
275
- CHART_OF_ACCOUNT = Resource(
276
- noun="chart-of-account",
278
+ ACCOUNT = Resource(
279
+ noun="account",
277
280
  endpoint="chart-of-accounts",
278
281
  web_endpoint="double-entry/chart-of-accounts",
279
282
  fields=[
280
283
  f("name", "Account name", required=True),
281
284
  f("code", "Account code (unique integer per company)"),
282
285
  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)"),
286
+ f("parent-id", "Parent account id (makes this a sub-account)", dest="account_id"),
284
287
  f("description", "Description"),
285
288
  f("enabled", "Enable the record", is_flag=True, default=1),
286
289
  ],
@@ -291,7 +294,8 @@ CHART_OF_ACCOUNT = Resource(
291
294
  supports_toggle=False,
292
295
  build_create=build_account_create,
293
296
  build_update=build_account_update,
294
- help="Chart of accounts (read via API; create/update/delete via web, double-entry module)",
297
+ help="Chart of accounts (general ledger; read via API, create/update/delete via web, "
298
+ "double-entry module)",
295
299
  )
296
300
 
297
301
  JOURNAL_ENTRY = Resource(
@@ -320,9 +324,9 @@ JOURNAL_ENTRY = Resource(
320
324
 
321
325
 
322
326
  RESOURCES: list[Resource] = [
323
- CUSTOMER, VENDOR, ITEM, ACCOUNT, CATEGORY, TAX, CURRENCY,
327
+ CUSTOMER, VENDOR, ITEM, BANK, CATEGORY, TAX, CURRENCY,
324
328
  INVOICE, BILL, PAYMENT, TRANSFER,
325
- CHART_OF_ACCOUNT, JOURNAL_ENTRY,
329
+ ACCOUNT, JOURNAL_ENTRY,
326
330
  ]
327
331
 
328
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
  # --------------------------------------------------------------------------
@@ -436,6 +437,18 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
436
437
  contact_id = getattr(ns, "contact_id", None)
437
438
  category_id = getattr(ns, "category_id", None)
438
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
+
439
452
  if invoice_id:
440
453
  document = client.show("documents", invoice_id, type_scope="invoice")
441
454
  ptype = ptype or "income"
@@ -492,6 +505,8 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
492
505
  body["reference"] = ns.reference
493
506
  if getattr(ns, "description", None):
494
507
  body["description"] = ns.description
508
+ if de_account_id is not None:
509
+ body["de_account_id"] = de_account_id
495
510
  body.update(parse_set(getattr(ns, "set_", None)))
496
511
  body.update(load_data_arg(getattr(ns, "data", None)))
497
512
 
@@ -732,7 +747,7 @@ def _apply_sub_account(body: dict) -> None:
732
747
  def build_account_create(res: Resource, client: Client, ns: Any) -> dict:
733
748
  body = body_from_fields(res, ns, for_update=False)
734
749
  if not body.get("name"):
735
- raise ValueError("--name is required to create a chart-of-account")
750
+ raise ValueError("--name is required to create an account")
736
751
  _apply_sub_account(body)
737
752
  return body
738
753
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes