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
)

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

View file

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

View file

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