From a6b67b7416eca15a515ecfbe23fe6047a6a1ed93 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 10 Nov 2025 11:35:41 +0100 Subject: [PATCH] Improves Beancount entry generation and sanitization Adds a function to sanitize strings for use as Beancount links, ensuring compatibility with Beancount's link restrictions. Refactors the journal entry creation process to use EUR-based postings when fiat currency is provided, improving accuracy and consistency. The legacy SATS-based fallback is retained for cases without fiat currency information. Adjusts reference generation for Beancount entries using the sanitized description. --- helper/import_beancount.py | 29 +++++++++++++++++++++++++++- views_api.py | 39 ++++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/helper/import_beancount.py b/helper/import_beancount.py index eae4033..4429186 100755 --- a/helper/import_beancount.py +++ b/helper/import_beancount.py @@ -238,6 +238,30 @@ class AccountLookup: # ===== CONVERSION FUNCTIONS ===== +def sanitize_link(text: str) -> str: + """ + Sanitize a string to make it valid for Beancount links. + + Beancount links can only contain: A-Z, a-z, 0-9, -, _, /, . + All other characters are replaced with hyphens. + + Examples: + >>> sanitize_link("Test (pending)") + 'Test-pending' + >>> sanitize_link("Invoice #123") + 'Invoice-123' + >>> sanitize_link("import-20250623-Action Ressourcerie") + 'import-20250623-Action-Ressourcerie' + """ + import re + # Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen + sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text) + # Remove consecutive hyphens + sanitized = re.sub(r'-+', '-', sanitized) + # Remove leading/trailing hyphens + sanitized = sanitized.strip('-') + return sanitized + def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int: """Convert EUR to satoshis using BTC/EUR rate""" btc_amount = eur_amount / Decimal(str(btc_eur_rate)) @@ -454,10 +478,13 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A "metadata": metadata }) + # Create sanitized reference link + desc_part = sanitize_link(parsed['description'][:30]) + return { "description": parsed['description'], "entry_date": parsed['date'].isoformat(), - "reference": f"import-{parsed['date'].strftime('%Y%m%d')}-{parsed['description'][:20].replace(' ', '-')}", + "reference": f"import-{parsed['date'].strftime('%Y%m%d')}-{desc_part}", "flag": "*", "meta": { "source": "beancount_import", diff --git a/views_api.py b/views_api.py index 73ebf20..783309c 100644 --- a/views_api.py +++ b/views_api.py @@ -611,19 +611,38 @@ async def api_create_journal_entry( fiat_amount_str = line.metadata.get("fiat_amount") fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None - # Create posting metadata (excluding fiat fields that go in cost basis) + # Create posting metadata (excluding fiat fields that are used for primary amount) posting_metadata = {k: v for k, v in line.metadata.items() if k not in ["fiat_currency", "fiat_amount"]} - if line.description: - posting_metadata["description"] = line.description - posting = format_posting_with_cost( - account=account.name, - amount_sats=line.amount, - fiat_currency=fiat_currency, - fiat_amount=abs(fiat_amount) if fiat_amount else None, - metadata=posting_metadata if posting_metadata else None - ) + # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) + # Otherwise, use SATS-based format + if fiat_currency and fiat_amount: + # EUR-based posting (current architecture) + posting_metadata["sats-equivalent"] = str(abs(line.amount)) + + # Apply the sign from line.amount to fiat_amount + # line.amount is positive for debits, negative for credits + signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount + + posting = { + "account": account.name, + "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", + "meta": posting_metadata if posting_metadata else None + } + else: + # SATS-based posting (legacy/fallback) + if line.description: + posting_metadata["description"] = line.description + + posting = format_posting_with_cost( + account=account.name, + amount_sats=line.amount, + fiat_currency=None, + fiat_amount=None, + metadata=posting_metadata if posting_metadata else None + ) + postings.append(posting) # Extract tags and links from meta