Refactors expense tracking to use fiat amounts

Updates the expense tracking system to store payables and receivables in fiat currency within Beancount.
This ensures accurate debt representation and simplifies balance calculations.
Changes include:
- Converting `format_expense_entry` and `format_receivable_entry` to use fiat amounts.
- Introducing `format_net_settlement_entry` for net settlement payments.
- Modifying `format_payment_entry` to use cost syntax for fiat tracking.
- Adjusting Fava client to correctly process new amount formats and metadata.
- Adding average cost basis posting format

The use of fiat amounts and cost basis aims to provide better accuracy and compatibility with existing Beancount workflows.
This commit is contained in:
padreug 2025-11-10 03:33:04 +01:00
parent 8396331d5a
commit ca2ce1dfcc
3 changed files with 372 additions and 123 deletions

View file

@ -92,11 +92,14 @@ def format_posting_with_cost(
This is the RECOMMENDED format for all Castle transactions.
Uses Beancount's cost basis syntax to preserve exchange rates.
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
This function calculates per-unit cost automatically.
Args:
account: Account name (e.g., "Expenses:Food:Groceries")
amount_sats: Amount in satoshis (signed: positive = debit, negative = credit)
fiat_currency: Fiat currency (EUR, USD, etc.)
fiat_amount: Fiat amount (Decimal, unsigned)
fiat_amount: Fiat amount TOTAL (Decimal, unsigned) - will be converted to per-unit
metadata: Optional posting metadata
Returns:
@ -107,26 +110,91 @@ def format_posting_with_cost(
account="Expenses:Food",
amount_sats=200000,
fiat_currency="EUR",
fiat_amount=Decimal("100.00")
fiat_amount=Decimal("100.00") # Total cost
)
# Calculates per-unit: 100.00 / 200000 = 0.0005 EUR per SAT
# Returns: {
# "account": "Expenses:Food",
# "amount": "200000 SATS {100.00 EUR}",
# "amount": "200000 SATS {0.0005 EUR}",
# "meta": {}
# }
# Note: All fiat/exchange rate info is in the cost syntax "{100.00 EUR}"
"""
# Build amount string with cost basis
if fiat_currency and fiat_amount and fiat_amount > 0:
# Cost basis syntax: "200000 SATS {100.00 EUR}"
# Sign is on the sats amount, fiat amount in cost basis is always positive
amount_str = f"{amount_sats} SATS {{{abs(fiat_amount):.2f} {fiat_currency}}}"
if fiat_currency and fiat_amount and fiat_amount > 0 and amount_sats != 0:
# Calculate per-unit cost (Beancount requires per-unit, not total)
# Example: 1000.00 EUR / 1097994 SATS = 0.000911268 EUR per SAT
amount_sats_abs = abs(amount_sats)
per_unit_cost = abs(fiat_amount) / Decimal(str(amount_sats_abs))
# Use high precision for per-unit cost (8 decimal places)
# Cost basis syntax: "200000 SATS {0.00050000 EUR}"
# Sign is on the sats amount, cost is always positive per-unit value
amount_str = f"{amount_sats} SATS {{{per_unit_cost:.8f} {fiat_currency}}}"
else:
# No cost basis: "200000 SATS"
amount_str = f"{amount_sats} SATS"
# Build metadata (only include explicitly passed metadata, not redundant fiat info)
# The cost syntax "{69.00 EUR}" already contains all fiat/exchange rate information
# Build metadata - include total fiat amount to avoid rounding errors in balance calculations
posting_meta = metadata or {}
if fiat_currency and fiat_amount and fiat_amount > 0:
# Store the exact total fiat amount as metadata
# This preserves the original amount exactly, avoiding rounding errors from per-unit calculations
posting_meta["fiat-amount-total"] = f"{abs(fiat_amount):.2f}"
posting_meta["fiat-currency"] = fiat_currency
return {
"account": account,
"amount": amount_str,
"meta": posting_meta
}
def format_posting_at_average_cost(
account: str,
amount_sats: int,
cost_currency: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Format a posting to reduce at average cost for Fava API.
Use this for payments/settlements to reduce positions at average cost.
Specifying the cost currency tells Beancount which lots to reduce.
Args:
account: Account name
amount_sats: Amount in satoshis (signed)
cost_currency: Currency of the original cost basis (e.g., "EUR")
metadata: Optional posting metadata
Returns:
Fava API posting dict
Example:
posting = format_posting_at_average_cost(
account="Assets:Receivable:User-abc",
amount_sats=-996896,
cost_currency="EUR"
)
# Returns: {
# "account": "Assets:Receivable:User-abc",
# "amount": "-996896 SATS {EUR}",
# "meta": {}
# }
# Beancount will automatically reduce EUR balance proportionally
"""
# Cost currency specification: "996896 SATS {EUR}"
# This reduces positions with EUR cost at average cost
from loguru import logger
if cost_currency:
amount_str = f"{amount_sats} SATS {{{cost_currency}}}"
logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'")
else:
# No cost
amount_str = f"{amount_sats} SATS {{}}"
logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis")
posting_meta = metadata or {}
return {
@ -192,16 +260,19 @@ def format_expense_entry(
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.
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 (unsigned, will be signed correctly)
amount_sats: Amount in satoshis (for reference/metadata)
description: Entry description
entry_date: Date of entry
is_equity: Whether this is an equity contribution
fiat_currency: Optional fiat currency (EUR, USD)
fiat_amount: Optional fiat amount (unsigned)
fiat_currency: Fiat currency (EUR, USD) - REQUIRED
fiat_amount: Fiat amount (unsigned) - REQUIRED
reference: Optional reference (invoice ID, etc.)
Returns:
@ -219,37 +290,34 @@ def format_expense_entry(
fiat_amount=Decimal("100.00")
)
"""
# Ensure amounts are unsigned for cost basis
amount_sats_abs = abs(amount_sats)
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")
# Build narration
narration = description
if fiat_currency and fiat_amount_abs:
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
# Build postings with cost basis
# Build postings in EUR (debts are in operating currency)
postings = [
format_posting_with_cost(
account=expense_account,
amount_sats=amount_sats_abs, # Positive = debit (expense increase)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
),
format_posting_with_cost(
account=user_account,
amount_sats=-amount_sats_abs, # Negative = credit (liability/equity increase)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
)
{
"account": expense_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
},
{
"account": user_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
}
]
# Build entry metadata
# Note: created-via is redundant with #expense-entry tag
# Note: is-equity is redundant with account name (Equity vs Liabilities:Payable) and tags
entry_meta = {
"user-id": user_id,
"source": "castle-api"
"source": "castle-api",
"sats-amount": str(abs(amount_sats))
}
# Build links
@ -303,33 +371,32 @@ def format_receivable_entry(
Returns:
Fava API entry dict
"""
amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
narration = description
if fiat_currency and fiat_amount_abs:
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
if not fiat_currency or not fiat_amount_abs:
raise ValueError("fiat_currency and fiat_amount are required for receivable entries")
narration = description
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
# Build postings in EUR (debts are in operating currency)
postings = [
format_posting_with_cost(
account=receivable_account,
amount_sats=amount_sats_abs, # Positive = debit (asset increase - user owes)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
),
format_posting_with_cost(
account=revenue_account,
amount_sats=-amount_sats_abs, # Negative = credit (revenue increase)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
)
{
"account": receivable_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
},
{
"account": revenue_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
}
]
# Note: created-via is redundant with #receivable-entry tag
# Note: debtor-user-id is the same as user-id for receivables (redundant)
entry_meta = {
"user-id": user_id,
"source": "castle-api"
"source": "castle-api",
"sats-amount": str(abs(amount_sats))
}
links = []
@ -338,7 +405,7 @@ def format_receivable_entry(
return format_transaction(
date_val=entry_date,
flag="!", # Pending until paid
flag="*", # Receivables are immediately cleared (approved)
narration=narration,
postings=postings,
tags=["receivable-entry"],
@ -384,40 +451,55 @@ def format_payment_entry(
amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
if is_payable:
# Castle paying user: DR Payable, CR Lightning
postings = [
format_posting_with_cost(
account=payable_or_receivable_account,
amount_sats=amount_sats_abs, # Positive = debit (liability decrease)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
),
format_posting_with_cost(
account=payment_account,
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None
)
]
# For payment settlements with fiat tracking, use cost syntax with per-unit cost
# This allows Beancount to match against existing lots and reduce them
# The per-unit cost is calculated from: fiat_amount / sats_amount
# Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate)
if fiat_currency and fiat_amount_abs and amount_sats_abs > 0:
if is_payable:
# Castle paying user: DR Payable, CR Lightning
postings = [
format_posting_with_cost(
account=payable_or_receivable_account,
amount_sats=amount_sats_abs,
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs # Will be converted to per-unit cost
),
format_posting_simple(
account=payment_account,
amount_sats=-amount_sats_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None
)
]
else:
# User paying castle: DR Lightning, CR Receivable
postings = [
format_posting_simple(
account=payment_account,
amount_sats=amount_sats_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None
),
format_posting_with_cost(
account=payable_or_receivable_account,
amount_sats=-amount_sats_abs,
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs # Will be converted to per-unit cost
)
]
else:
# User paying castle: DR Lightning, CR Receivable
postings = [
format_posting_with_cost(
account=payment_account,
amount_sats=amount_sats_abs, # Positive = debit (asset increase)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None
),
format_posting_with_cost(
account=payable_or_receivable_account,
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount_abs
)
]
# No fiat tracking, use simple postings
if is_payable:
postings = [
format_posting_simple(account=payable_or_receivable_account, amount_sats=amount_sats_abs),
format_posting_simple(account=payment_account, amount_sats=-amount_sats_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None)
]
else:
postings = [
format_posting_simple(account=payment_account, amount_sats=amount_sats_abs,
metadata={"payment-hash": payment_hash} if payment_hash else None),
format_posting_simple(account=payable_or_receivable_account, amount_sats=-amount_sats_abs)
]
# Note: created-via is redundant with #lightning-payment tag
# Note: payer/payee can be inferred from transaction direction and accounts
@ -446,6 +528,98 @@ def format_payment_entry(
)
def format_net_settlement_entry(
user_id: str,
payment_account: str,
receivable_account: str,
payable_account: str,
amount_sats: int,
net_fiat_amount: Decimal,
total_receivable_fiat: Decimal,
total_payable_fiat: Decimal,
fiat_currency: str,
description: str,
entry_date: date,
payment_hash: Optional[str] = None,
reference: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a net settlement payment entry (user paying net balance).
Creates a three-posting transaction:
1. Lightning payment in SATS with @@ total price notation
2. Clear receivables in EUR
3. Clear payables in EUR
Example:
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR
= 517 - 555 + 38 = 0
Args:
user_id: User ID
payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning")
receivable_account: User's receivable account
payable_account: User's payable account
amount_sats: SATS amount paid
net_fiat_amount: Net fiat amount (receivable - payable)
total_receivable_fiat: Total receivables to clear
total_payable_fiat: Total payables to clear
fiat_currency: Currency (EUR, USD)
description: Payment description
entry_date: Date of payment
payment_hash: Lightning payment hash
reference: Optional reference
Returns:
Fava API entry dict
"""
# Build postings for net settlement
postings = [
{
"account": payment_account,
"amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
"meta": {"payment-hash": payment_hash} if payment_hash else {}
},
{
"account": receivable_account,
"amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
},
{
"account": payable_account,
"amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
"meta": {}
}
]
entry_meta = {
"user-id": user_id,
"source": "lightning_payment",
"payment-type": "net-settlement"
}
if payment_hash:
entry_meta["payment-hash"] = payment_hash
links = []
if reference:
links.append(reference)
if payment_hash:
links.append(f"ln-{payment_hash[:16]}")
return format_transaction(
date_val=entry_date,
flag="*", # Cleared (payment already happened)
narration=description,
postings=postings,
tags=["lightning-payment", "net-settlement"],
links=links,
meta=entry_meta
)
def format_revenue_entry(
payment_account: str,
revenue_account: str,