From 50cb852130986249cacec411e23392a8610e12f1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 5 Jul 2026 19:17:04 +0200 Subject: feat(budget): tx edit --budget and budget update (v0.5.0) Add two deferred budget follow-ups: - tx edit --budget: re-assign a budget on an existing transaction, mirroring the existing tx add --budget flag (name/id resolved, hard error on miss). Resolves the ISSUES.md backfill wish. - budget update: rename a budget and/or edit its auto-budget fields via PUT /budgets/{id}; only fields passed are sent, empty is a hard error. New command adds to the CLI surface without breaking callers, so this is a MINOR bump (0.4.1 -> 0.5.0). Docs (README, SKILL.md), completion, and TODO synced. The tx add --budget-id item stays open (still YAGNI). Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +++- SKILL.md | 11 ++++++--- TODO.md | 8 +++---- completions/firefly.bash | 5 ++-- firefly_cli/__init__.py | 2 +- firefly_cli/commands/budget.py | 32 +++++++++++++++++++++++++ firefly_cli/commands/transaction.py | 5 ++++ pyproject.toml | 2 +- tests/unit/test_commands_budget.py | 42 +++++++++++++++++++++++++++++++++ tests/unit/test_commands_transaction.py | 22 +++++++++++++---- 10 files changed, 117 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 48be94a..c6cdb80 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ firefly tx list [--since D] [--until D] [--account A] [--limit N] firefly tx get show one transaction firefly tx search search transactions by Firefly query string firefly tx edit [--amount N] [--date D] [--desc T] [--from A] - [--to A] [--category C] [--tags a,b] [--type T] + [--to A] [--category C] [--tags a,b] [--budget B] [--type T] firefly tx delete --yes delete one transaction (requires --yes) firefly budget list [--start D] [--end D] @@ -70,6 +70,8 @@ firefly budget create [--active|--inactive] [--currency CODE] [--auto-budget-amount N] [--auto-budget-period daily|weekly|monthly|quarterly|half_year|yearly] [--auto-budget-type reset|rollover|adjusted|none] +firefly budget update [--name NEW] [--currency CODE] + [--auto-budget-amount N] [--auto-budget-period P] [--auto-budget-type T] firefly budget enable mark a budget active firefly budget disable mark a budget inactive firefly budget delete --yes @@ -98,6 +100,7 @@ The command set grows over time; see `CLAUDE.md` for how to add one. Override with `--type`. `tx edit` changes only the fields you pass. - Budgets are managed by name or id. `budget list` reports spent per budget for a period; `budget limit-set` sets a spending limit over a date range. + `budget update` renames a budget or edits its auto-budget fields. `budget delete` and `tx delete` require `--yes`. ## For agents (skill) diff --git a/SKILL.md b/SKILL.md index b3ead3d..87dcc94 100644 --- a/SKILL.md +++ b/SKILL.md @@ -65,7 +65,7 @@ firefly tx add (--from | --from-id ) (--to | --to-id [--budget ] [--dry-run] [--skip-dupes] firefly tx edit [--amount N] [--date YYYY-MM-DD] [--desc TEXT] [--from ] [--to ] - [--category NAME] [--tags a,b] [--type T] # only fields passed are changed + [--category NAME] [--tags a,b] [--budget ] [--type T] # only fields passed are changed firefly tx delete --yes # --yes required, no prompt firefly tx list [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--account NAME] [--limit N] [--all] firefly tx get @@ -75,6 +75,8 @@ firefly tag list firefly budget list [--start YYYY-MM-DD] [--end YYYY-MM-DD] # default: current month firefly budget create [--active|--inactive] [--auto-budget-amount N --auto-budget-period P --auto-budget-type T] [--currency CODE] +firefly budget update [--name NEW] + [--auto-budget-amount N --auto-budget-period P --auto-budget-type T] [--currency CODE] firefly budget enable firefly budget disable firefly budget delete --yes # --yes required, no prompt @@ -153,13 +155,16 @@ firefly budget limit-set Groceries --amount 400 # cap for the current mon firefly budget list # each budget + spent this month firefly budget list --start 2026-05-01 --end 2026-05-31 # spent in a chosen period firefly tx add 42.50 --from test01 --to Supermarket --budget Groceries +firefly tx edit 75 --budget Groceries # (re)budget an existing tx +firefly budget update Groceries --name Food # rename a budget firefly budget limit-list Groceries # existing caps for a budget firefly budget disable Groceries # stop budgeting it (toggle active) ``` `budget list` and `budget limit-set` default to the current calendar month when `--start`/`--end` are omitted. `budget delete --yes` removes a budget -(the `--yes` guard mirrors `tx delete`). Note the leaf names are hyphenated -(`limit-list`, `limit-set`), not `limit list`. +(the `--yes` guard mirrors `tx delete`). `budget update` patches only the +fields you pass (rename via `--name`, or edit the auto-budget fields). Note the +leaf names are hyphenated (`limit-list`, `limit-set`), not `limit list`. **Check a balance:** ```bash diff --git a/TODO.md b/TODO.md index fe1fafc..3208c57 100644 --- a/TODO.md +++ b/TODO.md @@ -18,11 +18,9 @@ bash completion). ## Verbs on existing groups - [ ] `account delete` — currently needs a manual curl DELETE. - [ ] `tx update` / `tx delete`. -- [ ] `tx edit --budget` — `tx add` can assign a budget (v0.4.0) but `tx edit` - cannot re-budget an existing transaction. Add the flag mirroring `tx add`. -- [ ] `budget update` — rename a budget and/or edit its auto-budget fields - (PUT /budgets/{id} accepts name + auto_budget_*). Only enable/disable - (active toggle) shipped in v0.4.0; rename currently needs the web UI. +- [x] `tx edit --budget` — mirrors `tx add --budget`. Shipped v0.5.0. +- [x] `budget update` — rename + auto-budget fields via PUT /budgets/{id}. + Shipped v0.5.0. - [ ] `tx add --budget-id` — `--budget` resolves a name-or-id but sniffs all-digit refs as ids, so a budget literally named "7" is unreachable by name (see the ponytail note in `resolver.py`). Add an explicit diff --git a/completions/firefly.bash b/completions/firefly.bash index 06df14e..34171f9 100644 --- a/completions/firefly.bash +++ b/completions/firefly.bash @@ -42,12 +42,13 @@ _firefly() { "account list") leaf_opts="--type";; "tx add") leaf_opts="--budget --category --date --desc --dry-run --from --from-id --skip-dupes --tags --to --to-id --type";; "tx delete") leaf_opts="--yes";; - "tx edit") leaf_opts="--amount --category --date --desc --from --tags --to --type";; + "tx edit") leaf_opts="--amount --budget --category --date --desc --from --tags --to --type";; "tx list") leaf_opts="--account --all --flat --limit --since --until";; "budget create") leaf_opts="--active --auto-budget-amount --auto-budget-period --auto-budget-type --currency --inactive";; "budget delete") leaf_opts="--yes";; "budget limit-set") leaf_opts="--amount --currency --end --start";; "budget list") leaf_opts="--end --start";; + "budget update") leaf_opts="--auto-budget-amount --auto-budget-period --auto-budget-type --currency --name";; esac # Leaves per group. @@ -58,7 +59,7 @@ _firefly() { category) leaves="list";; tag) leaves="list";; tx) leaves="add delete edit get list search";; - budget) leaves="create delete disable enable limit-list limit-set list";; + budget) leaves="create delete disable enable limit-list limit-set list update";; esac # Flag values: when the previous word is a flag with a fixed value set for diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py index 1d2fe01..d4060af 100644 --- a/firefly_cli/__init__.py +++ b/firefly_cli/__init__.py @@ -2,4 +2,4 @@ # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License v2.0 only. -__version__ = "0.4.1" +__version__ = "0.5.0" diff --git a/firefly_cli/commands/budget.py b/firefly_cli/commands/budget.py index 86317fa..8e35a16 100644 --- a/firefly_cli/commands/budget.py +++ b/firefly_cli/commands/budget.py @@ -99,6 +99,38 @@ def _set_active(ctx, ref, active): output.emit(output.unwrap(resp), human=ctx.human) return 0 +def _update_args(p): + _ref_arg(p) + p.add_argument("--name", default=None, help="new budget name") + p.add_argument("--currency", default=None, help="auto-budget currency code, e.g. EUR") + p.add_argument("--auto-budget-amount", dest="auto_budget_amount", default=None, + help="recurring auto-budget amount") + p.add_argument("--auto-budget-period", dest="auto_budget_period", default=None, + help="daily|weekly|monthly|quarterly|half_year|yearly") + p.add_argument("--auto-budget-type", dest="auto_budget_type", default=None, + help="reset|rollover|adjusted|none") + +@registry.command("budget update", help="rename a budget and/or edit its auto-budget fields (name or id)", args=_update_args) +def cmd_update(args, ctx): + b = ctx.resolver.budget(args.ref) + body = {} + if args.name is not None: + body["name"] = args.name + if args.auto_budget_amount is not None: + body["auto_budget_amount"] = str(args.auto_budget_amount) + if args.auto_budget_period is not None: + body["auto_budget_period"] = args.auto_budget_period + if args.auto_budget_type is not None: + body["auto_budget_type"] = args.auto_budget_type + if args.currency: + body["currency_code"] = args.currency + if not body: + raise FireflyError("budget update: nothing to change; pass at least one field") + resp = ctx.client.request("PUT", f"/api/v1/budgets/{b['id']}", body=body) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + + @registry.command("budget enable", help="mark a budget active", args=_ref_arg) def cmd_enable(args, ctx): return _set_active(ctx, args.ref, True) diff --git a/firefly_cli/commands/transaction.py b/firefly_cli/commands/transaction.py index 9f8e130..1772d87 100644 --- a/firefly_cli/commands/transaction.py +++ b/firefly_cli/commands/transaction.py @@ -109,6 +109,8 @@ def _edit_args(p): p.add_argument("--to", dest="dest", default=None, help="destination account") p.add_argument("--category", default=None) p.add_argument("--tags", default=None, help="comma-separated") + p.add_argument("--budget", default=None, + help="budget name or id to (re)assign (must already exist)") p.add_argument("--type", default=None, help="withdrawal|deposit|transfer") # ponytail: single-split journals only; multi-split edits need transaction_journal_id per row. @@ -129,6 +131,9 @@ def cmd_edit(args, ctx): split["category_name"] = args.category if args.tags is not None: split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] + if args.budget is not None: + # Budgets must pre-exist; resolve name/id -> id (hard error on miss). + split["budget_id"] = ctx.resolver.budget(args.budget)["id"] if args.type is not None: split["type"] = args.type if not split: diff --git a/pyproject.toml b/pyproject.toml index 27034f8..409c096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "firefly-iii-agent" -version = "0.4.1" +version = "0.5.0" description = "CLI tool for agent interaction with Firefly III" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/unit/test_commands_budget.py b/tests/unit/test_commands_budget.py index b365d27..96452c1 100644 --- a/tests/unit/test_commands_budget.py +++ b/tests/unit/test_commands_budget.py @@ -119,6 +119,47 @@ class TestBudgetLifecycle(unittest.TestCase): self.assertFalse(client.request.call_args[1]["body"]["active"]) +class TestBudgetUpdate(unittest.TestCase): + def _resolver(self): + r = MagicMock() + r.budget.return_value = {"id": "5", "name": "Rent"} + return r + + def _args(self, **kw): + base = dict(ref="Rent", name=None, currency=None, auto_budget_amount=None, + auto_budget_period=None, auto_budget_type=None) + base.update(kw) + return SimpleNamespace(**base) + + def test_rename_puts_name(self): + client = MagicMock() + client.request.return_value = {"data": {"id": "5", "attributes": {}}} + budget.cmd_update(self._args(name="Housing"), + _ctx(client=client, resolver=self._resolver())) + method, path = client.request.call_args[0][:2] + body = client.request.call_args[1]["body"] + self.assertEqual((method, path), ("PUT", "/api/v1/budgets/5")) + self.assertEqual(body, {"name": "Housing"}) + + def test_auto_budget_fields(self): + client = MagicMock() + client.request.return_value = {"data": {"id": "5", "attributes": {}}} + budget.cmd_update( + self._args(auto_budget_amount=500, auto_budget_period="monthly", + auto_budget_type="reset", currency="EUR"), + _ctx(client=client, resolver=self._resolver())) + body = client.request.call_args[1]["body"] + self.assertEqual(body["auto_budget_amount"], "500") + self.assertEqual(body["auto_budget_period"], "monthly") + self.assertEqual(body["auto_budget_type"], "reset") + self.assertEqual(body["currency_code"], "EUR") + self.assertNotIn("name", body) + + def test_no_fields_errors(self): + with self.assertRaises(FireflyError): + budget.cmd_update(self._args(), _ctx(resolver=self._resolver())) + + class TestBudgetLimit(unittest.TestCase): def _resolver(self): r = MagicMock() @@ -168,6 +209,7 @@ class TestBudgetRegistration(unittest.TestCase): expected = { "budget list": budget.cmd_list, "budget create": budget.cmd_create, + "budget update": budget.cmd_update, "budget delete": budget.cmd_delete, "budget enable": budget.cmd_enable, "budget disable": budget.cmd_disable, diff --git a/tests/unit/test_commands_transaction.py b/tests/unit/test_commands_transaction.py index 7366b5c..ec4e54c 100644 --- a/tests/unit/test_commands_transaction.py +++ b/tests/unit/test_commands_transaction.py @@ -274,7 +274,8 @@ class TestTxEdit(unittest.TestCase): ctx, client, resolver = make_ctx() client.request.return_value = {"data": {"id": "9", "attributes": {}}} args = MagicMock(id="9", amount="12.00", date=None, desc="fixed", - source=None, dest=None, category=None, tags=None, type=None) + source=None, dest=None, category=None, tags=None, + budget=None, type=None) rc = tx.cmd_edit(args, ctx) self.assertEqual(rc, 0) method, path = client.request.call_args[0][:2] @@ -291,7 +292,8 @@ class TestTxEdit(unittest.TestCase): }[n] client.request.return_value = {"data": {"id": "9", "attributes": {}}} args = MagicMock(id="9", amount=None, date=None, desc=None, - source="BBVA", dest="Medio", category=None, tags=None, type=None) + source="BBVA", dest="Medio", category=None, tags=None, + budget=None, type=None) tx.cmd_edit(args, ctx) split = client.request.call_args[1]["body"]["transactions"][0] self.assertEqual(split, {"source_id": "3", "destination_id": "4"}) @@ -300,7 +302,8 @@ class TestTxEdit(unittest.TestCase): ctx, client, resolver = make_ctx() client.request.return_value = {"data": {"id": "9", "attributes": {}}} args = MagicMock(id="9", amount=None, date=None, desc=None, source=None, - dest=None, category="Cat", tags="a, b", type="transfer") + dest=None, category="Cat", tags="a, b", budget=None, + type="transfer") tx.cmd_edit(args, ctx) split = client.request.call_args[1]["body"]["transactions"][0] self.assertEqual(split, @@ -311,11 +314,22 @@ class TestTxEdit(unittest.TestCase): from firefly_cli.errors import FireflyError ctx, client, _ = make_ctx() args = MagicMock(id="9", amount=None, date=None, desc=None, source=None, - dest=None, category=None, tags=None, type=None) + dest=None, category=None, tags=None, budget=None, type=None) with self.assertRaises(FireflyError): tx.cmd_edit(args, ctx) client.request.assert_not_called() + def test_edit_budget_ref_sets_budget_id(self): + ctx, client, resolver = make_ctx() + resolver.budget.return_value = {"id": "12", "name": "Groceries"} + client.request.return_value = {"data": {"id": "9", "attributes": {}}} + args = MagicMock(id="9", amount=None, date=None, desc=None, source=None, + dest=None, category=None, tags=None, budget="Groceries", + type=None) + tx.cmd_edit(args, ctx) + split = client.request.call_args[1]["body"]["transactions"][0] + self.assertEqual(split, {"budget_id": "12"}) + class TestTxDelete(unittest.TestCase): def test_delete_requires_yes(self): -- cgit v1.2.3