From 490b361268e0eba0b7c064bdda20ec62b3e4b857 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 10 Nov 2025 10:51:55 +0100 Subject: [PATCH] Adds fiat settlement entry formatting Introduces a function to format fiat settlement entries for Beancount, handling cash, bank transfers, and other non-lightning payments. This allows for recording transactions in fiat currency with sats as metadata. Updates the API endpoint to use the new function when settling receivables with fiat currencies. --- beancount_format.py | 106 ++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 69 +++++++++++++++++----------- 2 files changed, 148 insertions(+), 27 deletions(-) diff --git a/beancount_format.py b/beancount_format.py index adc97c6..06f0afb 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -528,6 +528,112 @@ def format_payment_entry( ) +def format_fiat_settlement_entry( + user_id: str, + payment_account: str, + payable_or_receivable_account: str, + fiat_amount: Decimal, + fiat_currency: str, + amount_sats: int, + description: str, + entry_date: date, + is_payable: bool = True, + payment_method: str = "cash", + reference: Optional[str] = None +) -> Dict[str, Any]: + """ + Format a fiat (cash/bank) settlement entry. + + Unlike Lightning payments, fiat settlements use fiat currency as the primary amount + with SATS stored as metadata for reference. + + Args: + user_id: User ID + payment_account: Payment method account (e.g., "Assets:Cash", "Assets:Bank") + payable_or_receivable_account: User's account being settled + fiat_amount: Amount in fiat currency (unsigned) + fiat_currency: Fiat currency code (EUR, USD, etc.) + amount_sats: Equivalent amount in satoshis (for metadata only) + description: Payment description + entry_date: Date of settlement + is_payable: True if castle paying user (payable), False if user paying castle (receivable) + payment_method: Payment method (cash, bank_transfer, check, etc.) + reference: Optional reference + + Returns: + Fava API entry dict + """ + fiat_amount_abs = abs(fiat_amount) + amount_sats_abs = abs(amount_sats) + + if is_payable: + # Castle paying user: DR Payable, CR Cash/Bank + postings = [ + { + "account": payable_or_receivable_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", + "meta": { + "sats-equivalent": str(amount_sats_abs) + } + }, + { + "account": payment_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", + "meta": { + "sats-equivalent": str(amount_sats_abs) + } + } + ] + else: + # User paying castle: DR Cash/Bank, CR Receivable + postings = [ + { + "account": payment_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", + "meta": { + "sats-equivalent": str(amount_sats_abs) + } + }, + { + "account": payable_or_receivable_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", + "meta": { + "sats-equivalent": str(amount_sats_abs) + } + } + ] + + # Map payment method to appropriate source and tag + payment_method_map = { + "cash": ("cash_settlement", "cash-payment"), + "bank_transfer": ("bank_settlement", "bank-transfer"), + "check": ("check_settlement", "check-payment"), + "btc_onchain": ("onchain_settlement", "onchain-payment"), + "other": ("manual_settlement", "manual-payment") + } + + source, tag = payment_method_map.get(payment_method.lower(), ("manual_settlement", "manual-payment")) + + entry_meta = { + "user-id": user_id, + "source": source + } + + links = [] + if reference: + links.append(reference) + + return format_transaction( + date_val=entry_date, + flag="*", # Cleared (payment already happened) + narration=description, + postings=postings, + tags=[tag], + links=links, + meta=entry_meta + ) + + def format_net_settlement_entry( user_id: str, payment_account: str, diff --git a/views_api.py b/views_api.py index d668e01..b375292 100644 --- a/views_api.py +++ b/views_api.py @@ -1569,43 +1569,58 @@ async def api_settle_receivable( # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # This records that user paid their debt from .fava_client import get_fava_client - from .beancount_format import format_payment_entry + from .beancount_format import format_payment_entry, format_fiat_settlement_entry from decimal import Decimal fava = get_fava_client() - # Determine amount and currency - if data.currency: - # Fiat currency payment (e.g., EUR, USD) - # Use the sats equivalent for the journal entry to match the receivable + # Determine if this is a fiat or lightning payment + is_fiat_payment = data.currency and data.payment_method.lower() in [ + "cash", "bank_transfer", "check", "other" + ] + + if is_fiat_payment: + # Fiat currency payment (cash, bank transfer, etc.) + # Record in fiat currency with sats as metadata if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="amount_sats is required when settling with fiat currency" ) - amount_in_sats = data.amount_sats - fiat_currency = data.currency.upper() - fiat_amount = data.amount - else: - # Satoshi payment - amount_in_sats = int(data.amount) - fiat_currency = None - fiat_amount = None - # Format payment entry - entry = format_payment_entry( - user_id=data.user_id, - payment_account=payment_account.name, - payable_or_receivable_account=user_receivable.name, - amount_sats=amount_in_sats, - description=data.description, - entry_date=datetime.now().date(), - is_payable=False, # User paying castle (receivable settlement) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - payment_hash=data.payment_hash, - reference=data.reference or f"MANUAL-{data.user_id[:8]}" - ) + entry = format_fiat_settlement_entry( + user_id=data.user_id, + payment_account=payment_account.name, + payable_or_receivable_account=user_receivable.name, + fiat_amount=Decimal(str(data.amount)), + fiat_currency=data.currency.upper(), + amount_sats=data.amount_sats, + description=data.description, + entry_date=datetime.now().date(), + is_payable=False, # User paying castle (receivable settlement) + payment_method=data.payment_method, + reference=data.reference or f"MANUAL-{data.user_id[:8]}" + ) + else: + # Lightning or BTC onchain payment + # Record in SATS with optional fiat metadata + amount_in_sats = data.amount_sats if data.amount_sats else int(data.amount) + fiat_currency = data.currency.upper() if data.currency else None + fiat_amount = Decimal(str(data.amount)) if data.currency else None + + entry = format_payment_entry( + user_id=data.user_id, + payment_account=payment_account.name, + payable_or_receivable_account=user_receivable.name, + amount_sats=amount_in_sats, + description=data.description, + entry_date=datetime.now().date(), + is_payable=False, # User paying castle (receivable settlement) + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + payment_hash=data.payment_hash, + reference=data.reference or f"MANUAL-{data.user_id[:8]}" + ) # Add additional metadata to entry if "meta" not in entry: