# Budget management design

Date: 2026-07-03
Status: approved (design), pending spec review
Firefly reference: v6.6.6 (matches running instance)

## Goal

Let the agent (and user) manage Firefly III budgets from the `firefly` CLI:
see spending vs budget, set/change monthly limits, create/delete budgets,
enable/disable them, and assign a budget to a transaction when adding it.

Ships as ONE MINOR release (new `budget` group + one new optional flag on
`tx add`, no breaking change to the existing contract).

## Firefly API facts (verified against v6.6.6)

- `GET /api/v1/budgets?start=&end=` — list budgets. With a start/end range the
  budget objects carry `spent` (array per currency) for that range. Without it,
  `spent` is null. Budget transformer fields include: `id`, `name`, `active`,
  `auto_budget_type`, `auto_budget_period`, `auto_budget_amount`,
  `currency_code`, `spent`.
- `POST /api/v1/budgets` — create. Accepts `name` (required, unique per user),
  `active` (bool), `auto_budget_type` (in: reset,rollover,adjusted,none),
  `auto_budget_amount`, `auto_budget_period`, `currency_code`.
- `PUT /api/v1/budgets/{id}` — update. Accepts `name`, `active`, and the same
  auto-budget fields. There is NO dedicated enable/disable endpoint; toggling
  `active` is done via this update.
- `DELETE /api/v1/budgets/{id}` — delete.
- `GET /api/v1/budgets/{id}/limits` — list budget limits (the period caps).
- `POST /api/v1/budgets/{id}/limits` — create a limit. Accepts `start`
  (required, date, before end), `end` (required, after start), `amount`
  (required, positive), `currency_id`/`currency_code`, `notes`. BudgetLimit
  transformer fields include: `id`, `budget_id`, `start`, `end`, `amount`,
  `period`, `spent`, `currency_code`.
- Transaction split accepts `budget_id` (and `budget_name`) — see
  `StoreRequest.php` lines 262-263. Firefly does NOT auto-create budgets from a
  transaction: the budget must already exist. So we resolve name -> id and pass
  `budget_id`.

Budget names are unique per user (`uniqueObjectForUser:budgets,name`), so
name->id resolution is unambiguous in practice; the resolver still errors hard
on a miss, matching account handling.

## Commands

New module `firefly_cli/commands/budget.py`. All handlers register via
`@registry.command(...)` with the decorator immediately above the handler def
(the v0.3.7 misbinding bug — keep decorator adjacent to `cmd_*`).

| Command | HTTP | Args |
|---|---|---|
| `budget list` | `GET /budgets?start&end` | `--start`, `--end` (default: current month) |
| `budget create <name>` | `POST /budgets` | `--active`/`--inactive` (default active), `--auto-budget-amount`, `--auto-budget-period`, `--auto-budget-type`, `--currency` |
| `budget delete <ref>` | `DELETE /budgets/{id}` | `--yes` (required, matches `tx delete`) |
| `budget enable <ref>` | `PUT /budgets/{id}` `{active:true}` | — |
| `budget disable <ref>` | `PUT /budgets/{id}` `{active:false}` | — |
| `budget limit list <ref>` | `GET /budgets/{id}/limits` | — |
| `budget limit set <ref>` | `POST /budgets/{id}/limits` | `--amount` (required), `--start`, `--end` (default current month), `--currency` |

`<ref>` is a budget name OR id, resolved by the new `resolver.budget()`.

`budget limit` is a two-level group (`budget limit list`, `budget limit set`),
consistent with how the registry keys multi-word command names.

### tx add --budget

Add one optional flag to `tx add`:
`--budget <ref>` — resolve name/id via `resolver.budget()`, set `budget_id` on
the transaction split. Omitted = no budget (unchanged behavior).

Not added to `tx edit` this release (YAGNI; add when a re-budgeting task needs
it).

## Resolver

Add `Resolver.budget(name_or_id)` mirroring `Resolver.account`:
- numeric-looking arg -> fetch `GET /budgets/{id}` (via a `budget_by_id` path
  or inline), return the object; miss -> `ResolutionError`.
- otherwise list budgets and match by name; ambiguous or missing ->
  `ResolutionError` listing candidates.

Reuse the existing `_match` helper and `_list` pattern. `GET /budgets/{id}`
returns a single budget; if Firefly returns a non-404 (e.g. the 401 quirk seen
for unknown account ids) the resolver still fails closed. Add a `ponytail:`
note only if the id path shows the same 401-for-unknown-id quirk in smoke.

## Period helper

`_current_month()` -> `(first_iso, last_iso)` using `datetime.date`:
first = `date.today().replace(day=1)`, last = first day of next month minus one
day (via `calendar.monthrange`). Shared by `budget list` and `budget limit set`
default range. Stdlib only.

## Output views (--human)

JSON stays the default. Add to `output.py` `_VIEWS`:
- budget view: columns `id`, `name`, `active`, `spent` (summed/trimmed like tx
  amounts), plus limit info if a limit is present in the range.
- budget-limit view: `id`, `budget_id`, `start`, `end`, `amount`, `spent`,
  `period`.

Match the existing `_VIEWS` signature-matching mechanism (a set of keys ->
column list). If a resource shape doesn't match cleanly, fall back to raw JSON
(existing behavior), no crash.

## Completion

Regenerate `completions/firefly.bash` via
`python scripts/gen_completion.py > completions/firefly.bash`. Add fixed-enum
values to `FLAG_VALUES` in `gen_completion.py` for `--auto-budget-type`
(reset|rollover|adjusted|none), `--auto-budget-period`, and the
`--active/--inactive` pair.

## Errors

All failures are `FireflyError` subclasses (existing contract): unresolved
budget ref -> `ResolutionError` with candidates; a missing required `--amount`
on `limit set` -> argparse error; Firefly 4xx/5xx -> surfaced by `client.py`.
`budget delete` without `--yes` -> hard error, no call made.

## Testing

- Unit tests under `tests/unit/` per command, mocking `ctx.client` /
  `ctx.resolver` (the established pattern). Cover: list with/without period,
  create (active + inactive + auto-budget), delete (with and without --yes),
  enable/disable body shape, limit list, limit set (default + explicit period),
  tx add --budget sets `budget_id`, resolver.budget name + id + miss +
  ambiguous.
- One handler-registration regression test asserting each registered
  `budget*` command binds to its `cmd_*` (registry misbinding is invisible to
  direct-call unit tests — the v0.3.7 lesson).
- Live smoke against the test instance (source
  `~/.config/firefly-cli/test-creds.env`) after implementation: create a
  budget, set a limit, list, assign via tx add (create-then-delete own data),
  because the mocked suite bypasses the registry.

## SKILL.md

Document the new `budget` group, name->id resolution for budgets (must
pre-exist, not auto-created), the current-month default period, and the
`tx add --budget` flag.

## Versioning

New command group + new optional flag, contract-additive -> MINOR bump. Bump
`pyproject.toml` and `firefly_cli/__init__.py` together, tag `vX.Y.Z` signed,
push `--follow-tags`.

## Out of scope (YAGNI)

- `budget update` for rename / auto-budget edits (delete+recreate, or web UI).
- `budget limit update` / `budget limit delete`.
- `tx edit --budget`.
- available-budgets endpoints, object-groups.
Add any of these when a concrete task needs it.
