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
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
109
views_api.py
109
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,15 +1966,41 @@ 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
|
||||
# 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"
|
||||
)
|
||||
|
||||
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:
|
||||
# Fiat currency payment (e.g., EUR, USD)
|
||||
# Use the sats equivalent for the journal entry to match the payable
|
||||
if not data.amount_sats:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
|
@ -1983,12 +2010,10 @@ async def api_pay_user(
|
|||
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,
|
||||
|
|
@ -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