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:
parent
df00def8d8
commit
dfdcc441a1
4 changed files with 323 additions and 79 deletions
136
fava_client.py
136
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue