Refactors expense tracking to use fiat amounts

Updates the expense tracking system to store payables and receivables in fiat currency within Beancount.
This ensures accurate debt representation and simplifies balance calculations.
Changes include:
- Converting `format_expense_entry` and `format_receivable_entry` to use fiat amounts.
- Introducing `format_net_settlement_entry` for net settlement payments.
- Modifying `format_payment_entry` to use cost syntax for fiat tracking.
- Adjusting Fava client to correctly process new amount formats and metadata.
- Adding average cost basis posting format

The use of fiat amounts and cost basis aims to provide better accuracy and compatibility with existing Beancount workflows.
This commit is contained in:
padreug 2025-11-10 03:33:04 +01:00
parent 8396331d5a
commit ca2ce1dfcc
3 changed files with 372 additions and 123 deletions

View file

@ -193,9 +193,12 @@ class FavaClient:
# Get all journal entries for this user
all_entries = await self.get_journal_entries()
logger.info(f"Processing {len(all_entries)} journal entries for user {user_id[:8]}")
total_sats = 0
fiat_balances = {}
accounts_dict = {} # Track balances per account
processed_count = 0
for entry in all_entries:
# Skip non-transactions, pending (!), and voided
@ -206,6 +209,11 @@ class FavaClient:
if "voided" in entry.get("tags", []):
continue
# Check if this entry has any User-375ec158 postings
has_user_posting = any(f":User-{user_id[:8]}" in p.get("account", "") for p in entry.get("postings", []))
if has_user_posting:
logger.info(f"Entry '{entry.get('narration', 'N/A')}' has {len(entry.get('postings', []))} postings")
# Process postings for this user
for posting in entry.get("postings", []):
account_name = posting.get("account", "")
@ -216,46 +224,78 @@ class FavaClient:
if "Payable" not in account_name and "Receivable" not in account_name:
continue
# Parse amount string: "36791 SATS {33.33 EUR}"
# Parse amount string: can be EUR, USD, or SATS
amount_str = posting.get("amount", "")
if not isinstance(amount_str, str) or not amount_str:
continue
logger.info(f"Processing posting in {account_name}: amount_str='{amount_str}'")
processed_count += 1
import re
# Extract SATS amount (with sign)
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = int(sats_match.group(1))
total_sats += sats_amount
# Track per account
if account_name not in accounts_dict:
accounts_dict[account_name] = {"account": account_name, "sats": 0}
accounts_dict[account_name]["sats"] += sats_amount
# Extract fiat from cost syntax: {33.33 EUR}
cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
if cost_match:
fiat_amount_unsigned = Decimal(cost_match.group(1))
fiat_currency = cost_match.group(2)
# Try to extract EUR/USD amount first (new format)
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
# Direct EUR/USD amount (new approach)
fiat_amount = Decimal(fiat_match.group(1))
fiat_currency = fiat_match.group(2)
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0)
# Apply the same sign as the SATS amount
if sats_match:
sats_amount_for_sign = int(sats_match.group(1))
if sats_amount_for_sign < 0:
fiat_amount_unsigned = -fiat_amount_unsigned
fiat_balances[fiat_currency] += fiat_amount
logger.info(f"Found fiat in {account_name}: {fiat_amount} {fiat_currency} (direct), running total: {fiat_balances[fiat_currency]}")
fiat_balances[fiat_currency] += fiat_amount_unsigned
logger.info(f"Found fiat in {account_name}: {fiat_amount_unsigned} {fiat_currency}, running total: {fiat_balances[fiat_currency]}")
# Also track SATS equivalent from metadata if available
posting_meta = posting.get("meta", {})
sats_equiv = posting_meta.get("sats-equivalent")
if sats_equiv:
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
total_sats += sats_amount
if account_name not in accounts_dict:
accounts_dict[account_name] = {"account": account_name, "sats": 0}
accounts_dict[account_name]["sats"] += sats_amount
else:
# Old format: SATS with cost/price notation - extract SATS amount
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = int(sats_match.group(1))
total_sats += sats_amount
# Track per account
if account_name not in accounts_dict:
accounts_dict[account_name] = {"account": account_name, "sats": 0}
accounts_dict[account_name]["sats"] += sats_amount
# Try to extract fiat from metadata or cost syntax (backward compatibility)
posting_meta = posting.get("meta", {})
fiat_amount_total_str = posting_meta.get("fiat-amount-total")
fiat_currency_meta = posting_meta.get("fiat-currency")
if fiat_amount_total_str and fiat_currency_meta:
# Use exact total from metadata
fiat_total = Decimal(fiat_amount_total_str)
fiat_currency = fiat_currency_meta
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0)
# Apply the same sign as the SATS amount
if sats_match:
sats_amount_for_sign = int(sats_match.group(1))
if sats_amount_for_sign < 0:
fiat_total = -fiat_total
fiat_balances[fiat_currency] += fiat_total
logger.info(f"Found fiat in {account_name}: {fiat_total} {fiat_currency} (from SATS metadata), running total: {fiat_balances[fiat_currency]}")
result = {
"balance": total_sats,
"fiat_balances": fiat_balances,
"accounts": list(accounts_dict.values())
}
logger.info(f"Processed {processed_count} postings for user {user_id[:8]}")
logger.info(f"Returning balance for user {user_id[:8]}: sats={total_sats}, fiat_balances={fiat_balances}")
return result
@ -502,7 +542,14 @@ class FavaClient:
response = await client.get(f"{self.base_url}/journal")
response.raise_for_status()
result = response.json()
return result.get("data", [])
entries = result.get("data", [])
logger.info(f"Fava /journal returned {len(entries)} entries")
# Log transactions with "Lightning payment" in narration
lightning_entries = [e for e in entries if "Lightning payment" in e.get("narration", "")]
logger.info(f"Found {len(lightning_entries)} Lightning payment entries in journal")
return entries
except httpx.HTTPStatusError as e:
logger.error(f"Fava journal error: {e.response.status_code} - {e.response.text}")