aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-05 19:17:04 +0200
committerDanilo M. <danix@danix.xyz>2026-07-05 19:17:04 +0200
commit50cb852130986249cacec411e23392a8610e12f1 (patch)
treed49c544811f3f088b966046c859412f1b1015162
parent2f768bb3373105e96d0cbc6fb4ba37c777bbca25 (diff)
downloadfirefly-cli-master.tar.gz
firefly-cli-master.zip
feat(budget): tx edit --budget and budget update (v0.5.0)HEADv0.5.0master
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 <noreply@anthropic.com>
-rw-r--r--README.md5
-rw-r--r--SKILL.md11
-rw-r--r--TODO.md8
-rw-r--r--completions/firefly.bash5
-rw-r--r--firefly_cli/__init__.py2
-rw-r--r--firefly_cli/commands/budget.py32
-rw-r--r--firefly_cli/commands/transaction.py5
-rw-r--r--pyproject.toml2
-rw-r--r--tests/unit/test_commands_budget.py42
-rw-r--r--tests/unit/test_commands_transaction.py22
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 <id> show one transaction
firefly tx search <query> search transactions by Firefly query string
firefly tx edit <id> [--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 <id> --yes delete one transaction (requires --yes)
firefly budget list [--start D] [--end D]
@@ -70,6 +70,8 @@ firefly budget create <name> [--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|id> [--name NEW] [--currency CODE]
+ [--auto-budget-amount N] [--auto-budget-period P] [--auto-budget-type T]
firefly budget enable <name|id> mark a budget active
firefly budget disable <name|id> mark a budget inactive
firefly budget delete <name|id> --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 <amount> (--from <acct> | --from-id <id>) (--to <acct> | --to-id
[--budget <name|id>] [--dry-run] [--skip-dupes]
firefly tx edit <id>
[--amount N] [--date YYYY-MM-DD] [--desc TEXT] [--from <acct>] [--to <acct>]
- [--category NAME] [--tags a,b] [--type T] # only fields passed are changed
+ [--category NAME] [--tags a,b] [--budget <name|id>] [--type T] # only fields passed are changed
firefly tx delete <id> --yes # --yes required, no prompt
firefly tx list [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--account NAME] [--limit N] [--all]
firefly tx get <id>
@@ -75,6 +75,8 @@ firefly tag list
firefly budget list [--start YYYY-MM-DD] [--end YYYY-MM-DD] # default: current month
firefly budget create <name> [--active|--inactive]
[--auto-budget-amount N --auto-budget-period P --auto-budget-type T] [--currency CODE]
+firefly budget update <name|id> [--name NEW]
+ [--auto-budget-amount N --auto-budget-period P --auto-budget-type T] [--currency CODE]
firefly budget enable <name|id>
firefly budget disable <name|id>
firefly budget delete <name|id> --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 <ref> --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. <danix@danix.xyz>
# 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):