# Copyright (C) 2026 Danilo M. 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)