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:
parent
1d605be021
commit
0e93fc5ffc
2 changed files with 41 additions and 50 deletions
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=entry, headers=headers)
|
try:
|
||||||
response.raise_for_status()
|
response = requests.post(url, json=entry, headers=headers)
|
||||||
return response.json()
|
response.raise_for_status()
|
||||||
|
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 =====
|
||||||
|
|
||||||
|
|
|
||||||
46
views_api.py
46
views_api.py
|
|
@ -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')}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue