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.
- {akt_cli-0.5.0 → akt_cli-0.7.0}/PKG-INFO +60 -3
- {akt_cli-0.5.0 → akt_cli-0.7.0}/README.md +59 -2
- {akt_cli-0.5.0 → akt_cli-0.7.0}/pyproject.toml +1 -1
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/cli.py +218 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/client.py +15 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/coa.py +24 -5
- akt_cli-0.7.0/src/akt/ledger.py +13 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/registry.py +3 -0
- akt_cli-0.7.0/src/akt/reports.py +99 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/resources.py +24 -6
- akt_cli-0.7.0/src/akt/verify.py +73 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/LICENSE +0 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/__init__.py +0 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/commands.py +0 -0
- {akt_cli-0.5.0 → akt_cli-0.7.0}/src/akt/config.py +0 -0
- {akt_cli-0.5.0 → akt_cli-0.7.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.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`
|
|
261
|
-
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.
|
|
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`
|
|
240
|
-
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.
|
|
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:
|
|
@@ -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
|
|
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(
|
|
@@ -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
|
-
|
|
456
|
-
|
|
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(
|
|
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
|