akt-cli 0.8.1__tar.gz → 0.9.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.8.1
3
+ Version: 0.9.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
@@ -265,6 +265,27 @@ agree. `--account` takes a GL code or name; `--category` a mirror-category name
265
265
  (see `--bank` for the bank/cash account). An explicit `--set de_account_id=` still
266
266
  wins.
267
267
 
268
+ ### Split one transaction across several GL accounts
269
+
270
+ `--split` posts **one bank transaction to multiple GL accounts at once** — the way
271
+ an invoice's line items each hit their own account. Repeat it per leg as
272
+ `account=<code|name>,debit=<x>` or `account=<code|name>,credit=<x>`; the legs must
273
+ net to `--amount` (income legs credit-heavy, expense legs debit-heavy):
274
+
275
+ ```bash
276
+ # one $13,400 PayPro deposit that books gross revenue, a refund and a fee together
277
+ akt payment create --type income --bank 20 --amount 13400 \
278
+ --split 'account=400,credit=13686' \
279
+ --split 'account=545,debit=280' \
280
+ --split 'account=605,debit=6'
281
+ ```
282
+
283
+ Needs the [`akt-api` companion module](akt-api/) — it writes the extra DoubleEntry
284
+ item legs (a standalone transaction can't natively carry more than one). The
285
+ transaction keeps a **single `category_id`**, a label just like a multi-item
286
+ invoice's, so Akaunting's native category report shows it under one bucket; the
287
+ **DoubleEntry GL (the item legs) is the source of truth**.
288
+
268
289
  **Enforcement (when a `--coa` config is loaded):** akt refuses to create a
269
290
  *standalone* income/expense transaction that has no GL account — you must pass
270
291
  `--account`, `--category`, or an explicit `--set de_account_id=`. This makes the
@@ -244,6 +244,27 @@ agree. `--account` takes a GL code or name; `--category` a mirror-category name
244
244
  (see `--bank` for the bank/cash account). An explicit `--set de_account_id=` still
245
245
  wins.
246
246
 
247
+ ### Split one transaction across several GL accounts
248
+
249
+ `--split` posts **one bank transaction to multiple GL accounts at once** — the way
250
+ an invoice's line items each hit their own account. Repeat it per leg as
251
+ `account=<code|name>,debit=<x>` or `account=<code|name>,credit=<x>`; the legs must
252
+ net to `--amount` (income legs credit-heavy, expense legs debit-heavy):
253
+
254
+ ```bash
255
+ # one $13,400 PayPro deposit that books gross revenue, a refund and a fee together
256
+ akt payment create --type income --bank 20 --amount 13400 \
257
+ --split 'account=400,credit=13686' \
258
+ --split 'account=545,debit=280' \
259
+ --split 'account=605,debit=6'
260
+ ```
261
+
262
+ Needs the [`akt-api` companion module](akt-api/) — it writes the extra DoubleEntry
263
+ item legs (a standalone transaction can't natively carry more than one). The
264
+ transaction keeps a **single `category_id`**, a label just like a multi-item
265
+ invoice's, so Akaunting's native category report shows it under one bucket; the
266
+ **DoubleEntry GL (the item legs) is the source of truth**.
267
+
247
268
  **Enforcement (when a `--coa` config is loaded):** akt refuses to create a
248
269
  *standalone* income/expense transaction that has no GL account — you must pass
249
270
  `--account`, `--category`, or an explicit `--set de_account_id=`. This makes the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "akt-cli"
3
- version = "0.8.1"
3
+ version = "0.9.0"
4
4
  description = "akt — a CLI toolbox to fully drive an Akaunting accounting instance"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -48,6 +48,15 @@ def _add_field_args(p: argparse.ArgumentParser, res: Resource, *, for_update: bo
48
48
  p.add_argument("--item", action="append", metavar="K=V,...",
49
49
  help="ledger line (>= 2, must balance), e.g. "
50
50
  "'account_id=10,debit=100' or 'account_id=20,credit=100' (repeatable)")
51
+ elif res.endpoint == "transactions" and not for_update:
52
+ p.add_argument("--split", action="append", metavar="account=CODE,debit|credit=X",
53
+ help="split the GL posting across multiple accounts (repeatable): "
54
+ "one bank transaction that posts N GL item legs, the way an "
55
+ "invoice's line items do. e.g. 'account=400,credit=13686' "
56
+ "'account=545,debit=280'. Legs must net to --amount; needs a "
57
+ "--coa config and the akt-api companion module. category_id "
58
+ "stays a single label — the DoubleEntry GL (item legs) is the "
59
+ "source of truth.")
51
60
  if res.supports_attachments:
52
61
  p.add_argument("--attachment", action="append", metavar="PATH",
53
62
  help="attach a file (pdf/jpg/png, repeatable); switches the "
@@ -16,6 +16,7 @@ from .resources import (
16
16
  redate_opening_balance,
17
17
  resolve_payment_delete,
18
18
  resolve_payment_update,
19
+ split_payment_legs,
19
20
  )
20
21
 
21
22
  # Common column sets
@@ -251,6 +252,7 @@ PAYMENT = Resource(
251
252
  build_create=build_payment_create,
252
253
  delete_resolver=resolve_payment_delete,
253
254
  update_resolver=resolve_payment_update,
255
+ post_write=split_payment_legs,
254
256
  help="Payments / transactions (income & expense)",
255
257
  )
256
258
 
@@ -436,6 +436,33 @@ def build_document_update(res: Resource, client: Client, ns: Any, current: dict)
436
436
  # payment (transaction) body builder
437
437
  # --------------------------------------------------------------------------
438
438
 
439
+ def parse_split_leg(spec: str) -> dict:
440
+ """Parse one ``--split`` leg: ``account=<code|name>,debit=<x>`` or
441
+ ``account=<code|name>,credit=<x>``. Exactly one of debit/credit; ``account`` is a
442
+ GL code or name resolved against the COA (like ``--account``). Returns
443
+ ``{'account': str, 'debit'|'credit': float}``."""
444
+ item: dict[str, Any] = {}
445
+ for part in spec.split(","):
446
+ if not part.strip():
447
+ continue
448
+ if "=" not in part:
449
+ raise ValueError(f"--split field must be key=value, got {part!r}")
450
+ k, _, v = part.partition("=")
451
+ item[k.strip()] = v.strip()
452
+ if not item.get("account"):
453
+ raise ValueError(f"--split leg requires an account=<code|name> field: {spec!r}")
454
+ has_d = bool(item.get("debit"))
455
+ has_c = bool(item.get("credit"))
456
+ if has_d == has_c:
457
+ raise ValueError(f"--split leg requires exactly one of debit=/credit=: {spec!r}")
458
+ leg = {"account": item["account"]}
459
+ if has_d:
460
+ leg["debit"] = float(item["debit"])
461
+ else:
462
+ leg["credit"] = float(item["credit"])
463
+ return leg
464
+
465
+
439
466
  def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
440
467
  invoice_id = getattr(ns, "invoice", None)
441
468
  bill_id = getattr(ns, "bill", None)
@@ -462,6 +489,33 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
462
489
  if category_id is None: # explicit --category-id wins
463
490
  category_id = coa_category_id
464
491
 
492
+ # --split: one bank transaction posting N GL item legs (like an invoice's line
493
+ # items). Resolve each leg's account to a de_account_id; the placeholder item
494
+ # leg uses the first leg's account, then the split_payment_legs post_write hook
495
+ # fans it out into the N legs via the akt-api endpoint. category_id stays a
496
+ # single representative label (mirrors invoice behavior); the GL is the truth.
497
+ split_specs = getattr(ns, "split", None)
498
+ split_legs = None
499
+ if split_specs:
500
+ if account_ref or category_ref:
501
+ raise ValueError("pass either --split or --account/--category, not both")
502
+ if coa is None:
503
+ raise ValueError("--split requires a COA config (pass --coa FILE or set AKT_COA_FILE)")
504
+ split_legs = []
505
+ for spec in split_specs:
506
+ leg = parse_split_leg(spec)
507
+ leg_de_account_id, leg_category_id = resolve_coding(coa, client, account_ref=leg["account"])
508
+ resolved = {"account_id": leg_de_account_id}
509
+ if "debit" in leg:
510
+ resolved["debit"] = leg["debit"]
511
+ else:
512
+ resolved["credit"] = leg["credit"]
513
+ split_legs.append(resolved)
514
+ if category_id is None: # first leg's category = representative label
515
+ category_id = leg_category_id
516
+ de_account_id = split_legs[0]["account_id"] # placeholder item leg
517
+ ns._split_resolved = split_legs
518
+
465
519
  if invoice_id:
466
520
  document = client.show("documents", invoice_id, type_scope="invoice")
467
521
  ptype = ptype or "income"
@@ -503,6 +557,17 @@ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
503
557
  if amount is None:
504
558
  raise ValueError("--amount is required")
505
559
 
560
+ # --split balance: the legs must net to the transaction's single item leg, which
561
+ # mirrors --amount and the bank leg. Income posts the item leg as a credit of
562
+ # --amount (net -amount); expense as a debit (net +amount). Fail fast client-side.
563
+ if split_legs is not None:
564
+ expected = float(amount) if ptype == "expense" else -float(amount)
565
+ net = sum(l.get("debit", 0) - l.get("credit", 0) for l in split_legs)
566
+ if abs(net - expected) > 0.005:
567
+ raise ValueError(
568
+ f"--split legs net {net:.2f} but --amount {amount} (type {ptype}) needs "
569
+ f"{expected:.2f} — income legs must be credit-heavy, expense debit-heavy")
570
+
506
571
  account_id = getattr(ns, "account_id", None)
507
572
  if account_id is None:
508
573
  val = client.setting("default.account")
@@ -576,6 +641,32 @@ def resolve_payment_update(res: Resource, client: Client, ident: str,
576
641
  return f"transactions/{ident}", None
577
642
 
578
643
 
644
+ def split_payment_legs(res: Resource, client: Client, record: dict, ns: Any) -> None:
645
+ """post_write hook for ``payment create --split``: after the transaction is
646
+ created (with a single placeholder item leg), fan that leg out into the N
647
+ resolved legs via the akt-api ``POST /ledgers/{id}/split`` endpoint. No-op unless
648
+ ``--split`` was given. Requires the akt-api companion module."""
649
+ legs = getattr(ns, "_split_resolved", None)
650
+ if not legs:
651
+ return
652
+ if not client.has_ledger_api():
653
+ raise ValueError(
654
+ "--split needs the akt-api companion module (GET /api/akt-api/ledgers is "
655
+ "404). Deploy it (scripts/deploy-akt-api.sh) and retry.")
656
+ txn_id = record.get("id")
657
+ payload = client.get("akt-api/ledgers", params={
658
+ "ledgerable_type": "App\\Models\\Banking\\Transaction",
659
+ "ledgerable_id": txn_id,
660
+ "entry_type": "item",
661
+ })
662
+ rows = payload.get("data", payload) if isinstance(payload, dict) else payload
663
+ if len(rows) != 1:
664
+ raise ValueError(
665
+ f"expected exactly one item leg on transaction {txn_id} to split, found "
666
+ f"{len(rows)}")
667
+ client.post(f"akt-api/ledgers/{rows[0]['id']}/split", {"legs": legs})
668
+
669
+
579
670
  def build_transfer_create(res: Resource, client: Client, ns: Any) -> dict:
580
671
  body = body_from_fields(res, ns, for_update=False)
581
672
  # Transfers validate transferred_at as date-only (Y-m-d), unlike transactions.
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