akt-cli 0.6.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.6.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
@@ -256,7 +256,7 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
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
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"
259
+ akt payment create --type expense --bank 1 --amount 120 --category "Cloud Hosting"
260
260
  ```
261
261
 
262
262
  `--account`/`--category` are two ends of the same coin: give either and akt fills
@@ -280,6 +280,24 @@ akt company
280
280
  akt settings --search 'key:default.account'
281
281
  ```
282
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
+
283
301
  ## Reading the ledger: the `akt-api` companion module
284
302
 
285
303
  Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
@@ -235,7 +235,7 @@ akt coa sync --prune # also DISABLE accounts/categories absent from the
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
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"
238
+ akt payment create --type expense --bank 1 --amount 120 --category "Cloud Hosting"
239
239
  ```
240
240
 
241
241
  `--account`/`--category` are two ends of the same coin: give either and akt fills
@@ -259,6 +259,24 @@ akt company
259
259
  akt settings --search 'key:default.account'
260
260
  ```
261
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
+
262
280
  ## Reading the ledger: the `akt-api` companion module
263
281
 
264
282
  Akaunting's Double-Entry app exposes only `journal-entry` and `chart-of-accounts`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.6.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"
@@ -24,6 +24,7 @@ from .resources import Resource, load_data_arg
24
24
  from .coa import load_coa, plan_sync, render_plan, apply_plan
25
25
  from .ledger import resolve_account_id
26
26
  from .verify import find_miscodings, build_recode_plan
27
+ from . import reports
27
28
 
28
29
 
29
30
  def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bool) -> None:
@@ -202,9 +203,51 @@ def _build_parser() -> argparse.ArgumentParser:
202
203
  help="show the plan without writing anything")
203
204
  rcp.set_defaults(_special="recode")
204
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
+
205
229
  return parser
206
230
 
207
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
+
208
251
  def _run_special(name: str, client: Client, ns: Any) -> int:
209
252
  if name == "ping":
210
253
  emit(client.get("ping"), as_json=True)
@@ -344,6 +387,63 @@ def _run_special(name: str, client: Client, ns: Any) -> int:
344
387
  done += 1
345
388
  print(f"recoded {done} transaction(s).")
346
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
347
447
  raise ValueError(name)
348
448
 
349
449
 
@@ -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}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes