diff options
Diffstat (limited to 'docs/superpowers/plans/2026-07-03-budget-management.md')
| -rw-r--r-- | docs/superpowers/plans/2026-07-03-budget-management.md | 805 |
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. |
