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.
- {akt_cli-0.6.0 → akt_cli-0.7.0}/PKG-INFO +20 -2
- {akt_cli-0.6.0 → akt_cli-0.7.0}/README.md +19 -1
- {akt_cli-0.6.0 → akt_cli-0.7.0}/pyproject.toml +1 -1
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/cli.py +100 -0
- akt_cli-0.7.0/src/akt/reports.py +99 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/LICENSE +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/__init__.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/client.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/coa.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/commands.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/config.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/ledger.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/output.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/registry.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/resources.py +0 -0
- {akt_cli-0.6.0 → akt_cli-0.7.0}/src/akt/verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: akt-cli
|
|
3
|
-
Version: 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 "
|
|
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 "
|
|
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`
|
|
@@ -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
|