akt-cli 0.4.1__tar.gz → 0.6.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.4.1 → akt_cli-0.6.0}/PKG-INFO +51 -3
- {akt_cli-0.4.1 → akt_cli-0.6.0}/README.md +50 -2
- {akt_cli-0.4.1 → akt_cli-0.6.0}/pyproject.toml +1 -1
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/cli.py +118 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/client.py +15 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/coa.py +24 -5
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/commands.py +4 -0
- akt_cli-0.6.0/src/akt/ledger.py +13 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/registry.py +9 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/resources.py +102 -6
- akt_cli-0.6.0/src/akt/verify.py +73 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/LICENSE +0 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/__init__.py +0 -0
- {akt_cli-0.4.1 → akt_cli-0.6.0}/src/akt/config.py +0 -0
- {akt_cli-0.4.1 → akt_cli-0.6.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.6.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
|
|
@@ -171,6 +171,15 @@ akt tax create --name "Sales Tax" --rate 8.25
|
|
|
171
171
|
akt bank create --name "Business Checking" --number 1001 --currency-code USD
|
|
172
172
|
akt bank list
|
|
173
173
|
|
|
174
|
+
# Open a book with a prior-period opening balance. The Double-Entry module
|
|
175
|
+
# auto-posts an opening-balance journal entry dated to the account's creation
|
|
176
|
+
# date; --opening-balance-date re-dates it to the period boundary so it lands in
|
|
177
|
+
# the right financial year. Needs a positive --opening-balance.
|
|
178
|
+
akt bank create --name "Business Checking" --number 1001 --currency-code USD \
|
|
179
|
+
--opening-balance 5000 --opening-balance-date 2024-12-31
|
|
180
|
+
# Re-date the opening entry of an existing account:
|
|
181
|
+
akt bank update 3 --opening-balance-date 2024-12-31
|
|
182
|
+
|
|
174
183
|
# Invoice with line items (totals computed server-side; number auto-generated)
|
|
175
184
|
akt invoice create --contact 12 \
|
|
176
185
|
--item 'name=Consulting,price=150,quantity=10,item_id=2' \
|
|
@@ -246,10 +255,23 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
|
|
|
246
255
|
|
|
247
256
|
# code a transaction by GL account — akt auto-fills the mirror category:
|
|
248
257
|
akt payment create --type expense --bank 1 --amount 120 --account 628
|
|
258
|
+
# ...or by category name — akt reverse-fills the matching GL account:
|
|
259
|
+
akt payment create --type expense --bank 1 --amount 120 --category "Market Data Feeds"
|
|
249
260
|
```
|
|
250
261
|
|
|
251
|
-
`--account`
|
|
252
|
-
the
|
|
262
|
+
`--account`/`--category` are two ends of the same coin: give either and akt fills
|
|
263
|
+
the other from the COA config, so the category report and the COA report always
|
|
264
|
+
agree. `--account` takes a GL code or name; `--category` a mirror-category name
|
|
265
|
+
(see `--bank` for the bank/cash account). An explicit `--set de_account_id=` still
|
|
266
|
+
wins.
|
|
267
|
+
|
|
268
|
+
**Enforcement (when a `--coa` config is loaded):** akt refuses to create a
|
|
269
|
+
*standalone* income/expense transaction that has no GL account — you must pass
|
|
270
|
+
`--account`, `--category`, or an explicit `--set de_account_id=`. This makes the
|
|
271
|
+
"category set but GL account left to the module's 628/400 default" mistake
|
|
272
|
+
impossible. Bill/invoice payments (which post to A/P–A/R) are exempt, and with no
|
|
273
|
+
`--coa` config the old behaviour is unchanged. Use `akt verify` to catch any
|
|
274
|
+
transactions (e.g. web-UI entries) that slipped past.
|
|
253
275
|
|
|
254
276
|
# Anything else: raw API access
|
|
255
277
|
akt raw GET reports
|
|
@@ -258,6 +280,32 @@ akt company
|
|
|
258
280
|
akt settings --search 'key:default.account'
|
|
259
281
|
```
|
|
260
282
|
|
|
283
|
+
## Reading the ledger: the `akt-api` companion module
|
|
284
|
+
|
|
285
|
+
Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
|
|
286
|
+
over the API — the ledger itself (where every transaction's postings land) has no
|
|
287
|
+
endpoint. The optional **`akt-api`** companion module (in `akt-api/`, install it
|
|
288
|
+
into your Akaunting `modules/` directory — see `akt-api/README.md`) adds a
|
|
289
|
+
read-only `GET /api/akt/ledgers`. When it's present, extra commands light up; when
|
|
290
|
+
it's not, they print an install hint and everything else works unchanged.
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
# Show what actually posted to a GL account (by code or name):
|
|
294
|
+
akt ledger --account 628 --from 2025-01-01 --to 2025-12-31
|
|
295
|
+
|
|
296
|
+
# Audit: every standalone income/expense transaction whose ACTUAL posted GL
|
|
297
|
+
# account differs from the account that mirrors its category. Needs a --coa
|
|
298
|
+
# config (to know the mapping) and the companion module (to read the ledger).
|
|
299
|
+
# Exits non-zero when it finds mis-postings — handy in CI / a pre-close gate.
|
|
300
|
+
akt --coa coa.toml verify --from 2025-01-01 --to 2025-12-31
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Because the Double-Entry module posts to the ledger by `de_account_id`, not by
|
|
304
|
+
category, a transaction can carry a tidy category yet post to the wrong GL
|
|
305
|
+
account (e.g. the module's default "Other / Uncategorized"). `akt verify` is how
|
|
306
|
+
you catch that — the category report and the COA report only agree when it comes
|
|
307
|
+
back clean.
|
|
308
|
+
|
|
261
309
|
## Akaunting gotchas `akt` handles for you
|
|
262
310
|
|
|
263
311
|
Driving Akaunting's API directly has sharp edges; `akt` papers over these:
|
|
@@ -150,6 +150,15 @@ akt tax create --name "Sales Tax" --rate 8.25
|
|
|
150
150
|
akt bank create --name "Business Checking" --number 1001 --currency-code USD
|
|
151
151
|
akt bank list
|
|
152
152
|
|
|
153
|
+
# Open a book with a prior-period opening balance. The Double-Entry module
|
|
154
|
+
# auto-posts an opening-balance journal entry dated to the account's creation
|
|
155
|
+
# date; --opening-balance-date re-dates it to the period boundary so it lands in
|
|
156
|
+
# the right financial year. Needs a positive --opening-balance.
|
|
157
|
+
akt bank create --name "Business Checking" --number 1001 --currency-code USD \
|
|
158
|
+
--opening-balance 5000 --opening-balance-date 2024-12-31
|
|
159
|
+
# Re-date the opening entry of an existing account:
|
|
160
|
+
akt bank update 3 --opening-balance-date 2024-12-31
|
|
161
|
+
|
|
153
162
|
# Invoice with line items (totals computed server-side; number auto-generated)
|
|
154
163
|
akt invoice create --contact 12 \
|
|
155
164
|
--item 'name=Consulting,price=150,quantity=10,item_id=2' \
|
|
@@ -225,10 +234,23 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
|
|
|
225
234
|
|
|
226
235
|
# code a transaction by GL account — akt auto-fills the mirror category:
|
|
227
236
|
akt payment create --type expense --bank 1 --amount 120 --account 628
|
|
237
|
+
# ...or by category name — akt reverse-fills the matching GL account:
|
|
238
|
+
akt payment create --type expense --bank 1 --amount 120 --category "Market Data Feeds"
|
|
228
239
|
```
|
|
229
240
|
|
|
230
|
-
`--account`
|
|
231
|
-
the
|
|
241
|
+
`--account`/`--category` are two ends of the same coin: give either and akt fills
|
|
242
|
+
the other from the COA config, so the category report and the COA report always
|
|
243
|
+
agree. `--account` takes a GL code or name; `--category` a mirror-category name
|
|
244
|
+
(see `--bank` for the bank/cash account). An explicit `--set de_account_id=` still
|
|
245
|
+
wins.
|
|
246
|
+
|
|
247
|
+
**Enforcement (when a `--coa` config is loaded):** akt refuses to create a
|
|
248
|
+
*standalone* income/expense transaction that has no GL account — you must pass
|
|
249
|
+
`--account`, `--category`, or an explicit `--set de_account_id=`. This makes the
|
|
250
|
+
"category set but GL account left to the module's 628/400 default" mistake
|
|
251
|
+
impossible. Bill/invoice payments (which post to A/P–A/R) are exempt, and with no
|
|
252
|
+
`--coa` config the old behaviour is unchanged. Use `akt verify` to catch any
|
|
253
|
+
transactions (e.g. web-UI entries) that slipped past.
|
|
232
254
|
|
|
233
255
|
# Anything else: raw API access
|
|
234
256
|
akt raw GET reports
|
|
@@ -237,6 +259,32 @@ akt company
|
|
|
237
259
|
akt settings --search 'key:default.account'
|
|
238
260
|
```
|
|
239
261
|
|
|
262
|
+
## Reading the ledger: the `akt-api` companion module
|
|
263
|
+
|
|
264
|
+
Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
|
|
265
|
+
over the API — the ledger itself (where every transaction's postings land) has no
|
|
266
|
+
endpoint. The optional **`akt-api`** companion module (in `akt-api/`, install it
|
|
267
|
+
into your Akaunting `modules/` directory — see `akt-api/README.md`) adds a
|
|
268
|
+
read-only `GET /api/akt/ledgers`. When it's present, extra commands light up; when
|
|
269
|
+
it's not, they print an install hint and everything else works unchanged.
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# Show what actually posted to a GL account (by code or name):
|
|
273
|
+
akt ledger --account 628 --from 2025-01-01 --to 2025-12-31
|
|
274
|
+
|
|
275
|
+
# Audit: every standalone income/expense transaction whose ACTUAL posted GL
|
|
276
|
+
# account differs from the account that mirrors its category. Needs a --coa
|
|
277
|
+
# config (to know the mapping) and the companion module (to read the ledger).
|
|
278
|
+
# Exits non-zero when it finds mis-postings — handy in CI / a pre-close gate.
|
|
279
|
+
akt --coa coa.toml verify --from 2025-01-01 --to 2025-12-31
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Because the Double-Entry module posts to the ledger by `de_account_id`, not by
|
|
283
|
+
category, a transaction can carry a tidy category yet post to the wrong GL
|
|
284
|
+
account (e.g. the module's default "Other / Uncategorized"). `akt verify` is how
|
|
285
|
+
you catch that — the category report and the COA report only agree when it comes
|
|
286
|
+
back clean.
|
|
287
|
+
|
|
240
288
|
## Akaunting gotchas `akt` handles for you
|
|
241
289
|
|
|
242
290
|
Driving Akaunting's API directly has sharp edges; `akt` papers over these:
|
|
@@ -22,6 +22,8 @@ from .output import emit
|
|
|
22
22
|
from .registry import RESOURCES, BY_NOUN
|
|
23
23
|
from .resources import Resource, load_data_arg
|
|
24
24
|
from .coa import load_coa, plan_sync, render_plan, apply_plan
|
|
25
|
+
from .ledger import resolve_account_id
|
|
26
|
+
from .verify import find_miscodings, build_recode_plan
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bool) -> None:
|
|
@@ -176,6 +178,30 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
176
178
|
help="disable accounts/categories absent from the config (never deletes)")
|
|
177
179
|
csp.set_defaults(_special="coa_sync")
|
|
178
180
|
|
|
181
|
+
lp = sub.add_parser("ledger", parents=[common],
|
|
182
|
+
help="Show general-ledger postings (needs the akt-api module)")
|
|
183
|
+
lp.add_argument("--account", required=True, metavar="CODE|NAME",
|
|
184
|
+
help="Chart-of-accounts code or name to show postings for")
|
|
185
|
+
lp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest issued_at")
|
|
186
|
+
lp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest issued_at")
|
|
187
|
+
lp.set_defaults(_special="ledger")
|
|
188
|
+
|
|
189
|
+
vp = sub.add_parser("verify", parents=[common],
|
|
190
|
+
help="Audit standalone income/expense postings vs their category "
|
|
191
|
+
"(needs akt-api + --coa)")
|
|
192
|
+
vp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest paid_at")
|
|
193
|
+
vp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest paid_at")
|
|
194
|
+
vp.set_defaults(_special="verify")
|
|
195
|
+
|
|
196
|
+
rcp = sub.add_parser("recode", parents=[common],
|
|
197
|
+
help="Repost mis-coded standalone income/expense txns to their "
|
|
198
|
+
"category's GL account (needs akt-api + --coa)")
|
|
199
|
+
rcp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest paid_at")
|
|
200
|
+
rcp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest paid_at")
|
|
201
|
+
rcp.add_argument("--dry-run", dest="dry_run", action="store_true",
|
|
202
|
+
help="show the plan without writing anything")
|
|
203
|
+
rcp.set_defaults(_special="recode")
|
|
204
|
+
|
|
179
205
|
return parser
|
|
180
206
|
|
|
181
207
|
|
|
@@ -226,6 +252,98 @@ def _run_special(name: str, client: Client, ns: Any) -> int:
|
|
|
226
252
|
summary = apply_plan(client, plan, prune=ns.prune)
|
|
227
253
|
print("applied: " + ", ".join(f"{k}={v}" for k, v in summary.items() if v))
|
|
228
254
|
return 0
|
|
255
|
+
if name == "ledger":
|
|
256
|
+
if not client.has_ledger_api():
|
|
257
|
+
raise ValueError("`akt ledger` needs the akt-api companion module — "
|
|
258
|
+
"install it into your Akaunting modules/ directory (see akt-api/README.md)")
|
|
259
|
+
accounts = client.list("chart-of-accounts", all_pages=True)
|
|
260
|
+
account_id = resolve_account_id(accounts, ns.account)
|
|
261
|
+
params: dict = {"account_id": account_id}
|
|
262
|
+
if ns.date_from:
|
|
263
|
+
params["issued_from"] = ns.date_from
|
|
264
|
+
if ns.date_to:
|
|
265
|
+
params["issued_to"] = ns.date_to
|
|
266
|
+
rows = client.list("akt-api/ledgers", params=params, all_pages=True)
|
|
267
|
+
cols = ["issued_at", "entry_type", "debit", "credit", "ledgerable_type", "ledgerable_id"]
|
|
268
|
+
emit(rows, as_json=ns.json, columns=None if ns.json else cols,
|
|
269
|
+
headers=["Date", "Type", "Debit", "Credit", "Source", "Source ID"])
|
|
270
|
+
return 0
|
|
271
|
+
if name == "verify":
|
|
272
|
+
coa = ns._coa
|
|
273
|
+
if coa is None:
|
|
274
|
+
raise ValueError("`akt verify` needs a COA config (use --coa FILE or set AKT_COA_FILE)")
|
|
275
|
+
if not client.has_ledger_api():
|
|
276
|
+
raise ValueError("`akt verify` needs the akt-api companion module — "
|
|
277
|
+
"install it into your Akaunting modules/ directory (see akt-api/README.md)")
|
|
278
|
+
|
|
279
|
+
txns = [t for t in client.list("transactions", all_pages=True)
|
|
280
|
+
if t.get("type") in ("income", "expense") and not t.get("document_id")]
|
|
281
|
+
if ns.date_from:
|
|
282
|
+
txns = [t for t in txns if str(t.get("paid_at", ""))[:10] >= ns.date_from]
|
|
283
|
+
if ns.date_to:
|
|
284
|
+
txns = [t for t in txns if str(t.get("paid_at", ""))[:10] <= ns.date_to]
|
|
285
|
+
|
|
286
|
+
categories_by_id = {c["id"]: {"name": c.get("name"), "type": c.get("type")}
|
|
287
|
+
for c in client.list("categories", all_pages=True)}
|
|
288
|
+
accounts = client.list("chart-of-accounts", all_pages=True)
|
|
289
|
+
accounts_by_id = {a["id"]: {"code": a.get("code"), "name": a.get("name")} for a in accounts}
|
|
290
|
+
accounts_by_code = {int(a["code"]): a["id"] for a in accounts if a.get("code") is not None}
|
|
291
|
+
|
|
292
|
+
item_ledgers = client.list("akt-api/ledgers", all_pages=True, params={
|
|
293
|
+
"ledgerable_type": "App\\Models\\Banking\\Transaction", "entry_type": "item"})
|
|
294
|
+
item_account_by_txn = {int(l["ledgerable_id"]): int(l["account_id"]) for l in item_ledgers}
|
|
295
|
+
|
|
296
|
+
findings = find_miscodings(txns, categories_by_id, accounts_by_id,
|
|
297
|
+
accounts_by_code, item_account_by_txn, coa)
|
|
298
|
+
cols = ["transaction_id", "paid_at", "amount", "category",
|
|
299
|
+
"expected_code", "actual_code", "reason"]
|
|
300
|
+
emit(findings, as_json=ns.json, columns=None if ns.json else cols,
|
|
301
|
+
headers=["Txn", "Date", "Amount", "Category", "Expected", "Actual", "Reason"])
|
|
302
|
+
return 0 if not findings else 1
|
|
303
|
+
if name == "recode":
|
|
304
|
+
coa = ns._coa
|
|
305
|
+
if coa is None:
|
|
306
|
+
raise ValueError("`akt recode` needs a COA config (use --coa FILE or set AKT_COA_FILE)")
|
|
307
|
+
if not client.has_ledger_api():
|
|
308
|
+
raise ValueError("`akt recode` needs the akt-api companion module — "
|
|
309
|
+
"install it into your Akaunting modules/ directory (see akt-api/README.md)")
|
|
310
|
+
|
|
311
|
+
txns = [t for t in client.list("transactions", all_pages=True)
|
|
312
|
+
if t.get("type") in ("income", "expense") and not t.get("document_id")]
|
|
313
|
+
if ns.date_from:
|
|
314
|
+
txns = [t for t in txns if str(t.get("paid_at", ""))[:10] >= ns.date_from]
|
|
315
|
+
if ns.date_to:
|
|
316
|
+
txns = [t for t in txns if str(t.get("paid_at", ""))[:10] <= ns.date_to]
|
|
317
|
+
category_by_txn = {int(t["id"]): t.get("category_id") for t in txns}
|
|
318
|
+
|
|
319
|
+
categories_by_id = {c["id"]: {"name": c.get("name"), "type": c.get("type")}
|
|
320
|
+
for c in client.list("categories", all_pages=True)}
|
|
321
|
+
accounts = client.list("chart-of-accounts", all_pages=True)
|
|
322
|
+
accounts_by_id = {a["id"]: {"code": a.get("code"), "name": a.get("name")} for a in accounts}
|
|
323
|
+
accounts_by_code = {int(a["code"]): a["id"] for a in accounts if a.get("code") is not None}
|
|
324
|
+
item_ledgers = [{"id": l["id"], "ledgerable_id": int(l["ledgerable_id"]),
|
|
325
|
+
"account_id": int(l["account_id"])}
|
|
326
|
+
for l in client.list("akt-api/ledgers", all_pages=True, params={
|
|
327
|
+
"ledgerable_type": "App\\Models\\Banking\\Transaction",
|
|
328
|
+
"entry_type": "item"})]
|
|
329
|
+
|
|
330
|
+
plan = build_recode_plan(item_ledgers, category_by_txn, categories_by_id,
|
|
331
|
+
accounts_by_id, accounts_by_code, coa)
|
|
332
|
+
cols = ["transaction_id", "category", "from_code", "to_code", "ledger_id"]
|
|
333
|
+
if ns.dry_run or not plan:
|
|
334
|
+
emit(plan, as_json=ns.json, columns=None if ns.json else cols,
|
|
335
|
+
headers=["Txn", "Category", "From", "To", "LedgerRow"])
|
|
336
|
+
if not ns.json:
|
|
337
|
+
print(f"\n{len(plan)} transaction(s) would be recoded (dry-run)."
|
|
338
|
+
if plan else "nothing to recode — all coded correctly.")
|
|
339
|
+
return 0
|
|
340
|
+
done = 0
|
|
341
|
+
for p in plan:
|
|
342
|
+
client.request("PATCH", f"akt-api/ledgers/{p['ledger_id']}",
|
|
343
|
+
json_body={"account_id": p["to_account_id"]})
|
|
344
|
+
done += 1
|
|
345
|
+
print(f"recoded {done} transaction(s).")
|
|
346
|
+
return 0
|
|
229
347
|
raise ValueError(name)
|
|
230
348
|
|
|
231
349
|
|
|
@@ -69,6 +69,7 @@ class Client:
|
|
|
69
69
|
self._last_request = 0.0
|
|
70
70
|
self._settings_cache: dict[str, Any] = {}
|
|
71
71
|
self._settings_loaded = False
|
|
72
|
+
self._capabilities: dict[str, bool] = {} # optional-module probes (e.g. akt-api)
|
|
72
73
|
self._web_authed = False # whether a web (session-cookie) login has run
|
|
73
74
|
self._session = requests.Session()
|
|
74
75
|
self._session.auth = (config.email, config.password)
|
|
@@ -191,6 +192,20 @@ class Client:
|
|
|
191
192
|
def get(self, path: str, **kw) -> Any:
|
|
192
193
|
return self.request("GET", path, **kw)
|
|
193
194
|
|
|
195
|
+
def has_ledger_api(self) -> bool:
|
|
196
|
+
"""True if the akt-api companion module is installed (GET /api/akt-api/ledgers
|
|
197
|
+
is reachable). A 404 means the module is absent. Cached per client."""
|
|
198
|
+
if "ledger_api" not in self._capabilities:
|
|
199
|
+
try:
|
|
200
|
+
self.get("akt-api/ledgers", params={"limit": 1})
|
|
201
|
+
self._capabilities["ledger_api"] = True
|
|
202
|
+
except ApiError as e:
|
|
203
|
+
if e.status == 404:
|
|
204
|
+
self._capabilities["ledger_api"] = False
|
|
205
|
+
else:
|
|
206
|
+
raise
|
|
207
|
+
return self._capabilities["ledger_api"]
|
|
208
|
+
|
|
194
209
|
def post(self, path: str, json_body: Any, **kw) -> Any:
|
|
195
210
|
return self.request("POST", path, json_body=json_body, **kw)
|
|
196
211
|
|
|
@@ -53,6 +53,16 @@ class CoaConfig:
|
|
|
53
53
|
return a
|
|
54
54
|
return None
|
|
55
55
|
|
|
56
|
+
def by_category(self, category: str, category_type: str | None = None) -> CoaAccount | None:
|
|
57
|
+
"""Reverse of by_name: the mirrored account whose mirror category matches
|
|
58
|
+
(name, and type when given). Non-mirrored accounts have no category."""
|
|
59
|
+
for a in self.accounts:
|
|
60
|
+
if a.mirror and a.category == category and (
|
|
61
|
+
category_type is None or a.category_type == category_type
|
|
62
|
+
):
|
|
63
|
+
return a
|
|
64
|
+
return None
|
|
65
|
+
|
|
56
66
|
@property
|
|
57
67
|
def mirrored(self) -> list[CoaAccount]:
|
|
58
68
|
return [a for a in self.accounts if a.mirror]
|
|
@@ -265,12 +275,21 @@ def _find_account(config: CoaConfig, ref: str) -> CoaAccount:
|
|
|
265
275
|
return acct
|
|
266
276
|
|
|
267
277
|
|
|
268
|
-
def resolve_coding(config: CoaConfig, client, *, account_ref: str
|
|
269
|
-
|
|
278
|
+
def resolve_coding(config: CoaConfig, client, *, account_ref: str | None = None,
|
|
279
|
+
category_ref: str | None = None) -> tuple[int, int]:
|
|
280
|
+
"""Resolve --account OR --category to live (de_account_id, category_id).
|
|
281
|
+
|
|
282
|
+
Pass exactly one ref. Both directions require the account to be mirrored and
|
|
283
|
+
both the account and its mirror category to already exist (run `coa sync`)."""
|
|
284
|
+
if (account_ref is None) == (category_ref is None):
|
|
285
|
+
raise ValueError("resolve_coding needs exactly one of account_ref / category_ref")
|
|
270
286
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
if account_ref is not None:
|
|
288
|
+
acct = _find_account(config, account_ref)
|
|
289
|
+
else:
|
|
290
|
+
acct = next((a for a in config.accounts if a.mirror and a.category == category_ref), None)
|
|
291
|
+
if acct is None:
|
|
292
|
+
raise ValueError(f"category {category_ref!r} has no mirrored account in the COA config")
|
|
274
293
|
|
|
275
294
|
if not acct.mirror:
|
|
276
295
|
raise ValueError(
|
|
@@ -56,6 +56,8 @@ def cmd_create(res: Resource, client: Client, ns: Any) -> int:
|
|
|
56
56
|
else:
|
|
57
57
|
payload = client.post(endpoint, body, type_scope=type_scope)
|
|
58
58
|
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
59
|
+
if res.post_write:
|
|
60
|
+
res.post_write(res, client, data, ns)
|
|
59
61
|
emit(data, as_json=True)
|
|
60
62
|
return 0
|
|
61
63
|
|
|
@@ -104,6 +106,8 @@ def cmd_update(res: Resource, client: Client, ns: Any) -> int:
|
|
|
104
106
|
body["remove_attachment"] = 1
|
|
105
107
|
payload = client.put(path, body, type_scope=type_scope)
|
|
106
108
|
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
109
|
+
if res.post_write:
|
|
110
|
+
res.post_write(res, client, data, ns)
|
|
107
111
|
emit(data, as_json=True)
|
|
108
112
|
return 0
|
|
109
113
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Helpers for the `akt ledger` command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def resolve_account_id(live_accounts: list[dict], ref: str) -> int:
|
|
6
|
+
"""Map a chart-of-accounts ref (numeric code or exact name) to its live id."""
|
|
7
|
+
if str(ref).lstrip("-").isdigit():
|
|
8
|
+
hit = next((a for a in live_accounts if int(a.get("code", -1)) == int(ref)), None)
|
|
9
|
+
else:
|
|
10
|
+
hit = next((a for a in live_accounts if a.get("name") == ref), None)
|
|
11
|
+
if hit is None:
|
|
12
|
+
raise ValueError(f"account {ref!r} not found in the chart of accounts")
|
|
13
|
+
return int(hit["id"])
|
|
@@ -13,6 +13,7 @@ from .resources import (
|
|
|
13
13
|
build_journal_update,
|
|
14
14
|
build_payment_create,
|
|
15
15
|
build_transfer_create,
|
|
16
|
+
redate_opening_balance,
|
|
16
17
|
resolve_payment_delete,
|
|
17
18
|
resolve_payment_update,
|
|
18
19
|
)
|
|
@@ -101,6 +102,10 @@ BANK = Resource(
|
|
|
101
102
|
f("type", "Account type", default="bank"),
|
|
102
103
|
f("currency-code", "Currency code", default="USD"),
|
|
103
104
|
f("opening-balance", "Opening balance", default=0),
|
|
105
|
+
f("opening-balance-date",
|
|
106
|
+
"Date for the opening-balance journal entry (YYYY-MM-DD). Re-dates the "
|
|
107
|
+
"entry the Double-Entry module auto-posts; needs a positive opening balance.",
|
|
108
|
+
dest="opening_balance_date", local=True),
|
|
104
109
|
f("bank-name", "Bank name"),
|
|
105
110
|
f("bank-phone", "Bank phone"),
|
|
106
111
|
f("bank-address", "Bank address"),
|
|
@@ -112,6 +117,7 @@ BANK = Resource(
|
|
|
112
117
|
("Enabled", "enabled"),
|
|
113
118
|
],
|
|
114
119
|
help="Bank / cash accounts",
|
|
120
|
+
post_write=redate_opening_balance,
|
|
115
121
|
)
|
|
116
122
|
|
|
117
123
|
CATEGORY = Resource(
|
|
@@ -223,6 +229,9 @@ PAYMENT = Resource(
|
|
|
223
229
|
f("account", "GL/chart-of-accounts code or name to post to (the double-entry "
|
|
224
230
|
"account). Requires a --coa config; auto-fills the mirrored "
|
|
225
231
|
"category. See --bank for the bank/cash account."),
|
|
232
|
+
f("category", "Mirror category NAME to post under (requires a --coa config; "
|
|
233
|
+
"reverse-fills the matching GL account). Mutually exclusive "
|
|
234
|
+
"with --account."),
|
|
226
235
|
f("document-id", "Linked document id (advanced)"),
|
|
227
236
|
f("contact-id", "Contact id"),
|
|
228
237
|
f("amount", "Payment amount"),
|
|
@@ -12,6 +12,7 @@ import datetime as _dt
|
|
|
12
12
|
import json
|
|
13
13
|
import mimetypes
|
|
14
14
|
import os
|
|
15
|
+
import sys
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from typing import Any, Callable
|
|
17
18
|
|
|
@@ -32,6 +33,7 @@ class Field:
|
|
|
32
33
|
default: Any = None # default applied on create when omitted
|
|
33
34
|
is_flag: bool = False # store_true boolean flag
|
|
34
35
|
choices: list[str] | None = None
|
|
36
|
+
local: bool = False # CLI flag only; never sent in the request body
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def f(name: str, help: str = "", **kw) -> Field:
|
|
@@ -69,6 +71,10 @@ class Resource:
|
|
|
69
71
|
# returns (path, type_scope) for update; lets document-linked payments use
|
|
70
72
|
# the nested route (the flat /transactions route 400s on any document_id)
|
|
71
73
|
update_resolver: Callable[["Resource", Client, str, dict], "tuple[str, str | None]"] | None = None
|
|
74
|
+
# runs after a successful create/update on the /api path, with the returned
|
|
75
|
+
# record + the argparse namespace; used by bank to re-date the auto-posted
|
|
76
|
+
# opening-balance journal entry.
|
|
77
|
+
post_write: Callable[["Resource", Client, dict, Any], None] | None = None
|
|
72
78
|
|
|
73
79
|
def contact_scope(self) -> str:
|
|
74
80
|
"""ACL scope of the contact tied to a document resource."""
|
|
@@ -205,6 +211,8 @@ def body_from_fields(res: Resource, ns: Any, *, for_update: bool,
|
|
|
205
211
|
"""
|
|
206
212
|
body: dict[str, Any] = {}
|
|
207
213
|
for fld in res.fields:
|
|
214
|
+
if fld.local:
|
|
215
|
+
continue
|
|
208
216
|
val = getattr(ns, fld.dest, None)
|
|
209
217
|
if fld.is_flag:
|
|
210
218
|
# tri-state: only include if explicitly toggled
|
|
@@ -439,13 +447,18 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
|
|
|
439
447
|
|
|
440
448
|
coa = getattr(ns, "_coa", None)
|
|
441
449
|
account_ref = getattr(ns, "account", None)
|
|
450
|
+
category_ref = getattr(ns, "category", None)
|
|
442
451
|
de_account_id = None
|
|
443
|
-
if account_ref:
|
|
452
|
+
if account_ref and category_ref:
|
|
453
|
+
raise ValueError("pass only one of --account / --category")
|
|
454
|
+
if account_ref or category_ref:
|
|
444
455
|
if coa is None:
|
|
445
456
|
raise ValueError(
|
|
446
|
-
"--account requires a COA config (pass --coa FILE or set AKT_COA_FILE)")
|
|
447
|
-
|
|
448
|
-
|
|
457
|
+
"--account/--category requires a COA config (pass --coa FILE or set AKT_COA_FILE)")
|
|
458
|
+
if account_ref:
|
|
459
|
+
de_account_id, coa_category_id = resolve_coding(coa, client, account_ref=account_ref)
|
|
460
|
+
else: # --category NAME -> reverse-resolve the GL account
|
|
461
|
+
de_account_id, coa_category_id = resolve_coding(coa, client, category_ref=category_ref)
|
|
449
462
|
if category_id is None: # explicit --category-id wins
|
|
450
463
|
category_id = coa_category_id
|
|
451
464
|
|
|
@@ -463,6 +476,20 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
|
|
|
463
476
|
contact_id = contact_id or document.get("contact_id")
|
|
464
477
|
category_id = category_id or document.get("category_id")
|
|
465
478
|
|
|
479
|
+
# Config-gated enforcement (revises the COA spec's Decision #3): with a COA
|
|
480
|
+
# config loaded, a STANDALONE income/expense payment must resolve a GL account
|
|
481
|
+
# — via --account, --category, or an explicit --set/--data de_account_id.
|
|
482
|
+
# Bill/invoice payments (document_id set) post to A/P–A/R and are exempt.
|
|
483
|
+
# With no COA config, behaviour is unchanged (legacy).
|
|
484
|
+
overrides = {**parse_set(getattr(ns, "set_", None)),
|
|
485
|
+
**load_data_arg(getattr(ns, "data", None))}
|
|
486
|
+
if (coa is not None and document_id is None and de_account_id is None
|
|
487
|
+
and "de_account_id" not in overrides):
|
|
488
|
+
raise ValueError(
|
|
489
|
+
"standalone income/expense payment needs a GL account — pass --account "
|
|
490
|
+
"CODE|NAME or --category NAME (with a COA config loaded, akt won't create "
|
|
491
|
+
"an un-GL-coded transaction). Use --set de_account_id=<id> to override.")
|
|
492
|
+
|
|
466
493
|
if category_id is None:
|
|
467
494
|
key = "default.income_category" if ptype == "income" else "default.expense_category"
|
|
468
495
|
val = client.setting(key)
|
|
@@ -507,8 +534,7 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
|
|
|
507
534
|
body["description"] = ns.description
|
|
508
535
|
if de_account_id is not None:
|
|
509
536
|
body["de_account_id"] = de_account_id
|
|
510
|
-
body.update(
|
|
511
|
-
body.update(load_data_arg(getattr(ns, "data", None)))
|
|
537
|
+
body.update(overrides) # --set / --data win (computed once, above)
|
|
512
538
|
|
|
513
539
|
# A payment tied to a document must be posted to the nested route
|
|
514
540
|
# POST /documents/{id}/transactions (the flat /transactions endpoint rejects
|
|
@@ -733,6 +759,76 @@ def build_journal_update(res: Resource, client: Client, ns: Any, current: dict)
|
|
|
733
759
|
return body
|
|
734
760
|
|
|
735
761
|
|
|
762
|
+
# --------------------------------------------------------------------------
|
|
763
|
+
# opening-balance journal entry re-dating (bank post_write hook)
|
|
764
|
+
# --------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
def _journal_reput_body(current: dict, *, paid_at: str, journal_number: str) -> dict:
|
|
767
|
+
"""Full journal-entry PUT body that re-dates an existing entry, preserving
|
|
768
|
+
its ledgers (by id) and amounts. ``journal_number`` is supplied by the caller
|
|
769
|
+
(the server-created opening-balance entry carries none, but the API requires
|
|
770
|
+
one)."""
|
|
771
|
+
body: dict[str, Any] = {
|
|
772
|
+
"paid_at": paid_at,
|
|
773
|
+
"journal_number": journal_number,
|
|
774
|
+
"description": current.get("description"),
|
|
775
|
+
"basis": current.get("basis") or "accrual",
|
|
776
|
+
"currency_code": current.get("currency_code") or "USD",
|
|
777
|
+
"currency_rate": current.get("currency_rate", 1) or 1,
|
|
778
|
+
"amount": 0,
|
|
779
|
+
"items": _journal_items_from_current(current),
|
|
780
|
+
}
|
|
781
|
+
if current.get("reference"):
|
|
782
|
+
body["reference"] = current["reference"]
|
|
783
|
+
return body
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _find_opening_balance_je(client: Client, record: dict) -> dict | None:
|
|
787
|
+
"""Locate the opening-balance journal entry the Double-Entry module auto-posts
|
|
788
|
+
for a bank/cash account. It carries ``reference = "opening-balance:<coa_id>"``
|
|
789
|
+
and ``description`` ending ``";<account name>"``. The chart-of-accounts API
|
|
790
|
+
doesn't expose the bank<->coa link, so match on those two fields and, if an
|
|
791
|
+
account name is duplicated, take the newest by ``created_at``."""
|
|
792
|
+
name = str(record.get("name") or "")
|
|
793
|
+
suffix = ";" + name
|
|
794
|
+
matches = [
|
|
795
|
+
e for e in client.list("journal-entry", all_pages=True)
|
|
796
|
+
if str(e.get("reference") or "").startswith("opening-balance:")
|
|
797
|
+
and str(e.get("description") or "").endswith(suffix)
|
|
798
|
+
]
|
|
799
|
+
if not matches:
|
|
800
|
+
return None
|
|
801
|
+
matches.sort(key=lambda e: str(e.get("created_at") or ""), reverse=True)
|
|
802
|
+
return matches[0]
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def redate_opening_balance(res: "Resource", client: Client, record: dict, ns: Any) -> None:
|
|
806
|
+
"""post_write hook for bank create/update: re-date the auto-posted
|
|
807
|
+
opening-balance journal entry to ``--opening-balance-date``.
|
|
808
|
+
|
|
809
|
+
The Double-Entry module stamps that entry with the account's created_at and
|
|
810
|
+
exposes no date field, so we set the balance normally (keeping the Banking
|
|
811
|
+
register correct) and rewrite the entry's date here. Re-dating via the
|
|
812
|
+
journal-entry route also rewrites each ledger's issued_at (what reports
|
|
813
|
+
filter on), and the module's account-update path only touches ledger
|
|
814
|
+
amounts, so the date sticks across later edits."""
|
|
815
|
+
date = getattr(ns, "opening_balance_date", None)
|
|
816
|
+
if not date:
|
|
817
|
+
return
|
|
818
|
+
je = _find_opening_balance_je(client, record)
|
|
819
|
+
if je is None:
|
|
820
|
+
print(f"note: no opening-balance journal entry found for {res.noun} "
|
|
821
|
+
f"{record.get('id')}; --opening-balance-date had no effect "
|
|
822
|
+
f"(opening balance is 0, or the Double-Entry module / owners-"
|
|
823
|
+
f"contribution account is not configured)", file=sys.stderr)
|
|
824
|
+
return
|
|
825
|
+
number = je.get("journal_number") or _next_journal_number(client)
|
|
826
|
+
body = _journal_reput_body(je, paid_at=_normalize_date(date), journal_number=number)
|
|
827
|
+
client.put(f"journal-entry/{je['id']}", body)
|
|
828
|
+
print(f"note: re-dated opening-balance journal entry {je['id']} to {date}",
|
|
829
|
+
file=sys.stderr)
|
|
830
|
+
|
|
831
|
+
|
|
736
832
|
# --------------------------------------------------------------------------
|
|
737
833
|
# chart-of-accounts (double-entry) — web-surface CRUD body builder
|
|
738
834
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Read-and-compare audit: each standalone income/expense transaction's actual
|
|
2
|
+
posted GL account vs the COA mirror of its category."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .coa import CoaConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_miscodings(transactions: list[dict], categories_by_id: dict[int, dict],
|
|
9
|
+
accounts_by_id: dict[int, dict], accounts_by_code: dict[int, int],
|
|
10
|
+
item_account_by_txn: dict[int, int], coa: CoaConfig) -> list[dict]:
|
|
11
|
+
"""Return one finding per transaction whose actual posted item-leg GL account
|
|
12
|
+
differs from the account that mirrors its category. Pure — all lookups are
|
|
13
|
+
passed in pre-fetched."""
|
|
14
|
+
findings: list[dict] = []
|
|
15
|
+
for t in transactions:
|
|
16
|
+
cat = categories_by_id.get(t.get("category_id"))
|
|
17
|
+
cat_name = cat["name"] if cat else None
|
|
18
|
+
expected = coa.by_category(cat_name, cat["type"]) if cat else None
|
|
19
|
+
actual_id = item_account_by_txn.get(t["id"])
|
|
20
|
+
actual = accounts_by_id.get(actual_id) if actual_id is not None else None
|
|
21
|
+
|
|
22
|
+
if expected is None:
|
|
23
|
+
reason = "category has no mirror account in COA"
|
|
24
|
+
else:
|
|
25
|
+
expected_id = accounts_by_code.get(expected.code)
|
|
26
|
+
if actual_id == expected_id:
|
|
27
|
+
continue # correctly coded
|
|
28
|
+
reason = "posted to the wrong GL account" if actual_id is not None \
|
|
29
|
+
else "not posted to the ledger"
|
|
30
|
+
|
|
31
|
+
findings.append({
|
|
32
|
+
"transaction_id": t["id"],
|
|
33
|
+
"paid_at": str(t.get("paid_at", ""))[:10],
|
|
34
|
+
"amount": t.get("amount"),
|
|
35
|
+
"category": cat_name,
|
|
36
|
+
"expected_code": expected.code if expected else None,
|
|
37
|
+
"expected_name": expected.name if expected else None,
|
|
38
|
+
"actual_code": actual["code"] if actual else None,
|
|
39
|
+
"actual_name": actual["name"] if actual else None,
|
|
40
|
+
"reason": reason,
|
|
41
|
+
})
|
|
42
|
+
return findings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_recode_plan(item_ledgers: list[dict], category_by_txn: dict[int, int],
|
|
46
|
+
categories_by_id: dict[int, dict], accounts_by_id: dict[int, dict],
|
|
47
|
+
accounts_by_code: dict[int, int], coa: CoaConfig) -> list[dict]:
|
|
48
|
+
"""Per mis-coded item-leg, the ledger row + target account to repoint it to.
|
|
49
|
+
|
|
50
|
+
Only rows whose transaction is in ``category_by_txn`` (the standalone
|
|
51
|
+
income/expense set) and whose category maps to a mirror account that differs
|
|
52
|
+
from where it currently sits are included."""
|
|
53
|
+
plan: list[dict] = []
|
|
54
|
+
for row in item_ledgers:
|
|
55
|
+
txn = row["ledgerable_id"]
|
|
56
|
+
cat = categories_by_id.get(category_by_txn.get(txn))
|
|
57
|
+
expected = coa.by_category(cat["name"], cat["type"]) if cat else None
|
|
58
|
+
if expected is None:
|
|
59
|
+
continue
|
|
60
|
+
to_id = accounts_by_code.get(expected.code)
|
|
61
|
+
if to_id is None or row["account_id"] == to_id:
|
|
62
|
+
continue # unmapped, or already correct
|
|
63
|
+
frm = accounts_by_id.get(row["account_id"]) or {}
|
|
64
|
+
plan.append({
|
|
65
|
+
"ledger_id": row["id"],
|
|
66
|
+
"transaction_id": txn,
|
|
67
|
+
"from_account_id": row["account_id"],
|
|
68
|
+
"from_code": frm.get("code"),
|
|
69
|
+
"to_account_id": to_id,
|
|
70
|
+
"to_code": expected.code,
|
|
71
|
+
"category": cat["name"],
|
|
72
|
+
})
|
|
73
|
+
return plan
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|