Enhances Beancount import and API entry creation

Improves the Beancount import process to send SATS amounts with fiat metadata to the API, enabling automatic conversion to EUR-based postings.
Updates the API to store entries in Fava instead of the Castle DB, simplifying the JournalEntry creation process.
Adds error handling to the upload entry function.
Includes a note about imported transactions being stored in EUR with SATS in metadata.
This commit is contained in:
padreug 2025-11-10 11:29:01 +01:00
parent 1d605be021
commit 0e93fc5ffc
2 changed files with 41 additions and 50 deletions

View file

@ -245,18 +245,19 @@ def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int:
return int(sats.quantize(Decimal('1'))) return int(sats.quantize(Decimal('1')))
def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict: def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
"""Build metadata dict for Castle entry line""" """
Build metadata dict for Castle entry line.
The API will extract fiat_currency and fiat_amount and use them
to create proper EUR-based postings with SATS in metadata.
"""
abs_eur = abs(eur_amount) abs_eur = abs(eur_amount)
abs_sats = abs(eur_to_sats(abs_eur, btc_eur_rate)) abs_sats = abs(eur_to_sats(abs_eur, btc_eur_rate))
# fiat_rate = sats per EUR
fiat_rate = float(abs_sats) / float(abs_eur) if abs_eur > 0 else 0
return { return {
"fiat_currency": "EUR", "fiat_currency": "EUR",
"fiat_amount": str(abs_eur.quantize(Decimal("0.001"))), "fiat_amount": str(abs_eur.quantize(Decimal("0.01"))), # Store as string for JSON
"fiat_rate": fiat_rate, "btc_rate": str(btc_eur_rate) # Store exchange rate for reference
"btc_rate": btc_eur_rate
} }
# ===== BEANCOUNT PARSER ===== # ===== BEANCOUNT PARSER =====
@ -407,7 +408,12 @@ def determine_user_id(postings: list) -> Optional[str]:
# ===== CASTLE CONVERTER ===== # ===== CASTLE CONVERTER =====
def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
"""Convert parsed Beancount transaction to Castle format""" """
Convert parsed Beancount transaction to Castle format.
Sends SATS amounts with fiat metadata. The Castle API will automatically
convert to EUR-based postings with SATS stored in metadata.
"""
# Determine which user this transaction is for (based on user-specific accounts) # Determine which user this transaction is for (based on user-specific accounts)
user_id = determine_user_id(parsed['postings']) user_id = determine_user_id(parsed['postings'])
@ -435,10 +441,10 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
if eur_amount is None: if eur_amount is None:
raise ValueError(f"Could not determine amount for {posting['account']}") raise ValueError(f"Could not determine amount for {posting['account']}")
# Convert EUR to sats # Convert EUR to sats (amount sent to API)
sats = eur_to_sats(eur_amount, btc_eur_rate) sats = eur_to_sats(eur_amount, btc_eur_rate)
# Build metadata # Build metadata (API will extract fiat_currency and fiat_amount)
metadata = build_metadata(eur_amount, btc_eur_rate) metadata = build_metadata(eur_amount, btc_eur_rate)
lines.append({ lines.append({
@ -456,7 +462,7 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
"meta": { "meta": {
"source": "beancount_import", "source": "beancount_import",
"imported_at": datetime.now().isoformat(), "imported_at": datetime.now().isoformat(),
"btc_eur_rate": btc_eur_rate, "btc_eur_rate": str(btc_eur_rate),
"user_id": user_id # Track which user this transaction is for "user_id": user_id # Track which user this transaction is for
}, },
"lines": lines "lines": lines
@ -486,9 +492,18 @@ def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
"Content-Type": "application/json" "Content-Type": "application/json"
} }
try:
response = requests.post(url, json=entry, headers=headers) response = requests.post(url, json=entry, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except requests.exceptions.HTTPError as e:
print(f" ❌ HTTP Error: {e}")
if response.text:
print(f" Response: {response.text}")
raise
except Exception as e:
print(f" ❌ Error: {e}")
raise
# ===== MAIN IMPORT FUNCTION ===== # ===== MAIN IMPORT FUNCTION =====
@ -590,6 +605,8 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
if success_count > 0 and not dry_run: if success_count > 0 and not dry_run:
print(f"\n✅ Successfully imported {success_count} transactions to Castle!") print(f"\n✅ Successfully imported {success_count} transactions to Castle!")
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
print(f" Check Fava to see the imported entries.")
# ===== MAIN ===== # ===== MAIN =====

View file

@ -654,8 +654,8 @@ async def api_create_journal_entry(
result = await fava.add_entry(entry) result = await fava.add_entry(entry)
logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}")
# Return mock JournalEntry for API compatibility # Return simplified JournalEntry for API compatibility
# TODO: Query Fava to get the actual entry back with its hash # Note: Castle no longer stores entries in DB, Fava is the source of truth
timestamp = datetime.now().timestamp() timestamp = datetime.now().timestamp()
return JournalEntry( return JournalEntry(
id=f"fava-{timestamp}", id=f"fava-{timestamp}",
@ -665,18 +665,8 @@ async def api_create_journal_entry(
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=data.reference,
flag=data.flag if data.flag else JournalEntryFlag.CLEARED, flag=data.flag if data.flag else JournalEntryFlag.CLEARED,
lines=[ lines=[], # Empty - entry is stored in Fava, not Castle DB
EntryLine( meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
id=f"fava-{timestamp}-{i}",
journal_entry_id=f"fava-{timestamp}",
account_id=line.account_id,
amount=line.amount,
description=line.description,
metadata=line.metadata
)
for i, line in enumerate(data.lines)
],
meta={**data.meta, "source": "fava", "fava_response": result.get('data', 'Unknown')}
) )
@ -1068,33 +1058,17 @@ async def api_create_revenue_entry(
result = await fava.add_entry(entry) result = await fava.add_entry(entry)
logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}")
# Return JournalEntry for API compatibility # Return simplified JournalEntry for API compatibility
# Note: Castle no longer stores entries in DB, Fava is the source of truth
return JournalEntry( return JournalEntry(
id=entry_id, # Use the generated castle entry ID id=entry_id,
description=data.description, description=data.description,
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=wallet.wallet.id, created_by=wallet.wallet.id,
created_at=datetime.now(), created_at=datetime.now(),
reference=castle_reference, # Use castle reference with unique ID reference=castle_reference,
flag=JournalEntryFlag.CLEARED, # Revenue entries are cleared flag=JournalEntryFlag.CLEARED,
lines=[ lines=[], # Empty - entry is stored in Fava, not Castle DB
EntryLine(
id=f"line-1-{entry_id}",
journal_entry_id=entry_id,
account_id=payment_account.id,
amount=amount_sats,
description="Payment received",
metadata={"fiat_currency": fiat_currency, "fiat_amount": str(fiat_amount)} if fiat_currency else {}
),
EntryLine(
id=f"line-2-{entry_id}",
journal_entry_id=entry_id,
account_id=revenue_account.id,
amount=-amount_sats,
description="Revenue earned",
metadata={}
)
],
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
) )