akt-cli 0.3.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.
- {akt_cli-0.3.0 → akt_cli-0.4.0}/PKG-INFO +53 -81
- {akt_cli-0.3.0 → akt_cli-0.4.0}/README.md +52 -80
- {akt_cli-0.3.0 → akt_cli-0.4.0}/pyproject.toml +1 -1
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/cli.py +54 -0
- akt_cli-0.4.0/src/akt/coa.py +289 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/registry.py +15 -11
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/resources.py +16 -1
- {akt_cli-0.3.0 → akt_cli-0.4.0}/LICENSE +0 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/__init__.py +0 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/client.py +0 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/commands.py +0 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/config.py +0 -0
- {akt_cli-0.3.0 → akt_cli-0.4.0}/src/akt/output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akt-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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,
|
|
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
|
[](https://pypi.org/project/akt-cli/)
|
|
30
30
|
[](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
|
-
| `
|
|
106
|
-
| `
|
|
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 `
|
|
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
|
|
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
|
|
196
|
-
akt
|
|
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
|
|
202
|
-
akt
|
|
203
|
-
akt
|
|
204
|
-
akt
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
343
|
-
|
|
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,
|
|
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
|
[](https://pypi.org/project/akt-cli/)
|
|
9
9
|
[](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
|
-
| `
|
|
85
|
-
| `
|
|
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 `
|
|
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
|
|
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
|
|
175
|
-
akt
|
|
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
|
|
181
|
-
akt
|
|
182
|
-
akt
|
|
183
|
-
akt
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
|
@@ -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,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)
|
|
@@ -92,8 +92,8 @@ ITEM = Resource(
|
|
|
92
92
|
help="Products and services",
|
|
93
93
|
)
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
noun="
|
|
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("
|
|
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-
|
|
250
|
-
f("to-
|
|
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
|
-
|
|
276
|
-
noun="
|
|
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("
|
|
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
|
|
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,
|
|
327
|
+
CUSTOMER, VENDOR, ITEM, BANK, CATEGORY, TAX, CURRENCY,
|
|
324
328
|
INVOICE, BILL, PAYMENT, TRANSFER,
|
|
325
|
-
|
|
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
|
|
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
|