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'}",