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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue