diff --git a/beancount_format.py b/beancount_format.py index 2ffb01b..2ba5b96 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -15,6 +15,12 @@ from datetime import date, datetime from decimal import Decimal from typing import Any, Dict, List, Optional import re +import uuid + + +def generate_entry_id() -> str: + """Generate a unique 16-character entry ID.""" + return str(uuid.uuid4()).replace("-", "")[:16] def sanitize_link(text: str) -> str: @@ -308,30 +314,30 @@ def format_expense_entry( is_equity: bool = False, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + entry_id: Optional[str] = None ) -> Dict[str, Any]: """ Format an expense entry for submission to Fava. - 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. + Uses price notation (@@ SATS) for BQL-queryable SATS tracking. + Generates unique expense link (^exp-{entry_id}) for settlement tracking. 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 (for reference/metadata) + amount_sats: Amount in satoshis description: Entry description entry_date: Date of entry is_equity: Whether this is an equity contribution fiat_currency: Fiat currency (EUR, USD) - REQUIRED fiat_amount: Fiat amount (unsigned) - REQUIRED reference: Optional reference (invoice ID, etc.) + entry_id: Optional unique entry ID (generated if not provided) Returns: - Fava API entry dict + Fava API entry dict with unique expense link (^exp-{entry_id}) Example: entry = format_expense_entry( @@ -344,27 +350,31 @@ def format_expense_entry( fiat_currency="EUR", fiat_amount=Decimal("100.00") ) + # Entry will have link: ^exp-a1b2c3d4e5f6g7h8 """ 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") + # Generate unique entry ID if not provided + if not entry_id: + entry_id = generate_entry_id() + # Build narration narration = description narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" - # Build postings in EUR (debts are in operating currency) + # Build postings using price notation (@@ SATS) for BQL queryability + sats_abs = abs(amount_sats) postings = [ { "account": expense_account, - "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", - "meta": {"sats-equivalent": str(abs(amount_sats))} + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS" }, { "account": user_account, - "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", - "meta": {"sats-equivalent": str(abs(amount_sats))} + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS" } ] @@ -372,13 +382,13 @@ def format_expense_entry( entry_meta = { "user-id": user_id, "source": "castle-api", - "sats-amount": str(abs(amount_sats)) + "entry-id": entry_id } - # Build links - links = [] + # Build links - include expense link for settlement tracking + links = [f"exp-{entry_id}"] if reference: - links.append(reference) + links.append(sanitize_link(reference)) # Build tags tags = ["expense-entry"] @@ -387,7 +397,7 @@ def format_expense_entry( return format_transaction( date_val=entry_date, - flag="!", # Pending approval + flag="!", # Pending - requires admin approval narration=narration, postings=postings, tags=tags, @@ -405,12 +415,14 @@ def format_receivable_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + entry_id: Optional[str] = None ) -> Dict[str, Any]: """ Format a receivable entry (user owes castle). - Creates a pending transaction that starts as receivable. + Uses price notation (@@ SATS) for BQL-queryable SATS tracking. + Generates unique receivable link (^rcv-{entry_id}) for settlement tracking. Args: user_id: User ID @@ -422,41 +434,46 @@ def format_receivable_entry( fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) reference: Optional reference + entry_id: Optional unique entry ID (generated if not provided) Returns: - Fava API entry dict + Fava API entry dict with unique receivable link (^rcv-{entry_id}) """ 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 receivable entries") + # Generate unique entry ID if not provided + if not entry_id: + entry_id = generate_entry_id() + narration = description narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" - # Build postings in EUR (debts are in operating currency) + # Build postings using price notation (@@ SATS) for BQL queryability + sats_abs = abs(amount_sats) postings = [ { "account": receivable_account, - "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", - "meta": {"sats-equivalent": str(abs(amount_sats))} + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS" }, { "account": revenue_account, - "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", - "meta": {"sats-equivalent": str(abs(amount_sats))} + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS" } ] entry_meta = { "user-id": user_id, "source": "castle-api", - "sats-amount": str(abs(amount_sats)) + "entry-id": entry_id } - links = [] + # Build links - include receivable link for settlement tracking + links = [f"rcv-{entry_id}"] if reference: - links.append(reference) + links.append(sanitize_link(reference)) return format_transaction( date_val=entry_date, @@ -594,13 +611,14 @@ def format_fiat_settlement_entry( entry_date: date, is_payable: bool = True, payment_method: str = "cash", - reference: Optional[str] = None + reference: Optional[str] = None, + settled_entry_links: Optional[List[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. + Uses price notation (@@ SATS) for BQL-queryable SATS tracking. + Includes links to the expense/receivable entries being settled. Args: user_id: User ID @@ -608,35 +626,31 @@ def format_fiat_settlement_entry( 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) + amount_sats: Equivalent amount in satoshis 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 + settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"]) Returns: - Fava API entry dict + Fava API entry dict with links to settled entries """ fiat_amount_abs = abs(fiat_amount) amount_sats_abs = abs(amount_sats) + # Build postings using price notation (@@ SATS) for BQL queryability 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) - } + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS" }, { "account": payment_account, - "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", - "meta": { - "sats-equivalent": str(amount_sats_abs) - } + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS" } ] else: @@ -644,17 +658,11 @@ def format_fiat_settlement_entry( postings = [ { "account": payment_account, - "amount": f"{fiat_amount_abs:.2f} {fiat_currency}", - "meta": { - "sats-equivalent": str(amount_sats_abs) - } + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS" }, { "account": payable_or_receivable_account, - "amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", - "meta": { - "sats-equivalent": str(amount_sats_abs) - } + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS" } ] @@ -674,16 +682,19 @@ def format_fiat_settlement_entry( "source": source } + # Build links - include all expense/receivable links being settled links = [] + if settled_entry_links: + links.extend(settled_entry_links) if reference: - links.append(reference) + links.append(sanitize_link(reference)) return format_transaction( date_val=entry_date, flag="*", # Cleared (payment already happened) narration=description, postings=postings, - tags=[tag], + tags=[tag, "settlement"], links=links, meta=entry_meta ) diff --git a/fava_client.py b/fava_client.py index 4cde16b..ac99bd2 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1195,6 +1195,142 @@ class FavaClient: raise + async def get_unsettled_entries( + self, + user_id: str, + entry_type: str = "expense" + ) -> List[Dict[str, Any]]: + """ + Get unsettled expense or receivable entries for a user. + + Finds entries with exp-{id} or rcv-{id} links that don't have + a corresponding settlement entry with the same link. + + Args: + user_id: User ID (first 8 characters used for account matching) + entry_type: "expense" (payables - castle owes user) or + "receivable" (user owes castle) + + Returns: + List of unsettled entries with: + - link: The entry's unique link (exp-xxx or rcv-xxx) + - date: Entry date + - narration: Description + - fiat_amount: Amount in fiat currency + - fiat_currency: Currency code + - sats_amount: Amount in SATS (from weight) + - entry_hash: For potential updates + + Example: + unsettled = await fava.get_unsettled_entries("cfe378b3...", "expense") + # Returns: [ + # {"link": "exp-abc123", "date": "2025-12-01", "narration": "Groceries", + # "fiat_amount": 50.00, "fiat_currency": "EUR", "sats_amount": 47000}, + # ... + # ] + """ + user_short = user_id[:8] + link_prefix = "exp-" if entry_type == "expense" else "rcv-" + + # Determine account pattern based on entry type + if entry_type == "expense": + account_pattern = f"Liabilities:Payable:User-{user_short}" + else: + account_pattern = f"Assets:Receivable:User-{user_short}" + + try: + # Get all journal entries + entries = await self.get_journal_entries() + + # Track entries by link and which links have been settled + entries_by_link: Dict[str, Dict[str, Any]] = {} + settled_links: set = set() + + for entry in entries: + entry_links = entry.get("links", []) + entry_tags = entry.get("tags", []) + postings = entry.get("postings", []) + + # Check if this entry has our user's account + has_user_account = any( + account_pattern in p.get("account", "") + for p in postings + ) + + if not has_user_account: + continue + + # Process each link in the entry + for link in entry_links: + if not link.startswith(link_prefix): + continue + + # Check if this is a settlement (has settlement tag) + if "settlement" in entry_tags: + settled_links.add(link) + else: + # This is an original expense/receivable entry + # Extract amount from the user's posting + for posting in postings: + if account_pattern in posting.get("account", ""): + amount_str = posting.get("amount", "") + # Parse amount like "-50.00 EUR @@ 47000 SATS" + fiat_amount = 0.0 + fiat_currency = "" + sats_amount = 0 + + # Extract fiat part + if " @@ " in amount_str: + fiat_part, sats_part = amount_str.split(" @@ ") + parts = fiat_part.strip().split() + if len(parts) >= 2: + fiat_amount = abs(float(parts[0])) + fiat_currency = parts[1] + # Extract sats + sats_parts = sats_part.strip().split() + if sats_parts: + sats_amount = abs(int(float(sats_parts[0]))) + else: + # Legacy format without @@ - try to parse + parts = amount_str.strip().split() + if len(parts) >= 2: + fiat_amount = abs(float(parts[0])) + fiat_currency = parts[1] + + entries_by_link[link] = { + "link": link, + "date": entry.get("date", ""), + "narration": entry.get("narration", ""), + "fiat_amount": fiat_amount, + "fiat_currency": fiat_currency, + "sats_amount": sats_amount, + "entry_hash": entry.get("entry_hash", ""), + "flag": entry.get("flag", "*") + } + break + + # Return entries whose links are NOT in settled_links + unsettled = [ + entry_data + for link, entry_data in entries_by_link.items() + if link not in settled_links + ] + + # Sort by date + unsettled.sort(key=lambda x: x.get("date", "")) + + logger.info( + f"Found {len(unsettled)} unsettled {entry_type} entries for user {user_short} " + f"(total: {len(entries_by_link)}, settled: {len(settled_links)})" + ) + + return unsettled + + except Exception as e: + logger.error(f"Error getting unsettled entries: {e}") + raise + + # Singleton instance (configured from settings) _fava_client: Optional[FavaClient] = None diff --git a/models.py b/models.py index 5199b6d..3b47210 100644 --- a/models.py +++ b/models.py @@ -209,6 +209,7 @@ class SettleReceivable(BaseModel): amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking) payment_hash: Optional[str] = None # For lightning payments txid: Optional[str] = None # For on-chain Bitcoin transactions + settled_entry_links: Optional[list[str]] = None # Links to receivable entries being settled (e.g., ["rcv-abc123"]) class PayUser(BaseModel): @@ -223,6 +224,7 @@ class PayUser(BaseModel): amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking) payment_hash: Optional[str] = None # For lightning payments txid: Optional[str] = None # For on-chain Bitcoin transactions + settled_entry_links: Optional[list[str]] = None # Links to expense entries being settled (e.g., ["exp-abc123"]) class AssertionStatus(str, Enum): diff --git a/views_api.py b/views_api.py index e94c9e6..b8c56f6 100644 --- a/views_api.py +++ b/views_api.py @@ -1850,7 +1850,8 @@ async def api_settle_receivable( 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]}" + reference=data.reference or f"MANUAL-{data.user_id[:8]}", + settled_entry_links=data.settled_entry_links ) else: # Lightning or BTC onchain payment @@ -1965,43 +1966,67 @@ async def api_pay_user( # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased) # This records that castle paid its 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 payable + # Determine if this is a fiat payment that should use format_fiat_settlement_entry + 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.) if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="amount_sats is required when paying 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_payable.name, - amount_sats=amount_in_sats, - description=data.description or f"Payment to user via {data.payment_method}", - entry_date=datetime.now().date(), - is_payable=True, # Castle paying user (payable settlement) - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - payment_hash=data.payment_hash, - reference=data.reference or f"PAY-{data.user_id[:8]}" - ) + entry = format_fiat_settlement_entry( + user_id=data.user_id, + payment_account=payment_account.name, + payable_or_receivable_account=user_payable.name, + fiat_amount=Decimal(str(data.amount)), + fiat_currency=data.currency.upper(), + amount_sats=data.amount_sats, + description=data.description or f"Payment to user via {data.payment_method}", + entry_date=datetime.now().date(), + is_payable=True, # Castle paying user (payable settlement) + payment_method=data.payment_method, + reference=data.reference or f"PAY-{data.user_id[:8]}", + settled_entry_links=data.settled_entry_links + ) + else: + # Lightning or BTC onchain payment + if data.currency: + if not data.amount_sats: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="amount_sats is required when paying with fiat currency" + ) + amount_in_sats = data.amount_sats + fiat_currency = data.currency.upper() + fiat_amount = data.amount + else: + amount_in_sats = int(data.amount) + fiat_currency = None + fiat_amount = None + + entry = format_payment_entry( + user_id=data.user_id, + payment_account=payment_account.name, + payable_or_receivable_account=user_payable.name, + amount_sats=amount_in_sats, + description=data.description or f"Payment to user via {data.payment_method}", + entry_date=datetime.now().date(), + is_payable=True, # Castle paying user (payable settlement) + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + payment_hash=data.payment_hash, + reference=data.reference or f"PAY-{data.user_id[:8]}" + ) # Add additional metadata to entry if "meta" not in entry: @@ -2156,6 +2181,76 @@ async def api_get_castle_users( return users +@castle_api_router.get("/api/v1/users/{user_id}/unsettled-entries") +async def api_get_unsettled_entries( + user_id: str, + entry_type: str = "expense", + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict: + """ + Get unsettled expense or receivable entries for a user. + + Returns entries that have unique links (exp-xxx or rcv-xxx) which + have not yet appeared in a settlement transaction. + + Args: + user_id: The user's ID + entry_type: "expense" (payables - castle owes user) or + "receivable" (user owes castle) + + Returns: + { + "user_id": "abc123...", + "entry_type": "expense", + "unsettled_entries": [ + { + "link": "exp-abc123", + "date": "2025-12-01", + "narration": "Groceries at Biocoop", + "fiat_amount": 50.00, + "fiat_currency": "EUR", + "sats_amount": 47000, + "flag": "!" # pending or "*" cleared + }, + ... + ], + "total_fiat": 150.00, + "total_fiat_currency": "EUR", + "total_sats": 141000, + "count": 3 + } + + Admin only - used when settling user balances. + """ + from .fava_client import get_fava_client + + if entry_type not in ["expense", "receivable"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="entry_type must be 'expense' or 'receivable'" + ) + + fava = get_fava_client() + unsettled = await fava.get_unsettled_entries(user_id, entry_type) + + # Calculate totals + total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled) + total_sats = sum(e.get("sats_amount", 0) for e in unsettled) + + # Get currency (assume all same currency for a user) + fiat_currency = unsettled[0].get("fiat_currency", "EUR") if unsettled else "EUR" + + return { + "user_id": user_id, + "entry_type": entry_type, + "unsettled_entries": unsettled, + "total_fiat": total_fiat, + "total_fiat_currency": fiat_currency, + "total_sats": total_sats, + "count": len(unsettled) + } + + @castle_api_router.get("/api/v1/user/wallet") async def api_get_user_wallet( user: User = Depends(check_user_exists),