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 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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
136
fava_client.py
136
fava_client.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
109
views_api.py
109
views_api.py
|
|
@ -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,15 +1966,41 @@ 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
|
||||||
|
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:
|
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:
|
if not data.amount_sats:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -1983,12 +2010,10 @@ async def api_pay_user(
|
||||||
fiat_currency = data.currency.upper()
|
fiat_currency = data.currency.upper()
|
||||||
fiat_amount = data.amount
|
fiat_amount = data.amount
|
||||||
else:
|
else:
|
||||||
# Satoshi payment
|
|
||||||
amount_in_sats = int(data.amount)
|
amount_in_sats = int(data.amount)
|
||||||
fiat_currency = None
|
fiat_currency = None
|
||||||
fiat_amount = None
|
fiat_amount = None
|
||||||
|
|
||||||
# Format payment entry
|
|
||||||
entry = format_payment_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,
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue