aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--firefly_cli/commands/__init__.py2
-rw-r--r--firefly_cli/commands/transaction.py95
-rw-r--r--tests/unit/test_commands_transaction.py68
3 files changed, 164 insertions, 1 deletions
diff --git a/firefly_cli/commands/__init__.py b/firefly_cli/commands/__init__.py
index 67cab2e..8204db6 100644
--- a/firefly_cli/commands/__init__.py
+++ b/firefly_cli/commands/__init__.py
@@ -1,3 +1,3 @@
# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
# Importing each module runs its @registry.command decorators.
-from firefly_cli.commands import auth, account, category, tag # noqa: F401
+from firefly_cli.commands import auth, account, category, tag, transaction # noqa: F401
diff --git a/firefly_cli/commands/transaction.py b/firefly_cli/commands/transaction.py
new file mode 100644
index 0000000..7d48f78
--- /dev/null
+++ b/firefly_cli/commands/transaction.py
@@ -0,0 +1,95 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+from firefly_cli import registry, output
+
+# Inference table keyed by (source_type, destination_type) -> firefly tx type.
+def _infer_type(src_type, dst_type):
+ s, d = (src_type or "").lower(), (dst_type or "").lower()
+ if s == "asset" and d == "asset":
+ return "transfer"
+ if s in ("revenue",) and d == "asset":
+ return "deposit"
+ if s == "asset" and d in ("expense",):
+ return "withdrawal"
+ # Fallback: asset source -> withdrawal, asset dest -> deposit.
+ if s == "asset":
+ return "withdrawal"
+ if d == "asset":
+ return "deposit"
+ raise ValueError(
+ f"Cannot infer transaction type from {src_type!r}->{dst_type!r}; "
+ "pass --type withdrawal|deposit|transfer.")
+
+def _add_args(p):
+ p.add_argument("amount")
+ p.add_argument("--from", dest="source", required=True, help="source account")
+ p.add_argument("--to", dest="dest", required=True, help="destination account")
+ p.add_argument("--desc", default=None)
+ p.add_argument("--date", default=None, help="YYYY-MM-DD (default today)")
+ p.add_argument("--category", default=None)
+ p.add_argument("--tags", default=None, help="comma-separated")
+ p.add_argument("--type", default=None,
+ help="withdrawal|deposit|transfer (overrides inference)")
+
+@registry.command("tx add", help="record a transaction", args=_add_args)
+def cmd_add(args, ctx):
+ src = ctx.resolver.account(args.source)
+ dst = ctx.resolver.account(args.dest)
+ ttype = args.type or _infer_type(src.get("type"), dst.get("type"))
+ from datetime import date as _date
+ split = {
+ "type": ttype,
+ "date": args.date or _date.today().isoformat(),
+ "amount": str(args.amount),
+ "description": args.desc or "",
+ "source_id": src["id"],
+ "destination_id": dst["id"],
+ }
+ if args.category:
+ split["category_name"] = ctx.resolver.category(args.category).get("name", args.category)
+ if args.tags:
+ split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
+ resp = ctx.client.request("POST", "/api/v1/transactions",
+ body={"transactions": [split]})
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+
+def _list_args(p):
+ p.add_argument("--since", default=None, help="start date YYYY-MM-DD")
+ p.add_argument("--until", default=None, help="end date YYYY-MM-DD")
+ p.add_argument("--account", default=None, help="filter by account name")
+ p.add_argument("--limit", type=int, default=20)
+
+@registry.command("tx list", help="list transactions", args=_list_args)
+def cmd_list(args, ctx):
+ if args.account:
+ acc = ctx.resolver.account(args.account)
+ path = f"/api/v1/accounts/{acc['id']}/transactions"
+ else:
+ path = "/api/v1/transactions"
+ params = {"limit": args.limit}
+ if args.since:
+ params["start"] = args.since
+ if args.until:
+ params["end"] = args.until
+ resp = ctx.client.request("GET", path, params=params)
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+
+def _id_arg(p):
+ p.add_argument("id")
+
+@registry.command("tx get", help="show one transaction", args=_id_arg)
+def cmd_get(args, ctx):
+ resp = ctx.client.request("GET", f"/api/v1/transactions/{args.id}")
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+
+def _query_arg(p):
+ p.add_argument("query")
+
+@registry.command("tx search", help="search transactions", args=_query_arg)
+def cmd_search(args, ctx):
+ resp = ctx.client.request("GET", "/api/v1/search/transactions",
+ params={"query": args.query})
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
diff --git a/tests/unit/test_commands_transaction.py b/tests/unit/test_commands_transaction.py
new file mode 100644
index 0000000..db187b7
--- /dev/null
+++ b/tests/unit/test_commands_transaction.py
@@ -0,0 +1,68 @@
+import unittest
+from unittest.mock import MagicMock
+from firefly_cli.commands import transaction as tx
+from firefly_cli.context import Context
+
+def make_ctx():
+ client = MagicMock()
+ resolver = MagicMock()
+ return Context(client=client, resolver=resolver, human=False), client, resolver
+
+class TestTxAdd(unittest.TestCase):
+ def test_infers_withdrawal_from_asset_to_expense(self):
+ ctx, client, resolver = make_ctx()
+ resolver.account.side_effect = lambda n: {
+ "Checking": {"id": "1", "name": "Checking", "type": "asset"},
+ "Groceries": {"id": "2", "name": "Groceries", "type": "expense"},
+ }[n]
+ client.request.return_value = {"data": {"id": "55", "type": "transactions",
+ "attributes": {}}}
+ args = MagicMock(amount="42.50", source="Checking", dest="Groceries",
+ desc="food", date="2026-06-30", category=None,
+ tags=None, type=None)
+ rc = tx.cmd_add(args, ctx)
+ self.assertEqual(rc, 0)
+ method, path = client.request.call_args[0][:2]
+ body = client.request.call_args[1]["body"]
+ split = body["transactions"][0]
+ self.assertEqual((method, path), ("POST", "/api/v1/transactions"))
+ self.assertEqual(split["type"], "withdrawal")
+ self.assertEqual(split["source_id"], "1")
+ self.assertEqual(split["destination_id"], "2")
+ self.assertEqual(split["amount"], "42.50")
+
+ def test_infers_deposit_revenue_to_asset(self):
+ ctx, client, resolver = make_ctx()
+ resolver.account.side_effect = lambda n: {
+ "Salary": {"id": "7", "name": "Salary", "type": "revenue"},
+ "Checking": {"id": "1", "name": "Checking", "type": "asset"},
+ }[n]
+ client.request.return_value = {"data": {"id": "1", "attributes": {}}}
+ args = MagicMock(amount="1000", source="Salary", dest="Checking",
+ desc="pay", date=None, category=None, tags=None, type=None)
+ tx.cmd_add(args, ctx)
+ self.assertEqual(client.request.call_args[1]["body"]["transactions"][0]["type"],
+ "deposit")
+
+ def test_explicit_type_overrides_inference(self):
+ ctx, client, resolver = make_ctx()
+ resolver.account.side_effect = lambda n: {"id": "1", "type": "asset", "name": n}
+ client.request.return_value = {"data": {"id": "1", "attributes": {}}}
+ args = MagicMock(amount="5", source="A", dest="B", desc=None, date=None,
+ category=None, tags="food,fun", type="transfer")
+ tx.cmd_add(args, ctx)
+ split = client.request.call_args[1]["body"]["transactions"][0]
+ self.assertEqual(split["type"], "transfer")
+ self.assertEqual(split["tags"], ["food", "fun"])
+
+class TestTxList(unittest.TestCase):
+ def test_list_passes_date_params(self):
+ ctx, client, _ = make_ctx()
+ client.request.return_value = {"data": []}
+ args = MagicMock(since="2026-06-01", until="2026-06-30",
+ account=None, limit=10)
+ tx.cmd_list(args, ctx)
+ params = client.request.call_args[1]["params"]
+ self.assertEqual(params["start"], "2026-06-01")
+ self.assertEqual(params["end"], "2026-06-30")
+ self.assertEqual(params["limit"], 10)