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

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