1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
|
# Copyright (C) 2026 Danilo M. <danix@danix.xyz> 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
@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
|