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 decimal import Decimal
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import re 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: def sanitize_link(text: str) -> str:
@ -308,30 +314,30 @@ def format_expense_entry(
is_equity: bool = False, is_equity: bool = False,
fiat_currency: Optional[str] = None, fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None, fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format an expense entry for submission to Fava. Format an expense entry for submission to Fava.
Creates a pending transaction (flag="!") that requires admin approval. Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Generates unique expense link (^exp-{entry_id}) for settlement tracking.
Stores payables in EUR (or other fiat) as this is the actual debt amount.
SATS amount stored as metadata for reference.
Args: Args:
user_id: User ID user_id: User ID
expense_account: Expense account name (e.g., "Expenses:Food:Groceries") expense_account: Expense account name (e.g., "Expenses:Food:Groceries")
user_account: User's liability/equity account name user_account: User's liability/equity account name
amount_sats: Amount in satoshis (for reference/metadata) amount_sats: Amount in satoshis
description: Entry description description: Entry description
entry_date: Date of entry entry_date: Date of entry
is_equity: Whether this is an equity contribution is_equity: Whether this is an equity contribution
fiat_currency: Fiat currency (EUR, USD) - REQUIRED fiat_currency: Fiat currency (EUR, USD) - REQUIRED
fiat_amount: Fiat amount (unsigned) - REQUIRED fiat_amount: Fiat amount (unsigned) - REQUIRED
reference: Optional reference (invoice ID, etc.) reference: Optional reference (invoice ID, etc.)
entry_id: Optional unique entry ID (generated if not provided)
Returns: Returns:
Fava API entry dict Fava API entry dict with unique expense link (^exp-{entry_id})
Example: Example:
entry = format_expense_entry( entry = format_expense_entry(
@ -344,27 +350,31 @@ def format_expense_entry(
fiat_currency="EUR", fiat_currency="EUR",
fiat_amount=Decimal("100.00") fiat_amount=Decimal("100.00")
) )
# Entry will have link: ^exp-a1b2c3d4e5f6g7h8
""" """
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
if not fiat_currency or not fiat_amount_abs: if not fiat_currency or not fiat_amount_abs:
raise ValueError("fiat_currency and fiat_amount are required for expense entries") 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 # Build narration
narration = description narration = description
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" 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 = [ postings = [
{ {
"account": expense_account, "account": expense_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
"meta": {"sats-equivalent": str(abs(amount_sats))}
}, },
{ {
"account": user_account, "account": user_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
"meta": {"sats-equivalent": str(abs(amount_sats))}
} }
] ]
@ -372,13 +382,13 @@ def format_expense_entry(
entry_meta = { entry_meta = {
"user-id": user_id, "user-id": user_id,
"source": "castle-api", "source": "castle-api",
"sats-amount": str(abs(amount_sats)) "entry-id": entry_id
} }
# Build links # Build links - include expense link for settlement tracking
links = [] links = [f"exp-{entry_id}"]
if reference: if reference:
links.append(reference) links.append(sanitize_link(reference))
# Build tags # Build tags
tags = ["expense-entry"] tags = ["expense-entry"]
@ -387,7 +397,7 @@ def format_expense_entry(
return format_transaction( return format_transaction(
date_val=entry_date, date_val=entry_date,
flag="!", # Pending approval flag="!", # Pending - requires admin approval
narration=narration, narration=narration,
postings=postings, postings=postings,
tags=tags, tags=tags,
@ -405,12 +415,14 @@ def format_receivable_entry(
entry_date: date, entry_date: date,
fiat_currency: Optional[str] = None, fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None, fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a receivable entry (user owes castle). 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: Args:
user_id: User ID user_id: User ID
@ -422,41 +434,46 @@ def format_receivable_entry(
fiat_currency: Optional fiat currency fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned) fiat_amount: Optional fiat amount (unsigned)
reference: Optional reference reference: Optional reference
entry_id: Optional unique entry ID (generated if not provided)
Returns: 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 fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
if not fiat_currency or not fiat_amount_abs: if not fiat_currency or not fiat_amount_abs:
raise ValueError("fiat_currency and fiat_amount are required for receivable entries") 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 = description
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})" 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 = [ postings = [
{ {
"account": receivable_account, "account": receivable_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
"meta": {"sats-equivalent": str(abs(amount_sats))}
}, },
{ {
"account": revenue_account, "account": revenue_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
"meta": {"sats-equivalent": str(abs(amount_sats))}
} }
] ]
entry_meta = { entry_meta = {
"user-id": user_id, "user-id": user_id,
"source": "castle-api", "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: if reference:
links.append(reference) links.append(sanitize_link(reference))
return format_transaction( return format_transaction(
date_val=entry_date, date_val=entry_date,
@ -594,13 +611,14 @@ def format_fiat_settlement_entry(
entry_date: date, entry_date: date,
is_payable: bool = True, is_payable: bool = True,
payment_method: str = "cash", payment_method: str = "cash",
reference: Optional[str] = None reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a fiat (cash/bank) settlement entry. Format a fiat (cash/bank) settlement entry.
Unlike Lightning payments, fiat settlements use fiat currency as the primary amount Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
with SATS stored as metadata for reference. Includes links to the expense/receivable entries being settled.
Args: Args:
user_id: User ID user_id: User ID
@ -608,35 +626,31 @@ def format_fiat_settlement_entry(
payable_or_receivable_account: User's account being settled payable_or_receivable_account: User's account being settled
fiat_amount: Amount in fiat currency (unsigned) fiat_amount: Amount in fiat currency (unsigned)
fiat_currency: Fiat currency code (EUR, USD, etc.) 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 description: Payment description
entry_date: Date of settlement entry_date: Date of settlement
is_payable: True if castle paying user (payable), False if user paying castle (receivable) is_payable: True if castle paying user (payable), False if user paying castle (receivable)
payment_method: Payment method (cash, bank_transfer, check, etc.) payment_method: Payment method (cash, bank_transfer, check, etc.)
reference: Optional reference reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"])
Returns: Returns:
Fava API entry dict Fava API entry dict with links to settled entries
""" """
fiat_amount_abs = abs(fiat_amount) fiat_amount_abs = abs(fiat_amount)
amount_sats_abs = abs(amount_sats) amount_sats_abs = abs(amount_sats)
# Build postings using price notation (@@ SATS) for BQL queryability
if is_payable: if is_payable:
# Castle paying user: DR Payable, CR Cash/Bank # Castle paying user: DR Payable, CR Cash/Bank
postings = [ postings = [
{ {
"account": payable_or_receivable_account, "account": payable_or_receivable_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
"meta": {
"sats-equivalent": str(amount_sats_abs)
}
}, },
{ {
"account": payment_account, "account": payment_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
"meta": {
"sats-equivalent": str(amount_sats_abs)
}
} }
] ]
else: else:
@ -644,17 +658,11 @@ def format_fiat_settlement_entry(
postings = [ postings = [
{ {
"account": payment_account, "account": payment_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
"meta": {
"sats-equivalent": str(amount_sats_abs)
}
}, },
{ {
"account": payable_or_receivable_account, "account": payable_or_receivable_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}", "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
"meta": {
"sats-equivalent": str(amount_sats_abs)
}
} }
] ]
@ -674,16 +682,19 @@ def format_fiat_settlement_entry(
"source": source "source": source
} }
# Build links - include all expense/receivable links being settled
links = [] links = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference: if reference:
links.append(reference) links.append(sanitize_link(reference))
return format_transaction( return format_transaction(
date_val=entry_date, date_val=entry_date,
flag="*", # Cleared (payment already happened) flag="*", # Cleared (payment already happened)
narration=description, narration=description,
postings=postings, postings=postings,
tags=[tag], tags=[tag, "settlement"],
links=links, links=links,
meta=entry_meta meta=entry_meta
) )

View file

@ -1195,6 +1195,142 @@ class FavaClient:
raise 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) # Singleton instance (configured from settings)
_fava_client: Optional[FavaClient] = None _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) amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments payment_hash: Optional[str] = None # For lightning payments
txid: Optional[str] = None # For on-chain Bitcoin transactions 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): class PayUser(BaseModel):
@ -223,6 +224,7 @@ class PayUser(BaseModel):
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking) amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments payment_hash: Optional[str] = None # For lightning payments
txid: Optional[str] = None # For on-chain Bitcoin transactions 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): class AssertionStatus(str, Enum):

View file

@ -1850,7 +1850,8 @@ async def api_settle_receivable(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
is_payable=False, # User paying castle (receivable settlement) is_payable=False, # User paying castle (receivable settlement)
payment_method=data.payment_method, 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: else:
# Lightning or BTC onchain payment # 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) # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that castle paid its debt # This records that castle paid its debt
from .fava_client import get_fava_client 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 from decimal import Decimal
fava = get_fava_client() fava = get_fava_client()
# Determine amount and currency # Determine if this is a fiat payment that should use format_fiat_settlement_entry
if data.currency: is_fiat_payment = data.currency and data.payment_method.lower() in [
# Fiat currency payment (e.g., EUR, USD) "cash", "bank_transfer", "check", "other"
# Use the sats equivalent for the journal entry to match the payable ]
if is_fiat_payment:
# Fiat currency payment (cash, bank transfer, etc.)
if not data.amount_sats: if not data.amount_sats:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency" 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_fiat_settlement_entry(
entry = format_payment_entry( user_id=data.user_id,
user_id=data.user_id, payment_account=payment_account.name,
payment_account=payment_account.name, payable_or_receivable_account=user_payable.name,
payable_or_receivable_account=user_payable.name, fiat_amount=Decimal(str(data.amount)),
amount_sats=amount_in_sats, fiat_currency=data.currency.upper(),
description=data.description or f"Payment to user via {data.payment_method}", amount_sats=data.amount_sats,
entry_date=datetime.now().date(), description=data.description or f"Payment to user via {data.payment_method}",
is_payable=True, # Castle paying user (payable settlement) entry_date=datetime.now().date(),
fiat_currency=fiat_currency, is_payable=True, # Castle paying user (payable settlement)
fiat_amount=fiat_amount, payment_method=data.payment_method,
payment_hash=data.payment_hash, reference=data.reference or f"PAY-{data.user_id[:8]}",
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 # Add additional metadata to entry
if "meta" not in entry: if "meta" not in entry:
@ -2156,6 +2181,76 @@ async def api_get_castle_users(
return 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") @castle_api_router.get("/api/v1/user/wallet")
async def api_get_user_wallet( async def api_get_user_wallet(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),