diff --git a/beancount_format.py b/beancount_format.py index 6b8d196..f0ec9c7 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -92,11 +92,14 @@ def format_posting_with_cost( This is the RECOMMENDED format for all Castle transactions. Uses Beancount's cost basis syntax to preserve exchange rates. + IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost. + This function calculates per-unit cost automatically. + Args: account: Account name (e.g., "Expenses:Food:Groceries") amount_sats: Amount in satoshis (signed: positive = debit, negative = credit) fiat_currency: Fiat currency (EUR, USD, etc.) - fiat_amount: Fiat amount (Decimal, unsigned) + fiat_amount: Fiat amount TOTAL (Decimal, unsigned) - will be converted to per-unit metadata: Optional posting metadata Returns: @@ -107,26 +110,91 @@ def format_posting_with_cost( account="Expenses:Food", amount_sats=200000, fiat_currency="EUR", - fiat_amount=Decimal("100.00") + fiat_amount=Decimal("100.00") # Total cost ) + # Calculates per-unit: 100.00 / 200000 = 0.0005 EUR per SAT # Returns: { # "account": "Expenses:Food", - # "amount": "200000 SATS {100.00 EUR}", + # "amount": "200000 SATS {0.0005 EUR}", # "meta": {} # } - # Note: All fiat/exchange rate info is in the cost syntax "{100.00 EUR}" """ # Build amount string with cost basis - if fiat_currency and fiat_amount and fiat_amount > 0: - # Cost basis syntax: "200000 SATS {100.00 EUR}" - # Sign is on the sats amount, fiat amount in cost basis is always positive - amount_str = f"{amount_sats} SATS {{{abs(fiat_amount):.2f} {fiat_currency}}}" + if fiat_currency and fiat_amount and fiat_amount > 0 and amount_sats != 0: + # Calculate per-unit cost (Beancount requires per-unit, not total) + # Example: 1000.00 EUR / 1097994 SATS = 0.000911268 EUR per SAT + amount_sats_abs = abs(amount_sats) + per_unit_cost = abs(fiat_amount) / Decimal(str(amount_sats_abs)) + + # Use high precision for per-unit cost (8 decimal places) + # Cost basis syntax: "200000 SATS {0.00050000 EUR}" + # Sign is on the sats amount, cost is always positive per-unit value + amount_str = f"{amount_sats} SATS {{{per_unit_cost:.8f} {fiat_currency}}}" else: # No cost basis: "200000 SATS" amount_str = f"{amount_sats} SATS" - # Build metadata (only include explicitly passed metadata, not redundant fiat info) - # The cost syntax "{69.00 EUR}" already contains all fiat/exchange rate information + # Build metadata - include total fiat amount to avoid rounding errors in balance calculations + posting_meta = metadata or {} + if fiat_currency and fiat_amount and fiat_amount > 0: + # Store the exact total fiat amount as metadata + # This preserves the original amount exactly, avoiding rounding errors from per-unit calculations + posting_meta["fiat-amount-total"] = f"{abs(fiat_amount):.2f}" + posting_meta["fiat-currency"] = fiat_currency + + return { + "account": account, + "amount": amount_str, + "meta": posting_meta + } + + +def format_posting_at_average_cost( + account: str, + amount_sats: int, + cost_currency: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + Format a posting to reduce at average cost for Fava API. + + Use this for payments/settlements to reduce positions at average cost. + Specifying the cost currency tells Beancount which lots to reduce. + + Args: + account: Account name + amount_sats: Amount in satoshis (signed) + cost_currency: Currency of the original cost basis (e.g., "EUR") + metadata: Optional posting metadata + + Returns: + Fava API posting dict + + Example: + posting = format_posting_at_average_cost( + account="Assets:Receivable:User-abc", + amount_sats=-996896, + cost_currency="EUR" + ) + # Returns: { + # "account": "Assets:Receivable:User-abc", + # "amount": "-996896 SATS {EUR}", + # "meta": {} + # } + # Beancount will automatically reduce EUR balance proportionally + """ + # Cost currency specification: "996896 SATS {EUR}" + # This reduces positions with EUR cost at average cost + from loguru import logger + + if cost_currency: + amount_str = f"{amount_sats} SATS {{{cost_currency}}}" + logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'") + else: + # No cost + amount_str = f"{amount_sats} SATS {{}}" + logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis") + posting_meta = metadata or {} return { @@ -192,16 +260,19 @@ def format_expense_entry( Creates a pending transaction (flag="!") that requires admin approval. + Stores payables in EUR (or other fiat) as this is the actual debt amount. + SATS amount stored as metadata for reference. + Args: user_id: User ID expense_account: Expense account name (e.g., "Expenses:Food:Groceries") user_account: User's liability/equity account name - amount_sats: Amount in satoshis (unsigned, will be signed correctly) + amount_sats: Amount in satoshis (for reference/metadata) description: Entry description entry_date: Date of entry is_equity: Whether this is an equity contribution - fiat_currency: Optional fiat currency (EUR, USD) - fiat_amount: Optional fiat amount (unsigned) + fiat_currency: Fiat currency (EUR, USD) - REQUIRED + fiat_amount: Fiat amount (unsigned) - REQUIRED reference: Optional reference (invoice ID, etc.) Returns: @@ -219,37 +290,34 @@ def format_expense_entry( fiat_amount=Decimal("100.00") ) """ - # Ensure amounts are unsigned for cost basis - amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None + if not fiat_currency or not fiat_amount_abs: + raise ValueError("fiat_currency and fiat_amount are required for expense entries") + # Build narration narration = description - if fiat_currency and fiat_amount_abs: - narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" + narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" - # Build postings with cost basis + # Build postings in EUR (debts are in operating currency) postings = [ - format_posting_with_cost( - account=expense_account, - amount_sats=amount_sats_abs, # Positive = debit (expense increase) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ), - format_posting_with_cost( - account=user_account, - amount_sats=-amount_sats_abs, # Negative = credit (liability/equity increase) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ) + { + "account": expense_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", + "meta": {"sats-equivalent": str(abs(amount_sats))} + }, + { + "account": user_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", + "meta": {"sats-equivalent": str(abs(amount_sats))} + } ] # Build entry metadata - # Note: created-via is redundant with #expense-entry tag - # Note: is-equity is redundant with account name (Equity vs Liabilities:Payable) and tags entry_meta = { "user-id": user_id, - "source": "castle-api" + "source": "castle-api", + "sats-amount": str(abs(amount_sats)) } # Build links @@ -303,33 +371,32 @@ def format_receivable_entry( Returns: Fava API entry dict """ - amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None - narration = description - if fiat_currency and fiat_amount_abs: - narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" + if not fiat_currency or not fiat_amount_abs: + raise ValueError("fiat_currency and fiat_amount are required for receivable entries") + narration = description + narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" + + # Build postings in EUR (debts are in operating currency) postings = [ - format_posting_with_cost( - account=receivable_account, - amount_sats=amount_sats_abs, # Positive = debit (asset increase - user owes) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ), - format_posting_with_cost( - account=revenue_account, - amount_sats=-amount_sats_abs, # Negative = credit (revenue increase) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ) + { + "account": receivable_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", + "meta": {"sats-equivalent": str(abs(amount_sats))} + }, + { + "account": revenue_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", + "meta": {"sats-equivalent": str(abs(amount_sats))} + } ] - # Note: created-via is redundant with #receivable-entry tag - # Note: debtor-user-id is the same as user-id for receivables (redundant) entry_meta = { "user-id": user_id, - "source": "castle-api" + "source": "castle-api", + "sats-amount": str(abs(amount_sats)) } links = [] @@ -338,7 +405,7 @@ def format_receivable_entry( return format_transaction( date_val=entry_date, - flag="!", # Pending until paid + flag="*", # Receivables are immediately cleared (approved) narration=narration, postings=postings, tags=["receivable-entry"], @@ -384,40 +451,55 @@ def format_payment_entry( amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None - if is_payable: - # Castle paying user: DR Payable, CR Lightning - postings = [ - format_posting_with_cost( - account=payable_or_receivable_account, - amount_sats=amount_sats_abs, # Positive = debit (liability decrease) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ), - format_posting_with_cost( - account=payment_account, - amount_sats=-amount_sats_abs, # Negative = credit (asset decrease) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs, - metadata={"payment-hash": payment_hash} if payment_hash else None - ) - ] + # For payment settlements with fiat tracking, use cost syntax with per-unit cost + # This allows Beancount to match against existing lots and reduce them + # The per-unit cost is calculated from: fiat_amount / sats_amount + # Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate) + if fiat_currency and fiat_amount_abs and amount_sats_abs > 0: + if is_payable: + # Castle paying user: DR Payable, CR Lightning + postings = [ + format_posting_with_cost( + account=payable_or_receivable_account, + amount_sats=amount_sats_abs, + fiat_currency=fiat_currency, + fiat_amount=fiat_amount_abs # Will be converted to per-unit cost + ), + format_posting_simple( + account=payment_account, + amount_sats=-amount_sats_abs, + metadata={"payment-hash": payment_hash} if payment_hash else None + ) + ] + else: + # User paying castle: DR Lightning, CR Receivable + postings = [ + format_posting_simple( + account=payment_account, + amount_sats=amount_sats_abs, + metadata={"payment-hash": payment_hash} if payment_hash else None + ), + format_posting_with_cost( + account=payable_or_receivable_account, + amount_sats=-amount_sats_abs, + fiat_currency=fiat_currency, + fiat_amount=fiat_amount_abs # Will be converted to per-unit cost + ) + ] else: - # User paying castle: DR Lightning, CR Receivable - postings = [ - format_posting_with_cost( - account=payment_account, - amount_sats=amount_sats_abs, # Positive = debit (asset increase) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs, - metadata={"payment-hash": payment_hash} if payment_hash else None - ), - format_posting_with_cost( - account=payable_or_receivable_account, - amount_sats=-amount_sats_abs, # Negative = credit (asset decrease) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount_abs - ) - ] + # No fiat tracking, use simple postings + if is_payable: + postings = [ + format_posting_simple(account=payable_or_receivable_account, amount_sats=amount_sats_abs), + format_posting_simple(account=payment_account, amount_sats=-amount_sats_abs, + metadata={"payment-hash": payment_hash} if payment_hash else None) + ] + else: + postings = [ + format_posting_simple(account=payment_account, amount_sats=amount_sats_abs, + metadata={"payment-hash": payment_hash} if payment_hash else None), + format_posting_simple(account=payable_or_receivable_account, amount_sats=-amount_sats_abs) + ] # Note: created-via is redundant with #lightning-payment tag # Note: payer/payee can be inferred from transaction direction and accounts @@ -446,6 +528,98 @@ def format_payment_entry( ) +def format_net_settlement_entry( + user_id: str, + payment_account: str, + receivable_account: str, + payable_account: str, + amount_sats: int, + net_fiat_amount: Decimal, + total_receivable_fiat: Decimal, + total_payable_fiat: Decimal, + fiat_currency: str, + description: str, + entry_date: date, + payment_hash: Optional[str] = None, + reference: Optional[str] = None +) -> Dict[str, Any]: + """ + Format a net settlement payment entry (user paying net balance). + + Creates a three-posting transaction: + 1. Lightning payment in SATS with @@ total price notation + 2. Clear receivables in EUR + 3. Clear payables in EUR + + Example: + Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR + Assets:Receivable:User -555.00 EUR + Liabilities:Payable:User 38.00 EUR + = 517 - 555 + 38 = 0 ✓ + + Args: + user_id: User ID + payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning") + receivable_account: User's receivable account + payable_account: User's payable account + amount_sats: SATS amount paid + net_fiat_amount: Net fiat amount (receivable - payable) + total_receivable_fiat: Total receivables to clear + total_payable_fiat: Total payables to clear + fiat_currency: Currency (EUR, USD) + description: Payment description + entry_date: Date of payment + payment_hash: Lightning payment hash + reference: Optional reference + + Returns: + Fava API entry dict + """ + # Build postings for net settlement + postings = [ + { + "account": payment_account, + "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}", + "meta": {"payment-hash": payment_hash} if payment_hash else {} + }, + { + "account": receivable_account, + "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}", + "meta": {"sats-equivalent": str(abs(amount_sats))} + }, + { + "account": payable_account, + "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}", + "meta": {} + } + ] + + entry_meta = { + "user-id": user_id, + "source": "lightning_payment", + "payment-type": "net-settlement" + } + + if payment_hash: + entry_meta["payment-hash"] = payment_hash + + links = [] + if reference: + links.append(reference) + if payment_hash: + links.append(f"ln-{payment_hash[:16]}") + + return format_transaction( + date_val=entry_date, + flag="*", # Cleared (payment already happened) + narration=description, + postings=postings, + tags=["lightning-payment", "net-settlement"], + links=links, + meta=entry_meta + ) + + def format_revenue_entry( payment_account: str, revenue_account: str, diff --git a/fava_client.py b/fava_client.py index 5619604..591619d 100644 --- a/fava_client.py +++ b/fava_client.py @@ -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}") diff --git a/tasks.py b/tasks.py index 4c84065..208fc09 100644 --- a/tasks.py +++ b/tasks.py @@ -175,7 +175,7 @@ async def on_invoice_paid(payment: Payment) -> None: from decimal import Decimal from .crud import get_account_by_name, get_or_create_user_account from .models import AccountType - from .beancount_format import format_payment_entry + from .beancount_format import format_net_settlement_entry # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 @@ -191,30 +191,58 @@ async def on_invoice_paid(payment: Payment) -> None: if fiat_amount_str: fiat_amount = Decimal(str(fiat_amount_str)) + if not fiat_currency or not fiat_amount: + logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata") + return + logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}") - # Get user's receivable account (what user owes) + # Get user's current balance to determine receivables and payables + balance = await fava.get_user_balance(user_id) + fiat_balances = balance.get("fiat_balances", {}) + total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) + + logger.info(f"User {user_id[:8]} current balance: {total_fiat_balance} {fiat_currency}") + + # Determine receivables and payables based on balance + # Positive balance = user owes castle (receivable) + # Negative balance = castle owes user (payable) + if total_fiat_balance > 0: + # User owes castle + total_receivable = total_fiat_balance + total_payable = Decimal(0) + else: + # Castle owes user + total_receivable = Decimal(0) + total_payable = abs(total_fiat_balance) + + logger.info(f"Settlement amounts - Receivable: {total_receivable}, Payable: {total_payable}, Net: {fiat_amount}") + + # Get account names user_receivable = await get_or_create_user_account( user_id, AccountType.ASSET, "Accounts Receivable" ) - - # Get lightning account + user_payable = await get_or_create_user_account( + user_id, AccountType.LIABILITY, "Accounts Payable" + ) lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") if not lightning_account: logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") return - # Format as Beancount transaction - entry = format_payment_entry( + # Format as net settlement transaction + entry = format_net_settlement_entry( user_id=user_id, - payment_account=lightning_account.name, # "Assets:Bitcoin:Lightning" - payable_or_receivable_account=user_receivable.name, # "Assets:Receivable:User-{id}" + payment_account=lightning_account.name, + receivable_account=user_receivable.name, + payable_account=user_payable.name, amount_sats=amount_sats, - description=f"Lightning payment from user {user_id[:8]}", - entry_date=datetime.now().date(), - is_payable=False, # User paying castle (receivable settlement) + net_fiat_amount=fiat_amount, + total_receivable_fiat=total_receivable, + total_payable_fiat=total_payable, fiat_currency=fiat_currency, - fiat_amount=fiat_amount, + description=f"Lightning payment settlement from user {user_id[:8]}", + entry_date=datetime.now().date(), payment_hash=payment.payment_hash, reference=payment.payment_hash )