diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-15 11:02:34 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-15 11:02:34 +0200 |
| commit | 07f4134d38e53bf6c9235f3ad18dd5b33e89d9ff (patch) | |
| tree | 65eed6420426560a7e8127baa3ea7a703d1223a0 /firefly_cli | |
| parent | 50cb852130986249cacec411e23392a8610e12f1 (diff) | |
| download | firefly-cli-7d80a77cba738afea1466bbc4cc1dd0f1b54c9fa.tar.gz firefly-cli-7d80a77cba738afea1466bbc4cc1dd0f1b54c9fa.zip | |
New `piggy` group:
- `piggy list` lists piggy banks with saved/target amounts and linked
accounts.
- `piggy add|remove <name|id> <amount> [--account NAME]` changes the
saved amount on a linked account via PUT /piggy-banks/{id}.
Firefly syncs the whole account list on that PUT, so every linked account
is resent (others without current_amount, which keeps their value) or it
would be unlinked. Removing below zero is rejected before any write, and
the saved amount is read back so a refused add fails loudly instead of
returning 200 unchanged.
add/remove currently hit an upstream bug on Firefly III <= 6.7.9:
PiggyBankFactory::linkToAccountIds passes the pivot current_amount (an int
from the DB) to bcsub() uncast, so any PUT with "accounts" 500s
(firefly-iii/firefly-iii#13063). Documented in SKILL.md/README; the live
integration test skips on that error and runs once upstream is fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'firefly_cli')
| -rw-r--r-- | firefly_cli/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/cli.py | 1 | ||||
| -rw-r--r-- | firefly_cli/commands/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/piggy.py | 81 | ||||
| -rw-r--r-- | firefly_cli/resolver.py | 8 |
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) |
