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
151
views_api.py
151
views_api.py
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue