From 56a3e9d4e9b0ed93011c8c6bee16a2298cd05914 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 9 Nov 2025 23:52:39 +0100 Subject: [PATCH] Refactors pending entries and adds fiat amounts Improves the handling of pending entries by extracting and deduplicating data from Fava's query results. Adds support for displaying fiat amounts alongside entries and extracts them from the position data in Fava. Streamlines receivables/payables/equity checks on the frontend by relying on BQL query to supply account type metadata and tags. --- fava_client.py | 21 ++++++++--- static/js/index.js | 57 +++++----------------------- views_api.py | 92 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/fava_client.py b/fava_client.py index 4aa7cd2..5b23568 100644 --- a/fava_client.py +++ b/fava_client.py @@ -436,21 +436,32 @@ class FavaClient: response.raise_for_status() result = response.json() - # Fava query API returns: {"data": {"rows": [...], "columns": [...]}} + # Fava query API returns: {"data": {"rows": [...], "types": [...]}} data = result.get("data", {}) rows = data.get("rows", []) + types = data.get("types", []) + + # Build column name mapping + column_names = [t.get("name") for t in types] # Transform Fava's query result to transaction list transactions = [] for row in rows: - # Fava returns rows with various fields depending on the query - # For "SELECT *", we get transaction details - if isinstance(row, dict): + # Rows are arrays, convert to dict using column names + if isinstance(row, list) and len(row) == len(column_names): + txn = dict(zip(column_names, row)) + # Filter by flag if needed - flag = row.get("flag", "*") + flag = txn.get("flag", "*") if not include_pending and flag == "!": continue + transactions.append(txn) + elif isinstance(row, dict): + # Already a dict (shouldn't happen with BQL, but handle it) + flag = row.get("flag", "*") + if not include_pending and flag == "!": + continue transactions.append(row) return transactions[:limit] diff --git a/static/js/index.js b/static/js/index.js index f58eef0..fcf5b48 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1501,69 +1501,30 @@ window.app = Vue.createApp({ return new Date(dateString).toLocaleDateString() }, getTotalAmount(entry) { - if (!entry.lines || entry.lines.length === 0) return 0 - // Beancount-style: use absolute value of amounts - // All lines have amounts (positive or negative), we want the transaction size - return entry.lines.reduce((sum, line) => sum + Math.abs(line.amount || 0), 0) / 2 + return entry.amount }, getEntryFiatAmount(entry) { - // Extract fiat amount from metadata if available - if (!entry.lines || entry.lines.length === 0) return null - - for (const line of entry.lines) { - if (line.metadata && line.metadata.fiat_currency && line.metadata.fiat_amount) { - return this.formatFiat(line.metadata.fiat_amount, line.metadata.fiat_currency) - } + if (entry.fiat_amount && entry.fiat_currency) { + return this.formatFiat(entry.fiat_amount, entry.fiat_currency) } return null }, isReceivable(entry) { // Check if this is a receivable entry (user owes castle) - // Receivables have a positive amount (debit) to an "Accounts Receivable" account - if (!entry.lines || entry.lines.length === 0) return false - - for (const line of entry.lines) { - // Look for a line with positive amount on an accounts receivable account - // Beancount-style: positive amount = debit (asset increase) - if (line.amount > 0) { - // Check if the account is associated with this user's receivables - const account = this.accounts.find(a => a.id === line.account_id) - if (account && account.name && account.name.includes('Assets:Receivable') && account.account_type === 'asset') { - return true - } - } - } + if (entry.tags && entry.tags.includes('receivable-entry')) return true + if (entry.account && entry.account.includes('Receivable')) return true return false }, isPayable(entry) { // Check if this is a payable entry (castle owes user) - // Payables have a negative amount (credit) to an "Accounts Payable" account - if (!entry.lines || entry.lines.length === 0) return false - - for (const line of entry.lines) { - // Look for a line with negative amount on an accounts payable account - // Beancount-style: negative amount = credit (liability increase) - if (line.amount < 0) { - // Check if the account is associated with this user's payables - const account = this.accounts.find(a => a.id === line.account_id) - if (account && account.name && account.name.includes('Liabilities:Payable') && account.account_type === 'liability') { - return true - } - } - } + if (entry.tags && entry.tags.includes('expense-entry')) return true + if (entry.account && entry.account.includes('Payable')) return true return false }, isEquity(entry) { // Check if this is an equity entry (user capital contribution/balance) - if (!entry.lines || entry.lines.length === 0) return false - - for (const line of entry.lines) { - // Check if the account is an equity account - const account = this.accounts.find(a => a.id === line.account_id) - if (account && account.account_type === 'equity') { - return true - } - } + if (entry.tags && entry.tags.includes('equity-contribution')) return true + if (entry.account && entry.account.includes('Equity')) return true return false } }, diff --git a/views_api.py b/views_api.py index f5d81f1..fbfc1a5 100644 --- a/views_api.py +++ b/views_api.py @@ -377,9 +377,71 @@ async def api_get_pending_entries( fava = get_fava_client() all_entries = await fava.query_transactions(limit=1000, include_pending=True) - # Filter for pending flag - pending_entries = [e for e in all_entries if e.get("flag") == "!"] - return pending_entries + # Deduplicate and extract amounts + # BQL returns one row per posting, so we group by transaction + seen_transactions = {} + + for e in all_entries: + if e.get("flag") == "!": + # Create unique transaction key + date = e.get("date", "") + narration = e.get("narration", "") + txn_key = f"{date}:{narration}" + + # Extract entry ID from links field + entry_id = None + links = e.get("links", []) + if isinstance(links, (list, set)): + for link in links: + if isinstance(link, str) and "castle-" in link: + parts = link.split("castle-") + if len(parts) > 1: + entry_id = parts[-1] + break + + # Extract amount and fiat info from position field + amount_sats = 0 + fiat_amount = None + fiat_currency = None + + position = e.get("position") + if isinstance(position, dict): + # Extract sats amount + units = position.get("units", {}) + if isinstance(units, dict) and "number" in units: + amount_sats = abs(int(units.get("number", 0))) + + # Extract fiat amount from cost basis + cost = position.get("cost", {}) + if isinstance(cost, dict): + if "number" in cost: + fiat_amount = cost.get("number") + if "currency" in cost: + fiat_currency = cost.get("currency") + + # Only keep first occurrence (or update with positive amount) + if txn_key not in seen_transactions or amount_sats > 0: + entry_data = { + "id": entry_id or "unknown", + "date": date, + "entry_date": date, # Add for frontend compatibility + "flag": e.get("flag"), + "description": narration, + "payee": e.get("payee"), + "tags": e.get("tags", []), + "links": links, + "amount": amount_sats, + "account": e.get("account", ""), + } + + # Add fiat info if available + if fiat_amount and fiat_currency: + entry_data["fiat_amount"] = fiat_amount + entry_data["fiat_currency"] = fiat_currency + + seen_transactions[txn_key] = entry_data + + return list(seen_transactions.values()) @castle_api_router.get("/api/v1/entries/{entry_id}") @@ -625,6 +687,15 @@ async def api_create_expense_entry( fiat_currency = metadata.get("fiat_currency") if metadata else None fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Add castle ID as reference/link + castle_reference = f"castle-{entry_id}" + if data.reference: + castle_reference = f"{data.reference}-{entry_id}" + # Format Beancount entry entry = format_expense_entry( user_id=wallet.wallet.user, @@ -636,36 +707,35 @@ async def api_create_expense_entry( is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=data.reference + reference=castle_reference # Add castle ID as link ) # Submit to Fava result = await fava.add_entry(entry) # Return a JournalEntry-like response for compatibility - # TODO: Query Fava to get the actual entry back with its hash from .models import EntryLine return JournalEntry( - id=f"fava-{datetime.now().timestamp()}", # Temporary ID + id=entry_id, # Use the generated castle entry ID description=data.description + description_suffix, entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), - reference=data.reference, + reference=castle_reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( - id=f"line-1-{datetime.now().timestamp()}", - journal_entry_id=f"fava-{datetime.now().timestamp()}", + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, account_id=expense_account.id, amount=amount_sats, description=f"Expense paid by user {wallet.wallet.user[:8]}", metadata=metadata or {} ), EntryLine( - id=f"line-2-{datetime.now().timestamp()}", - journal_entry_id=f"fava-{datetime.now().timestamp()}", + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, account_id=user_account.id, amount=-amount_sats, description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",