summaryrefslogtreecommitdiffstats
path: root/firefly_cli
diff options
context:
space:
mode:
Diffstat (limited to 'firefly_cli')
-rw-r--r--firefly_cli/__init__.py2
-rw-r--r--firefly_cli/cli.py1
-rw-r--r--firefly_cli/commands/__init__.py2
-rw-r--r--firefly_cli/commands/piggy.py81
-rw-r--r--firefly_cli/resolver.py8
5 files changed, 92 insertions, 2 deletions
diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py
index d4060af..1a475b7 100644
--- a/firefly_cli/__init__.py
+++ b/firefly_cli/__init__.py
@@ -2,4 +2,4 @@
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2.0 only.
-__version__ = "0.5.0"
+__version__ = "0.6.0"
diff --git a/firefly_cli/cli.py b/firefly_cli/cli.py
index 712d52d..7b624f2 100644
--- a/firefly_cli/cli.py
+++ b/firefly_cli/cli.py
@@ -20,6 +20,7 @@ _GROUP_HELP = {
"tag": "list tags (tags auto-create when attached to a tx)",
"tx": "record, list, and search transactions",
"budget": "manage budgets and per-period spending limits",
+ "piggy": "list piggy banks and move money in or out of them",
}
def _build_parser():
diff --git a/firefly_cli/commands/__init__.py b/firefly_cli/commands/__init__.py
index e87c623..99762a6 100644
--- a/firefly_cli/commands/__init__.py
+++ b/firefly_cli/commands/__init__.py
@@ -1,3 +1,3 @@
# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
# Importing each module runs its @registry.command decorators.
-from firefly_cli.commands import auth, account, category, tag, transaction, budget # noqa: F401
+from firefly_cli.commands import auth, account, category, tag, transaction, budget, piggy # noqa: F401
diff --git a/firefly_cli/commands/piggy.py b/firefly_cli/commands/piggy.py
new file mode 100644
index 0000000..9ade3c8
--- /dev/null
+++ b/firefly_cli/commands/piggy.py
@@ -0,0 +1,81 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+from decimal import Decimal, InvalidOperation
+from firefly_cli import registry, output
+from firefly_cli.errors import FireflyError
+
+
+@registry.command("piggy list", help="list piggy banks with saved/target amounts and linked accounts")
+def cmd_list(args, ctx):
+ resp = ctx.client.request("GET", "/api/v1/piggy-banks", params={"limit": 1000})
+ output.emit(output.unwrap(resp), human=ctx.human)
+ return 0
+
+
+def _move_args(p):
+ p.add_argument("ref", help="piggy bank name or id")
+ p.add_argument("amount", help="amount to move (positive)")
+ p.add_argument("--account", default=None,
+ help="linked asset account name (required if the piggy bank has several)")
+
+
+def _amount(raw):
+ try:
+ amt = Decimal(str(raw))
+ except InvalidOperation:
+ amt = None
+ if amt is None or not amt.is_finite() or amt <= 0:
+ raise FireflyError(f'amount must be a positive number, got "{raw}"')
+ return amt
+
+
+def _move(args, ctx, sign):
+ amt = _amount(args.amount)
+ pb = ctx.resolver.piggy(args.ref)
+ linked = pb.get("accounts") or []
+ if args.account is None:
+ if len(linked) != 1:
+ raise FireflyError(
+ f'piggy bank "{pb.get("name")}" is linked to {len(linked)} accounts; '
+ "pass --account: " + ", ".join(a.get("name", "") for a in linked))
+ target = linked[0]
+ else:
+ hits = [a for a in linked if str(a.get("name", "")).lower() == args.account.lower()]
+ if len(hits) != 1:
+ raise FireflyError(
+ f'account "{args.account}" is not linked to piggy bank "{pb.get("name")}". '
+ "Linked: " + (", ".join(a.get("name", "") for a in linked) or "(none)"))
+ target = hits[0]
+
+ new = Decimal(str(target.get("current_amount") or "0")) + sign * amt
+ if new < 0:
+ raise FireflyError(
+ f'cannot remove {amt}: only {target.get("current_amount")} saved from {target.get("name")}')
+ new_s = f"{new:.2f}"
+ # Firefly syncs the whole account list on PUT, so every linked account must
+ # be resent or it gets unlinked. Omitting current_amount keeps its saved value.
+ body = {"accounts": [
+ {"account_id": a["account_id"], "current_amount": new_s} if a is target
+ else {"account_id": a["account_id"]}
+ for a in linked]}
+ resp = ctx.client.request("PUT", f"/api/v1/piggy-banks/{pb['id']}", body=body)
+ saved = output.unwrap(resp)
+ # Validation normally 422s an unaffordable add, but PiggyBankFactory can
+ # still skip it and answer 200. Read back and fail loudly instead.
+ after = next((a for a in saved.get("accounts") or []
+ if str(a.get("account_id")) == str(target["account_id"])), {})
+ if Decimal(str(after.get("current_amount") or "0")) != new:
+ raise FireflyError(
+ f'Firefly did not apply the change (saved {after.get("current_amount")}, wanted {new_s}): '
+ "not enough unallocated money on the account, or the target would be exceeded")
+ output.emit(saved, human=ctx.human)
+ return 0
+
+
+@registry.command("piggy add", help="put money into a piggy bank", args=_move_args)
+def cmd_add(args, ctx):
+ return _move(args, ctx, 1)
+
+
+@registry.command("piggy remove", help="take money out of a piggy bank", args=_move_args)
+def cmd_remove(args, ctx):
+ return _move(args, ctx, -1)
diff --git a/firefly_cli/resolver.py b/firefly_cli/resolver.py
index cb7e9a8..1a5de11 100644
--- a/firefly_cli/resolver.py
+++ b/firefly_cli/resolver.py
@@ -59,3 +59,11 @@ class Resolver:
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", {})}
+
+ def piggy(self, name_or_id):
+ # Same name-or-id sniff as budget(), same ponytail caveat (a piggy bank
+ # literally named "7" is unreachable by name).
+ if str(name_or_id).isdigit():
+ item = self.client.request("GET", f"/api/v1/piggy-banks/{name_or_id}")["data"]
+ return {"id": item["id"], **item.get("attributes", {})}
+ return self._match("piggy bank", self._list("/api/v1/piggy-banks"), name_or_id)