diff options
Diffstat (limited to 'firefly_cli')
| -rw-r--r-- | firefly_cli/__init__.py | 2 | ||||
| -rw-r--r-- | firefly_cli/commands/account.py | 28 | ||||
| -rw-r--r-- | firefly_cli/commands/transaction.py | 109 | ||||
| -rw-r--r-- | firefly_cli/output.py | 21 |
4 files changed, 157 insertions, 3 deletions
diff --git a/firefly_cli/__init__.py b/firefly_cli/__init__.py index 6173b2b..27e214f 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.2.2" +__version__ = "0.3.6" diff --git a/firefly_cli/commands/account.py b/firefly_cli/commands/account.py index 9dbfab6..81c2e1d 100644 --- a/firefly_cli/commands/account.py +++ b/firefly_cli/commands/account.py @@ -23,6 +23,8 @@ def _create_args(p): p.add_argument("--opening-balance", dest="opening_balance", default=None, help="initial balance (asset accounts); dated today") p.add_argument("--currency", default=None, help="currency code, e.g. EUR") + p.add_argument("--if-not-exists", dest="if_not_exists", action="store_true", + help="if an account with this name exists, return it (existed:true) instead of erroring") @registry.command("account create", help="create an asset, expense, or revenue account", args=_create_args) def cmd_create(args, ctx): @@ -30,6 +32,17 @@ def cmd_create(args, ctx): raise FireflyError( f'Unsupported account type "{args.type}". ' f'Use one of: {", ".join(_CREATE_TYPES)}.') + if getattr(args, "if_not_exists", False): + # Resolve by name to detect existence; avoids parsing Firefly's 422 + # "name already in use" error string. Missing = ResolutionError -> create. + from firefly_cli.errors import ResolutionError + try: + existing = ctx.resolver.account(args.name) + except ResolutionError: + existing = None + if existing is not None: + output.emit({**existing, "existed": True}, human=ctx.human) + return 0 body = {"name": args.name, "type": args.type} if args.type == "asset": body["account_role"] = "defaultAsset" @@ -52,9 +65,22 @@ def cmd_get(args, ctx): output.emit(acc, human=ctx.human) return 0 -@registry.command("account balance", help="show current balance for one account (name or id)", args=_name_arg) +def _balance_args(p): + p.add_argument("account", help="account name or id") + p.add_argument("--at", default=None, + help="balance as of this date YYYY-MM-DD (default: current)") + +@registry.command("account balance", help="show balance for one account (name or id); --at for a historical date", args=_balance_args) def cmd_balance(args, ctx): acc = ctx.resolver.account(args.account) + if args.at: + # Firefly recomputes current_balance as of ?date= on the account show endpoint. + dated = output.unwrap(ctx.client.request( + "GET", f"/api/v1/accounts/{acc['id']}", params={"date": args.at})) + output.emit({"id": acc["id"], "name": dated.get("name") or acc.get("name"), + "date": args.at, + "current_balance": dated.get("current_balance")}, human=ctx.human) + return 0 output.emit({"id": acc["id"], "name": acc.get("name"), "current_balance": acc.get("current_balance")}, human=ctx.human) return 0 diff --git a/firefly_cli/commands/transaction.py b/firefly_cli/commands/transaction.py index 67d8b71..714d2d4 100644 --- a/firefly_cli/commands/transaction.py +++ b/firefly_cli/commands/transaction.py @@ -1,5 +1,6 @@ # Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only from firefly_cli import registry, output +from firefly_cli.errors import FireflyError # Inference table keyed by (source_type, destination_type) -> firefly tx type. def _infer_type(src_type, dst_type): @@ -29,6 +30,10 @@ def _add_args(p): p.add_argument("--tags", default=None, help="comma-separated") p.add_argument("--type", default=None, help="withdrawal|deposit|transfer (overrides inference)") + p.add_argument("--dry-run", dest="dry_run", action="store_true", + help="resolve accounts and show what would be sent; write nothing") + p.add_argument("--skip-dupes", dest="skip_dupes", action="store_true", + help="skip if a tx with same amount+date+source+destination exists") @registry.command("tx add", help="record a transaction; source/destination resolve to accounts, category/tags auto-create", args=_add_args) def cmd_add(args, ctx): @@ -49,16 +54,93 @@ def cmd_add(args, ctx): split["category_name"] = args.category if args.tags: split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] + if ttype == "transfer": + # Transfer direction is easy to reverse silently (ISSUES.md #5); echo it + # to stderr so the user/agent can catch a swapped --from/--to. stdout + # JSON unchanged; shown for both real writes and --dry-run. + import sys + print(f'transfer: {src["name"]} → {dst["name"]}, {split["amount"]}', + file=sys.stderr) + if args.dry_run: + # Accounts already resolved above (missing name = hard error). Write nothing. + # dry-run wins over skip-dupes: caller wants a preview, not a write. + output.emit({"dry_run": True, "would_send": split}, human=ctx.human) + return 0 + if args.skip_dupes: + # Firefly's search matches amount numerically; names quoted for spaces. + query = (f'amount_is:{split["amount"]} date_on:{split["date"]} ' + f'source_account_is:"{src["name"]}" ' + f'destination_account_is:"{dst["name"]}"') + hits = output.unwrap(ctx.client.request( + "GET", "/api/v1/search/transactions", params={"query": query})) + if hits: + output.emit({"skipped": "duplicate", "matched_id": hits[0].get("id")}, + human=ctx.human) + return 0 resp = ctx.client.request("POST", "/api/v1/transactions", body={"transactions": [split]}) output.emit(output.unwrap(resp), human=ctx.human) return 0 +def _edit_args(p): + p.add_argument("id") + p.add_argument("--amount", default=None) + p.add_argument("--date", default=None, help="YYYY-MM-DD") + p.add_argument("--desc", default=None) + p.add_argument("--from", dest="source", default=None, help="source account") + p.add_argument("--to", dest="dest", default=None, help="destination account") + p.add_argument("--category", default=None) + p.add_argument("--tags", default=None, help="comma-separated") + p.add_argument("--type", default=None, help="withdrawal|deposit|transfer") + +# ponytail: single-split journals only; multi-split edits need transaction_journal_id per row. +@registry.command("tx edit", help="modify one transaction by id; only the fields you pass change", args=_edit_args) +def cmd_edit(args, ctx): + split = {} + if args.amount is not None: + split["amount"] = str(args.amount) + if args.date is not None: + split["date"] = args.date + if args.desc is not None: + split["description"] = args.desc + if args.source is not None: + split["source_id"] = ctx.resolver.account(args.source)["id"] + if args.dest is not None: + split["destination_id"] = ctx.resolver.account(args.dest)["id"] + if args.category is not None: + split["category_name"] = args.category + if args.tags is not None: + split["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] + if args.type is not None: + split["type"] = args.type + if not split: + raise FireflyError("tx edit: nothing to change; pass at least one field") + resp = ctx.client.request("PUT", f"/api/v1/transactions/{args.id}", + body={"transactions": [split]}) + output.emit(output.unwrap(resp), human=ctx.human) + return 0 + +def _delete_args(p): + p.add_argument("id") + p.add_argument("--yes", action="store_true", help="confirm deletion (required)") + +@registry.command("tx delete", help="delete one transaction by id (requires --yes)", args=_delete_args) +def cmd_delete(args, ctx): + if not args.yes: + raise FireflyError(f"tx delete {args.id}: refusing without --yes") + ctx.client.request("DELETE", f"/api/v1/transactions/{args.id}") + output.emit({"deleted": args.id}, human=ctx.human) + return 0 + def _list_args(p): p.add_argument("--since", default=None, help="start date YYYY-MM-DD") p.add_argument("--until", default=None, help="end date YYYY-MM-DD") p.add_argument("--account", default=None, help="filter by account name") p.add_argument("--limit", type=int, default=20) + p.add_argument("--all", action="store_true", + help="fetch every page (ignores --limit truncation)") + p.add_argument("--flat", action="store_true", + help="one flat object per split; drop the transactions[] nesting (JSON only)") @registry.command("tx list", help="list recent transactions (newest first)", args=_list_args) def cmd_list(args, ctx): @@ -72,10 +154,35 @@ def cmd_list(args, ctx): params["start"] = args.since if args.until: params["end"] = args.until + + if args.all: + rows, page = [], 1 + while True: + resp = ctx.client.request("GET", path, params={**params, "page": page}) + rows.extend(output.unwrap(resp)) + pg = (resp.get("meta") or {}).get("pagination") or {} + if page >= (pg.get("total_pages") or 1): + break + page += 1 + output.emit(_maybe_flat(rows, args, ctx), human=ctx.human) + return 0 + resp = ctx.client.request("GET", path, params=params) - output.emit(output.unwrap(resp), human=ctx.human) + pg = (resp.get("meta") or {}).get("pagination") or {} + total, count = pg.get("total"), pg.get("count") + if total is not None and count is not None and count < total: + import sys + print(f"showing {count} of {total} (use --all for all)", file=sys.stderr) + output.emit(_maybe_flat(output.unwrap(resp), args, ctx), human=ctx.human) return 0 +# --flat is a JSON-only convenience; --human already explodes splits into a +# table, so leave its nested rows alone. +def _maybe_flat(rows, args, ctx): + if getattr(args, "flat", False) and not ctx.human: + return output.flatten_tx(rows) + return rows + def _id_arg(p): p.add_argument("id") diff --git a/firefly_cli/output.py b/firefly_cli/output.py index 166e1bf..6c6a61c 100644 --- a/firefly_cli/output.py +++ b/firefly_cli/output.py @@ -62,6 +62,27 @@ def _tx_rows(rows): def _is_tx(rows): return bool(rows) and isinstance(rows[0], dict) and "transactions" in rows[0] +def flatten_tx(rows): + """Explode unwrapped tx journals into one flat object per split. + + Each journal is {id, transactions: [split, ...], ...}. We emit one object + per split: the split's raw Firefly fields (source_name, amount, etc.) with + the journal id merged in and the `transactions` list dropped. Single-split + journals (the common case) become one clean object. Rows without a + `transactions` list pass through unchanged. + """ + out = [] + for r in rows: + splits = r.get("transactions") + if not isinstance(splits, list): + out.append(r) + continue + for s in splits: + flat = dict(s) + flat["id"] = r.get("id") + out.append(flat) + return out + # Per-resource column whitelists for --human. Firefly returns ~50 fields per # row; only a handful are worth a table. A row is matched by a signature key. # (signature, columns) -- first match wins; unmatched rows use a generic table. |
