akt-cli 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: akt-cli
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -255,10 +255,23 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
255
255
 
256
256
  # code a transaction by GL account — akt auto-fills the mirror category:
257
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 "Cloud Hosting"
258
260
  ```
259
261
 
260
- `--account` takes a GL code or name (the double-entry account; see `--bank` for
261
- the bank/cash account). An explicit `--set de_account_id=` still wins.
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.
262
275
 
263
276
  # Anything else: raw API access
264
277
  akt raw GET reports
@@ -267,6 +280,50 @@ akt company
267
280
  akt settings --search 'key:default.account'
268
281
  ```
269
282
 
283
+ ## Financial reports (needs the akt-api module)
284
+
285
+ Akaunting's trial balance, P&L, and balance sheet aren't in its JSON API. With
286
+ the companion module installed, akt computes them from the ledger:
287
+
288
+ ```bash
289
+ akt trial-balance --to 2025-12-31 # every account, debits = credits
290
+ akt report profit-loss --from 2025-01-01 --to 2025-12-31
291
+ akt report balance-sheet --to 2025-12-31
292
+ akt balance --account 105 --to 2025-12-31 # one account's balance
293
+ akt balance --account 200 --to 2025-12-31 --expected 0 # reconcile: exit 1 on mismatch
294
+ ```
295
+
296
+ `--expected` turns `akt balance` into a reconciliation primitive (compare an
297
+ account to an external figure, non-zero exit on a mismatch). Balances aggregate
298
+ through Akaunting's ledger relation, so soft-deleted transactions are excluded
299
+ just as its own reports do.
300
+
301
+ ## Reading the ledger: the `akt-api` companion module
302
+
303
+ Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
304
+ over the API — the ledger itself (where every transaction's postings land) has no
305
+ endpoint. The optional **`akt-api`** companion module (in `akt-api/`, install it
306
+ into your Akaunting `modules/` directory — see `akt-api/README.md`) adds a
307
+ read-only `GET /api/akt/ledgers`. When it's present, extra commands light up; when
308
+ it's not, they print an install hint and everything else works unchanged.
309
+
310
+ ```bash
311
+ # Show what actually posted to a GL account (by code or name):
312
+ akt ledger --account 628 --from 2025-01-01 --to 2025-12-31
313
+
314
+ # Audit: every standalone income/expense transaction whose ACTUAL posted GL
315
+ # account differs from the account that mirrors its category. Needs a --coa
316
+ # config (to know the mapping) and the companion module (to read the ledger).
317
+ # Exits non-zero when it finds mis-postings — handy in CI / a pre-close gate.
318
+ akt --coa coa.toml verify --from 2025-01-01 --to 2025-12-31
319
+ ```
320
+
321
+ Because the Double-Entry module posts to the ledger by `de_account_id`, not by
322
+ category, a transaction can carry a tidy category yet post to the wrong GL
323
+ account (e.g. the module's default "Other / Uncategorized"). `akt verify` is how
324
+ you catch that — the category report and the COA report only agree when it comes
325
+ back clean.
326
+
270
327
  ## Akaunting gotchas `akt` handles for you
271
328
 
272
329
  Driving Akaunting's API directly has sharp edges; `akt` papers over these:
@@ -234,10 +234,23 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
234
234
 
235
235
  # code a transaction by GL account — akt auto-fills the mirror category:
236
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 "Cloud Hosting"
237
239
  ```
238
240
 
239
- `--account` takes a GL code or name (the double-entry account; see `--bank` for
240
- the bank/cash account). An explicit `--set de_account_id=` still wins.
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.
241
254
 
242
255
  # Anything else: raw API access
243
256
  akt raw GET reports
@@ -246,6 +259,50 @@ akt company
246
259
  akt settings --search 'key:default.account'
247
260
  ```
248
261
 
262
+ ## Financial reports (needs the akt-api module)
263
+
264
+ Akaunting's trial balance, P&L, and balance sheet aren't in its JSON API. With
265
+ the companion module installed, akt computes them from the ledger:
266
+
267
+ ```bash
268
+ akt trial-balance --to 2025-12-31 # every account, debits = credits
269
+ akt report profit-loss --from 2025-01-01 --to 2025-12-31
270
+ akt report balance-sheet --to 2025-12-31
271
+ akt balance --account 105 --to 2025-12-31 # one account's balance
272
+ akt balance --account 200 --to 2025-12-31 --expected 0 # reconcile: exit 1 on mismatch
273
+ ```
274
+
275
+ `--expected` turns `akt balance` into a reconciliation primitive (compare an
276
+ account to an external figure, non-zero exit on a mismatch). Balances aggregate
277
+ through Akaunting's ledger relation, so soft-deleted transactions are excluded
278
+ just as its own reports do.
279
+
280
+ ## Reading the ledger: the `akt-api` companion module
281
+
282
+ Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
283
+ over the API — the ledger itself (where every transaction's postings land) has no
284
+ endpoint. The optional **`akt-api`** companion module (in `akt-api/`, install it
285
+ into your Akaunting `modules/` directory — see `akt-api/README.md`) adds a
286
+ read-only `GET /api/akt/ledgers`. When it's present, extra commands light up; when
287
+ it's not, they print an install hint and everything else works unchanged.
288
+
289
+ ```bash
290
+ # Show what actually posted to a GL account (by code or name):
291
+ akt ledger --account 628 --from 2025-01-01 --to 2025-12-31
292
+
293
+ # Audit: every standalone income/expense transaction whose ACTUAL posted GL
294
+ # account differs from the account that mirrors its category. Needs a --coa
295
+ # config (to know the mapping) and the companion module (to read the ledger).
296
+ # Exits non-zero when it finds mis-postings — handy in CI / a pre-close gate.
297
+ akt --coa coa.toml verify --from 2025-01-01 --to 2025-12-31
298
+ ```
299
+
300
+ Because the Double-Entry module posts to the ledger by `de_account_id`, not by
301
+ category, a transaction can carry a tidy category yet post to the wrong GL
302
+ account (e.g. the module's default "Other / Uncategorized"). `akt verify` is how
303
+ you catch that — the category report and the COA report only agree when it comes
304
+ back clean.
305
+
249
306
  ## Akaunting gotchas `akt` handles for you
250
307
 
251
308
  Driving Akaunting's API directly has sharp edges; `akt` papers over these:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "akt — a CLI toolbox to fully drive an Akaunting accounting instance"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,6 +22,9 @@ 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
27
+ from . import reports
25
28
 
26
29
 
27
30
  def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bool) -> None:
@@ -176,9 +179,75 @@ def _build_parser() -> argparse.ArgumentParser:
176
179
  help="disable accounts/categories absent from the config (never deletes)")
177
180
  csp.set_defaults(_special="coa_sync")
178
181
 
182
+ lp = sub.add_parser("ledger", parents=[common],
183
+ help="Show general-ledger postings (needs the akt-api module)")
184
+ lp.add_argument("--account", required=True, metavar="CODE|NAME",
185
+ help="Chart-of-accounts code or name to show postings for")
186
+ lp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest issued_at")
187
+ lp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest issued_at")
188
+ lp.set_defaults(_special="ledger")
189
+
190
+ vp = sub.add_parser("verify", parents=[common],
191
+ help="Audit standalone income/expense postings vs their category "
192
+ "(needs akt-api + --coa)")
193
+ vp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest paid_at")
194
+ vp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest paid_at")
195
+ vp.set_defaults(_special="verify")
196
+
197
+ rcp = sub.add_parser("recode", parents=[common],
198
+ help="Repost mis-coded standalone income/expense txns to their "
199
+ "category's GL account (needs akt-api + --coa)")
200
+ rcp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD", help="Earliest paid_at")
201
+ rcp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD", help="Latest paid_at")
202
+ rcp.add_argument("--dry-run", dest="dry_run", action="store_true",
203
+ help="show the plan without writing anything")
204
+ rcp.set_defaults(_special="recode")
205
+
206
+ bp = sub.add_parser("balance", parents=[common],
207
+ help="Show one account's balance (needs the akt-api module)")
208
+ bp.add_argument("--account", required=True, metavar="CODE|NAME")
209
+ bp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD")
210
+ bp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD")
211
+ bp.add_argument("--expected", type=float, metavar="AMOUNT",
212
+ help="reconcile: compare to this figure, exit 1 on mismatch")
213
+ bp.set_defaults(_special="balance")
214
+
215
+ tp = sub.add_parser("trial-balance", parents=[common],
216
+ help="Trial balance (needs the akt-api module)")
217
+ tp.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD")
218
+ tp.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD")
219
+ tp.set_defaults(_special="trial_balance")
220
+
221
+ rpp = sub.add_parser("report", parents=[common], help="Financial reports (needs the akt-api module)")
222
+ rpv = rpp.add_subparsers(dest="report_verb", metavar="<report>")
223
+ for verb, sp in (("profit-loss", "report_pnl"), ("balance-sheet", "report_bs")):
224
+ p = rpv.add_parser(verb, parents=[common])
225
+ p.add_argument("--from", dest="date_from", metavar="YYYY-MM-DD")
226
+ p.add_argument("--to", dest="date_to", metavar="YYYY-MM-DD")
227
+ p.set_defaults(_special=sp)
228
+
179
229
  return parser
180
230
 
181
231
 
232
+ def _need_akt_api(client: Client, cmd: str) -> None:
233
+ if not client.has_ledger_api():
234
+ raise ValueError(f"`akt {cmd}` needs the akt-api companion module — "
235
+ "install it into your Akaunting modules/ directory (see akt-api/README.md)")
236
+
237
+
238
+ def _fetch_balances(client: Client, ns: Any):
239
+ accounts = client.list("chart-of-accounts", all_pages=True)
240
+ accts_by_id = {a["id"]: {"code": a.get("code"), "name": a.get("name"),
241
+ "type_id": a.get("type_id")} for a in accounts}
242
+ params: dict[str, Any] = {}
243
+ if getattr(ns, "date_from", None):
244
+ params["date_from"] = ns.date_from
245
+ if getattr(ns, "date_to", None):
246
+ params["date_to"] = ns.date_to
247
+ rows = client.list("akt-api/balances", all_pages=True, params=params or None)
248
+ return reports.balances_by_id(rows), accts_by_id, accounts
249
+
250
+
182
251
  def _run_special(name: str, client: Client, ns: Any) -> int:
183
252
  if name == "ping":
184
253
  emit(client.get("ping"), as_json=True)
@@ -226,6 +295,155 @@ def _run_special(name: str, client: Client, ns: Any) -> int:
226
295
  summary = apply_plan(client, plan, prune=ns.prune)
227
296
  print("applied: " + ", ".join(f"{k}={v}" for k, v in summary.items() if v))
228
297
  return 0
298
+ if name == "ledger":
299
+ if not client.has_ledger_api():
300
+ raise ValueError("`akt ledger` needs the akt-api companion module — "
301
+ "install it into your Akaunting modules/ directory (see akt-api/README.md)")
302
+ accounts = client.list("chart-of-accounts", all_pages=True)
303
+ account_id = resolve_account_id(accounts, ns.account)
304
+ params: dict = {"account_id": account_id}
305
+ if ns.date_from:
306
+ params["issued_from"] = ns.date_from
307
+ if ns.date_to:
308
+ params["issued_to"] = ns.date_to
309
+ rows = client.list("akt-api/ledgers", params=params, all_pages=True)
310
+ cols = ["issued_at", "entry_type", "debit", "credit", "ledgerable_type", "ledgerable_id"]
311
+ emit(rows, as_json=ns.json, columns=None if ns.json else cols,
312
+ headers=["Date", "Type", "Debit", "Credit", "Source", "Source ID"])
313
+ return 0
314
+ if name == "verify":
315
+ coa = ns._coa
316
+ if coa is None:
317
+ raise ValueError("`akt verify` needs a COA config (use --coa FILE or set AKT_COA_FILE)")
318
+ if not client.has_ledger_api():
319
+ raise ValueError("`akt verify` needs the akt-api companion module — "
320
+ "install it into your Akaunting modules/ directory (see akt-api/README.md)")
321
+
322
+ txns = [t for t in client.list("transactions", all_pages=True)
323
+ if t.get("type") in ("income", "expense") and not t.get("document_id")]
324
+ if ns.date_from:
325
+ txns = [t for t in txns if str(t.get("paid_at", ""))[:10] >= ns.date_from]
326
+ if ns.date_to:
327
+ txns = [t for t in txns if str(t.get("paid_at", ""))[:10] <= ns.date_to]
328
+
329
+ categories_by_id = {c["id"]: {"name": c.get("name"), "type": c.get("type")}
330
+ for c in client.list("categories", all_pages=True)}
331
+ accounts = client.list("chart-of-accounts", all_pages=True)
332
+ accounts_by_id = {a["id"]: {"code": a.get("code"), "name": a.get("name")} for a in accounts}
333
+ accounts_by_code = {int(a["code"]): a["id"] for a in accounts if a.get("code") is not None}
334
+
335
+ item_ledgers = client.list("akt-api/ledgers", all_pages=True, params={
336
+ "ledgerable_type": "App\\Models\\Banking\\Transaction", "entry_type": "item"})
337
+ item_account_by_txn = {int(l["ledgerable_id"]): int(l["account_id"]) for l in item_ledgers}
338
+
339
+ findings = find_miscodings(txns, categories_by_id, accounts_by_id,
340
+ accounts_by_code, item_account_by_txn, coa)
341
+ cols = ["transaction_id", "paid_at", "amount", "category",
342
+ "expected_code", "actual_code", "reason"]
343
+ emit(findings, as_json=ns.json, columns=None if ns.json else cols,
344
+ headers=["Txn", "Date", "Amount", "Category", "Expected", "Actual", "Reason"])
345
+ return 0 if not findings else 1
346
+ if name == "recode":
347
+ coa = ns._coa
348
+ if coa is None:
349
+ raise ValueError("`akt recode` needs a COA config (use --coa FILE or set AKT_COA_FILE)")
350
+ if not client.has_ledger_api():
351
+ raise ValueError("`akt recode` needs the akt-api companion module — "
352
+ "install it into your Akaunting modules/ directory (see akt-api/README.md)")
353
+
354
+ txns = [t for t in client.list("transactions", all_pages=True)
355
+ if t.get("type") in ("income", "expense") and not t.get("document_id")]
356
+ if ns.date_from:
357
+ txns = [t for t in txns if str(t.get("paid_at", ""))[:10] >= ns.date_from]
358
+ if ns.date_to:
359
+ txns = [t for t in txns if str(t.get("paid_at", ""))[:10] <= ns.date_to]
360
+ category_by_txn = {int(t["id"]): t.get("category_id") for t in txns}
361
+
362
+ categories_by_id = {c["id"]: {"name": c.get("name"), "type": c.get("type")}
363
+ for c in client.list("categories", all_pages=True)}
364
+ accounts = client.list("chart-of-accounts", all_pages=True)
365
+ accounts_by_id = {a["id"]: {"code": a.get("code"), "name": a.get("name")} for a in accounts}
366
+ accounts_by_code = {int(a["code"]): a["id"] for a in accounts if a.get("code") is not None}
367
+ item_ledgers = [{"id": l["id"], "ledgerable_id": int(l["ledgerable_id"]),
368
+ "account_id": int(l["account_id"])}
369
+ for l in client.list("akt-api/ledgers", all_pages=True, params={
370
+ "ledgerable_type": "App\\Models\\Banking\\Transaction",
371
+ "entry_type": "item"})]
372
+
373
+ plan = build_recode_plan(item_ledgers, category_by_txn, categories_by_id,
374
+ accounts_by_id, accounts_by_code, coa)
375
+ cols = ["transaction_id", "category", "from_code", "to_code", "ledger_id"]
376
+ if ns.dry_run or not plan:
377
+ emit(plan, as_json=ns.json, columns=None if ns.json else cols,
378
+ headers=["Txn", "Category", "From", "To", "LedgerRow"])
379
+ if not ns.json:
380
+ print(f"\n{len(plan)} transaction(s) would be recoded (dry-run)."
381
+ if plan else "nothing to recode — all coded correctly.")
382
+ return 0
383
+ done = 0
384
+ for p in plan:
385
+ client.request("PATCH", f"akt-api/ledgers/{p['ledger_id']}",
386
+ json_body={"account_id": p["to_account_id"]})
387
+ done += 1
388
+ print(f"recoded {done} transaction(s).")
389
+ return 0
390
+ if name == "balance":
391
+ _need_akt_api(client, "balance")
392
+ balances, accts_by_id, accounts = _fetch_balances(client, ns)
393
+ account_id = resolve_account_id(accounts, ns.account)
394
+ bal = balances.get(account_id, 0.0)
395
+ if ns.expected is not None:
396
+ r = reports.reconcile(bal, ns.expected)
397
+ emit(r, as_json=ns.json,
398
+ columns=None if ns.json else ["actual", "expected", "diff", "ok"],
399
+ headers=["Balance", "Expected", "Diff", "OK"])
400
+ return 0 if r["ok"] else 1
401
+ emit({"account_id": account_id, "balance": round(bal, 2)}, as_json=ns.json,
402
+ columns=None if ns.json else ["account_id", "balance"],
403
+ headers=["Account", "Balance"])
404
+ return 0
405
+ if name == "trial_balance":
406
+ _need_akt_api(client, "trial-balance")
407
+ balances, accts_by_id, _ = _fetch_balances(client, ns)
408
+ tb = reports.build_trial_balance(balances, accts_by_id)
409
+ if ns.json:
410
+ emit(tb, as_json=True)
411
+ else:
412
+ emit(tb["rows"], as_json=False, columns=["code", "name", "debit", "credit"],
413
+ headers=["Code", "Account", "Debit", "Credit"])
414
+ print(f" {'TOTAL':>34} {tb['total_debit']:>12,.2f} {tb['total_credit']:>12,.2f}")
415
+ print(" BALANCED" if tb["balanced"] else " *** OUT OF BALANCE")
416
+ return 0 if tb["balanced"] else 1
417
+ if name == "report_pnl":
418
+ _need_akt_api(client, "report profit-loss")
419
+ balances, accts_by_id, _ = _fetch_balances(client, ns)
420
+ pl = reports.build_profit_loss(balances, accts_by_id)
421
+ if ns.json:
422
+ emit(pl, as_json=True)
423
+ else:
424
+ print("INCOME")
425
+ emit(pl["income"], as_json=False, columns=["code", "name", "amount"], headers=["Code", "Account", "Amount"])
426
+ print(f" {'Total income':>40} {pl['total_income']:>12,.2f}\n\nEXPENSES")
427
+ emit(pl["expense"], as_json=False, columns=["code", "name", "amount"], headers=["Code", "Account", "Amount"])
428
+ print(f" {'Total expenses':>40} {pl['total_expense']:>12,.2f}")
429
+ print(f" {'NET PROFIT':>40} {pl['net_profit']:>12,.2f}")
430
+ return 0
431
+ if name == "report_bs":
432
+ _need_akt_api(client, "report balance-sheet")
433
+ balances, accts_by_id, _ = _fetch_balances(client, ns)
434
+ bs = reports.build_balance_sheet(balances, accts_by_id)
435
+ if ns.json:
436
+ emit(bs, as_json=True)
437
+ else:
438
+ for title, key, tot in (("ASSETS", "assets", "total_assets"),
439
+ ("LIABILITIES", "liabilities", "total_liabilities"),
440
+ ("EQUITY", "equity", "total_equity")):
441
+ print(title)
442
+ emit(bs[key], as_json=False, columns=["code", "name", "amount"], headers=["Code", "Account", "Amount"])
443
+ print(f" {'Total ' + title.lower():>40} {bs[tot]:>12,.2f}\n")
444
+ print(f" {'Net income to date':>40} {bs['net_income']:>12,.2f}")
445
+ print(" BALANCED" if bs["balanced"] else " *** DOES NOT BALANCE")
446
+ return 0 if bs["balanced"] else 1
229
447
  raise ValueError(name)
230
448
 
231
449
 
@@ -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) -> tuple[int, int]:
269
- """Resolve --account to live (de_account_id, category_id).
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
- The account must already exist in Akaunting (run `coa sync`); category_id
272
- is auto-derived from its mirror category, which must also already exist."""
273
- acct = _find_account(config, account_ref)
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(
@@ -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"])
@@ -229,6 +229,9 @@ PAYMENT = Resource(
229
229
  f("account", "GL/chart-of-accounts code or name to post to (the double-entry "
230
230
  "account). Requires a --coa config; auto-fills the mirrored "
231
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."),
232
235
  f("document-id", "Linked document id (advanced)"),
233
236
  f("contact-id", "Contact id"),
234
237
  f("amount", "Payment amount"),
@@ -0,0 +1,99 @@
1
+ """Financial report builders — pure functions over per-account balances.
2
+
3
+ A `balances` map is {account_id: debit - credit}. `accounts_by_id` is
4
+ {id: {"code":..., "name":..., "type_id":...}} from the chart of accounts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ # DoubleEntry standard seeds (Database/Seeds/Types.php): type_id -> class_id.
11
+ CLASS_NAMES = {1: "Assets", 2: "Liabilities", 3: "Expenses", 4: "Income", 5: "Equity"}
12
+ TYPE_ID_CLASS = {1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 10: 1, # assets (incl. depreciation)
13
+ 7: 2, 8: 2, 9: 2, 17: 2, # liabilities (incl. tax)
14
+ 11: 3, 12: 3, # expenses (incl. direct costs)
15
+ 13: 4, 14: 4, 15: 4, # income
16
+ 16: 5} # equity
17
+
18
+
19
+ def account_class(type_id) -> "int | None":
20
+ c = TYPE_ID_CLASS.get(type_id)
21
+ if c is None:
22
+ print(f"warning: unknown account type_id {type_id}; excluded from "
23
+ f"class-grouped reports", file=sys.stderr)
24
+ return c
25
+
26
+
27
+ def balances_by_id(rows: list[dict]) -> dict[int, float]:
28
+ return {int(r["account_id"]): round(float(r.get("debit") or 0)
29
+ - float(r.get("credit") or 0), 2)
30
+ for r in rows}
31
+
32
+
33
+ def _line(a: dict, amount: float) -> dict:
34
+ return {"code": a.get("code"), "name": a.get("name"), "amount": round(amount, 2)}
35
+
36
+
37
+ def build_trial_balance(balances: dict[int, float], accounts_by_id: dict[int, dict]) -> dict:
38
+ rows = []
39
+ for aid, bal in balances.items():
40
+ if abs(bal) < 0.005:
41
+ continue
42
+ a = accounts_by_id.get(aid, {})
43
+ rows.append({"code": a.get("code"), "name": a.get("name"),
44
+ "debit": round(bal, 2) if bal > 0 else 0.0,
45
+ "credit": round(-bal, 2) if bal < 0 else 0.0})
46
+ rows.sort(key=lambda r: (r["code"] is None, str(r["code"])))
47
+ total_debit = round(sum(r["debit"] for r in rows), 2)
48
+ total_credit = round(sum(r["credit"] for r in rows), 2)
49
+ return {"rows": rows, "total_debit": total_debit, "total_credit": total_credit,
50
+ "balanced": abs(total_debit - total_credit) < 0.02}
51
+
52
+
53
+ def build_profit_loss(balances: dict[int, float], accounts_by_id: dict[int, dict]) -> dict:
54
+ income, expense = [], []
55
+ for aid, bal in balances.items():
56
+ a = accounts_by_id.get(aid, {})
57
+ cls = account_class(a.get("type_id"))
58
+ if cls == 4 and abs(bal) >= 0.005:
59
+ income.append(_line(a, -bal)) # income is credit-positive
60
+ elif cls == 3 and abs(bal) >= 0.005:
61
+ expense.append(_line(a, bal)) # expense is debit-positive
62
+ income.sort(key=lambda r: str(r["code"]))
63
+ expense.sort(key=lambda r: str(r["code"]))
64
+ total_income = round(sum(i["amount"] for i in income), 2)
65
+ total_expense = round(sum(e["amount"] for e in expense), 2)
66
+ return {"income": income, "expense": expense, "total_income": total_income,
67
+ "total_expense": total_expense, "net_profit": round(total_income - total_expense, 2)}
68
+
69
+
70
+ def build_balance_sheet(balances: dict[int, float], accounts_by_id: dict[int, dict]) -> dict:
71
+ assets, liabilities, equity = [], [], []
72
+ net_income = 0.0
73
+ for aid, bal in balances.items():
74
+ a = accounts_by_id.get(aid, {})
75
+ cls = account_class(a.get("type_id"))
76
+ if cls == 1 and abs(bal) >= 0.005:
77
+ assets.append(_line(a, bal)) # asset debit-positive
78
+ elif cls == 2 and abs(bal) >= 0.005:
79
+ liabilities.append(_line(a, -bal)) # liability credit-positive
80
+ elif cls == 5 and abs(bal) >= 0.005:
81
+ equity.append(_line(a, -bal)) # equity credit-positive
82
+ elif cls in (3, 4):
83
+ net_income += -bal # income(+) and expense(-) both -bal
84
+ for s in (assets, liabilities, equity):
85
+ s.sort(key=lambda r: str(r["code"]))
86
+ total_assets = round(sum(x["amount"] for x in assets), 2)
87
+ total_liabilities = round(sum(x["amount"] for x in liabilities), 2)
88
+ total_equity = round(sum(x["amount"] for x in equity), 2)
89
+ net_income = round(net_income, 2)
90
+ return {"assets": assets, "liabilities": liabilities, "equity": equity,
91
+ "net_income": net_income, "total_assets": total_assets,
92
+ "total_liabilities": total_liabilities, "total_equity": total_equity,
93
+ "balanced": abs(total_assets - (total_liabilities + total_equity + net_income)) < 0.02}
94
+
95
+
96
+ def reconcile(actual: float, expected: float, tol: float = 0.005) -> dict:
97
+ diff = round(actual - expected, 2)
98
+ return {"actual": round(actual, 2), "expected": round(expected, 2),
99
+ "diff": diff, "ok": abs(actual - expected) < tol}
@@ -447,13 +447,18 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
447
447
 
448
448
  coa = getattr(ns, "_coa", None)
449
449
  account_ref = getattr(ns, "account", None)
450
+ category_ref = getattr(ns, "category", None)
450
451
  de_account_id = None
451
- 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:
452
455
  if coa is None:
453
456
  raise ValueError(
454
- "--account requires a COA config (pass --coa FILE or set AKT_COA_FILE)")
455
- coa_de_account_id, coa_category_id = resolve_coding(coa, client, account_ref=account_ref)
456
- de_account_id = coa_de_account_id
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)
457
462
  if category_id is None: # explicit --category-id wins
458
463
  category_id = coa_category_id
459
464
 
@@ -471,6 +476,20 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
471
476
  contact_id = contact_id or document.get("contact_id")
472
477
  category_id = category_id or document.get("category_id")
473
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
+
474
493
  if category_id is None:
475
494
  key = "default.income_category" if ptype == "income" else "default.expense_category"
476
495
  val = client.setting(key)
@@ -515,8 +534,7 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
515
534
  body["description"] = ns.description
516
535
  if de_account_id is not None:
517
536
  body["de_account_id"] = de_account_id
518
- body.update(parse_set(getattr(ns, "set_", None)))
519
- body.update(load_data_arg(getattr(ns, "data", None)))
537
+ body.update(overrides) # --set / --data win (computed once, above)
520
538
 
521
539
  # A payment tied to a document must be posted to the nested route
522
540
  # POST /documents/{id}/transactions (the flat /transactions endpoint rejects
@@ -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
File without changes