akt-cli 0.4.1__tar.gz → 0.6.0__tar.gz

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