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.
This commit is contained in:
padreug 2025-11-09 23:52:39 +01:00
parent 37fe34668f
commit 56a3e9d4e9
3 changed files with 106 additions and 64 deletions

View file

@ -436,21 +436,32 @@ class FavaClient:
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
# Fava query API returns: {"data": {"rows": [...], "columns": [...]}} # Fava query API returns: {"data": {"rows": [...], "types": [...]}}
data = result.get("data", {}) data = result.get("data", {})
rows = data.get("rows", []) 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 # Transform Fava's query result to transaction list
transactions = [] transactions = []
for row in rows: for row in rows:
# Fava returns rows with various fields depending on the query # Rows are arrays, convert to dict using column names
# For "SELECT *", we get transaction details if isinstance(row, list) and len(row) == len(column_names):
if isinstance(row, dict): txn = dict(zip(column_names, row))
# Filter by flag if needed # Filter by flag if needed
flag = row.get("flag", "*") flag = txn.get("flag", "*")
if not include_pending and flag == "!": if not include_pending and flag == "!":
continue 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) transactions.append(row)
return transactions[:limit] return transactions[:limit]

View file

@ -1501,69 +1501,30 @@ window.app = Vue.createApp({
return new Date(dateString).toLocaleDateString() return new Date(dateString).toLocaleDateString()
}, },
getTotalAmount(entry) { getTotalAmount(entry) {
if (!entry.lines || entry.lines.length === 0) return 0 return entry.amount
// 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
}, },
getEntryFiatAmount(entry) { getEntryFiatAmount(entry) {
// Extract fiat amount from metadata if available if (entry.fiat_amount && entry.fiat_currency) {
if (!entry.lines || entry.lines.length === 0) return null return this.formatFiat(entry.fiat_amount, entry.fiat_currency)
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)
}
} }
return null return null
}, },
isReceivable(entry) { isReceivable(entry) {
// Check if this is a receivable entry (user owes castle) // Check if this is a receivable entry (user owes castle)
// Receivables have a positive amount (debit) to an "Accounts Receivable" account if (entry.tags && entry.tags.includes('receivable-entry')) return true
if (!entry.lines || entry.lines.length === 0) return false if (entry.account && entry.account.includes('Receivable')) return true
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
}
}
}
return false return false
}, },
isPayable(entry) { isPayable(entry) {
// Check if this is a payable entry (castle owes user) // Check if this is a payable entry (castle owes user)
// Payables have a negative amount (credit) to an "Accounts Payable" account if (entry.tags && entry.tags.includes('expense-entry')) return true
if (!entry.lines || entry.lines.length === 0) return false if (entry.account && entry.account.includes('Payable')) return true
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
}
}
}
return false return false
}, },
isEquity(entry) { isEquity(entry) {
// Check if this is an equity entry (user capital contribution/balance) // Check if this is an equity entry (user capital contribution/balance)
if (!entry.lines || entry.lines.length === 0) return false if (entry.tags && entry.tags.includes('equity-contribution')) return true
if (entry.account && entry.account.includes('Equity')) return true
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
}
}
return false return false
} }
}, },

View file

@ -377,9 +377,71 @@ async def api_get_pending_entries(
fava = get_fava_client() fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True) all_entries = await fava.query_transactions(limit=1000, include_pending=True)
# Filter for pending flag # Deduplicate and extract amounts
pending_entries = [e for e in all_entries if e.get("flag") == "!"] # BQL returns one row per posting, so we group by transaction
return pending_entries 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}") @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_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 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 # Format Beancount entry
entry = format_expense_entry( entry = format_expense_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
@ -636,36 +707,35 @@ async def api_create_expense_entry(
is_equity=data.is_equity, is_equity=data.is_equity,
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=data.reference reference=castle_reference # Add castle ID as link
) )
# Submit to Fava # Submit to Fava
result = await fava.add_entry(entry) result = await fava.add_entry(entry)
# Return a JournalEntry-like response for compatibility # Return a JournalEntry-like response for compatibility
# TODO: Query Fava to get the actual entry back with its hash
from .models import EntryLine from .models import EntryLine
return JournalEntry( return JournalEntry(
id=f"fava-{datetime.now().timestamp()}", # Temporary ID id=entry_id, # Use the generated castle entry ID
description=data.description + description_suffix, description=data.description + description_suffix,
entry_date=data.entry_date if data.entry_date else datetime.now(), entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.id, created_by=wallet.wallet.id,
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=castle_reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
EntryLine( EntryLine(
id=f"line-1-{datetime.now().timestamp()}", id=f"line-1-{entry_id}",
journal_entry_id=f"fava-{datetime.now().timestamp()}", journal_entry_id=entry_id,
account_id=expense_account.id, account_id=expense_account.id,
amount=amount_sats, amount=amount_sats,
description=f"Expense paid by user {wallet.wallet.user[:8]}", description=f"Expense paid by user {wallet.wallet.user[:8]}",
metadata=metadata or {} metadata=metadata or {}
), ),
EntryLine( EntryLine(
id=f"line-2-{datetime.now().timestamp()}", id=f"line-2-{entry_id}",
journal_entry_id=f"fava-{datetime.now().timestamp()}", journal_entry_id=entry_id,
account_id=user_account.id, account_id=user_account.id,
amount=-amount_sats, amount=-amount_sats,
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",