# Copyright (C) 2026 Danilo M. GPL-2.0-only import calendar from datetime import date from decimal import Decimal, InvalidOperation from firefly_cli import registry, output from firefly_cli.errors import FireflyError 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 = Decimal(0) for entry in budget_obj.get("spent") or []: try: total += Decimal(str(entry.get("sum", 0))) except (TypeError, InvalidOperation): 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 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 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 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) @registry.command("budget disable", help="mark a budget inactive", args=_ref_arg) def cmd_disable(args, ctx): return _set_active(ctx, args.ref, False) @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