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.
- {akt_cli-0.2.0 → akt_cli-0.4.0}/PKG-INFO +93 -71
- {akt_cli-0.2.0 → akt_cli-0.4.0}/README.md +92 -70
- {akt_cli-0.2.0 → akt_cli-0.4.0}/pyproject.toml +1 -1
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/__init__.py +1 -1
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/cli.py +71 -10
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/client.py +57 -1
- akt_cli-0.4.0/src/akt/coa.py +289 -0
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/commands.py +16 -0
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/registry.py +70 -6
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/resources.py +207 -0
- {akt_cli-0.2.0 → akt_cli-0.4.0}/LICENSE +0 -0
- {akt_cli-0.2.0 → akt_cli-0.4.0}/src/akt/config.py +0 -0
- {akt_cli-0.2.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)
|
|
@@ -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
|
-
| `
|
|
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
|
-
##
|
|
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
|
-
|
|
293
|
-
|
|
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,
|
|
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)
|
|
@@ -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
|
-
| `
|
|
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
|
-
##
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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
|
-
|
|
92
|
-
noun="
|
|
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("
|
|
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-
|
|
246
|
-
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),
|
|
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,
|
|
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
|