diff options
| -rw-r--r-- | firefly_cli/commands/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/transaction.py | 95 | ||||
| -rw-r--r-- | tests/unit/test_commands_transaction.py | 68 |
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) |
