Add expense-to-settlement linking with price notation

Implement transaction linking to connect expenses with their settlements,
enabling audit trails and tracking of individual expense reimbursements.

Changes:
- beancount_format.py: Use @@ SATS price notation for BQL queryability,
  generate unique ^exp-{id} and ^rcv-{id} links, add #settlement tag
- fava_client.py: Add get_unsettled_entries() to find unlinked expenses
- models.py: Add settled_entry_links field to PayUser/SettleReceivable
- views_api.py: Add GET /users/{id}/unsettled-entries endpoint,
  pass settlement links through pay_user and settle_receivable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-12-14 23:40:33 +01:00
parent df00def8d8
commit dfdcc441a1
4 changed files with 323 additions and 79 deletions

View file

@ -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