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

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