diff --git a/fava_client.py b/fava_client.py index fea8c64..27e147d 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1132,9 +1132,27 @@ class FavaClient: limit=limit ) + def _extract_accounts_from_tree(self, tree: Any) -> List[str]: + """Recursively extract account names from a Fava tree structure.""" + accounts = [] + if isinstance(tree, dict): + for key, val in tree.items(): + if key == "account": + accounts.append(val) + elif isinstance(val, (dict, list)): + accounts.extend(self._extract_accounts_from_tree(val)) + elif isinstance(tree, list): + for item in tree: + accounts.extend(self._extract_accounts_from_tree(item)) + return accounts + async def get_all_accounts(self) -> List[Dict[str, Any]]: """ - Get all accounts from Beancount/Fava using BQL query. + Get all accounts from Beancount/Fava. + + Uses Fava's balance_sheet and income_statement API endpoints to + discover all opened accounts, including those with zero balances. + Falls back to BQL query if the tree endpoints fail. Returns: List of account dictionaries: @@ -1150,25 +1168,52 @@ class FavaClient: print(acc["account"]) # "Assets:Cash" """ try: - # Use BQL to get all unique accounts + # Use balance_sheet + income_statement to get ALL opened accounts + # (BQL's SELECT DISTINCT account only returns accounts with postings) + account_names: set[str] = set() + + async with httpx.AsyncClient(timeout=self.timeout) as client: + for endpoint in ("balance_sheet", "income_statement"): + try: + response = await client.get(f"{self.base_url}/{endpoint}") + if response.status_code == 200: + data = response.json().get("data", {}) + trees = data.get("trees", {}) + names = self._extract_accounts_from_tree(trees) + account_names.update(names) + except Exception as e: + logger.warning(f"Failed to fetch {endpoint}: {e}") + + # Filter out synthetic entries like "Net Profit" + account_names = { + name for name in account_names + if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses") + } + + if account_names: + accounts = [{"account": name, "meta": {}} for name in sorted(account_names)] + logger.debug(f"Fava returned {len(accounts)} accounts via tree endpoints") + return accounts + + # Fallback: BQL query (only finds accounts with postings) + logger.info("Tree endpoints returned no accounts, falling back to BQL") query = "SELECT DISTINCT account" result = await self.query_bql(query) - # Convert BQL result to expected format accounts = [] for row in result["rows"]: account_name = row[0] if isinstance(row, list) else row.get("account") if account_name: accounts.append({ "account": account_name, - "meta": {} # BQL doesn't return metadata easily + "meta": {} }) logger.debug(f"Fava returned {len(accounts)} accounts via BQL") return accounts except Exception as e: - logger.error(f"Failed to fetch accounts via BQL: {e}") + logger.error(f"Failed to fetch accounts: {e}") raise async def get_journal_entries(