aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-07-03-budget-management.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-07-03-budget-management.md')
-rw-r--r--docs/superpowers/plans/2026-07-03-budget-management.md805
1 files changed, 805 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-03-budget-management.md b/docs/superpowers/plans/2026-07-03-budget-management.md
new file mode 100644
index 0000000..01ab184
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-03-budget-management.md
@@ -0,0 +1,805 @@
+# Budget Management Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a `budget` command group (list/create/delete/enable/disable, limit list/set) plus `tx add --budget`, so the agent can manage Firefly III budgets and assign them to transactions from the CLI.
+
+**Architecture:** New `firefly_cli/commands/budget.py` module, self-registering via `@registry.command`. A new `resolver.budget()` resolves budget name-or-id to an object (mirrors `resolver.account`). A shared `_current_month()` helper supplies the default date range. `tx add` gets one optional `--budget` flag. Output stays JSON by default; `--human` gets a computed scalar `spent` for budgets.
+
+**Tech Stack:** Python 3.11+ stdlib only. Firefly III REST API v6.6.6. `unittest` (mocked) for unit tests.
+
+**Reference:** Spec at `docs/superpowers/specs/2026-07-03-budget-management-design.md`.
+
+---
+
+## File Structure
+
+- Create: `firefly_cli/commands/budget.py` — the whole `budget` group (7 handlers) + `_current_month()`.
+- Modify: `firefly_cli/resolver.py` — add `budget()` and `budget_by_id()`.
+- Modify: `firefly_cli/commands/transaction.py` — add `--budget` flag + `budget_id` on the split.
+- Modify: `firefly_cli/commands/__init__.py` — import `budget`.
+- Modify: `scripts/gen_completion.py` — `budget` in `GROUP_ORDER`, auto-budget enums in `FLAG_VALUES`.
+- Modify: `SKILL.md` — document the group.
+- Modify: `pyproject.toml`, `firefly_cli/__init__.py` — version bump.
+- Modify: `completions/firefly.bash` — regenerated, not hand-edited.
+- Create: `tests/unit/test_commands_budget.py`, `tests/unit/test_resolver.py` (append); extend `tests/unit/test_commands_transaction.py`.
+
+**Convention reminder (v0.3.7 bug):** the `@registry.command` decorator MUST sit immediately above its `cmd_*` def. Any helper goes ABOVE the decorated function, never between decorator and def.
+
+---
+
+## Task 1: resolver.budget() and budget_by_id()
+
+**Files:**
+- Modify: `firefly_cli/resolver.py:44` (after `category`)
+- Test: `tests/unit/test_resolver.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# APPEND to the existing tests/unit/test_resolver.py.
+# unittest / MagicMock / Resolver / ResolutionError are already imported at the
+# top of that file — do NOT re-import; add only the helper and the class below.
+
+def _list_resp(budgets):
+ return {"data": [{"id": b["id"], "attributes": {"name": b["name"]}}
+ for b in budgets]}
+
+
+class TestResolverBudget(unittest.TestCase):
+ def test_budget_by_name(self):
+ client = MagicMock()
+ client.request.return_value = _list_resp(
+ [{"id": "3", "name": "Groceries"}, {"id": "4", "name": "Rent"}])
+ r = Resolver(client)
+ self.assertEqual(r.budget("Groceries")["id"], "3")
+
+ def test_budget_missing_raises_with_candidates(self):
+ client = MagicMock()
+ client.request.return_value = _list_resp([{"id": "3", "name": "Rent"}])
+ r = Resolver(client)
+ with self.assertRaises(ResolutionError) as cm:
+ r.budget("Nope")
+ self.assertIn("Rent", str(cm.exception))
+
+ def test_budget_numeric_ref_goes_by_id(self):
+ client = MagicMock()
+ client.request.return_value = {
+ "data": {"id": "7", "attributes": {"name": "Fun"}}}
+ r = Resolver(client)
+ got = r.budget("7")
+ self.assertEqual(got["id"], "7")
+ self.assertEqual(got["name"], "Fun")
+ # Must hit the show endpoint, not list.
+ client.request.assert_called_with("GET", "/api/v1/budgets/7")
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_resolver -v`
+Expected: FAIL, `AttributeError: 'Resolver' object has no attribute 'budget'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `firefly_cli/resolver.py` after the `category` method (line 44):
+
+```python
+ def budget(self, name_or_id):
+ # Budgets must pre-exist (Firefly does not auto-create them for a
+ # transaction), so we resolve to an id. A numeric-looking ref goes
+ # straight to the show endpoint (lets same-name-safe callers pin an id);
+ # otherwise match by name against the list.
+ if str(name_or_id).isdigit():
+ return self.budget_by_id(name_or_id)
+ return self._match("budget", self._list("/api/v1/budgets"), name_or_id)
+
+ def budget_by_id(self, budget_id):
+ item = self.client.request("GET", f"/api/v1/budgets/{budget_id}")["data"]
+ return {"id": item["id"], **item.get("attributes", {})}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_resolver -v`
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/resolver.py tests/unit/test_resolver.py
+git commit -S -m "feat(resolver): budget name/id resolution"
+```
+
+---
+
+## Task 2: budget.py module skeleton + `budget list`
+
+**Files:**
+- Create: `firefly_cli/commands/budget.py`
+- Modify: `firefly_cli/commands/__init__.py:3`
+- Test: `tests/unit/test_commands_budget.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/unit/test_commands_budget.py
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+import unittest
+from unittest.mock import MagicMock
+from types import SimpleNamespace
+import firefly_cli.commands.budget as budget
+
+
+def _ctx(client=None, resolver=None):
+ return SimpleNamespace(client=client or MagicMock(),
+ resolver=resolver or MagicMock(), human=False)
+
+
+class TestBudgetList(unittest.TestCase):
+ def test_list_defaults_to_current_month(self):
+ client = MagicMock()
+ client.request.return_value = {"data": []}
+ args = SimpleNamespace(start=None, end=None)
+ rc = budget.cmd_list(args, _ctx(client=client))
+ self.assertEqual(rc, 0)
+ _, kwargs = client.request.call_args
+ params = kwargs["params"]
+ # current month: start is day 01, end is a valid last-of-month date
+ self.assertTrue(params["start"].endswith("-01"))
+ self.assertEqual(params["start"][:7], params["end"][:7])
+
+ def test_list_explicit_range(self):
+ client = MagicMock()
+ client.request.return_value = {"data": []}
+ args = SimpleNamespace(start="2026-01-01", end="2026-01-31")
+ budget.cmd_list(args, _ctx(client=client))
+ _, kwargs = client.request.call_args
+ self.assertEqual(kwargs["params"],
+ {"start": "2026-01-01", "end": "2026-01-31"})
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_commands_budget -v`
+Expected: FAIL, `ModuleNotFoundError: No module named 'firefly_cli.commands.budget'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `firefly_cli/commands/budget.py`:
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+import calendar
+from datetime import date
+from firefly_cli import registry, output
+
+
+def _current_month():
+ """(first, last) ISO dates for the current calendar month."""
+ today = date.today()
+ last_day = calendar.monthrange(today.year, today.month)[1]
+ first = today.replace(day=1).isoformat()
+ last = today.replace(day=last_day).isoformat()
+ return first, last
+
+
+def _spent_scalar(budget_obj):
+ """Sum a budget's nested `spent` array into a single number (for --human).
+ Firefly sends spent as a per-currency list of {sum: "-12.34", ...}; we sum
+ the sums. JSON output is untouched; this only feeds the table view."""
+ total = 0.0
+ for entry in budget_obj.get("spent") or []:
+ try:
+ total += float(entry.get("sum", 0))
+ except (TypeError, ValueError):
+ pass
+ return f"{total:.2f}"
+
+
+def _list_args(p):
+ p.add_argument("--start", default=None, help="YYYY-MM-DD (default: 1st of this month)")
+ p.add_argument("--end", default=None, help="YYYY-MM-DD (default: last of this month)")
+
+@registry.command("budget list", help="list budgets with spent for a period (default: current month)", args=_list_args)
+def cmd_list(args, ctx):
+ first, last = _current_month()
+ params = {"start": args.start or first, "end": args.end or last}
+ resp = ctx.client.request("GET", "/api/v1/budgets", params=params)
+ rows = output.unwrap(resp)
+ if ctx.human and isinstance(rows, list):
+ # Replace the nested spent array with a scalar so the table shows it.
+ rows = [{**r, "spent": _spent_scalar(r)} for r in rows]
+ output.emit(rows, human=ctx.human)
+ return 0
+```
+
+Modify `firefly_cli/commands/__init__.py` line 3:
+
+```python
+from firefly_cli.commands import auth, account, category, tag, transaction, budget # noqa: F401
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_commands_budget -v`
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/commands/budget.py firefly_cli/commands/__init__.py tests/unit/test_commands_budget.py
+git commit -S -m "feat(budget): budget list with current-month default"
+```
+
+---
+
+## Task 3: `budget create`
+
+**Files:**
+- Modify: `firefly_cli/commands/budget.py` (append)
+- Test: `tests/unit/test_commands_budget.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/unit/test_commands_budget.py`:
+
+```python
+class TestBudgetCreate(unittest.TestCase):
+ def test_create_minimal_active(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "9", "attributes": {"name": "Fun"}}}
+ args = SimpleNamespace(name="Fun", active=True, currency=None,
+ auto_budget_amount=None, auto_budget_period=None,
+ auto_budget_type=None)
+ rc = budget.cmd_create(args, _ctx(client=client))
+ self.assertEqual(rc, 0)
+ method, path = client.request.call_args[0][:2]
+ body = client.request.call_args[1]["body"]
+ self.assertEqual((method, path), ("POST", "/api/v1/budgets"))
+ self.assertEqual(body["name"], "Fun")
+ self.assertTrue(body["active"])
+
+ def test_create_inactive_with_auto_budget(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "9", "attributes": {}}}
+ args = SimpleNamespace(name="Cap", active=False, currency="EUR",
+ auto_budget_amount="500", auto_budget_period="monthly",
+ auto_budget_type="reset")
+ budget.cmd_create(args, _ctx(client=client))
+ body = client.request.call_args[1]["body"]
+ self.assertFalse(body["active"])
+ self.assertEqual(body["auto_budget_amount"], "500")
+ self.assertEqual(body["auto_budget_type"], "reset")
+ self.assertEqual(body["currency_code"], "EUR")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetCreate -v`
+Expected: FAIL, `AttributeError: module ... has no attribute 'cmd_create'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `firefly_cli/commands/budget.py`:
+
+```python
+def _create_args(p):
+ p.add_argument("name", help="budget name (must be unique)")
+ g = p.add_mutually_exclusive_group()
+ g.add_argument("--active", dest="active", action="store_true", default=True,
+ help="create active (default)")
+ g.add_argument("--inactive", dest="active", action="store_false",
+ help="create inactive")
+ 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 create", help="create a budget", args=_create_args)
+def cmd_create(args, ctx):
+ body = {"name": args.name, "active": args.active}
+ 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
+ resp = ctx.client.request("POST", "/api/v1/budgets", body=body)
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetCreate -v`
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py
+git commit -S -m "feat(budget): budget create"
+```
+
+---
+
+## Task 4: `budget delete`, `budget enable`, `budget disable`
+
+**Files:**
+- Modify: `firefly_cli/commands/budget.py` (append)
+- Test: `tests/unit/test_commands_budget.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/unit/test_commands_budget.py`:
+
+```python
+from firefly_cli.errors import FireflyError
+
+
+class TestBudgetLifecycle(unittest.TestCase):
+ def _resolver(self):
+ r = MagicMock()
+ r.budget.return_value = {"id": "5", "name": "Rent"}
+ return r
+
+ def test_delete_requires_yes(self):
+ args = SimpleNamespace(ref="Rent", yes=False)
+ with self.assertRaises(FireflyError):
+ budget.cmd_delete(args, _ctx(resolver=self._resolver()))
+
+ def test_delete_with_yes_calls_delete(self):
+ client = MagicMock()
+ args = SimpleNamespace(ref="Rent", yes=True)
+ rc = budget.cmd_delete(args, _ctx(client=client, resolver=self._resolver()))
+ self.assertEqual(rc, 0)
+ client.request.assert_called_with("DELETE", "/api/v1/budgets/5")
+
+ def test_enable_sets_active_true(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "5", "attributes": {}}}
+ args = SimpleNamespace(ref="Rent")
+ budget.cmd_enable(args, _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.assertTrue(body["active"])
+
+ def test_disable_sets_active_false(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "5", "attributes": {}}}
+ args = SimpleNamespace(ref="Rent")
+ budget.cmd_disable(args, _ctx(client=client, resolver=self._resolver()))
+ self.assertFalse(client.request.call_args[1]["body"]["active"])
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLifecycle -v`
+Expected: FAIL, `AttributeError: ... has no attribute 'cmd_delete'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `firefly_cli/commands/budget.py`. Add the import at the top of the file (next to the existing imports):
+
+```python
+from firefly_cli.errors import FireflyError
+```
+
+Then append the handlers:
+
+```python
+def _ref_arg(p):
+ p.add_argument("ref", help="budget name or id")
+
+def _delete_args(p):
+ _ref_arg(p)
+ p.add_argument("--yes", action="store_true", help="confirm deletion (required)")
+
+@registry.command("budget delete", help="delete a budget by name or id (requires --yes)", args=_delete_args)
+def cmd_delete(args, ctx):
+ if not args.yes:
+ raise FireflyError("budget delete needs --yes to confirm.")
+ b = ctx.resolver.budget(args.ref)
+ ctx.client.request("DELETE", f"/api/v1/budgets/{b['id']}")
+ output.emit({"deleted": b["id"], "name": b.get("name")}, human=ctx.human)
+ return 0
+
+def _set_active(ctx, ref, active):
+ b = ctx.resolver.budget(ref)
+ resp = ctx.client.request("PUT", f"/api/v1/budgets/{b['id']}",
+ body={"active": active})
+ 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)
+
+@registry.command("budget disable", help="mark a budget inactive", args=_ref_arg)
+def cmd_disable(args, ctx):
+ return _set_active(ctx, args.ref, False)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLifecycle -v`
+Expected: PASS (4 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py
+git commit -S -m "feat(budget): delete, enable, disable"
+```
+
+---
+
+## Task 5: `budget limit list` and `budget limit set`
+
+**Files:**
+- Modify: `firefly_cli/commands/budget.py` (append)
+- Test: `tests/unit/test_commands_budget.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/unit/test_commands_budget.py`:
+
+```python
+class TestBudgetLimit(unittest.TestCase):
+ def _resolver(self):
+ r = MagicMock()
+ r.budget.return_value = {"id": "5", "name": "Rent"}
+ return r
+
+ def test_limit_list(self):
+ client = MagicMock()
+ client.request.return_value = {"data": []}
+ args = SimpleNamespace(ref="Rent")
+ rc = budget.cmd_limit_list(args, _ctx(client=client, resolver=self._resolver()))
+ self.assertEqual(rc, 0)
+ client.request.assert_called_with("GET", "/api/v1/budgets/5/limits")
+
+ def test_limit_set_default_month(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "1", "attributes": {}}}
+ args = SimpleNamespace(ref="Rent", amount="800", start=None, end=None,
+ currency=None)
+ budget.cmd_limit_set(args, _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), ("POST", "/api/v1/budgets/5/limits"))
+ self.assertEqual(body["amount"], "800")
+ self.assertTrue(body["start"].endswith("-01"))
+ self.assertEqual(body["start"][:7], body["end"][:7])
+
+ def test_limit_set_explicit_range_and_currency(self):
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "1", "attributes": {}}}
+ args = SimpleNamespace(ref="Rent", amount="800", start="2026-02-01",
+ end="2026-02-28", currency="EUR")
+ budget.cmd_limit_set(args, _ctx(client=client, resolver=self._resolver()))
+ body = client.request.call_args[1]["body"]
+ self.assertEqual(body["start"], "2026-02-01")
+ self.assertEqual(body["end"], "2026-02-28")
+ self.assertEqual(body["currency_code"], "EUR")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLimit -v`
+Expected: FAIL, `AttributeError: ... has no attribute 'cmd_limit_list'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `firefly_cli/commands/budget.py`:
+
+```python
+@registry.command("budget limit list", help="list a budget's limits (name or id)", args=_ref_arg)
+def cmd_limit_list(args, ctx):
+ b = ctx.resolver.budget(args.ref)
+ resp = ctx.client.request("GET", f"/api/v1/budgets/{b['id']}/limits")
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+
+def _limit_set_args(p):
+ _ref_arg(p)
+ p.add_argument("--amount", required=True, help="limit amount (positive)")
+ p.add_argument("--start", default=None, help="YYYY-MM-DD (default: 1st of this month)")
+ p.add_argument("--end", default=None, help="YYYY-MM-DD (default: last of this month)")
+ p.add_argument("--currency", default=None, help="currency code, e.g. EUR")
+
+@registry.command("budget limit set", help="set (create) a spending limit for a budget over a period", args=_limit_set_args)
+def cmd_limit_set(args, ctx):
+ b = ctx.resolver.budget(args.ref)
+ first, last = _current_month()
+ body = {"amount": str(args.amount),
+ "start": args.start or first,
+ "end": args.end or last}
+ if args.currency:
+ body["currency_code"] = args.currency
+ resp = ctx.client.request("POST", f"/api/v1/budgets/{b['id']}/limits", body=body)
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetLimit -v`
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/commands/budget.py tests/unit/test_commands_budget.py
+git commit -S -m "feat(budget): limit list and limit set"
+```
+
+---
+
+## Task 6: `tx add --budget`
+
+**Files:**
+- Modify: `firefly_cli/commands/transaction.py` (args block ~line 34, handler ~line 65)
+- Test: `tests/unit/test_commands_transaction.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+Append a test to `tests/unit/test_commands_transaction.py`. (Match the file's existing ctx/args construction style; the assertion is what matters.)
+
+```python
+class TestTxAddBudget(unittest.TestCase):
+ def test_budget_ref_sets_budget_id_on_split(self):
+ from types import SimpleNamespace
+ from unittest.mock import MagicMock
+ import firefly_cli.commands.transaction as tx
+
+ client = MagicMock()
+ client.request.return_value = {"data": {"id": "1", "attributes": {}}}
+ resolver = MagicMock()
+ resolver.account.side_effect = lambda n: {"id": "1", "name": n, "type": "asset"}
+ resolver.budget.return_value = {"id": "12", "name": "Groceries"}
+ ctx = SimpleNamespace(client=client, resolver=resolver, human=False)
+
+ args = SimpleNamespace(
+ amount="10", source="Checking", dest="Shop",
+ source_id=None, dest_id=None, desc=None, date="2026-07-03",
+ category=None, tags=None, type="withdrawal",
+ dry_run=False, skip_dupes=False, budget="Groceries")
+ rc = tx.cmd_add(args, ctx)
+ self.assertEqual(rc, 0)
+ body = client.request.call_args[1]["body"]
+ self.assertEqual(body["transactions"][0]["budget_id"], "12")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m unittest tests.unit.test_commands_transaction.TestTxAddBudget -v`
+Expected: FAIL, `AttributeError: 'types.SimpleNamespace' object has no attribute 'budget'` is NOT the failure we want — the handler must READ `args.budget`. It fails because the handler doesn't set `budget_id`. Actual expected fail: `KeyError: 'budget_id'` on the assertion.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `firefly_cli/commands/transaction.py`, add the flag in `_add_args` (after the `--tags` line, ~line 34):
+
+```python
+ p.add_argument("--budget", default=None,
+ help="budget name or id to assign (must already exist)")
+```
+
+In `cmd_add`, after the `if args.tags:` block (~line 69) and before the `if ttype == "transfer":` block, add:
+
+```python
+ if args.budget:
+ # Budgets must pre-exist; resolve name/id -> id (hard error on miss).
+ split["budget_id"] = ctx.resolver.budget(args.budget)["id"]
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m unittest tests.unit.test_commands_transaction.TestTxAddBudget -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add firefly_cli/commands/transaction.py tests/unit/test_commands_transaction.py
+git commit -S -m "feat(tx): tx add --budget assigns a budget by name or id"
+```
+
+---
+
+## Task 7: handler-registration regression test
+
+**Files:**
+- Test: `tests/unit/test_commands_budget.py` (append)
+
+This guards the v0.3.7 misbinding class of bug: unit tests call `cmd_*` directly and bypass the registry, so a decorator bound to the wrong function is invisible to them.
+
+- [ ] **Step 1: Write the failing test (will pass once names line up)**
+
+Append to `tests/unit/test_commands_budget.py`:
+
+```python
+from firefly_cli import registry
+import firefly_cli.commands # noqa: F401 ensure all modules registered
+
+
+class TestBudgetRegistration(unittest.TestCase):
+ def test_each_budget_command_binds_to_its_handler(self):
+ expected = {
+ "budget list": budget.cmd_list,
+ "budget create": budget.cmd_create,
+ "budget delete": budget.cmd_delete,
+ "budget enable": budget.cmd_enable,
+ "budget disable": budget.cmd_disable,
+ "budget limit list": budget.cmd_limit_list,
+ "budget limit set": budget.cmd_limit_set,
+ }
+ by_name = {c.name: c.handler for c in registry.all_commands()}
+ for name, fn in expected.items():
+ self.assertIn(name, by_name, f"{name} not registered")
+ self.assertIs(by_name[name], fn, f"{name} bound to wrong handler")
+```
+
+Note: confirm the registry command object's handler attribute name. Check `firefly_cli/registry.py` — if the attribute is not `.handler`, use the actual name (e.g. `.func`). The v0.3.7 regression test in `tests/unit/` already does this; mirror it.
+
+- [ ] **Step 2: Run test**
+
+Run: `python -m unittest tests.unit.test_commands_budget.TestBudgetRegistration -v`
+Expected: PASS (if a decorator is misplaced, this FAILS — that is the point).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/test_commands_budget.py
+git commit -S -m "test(budget): assert each command binds to its handler"
+```
+
+---
+
+## Task 8: completion + full unit suite
+
+**Files:**
+- Modify: `scripts/gen_completion.py:22` (GROUP_ORDER), `:27` (FLAG_VALUES)
+- Regenerate: `completions/firefly.bash`
+
+- [ ] **Step 1: Add budget to GROUP_ORDER and enum values**
+
+In `scripts/gen_completion.py`, line 22:
+
+```python
+GROUP_ORDER = ["auth", "account", "category", "tag", "tx", "budget"]
+```
+
+Add to `FLAG_VALUES` (after the `tx edit` entry):
+
+```python
+ "budget create": {
+ "--auto-budget-type": "reset rollover adjusted none",
+ "--auto-budget-period": "daily weekly monthly quarterly half_year yearly",
+ },
+ "budget limit set": {},
+```
+
+- [ ] **Step 2: Regenerate completion**
+
+Run: `python scripts/gen_completion.py > completions/firefly.bash`
+Then verify it mentions budget:
+Run: `grep -c budget completions/firefly.bash`
+Expected: a count > 0.
+
+- [ ] **Step 3: Run the FULL unit suite**
+
+Run: `python -m unittest discover -s tests/unit`
+Expected: OK, all tests pass (prior count + the new budget/resolver/tx tests).
+
+- [ ] **Step 4: Smoke the CLI dispatch (registry path, not just direct calls)**
+
+Run: `python -m firefly_cli budget list --help`
+Expected: help text with `--start` and `--end`, exit 0.
+Run: `python -m firefly_cli budget limit set --help`
+Expected: help text with `--amount` (required), exit 0.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/gen_completion.py completions/firefly.bash
+git commit -S -m "build(completion): budget group and auto-budget enum values"
+```
+
+---
+
+## Task 9: SKILL.md + version bump + tag
+
+**Files:**
+- Modify: `SKILL.md`
+- Modify: `pyproject.toml`, `firefly_cli/__init__.py`
+
+- [ ] **Step 1: Document the budget group in SKILL.md**
+
+Add a budget section covering: the seven commands, that a budget `<ref>` is a name OR id resolved by the CLI, that **budgets must pre-exist (not auto-created like categories)**, the current-month default period for `budget list` and `budget limit set`, and `tx add --budget <ref>`. Match the existing SKILL.md command-doc style (read a nearby section first).
+
+- [ ] **Step 2: Bump version**
+
+Determine the current version:
+Run: `grep version pyproject.toml | head -1`
+This is a new command group + one optional flag → contract-additive → **MINOR** bump (e.g. 0.3.8 → 0.4.0). Set the SAME new version in both `pyproject.toml` and `firefly_cli/__init__.py`.
+
+- [ ] **Step 3: Re-run full suite**
+
+Run: `python -m unittest discover -s tests/unit`
+Expected: OK.
+
+- [ ] **Step 4: Commit and tag**
+
+```bash
+git add SKILL.md pyproject.toml firefly_cli/__init__.py
+git commit -S -m "feat: budget management group + tx add --budget (v0.4.0)"
+git tag -s v0.4.0 -m "budget management"
+git log --format='%h %G? %s' -3
+```
+Expected: commits show `G` (good signature). Adjust the version string if the base was not 0.3.8.
+
+---
+
+## Task 10: live smoke test (mocked suite bypasses the registry)
+
+The mocked suite never hits Firefly and bypasses `client`. A live smoke against
+the test instance is REQUIRED after any command-module structure change (the
+v0.3.7 lesson). NEVER point at real data; create-then-delete own records.
+
+- [ ] **Step 1: Source test creds**
+
+```bash
+set -a; . ~/.config/firefly-cli/test-creds.env; set +a
+FF="python -m firefly_cli --url $FIREFLY_TEST_URL --token $FIREFLY_TEST_TOKEN"
+```
+
+- [ ] **Step 2: Exercise the group (create → limit → assign → cleanup)**
+
+```bash
+$FF budget create "CLI Smoke Budget" # note the id
+$FF budget list --human
+$FF budget limit set "CLI Smoke Budget" --amount 100 # default current month
+$FF budget limit list "CLI Smoke Budget"
+$FF budget disable "CLI Smoke Budget"
+$FF budget enable "CLI Smoke Budget"
+# assign to a throwaway tx between two existing test asset accounts, then delete it
+$FF tx add 1 --from "<test-asset-a>" --to "<test-expense>" --budget "CLI Smoke Budget" --desc "budget smoke"
+$FF tx delete <that-tx-id> --yes
+$FF budget delete "CLI Smoke Budget" --yes
+```
+
+Expected: each returns JSON (or a table for `--human`), exit 0; the bad paths
+(`budget delete` without `--yes`, an unknown budget ref) exit 1 with an
+`{"error": ...}`. Confirm the created budget is gone from `budget list` at the
+end.
+
+- [ ] **Step 3: Push**
+
+```bash
+git push --follow-tags
+```
+Expected: reaches both remotes (danix_git gitolite and github). If the github
+push fails once with "agent refused operation" on the RSA key, retry directly.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** list ✓(T2), create ✓(T3), delete/enable/disable ✓(T4), limit list/set ✓(T5), tx add --budget ✓(T6), resolver.budget ✓(T1), current-month helper ✓(T2), output scalar spent ✓(T2), completion+enums ✓(T8), SKILL.md ✓(T9), version MINOR ✓(T9), live smoke ✓(T10). Out-of-scope items are not built.
+- **Output views:** the spec mentioned adding budget/limit views to `output.py` `_VIEWS`. Simplified: budgets reuse the existing `name` view plus a computed scalar `spent` (nested arrays don't tables well), and limits fall through the generic scalar-column table. No `_VIEWS` change needed. ponytail: add explicit views only if the generic table reads poorly in smoke.
+- **Registry handler attribute:** Task 7 depends on the command object's handler attribute name (`.handler` vs `.func`) — verify against `registry.py` / the existing v0.3.7 regression test before writing.