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:
parent
8396331d5a
commit
ca2ce1dfcc
3 changed files with 372 additions and 123 deletions
|
|
@ -92,11 +92,14 @@ def format_posting_with_cost(
|
||||||
This is the RECOMMENDED format for all Castle transactions.
|
This is the RECOMMENDED format for all Castle transactions.
|
||||||
Uses Beancount's cost basis syntax to preserve exchange rates.
|
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:
|
Args:
|
||||||
account: Account name (e.g., "Expenses:Food:Groceries")
|
account: Account name (e.g., "Expenses:Food:Groceries")
|
||||||
amount_sats: Amount in satoshis (signed: positive = debit, negative = credit)
|
amount_sats: Amount in satoshis (signed: positive = debit, negative = credit)
|
||||||
fiat_currency: Fiat currency (EUR, USD, etc.)
|
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
|
metadata: Optional posting metadata
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -107,26 +110,91 @@ def format_posting_with_cost(
|
||||||
account="Expenses:Food",
|
account="Expenses:Food",
|
||||||
amount_sats=200000,
|
amount_sats=200000,
|
||||||
fiat_currency="EUR",
|
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: {
|
# Returns: {
|
||||||
# "account": "Expenses:Food",
|
# "account": "Expenses:Food",
|
||||||
# "amount": "200000 SATS {100.00 EUR}",
|
# "amount": "200000 SATS {0.0005 EUR}",
|
||||||
# "meta": {}
|
# "meta": {}
|
||||||
# }
|
# }
|
||||||
# Note: All fiat/exchange rate info is in the cost syntax "{100.00 EUR}"
|
|
||||||
"""
|
"""
|
||||||
# Build amount string with cost basis
|
# Build amount string with cost basis
|
||||||
if fiat_currency and fiat_amount and fiat_amount > 0:
|
if fiat_currency and fiat_amount and fiat_amount > 0 and amount_sats != 0:
|
||||||
# Cost basis syntax: "200000 SATS {100.00 EUR}"
|
# Calculate per-unit cost (Beancount requires per-unit, not total)
|
||||||
# Sign is on the sats amount, fiat amount in cost basis is always positive
|
# Example: 1000.00 EUR / 1097994 SATS = 0.000911268 EUR per SAT
|
||||||
amount_str = f"{amount_sats} SATS {{{abs(fiat_amount):.2f} {fiat_currency}}}"
|
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:
|
else:
|
||||||
# No cost basis: "200000 SATS"
|
# No cost basis: "200000 SATS"
|
||||||
amount_str = f"{amount_sats} SATS"
|
amount_str = f"{amount_sats} SATS"
|
||||||
|
|
||||||
# Build metadata (only include explicitly passed metadata, not redundant fiat info)
|
# Build metadata - include total fiat amount to avoid rounding errors in balance calculations
|
||||||
# The cost syntax "{69.00 EUR}" already contains all fiat/exchange rate information
|
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 {}
|
posting_meta = metadata or {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -192,16 +260,19 @@ def format_expense_entry(
|
||||||
|
|
||||||
Creates a pending transaction (flag="!") that requires admin approval.
|
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:
|
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 (unsigned, will be signed correctly)
|
amount_sats: Amount in satoshis (for reference/metadata)
|
||||||
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: Optional fiat currency (EUR, USD)
|
fiat_currency: Fiat currency (EUR, USD) - REQUIRED
|
||||||
fiat_amount: Optional fiat amount (unsigned)
|
fiat_amount: Fiat amount (unsigned) - REQUIRED
|
||||||
reference: Optional reference (invoice ID, etc.)
|
reference: Optional reference (invoice ID, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -219,37 +290,34 @@ def format_expense_entry(
|
||||||
fiat_amount=Decimal("100.00")
|
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
|
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
|
# Build narration
|
||||||
narration = description
|
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 = [
|
postings = [
|
||||||
format_posting_with_cost(
|
{
|
||||||
account=expense_account,
|
"account": expense_account,
|
||||||
amount_sats=amount_sats_abs, # Positive = debit (expense increase)
|
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}",
|
||||||
fiat_currency=fiat_currency,
|
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
||||||
fiat_amount=fiat_amount_abs
|
},
|
||||||
),
|
{
|
||||||
format_posting_with_cost(
|
"account": user_account,
|
||||||
account=user_account,
|
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}",
|
||||||
amount_sats=-amount_sats_abs, # Negative = credit (liability/equity increase)
|
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
||||||
fiat_currency=fiat_currency,
|
}
|
||||||
fiat_amount=fiat_amount_abs
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Build entry metadata
|
# 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 = {
|
entry_meta = {
|
||||||
"user-id": user_id,
|
"user-id": user_id,
|
||||||
"source": "castle-api"
|
"source": "castle-api",
|
||||||
|
"sats-amount": str(abs(amount_sats))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build links
|
# Build links
|
||||||
|
|
@ -303,33 +371,32 @@ def format_receivable_entry(
|
||||||
Returns:
|
Returns:
|
||||||
Fava API entry dict
|
Fava API entry dict
|
||||||
"""
|
"""
|
||||||
amount_sats_abs = abs(amount_sats)
|
|
||||||
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:
|
||||||
|
raise ValueError("fiat_currency and fiat_amount are required for receivable entries")
|
||||||
|
|
||||||
narration = description
|
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 in EUR (debts are in operating currency)
|
||||||
postings = [
|
postings = [
|
||||||
format_posting_with_cost(
|
{
|
||||||
account=receivable_account,
|
"account": receivable_account,
|
||||||
amount_sats=amount_sats_abs, # Positive = debit (asset increase - user owes)
|
"amount": f"{fiat_amount_abs:.2f} {fiat_currency}",
|
||||||
fiat_currency=fiat_currency,
|
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
||||||
fiat_amount=fiat_amount_abs
|
},
|
||||||
),
|
{
|
||||||
format_posting_with_cost(
|
"account": revenue_account,
|
||||||
account=revenue_account,
|
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency}",
|
||||||
amount_sats=-amount_sats_abs, # Negative = credit (revenue increase)
|
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
||||||
fiat_currency=fiat_currency,
|
}
|
||||||
fiat_amount=fiat_amount_abs
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: created-via is redundant with #receivable-entry tag
|
|
||||||
# Note: debtor-user-id is the same as user-id for receivables (redundant)
|
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"user-id": user_id,
|
"user-id": user_id,
|
||||||
"source": "castle-api"
|
"source": "castle-api",
|
||||||
|
"sats-amount": str(abs(amount_sats))
|
||||||
}
|
}
|
||||||
|
|
||||||
links = []
|
links = []
|
||||||
|
|
@ -338,7 +405,7 @@ def format_receivable_entry(
|
||||||
|
|
||||||
return format_transaction(
|
return format_transaction(
|
||||||
date_val=entry_date,
|
date_val=entry_date,
|
||||||
flag="!", # Pending until paid
|
flag="*", # Receivables are immediately cleared (approved)
|
||||||
narration=narration,
|
narration=narration,
|
||||||
postings=postings,
|
postings=postings,
|
||||||
tags=["receivable-entry"],
|
tags=["receivable-entry"],
|
||||||
|
|
@ -384,40 +451,55 @@ def format_payment_entry(
|
||||||
amount_sats_abs = abs(amount_sats)
|
amount_sats_abs = abs(amount_sats)
|
||||||
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount 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:
|
if is_payable:
|
||||||
# Castle paying user: DR Payable, CR Lightning
|
# Castle paying user: DR Payable, CR Lightning
|
||||||
postings = [
|
postings = [
|
||||||
format_posting_with_cost(
|
format_posting_with_cost(
|
||||||
account=payable_or_receivable_account,
|
account=payable_or_receivable_account,
|
||||||
amount_sats=amount_sats_abs, # Positive = debit (liability decrease)
|
amount_sats=amount_sats_abs,
|
||||||
fiat_currency=fiat_currency,
|
fiat_currency=fiat_currency,
|
||||||
fiat_amount=fiat_amount_abs
|
fiat_amount=fiat_amount_abs # Will be converted to per-unit cost
|
||||||
),
|
),
|
||||||
format_posting_with_cost(
|
format_posting_simple(
|
||||||
account=payment_account,
|
account=payment_account,
|
||||||
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
|
amount_sats=-amount_sats_abs,
|
||||||
fiat_currency=fiat_currency,
|
|
||||||
fiat_amount=fiat_amount_abs,
|
|
||||||
metadata={"payment-hash": payment_hash} if payment_hash else None
|
metadata={"payment-hash": payment_hash} if payment_hash else None
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# User paying castle: DR Lightning, CR Receivable
|
# User paying castle: DR Lightning, CR Receivable
|
||||||
postings = [
|
postings = [
|
||||||
format_posting_with_cost(
|
format_posting_simple(
|
||||||
account=payment_account,
|
account=payment_account,
|
||||||
amount_sats=amount_sats_abs, # Positive = debit (asset increase)
|
amount_sats=amount_sats_abs,
|
||||||
fiat_currency=fiat_currency,
|
|
||||||
fiat_amount=fiat_amount_abs,
|
|
||||||
metadata={"payment-hash": payment_hash} if payment_hash else None
|
metadata={"payment-hash": payment_hash} if payment_hash else None
|
||||||
),
|
),
|
||||||
format_posting_with_cost(
|
format_posting_with_cost(
|
||||||
account=payable_or_receivable_account,
|
account=payable_or_receivable_account,
|
||||||
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
|
amount_sats=-amount_sats_abs,
|
||||||
fiat_currency=fiat_currency,
|
fiat_currency=fiat_currency,
|
||||||
fiat_amount=fiat_amount_abs
|
fiat_amount=fiat_amount_abs # Will be converted to per-unit cost
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
# 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: created-via is redundant with #lightning-payment tag
|
||||||
# Note: payer/payee can be inferred from transaction direction and accounts
|
# 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(
|
def format_revenue_entry(
|
||||||
payment_account: str,
|
payment_account: str,
|
||||||
revenue_account: str,
|
revenue_account: str,
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,12 @@ class FavaClient:
|
||||||
# Get all journal entries for this user
|
# Get all journal entries for this user
|
||||||
all_entries = await self.get_journal_entries()
|
all_entries = await self.get_journal_entries()
|
||||||
|
|
||||||
|
logger.info(f"Processing {len(all_entries)} journal entries for user {user_id[:8]}")
|
||||||
|
|
||||||
total_sats = 0
|
total_sats = 0
|
||||||
fiat_balances = {}
|
fiat_balances = {}
|
||||||
accounts_dict = {} # Track balances per account
|
accounts_dict = {} # Track balances per account
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
for entry in all_entries:
|
for entry in all_entries:
|
||||||
# Skip non-transactions, pending (!), and voided
|
# Skip non-transactions, pending (!), and voided
|
||||||
|
|
@ -206,6 +209,11 @@ class FavaClient:
|
||||||
if "voided" in entry.get("tags", []):
|
if "voided" in entry.get("tags", []):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check if this entry has any User-375ec158 postings
|
||||||
|
has_user_posting = any(f":User-{user_id[:8]}" in p.get("account", "") for p in entry.get("postings", []))
|
||||||
|
if has_user_posting:
|
||||||
|
logger.info(f"Entry '{entry.get('narration', 'N/A')}' has {len(entry.get('postings', []))} postings")
|
||||||
|
|
||||||
# Process postings for this user
|
# Process postings for this user
|
||||||
for posting in entry.get("postings", []):
|
for posting in entry.get("postings", []):
|
||||||
account_name = posting.get("account", "")
|
account_name = posting.get("account", "")
|
||||||
|
|
@ -216,13 +224,40 @@ class FavaClient:
|
||||||
if "Payable" not in account_name and "Receivable" not in account_name:
|
if "Payable" not in account_name and "Receivable" not in account_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse amount string: "36791 SATS {33.33 EUR}"
|
# Parse amount string: can be EUR, USD, or SATS
|
||||||
amount_str = posting.get("amount", "")
|
amount_str = posting.get("amount", "")
|
||||||
if not isinstance(amount_str, str) or not amount_str:
|
if not isinstance(amount_str, str) or not amount_str:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Processing posting in {account_name}: amount_str='{amount_str}'")
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
import re
|
import re
|
||||||
# Extract SATS amount (with sign)
|
# Try to extract EUR/USD amount first (new format)
|
||||||
|
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
|
||||||
|
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
|
||||||
|
# Direct EUR/USD amount (new approach)
|
||||||
|
fiat_amount = Decimal(fiat_match.group(1))
|
||||||
|
fiat_currency = fiat_match.group(2)
|
||||||
|
|
||||||
|
if fiat_currency not in fiat_balances:
|
||||||
|
fiat_balances[fiat_currency] = Decimal(0)
|
||||||
|
|
||||||
|
fiat_balances[fiat_currency] += fiat_amount
|
||||||
|
logger.info(f"Found fiat in {account_name}: {fiat_amount} {fiat_currency} (direct), running total: {fiat_balances[fiat_currency]}")
|
||||||
|
|
||||||
|
# Also track SATS equivalent from metadata if available
|
||||||
|
posting_meta = posting.get("meta", {})
|
||||||
|
sats_equiv = posting_meta.get("sats-equivalent")
|
||||||
|
if sats_equiv:
|
||||||
|
sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
|
||||||
|
total_sats += sats_amount
|
||||||
|
if account_name not in accounts_dict:
|
||||||
|
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||||
|
accounts_dict[account_name]["sats"] += sats_amount
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Old format: SATS with cost/price notation - extract SATS amount
|
||||||
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
|
||||||
if sats_match:
|
if sats_match:
|
||||||
sats_amount = int(sats_match.group(1))
|
sats_amount = int(sats_match.group(1))
|
||||||
|
|
@ -233,11 +268,15 @@ class FavaClient:
|
||||||
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
accounts_dict[account_name] = {"account": account_name, "sats": 0}
|
||||||
accounts_dict[account_name]["sats"] += sats_amount
|
accounts_dict[account_name]["sats"] += sats_amount
|
||||||
|
|
||||||
# Extract fiat from cost syntax: {33.33 EUR}
|
# Try to extract fiat from metadata or cost syntax (backward compatibility)
|
||||||
cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
|
posting_meta = posting.get("meta", {})
|
||||||
if cost_match:
|
fiat_amount_total_str = posting_meta.get("fiat-amount-total")
|
||||||
fiat_amount_unsigned = Decimal(cost_match.group(1))
|
fiat_currency_meta = posting_meta.get("fiat-currency")
|
||||||
fiat_currency = cost_match.group(2)
|
|
||||||
|
if fiat_amount_total_str and fiat_currency_meta:
|
||||||
|
# Use exact total from metadata
|
||||||
|
fiat_total = Decimal(fiat_amount_total_str)
|
||||||
|
fiat_currency = fiat_currency_meta
|
||||||
|
|
||||||
if fiat_currency not in fiat_balances:
|
if fiat_currency not in fiat_balances:
|
||||||
fiat_balances[fiat_currency] = Decimal(0)
|
fiat_balances[fiat_currency] = Decimal(0)
|
||||||
|
|
@ -246,16 +285,17 @@ class FavaClient:
|
||||||
if sats_match:
|
if sats_match:
|
||||||
sats_amount_for_sign = int(sats_match.group(1))
|
sats_amount_for_sign = int(sats_match.group(1))
|
||||||
if sats_amount_for_sign < 0:
|
if sats_amount_for_sign < 0:
|
||||||
fiat_amount_unsigned = -fiat_amount_unsigned
|
fiat_total = -fiat_total
|
||||||
|
|
||||||
fiat_balances[fiat_currency] += fiat_amount_unsigned
|
fiat_balances[fiat_currency] += fiat_total
|
||||||
logger.info(f"Found fiat in {account_name}: {fiat_amount_unsigned} {fiat_currency}, running total: {fiat_balances[fiat_currency]}")
|
logger.info(f"Found fiat in {account_name}: {fiat_total} {fiat_currency} (from SATS metadata), running total: {fiat_balances[fiat_currency]}")
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"balance": total_sats,
|
"balance": total_sats,
|
||||||
"fiat_balances": fiat_balances,
|
"fiat_balances": fiat_balances,
|
||||||
"accounts": list(accounts_dict.values())
|
"accounts": list(accounts_dict.values())
|
||||||
}
|
}
|
||||||
|
logger.info(f"Processed {processed_count} postings for user {user_id[:8]}")
|
||||||
logger.info(f"Returning balance for user {user_id[:8]}: sats={total_sats}, fiat_balances={fiat_balances}")
|
logger.info(f"Returning balance for user {user_id[:8]}: sats={total_sats}, fiat_balances={fiat_balances}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -502,7 +542,14 @@ class FavaClient:
|
||||||
response = await client.get(f"{self.base_url}/journal")
|
response = await client.get(f"{self.base_url}/journal")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
return result.get("data", [])
|
entries = result.get("data", [])
|
||||||
|
logger.info(f"Fava /journal returned {len(entries)} entries")
|
||||||
|
|
||||||
|
# Log transactions with "Lightning payment" in narration
|
||||||
|
lightning_entries = [e for e in entries if "Lightning payment" in e.get("narration", "")]
|
||||||
|
logger.info(f"Found {len(lightning_entries)} Lightning payment entries in journal")
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(f"Fava journal error: {e.response.status_code} - {e.response.text}")
|
logger.error(f"Fava journal error: {e.response.status_code} - {e.response.text}")
|
||||||
|
|
|
||||||
52
tasks.py
52
tasks.py
|
|
@ -175,7 +175,7 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from .crud import get_account_by_name, get_or_create_user_account
|
from .crud import get_account_by_name, get_or_create_user_account
|
||||||
from .models import AccountType
|
from .models import AccountType
|
||||||
from .beancount_format import format_payment_entry
|
from .beancount_format import format_net_settlement_entry
|
||||||
|
|
||||||
# Convert amount from millisatoshis to satoshis
|
# Convert amount from millisatoshis to satoshis
|
||||||
amount_sats = payment.amount // 1000
|
amount_sats = payment.amount // 1000
|
||||||
|
|
@ -191,30 +191,58 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
if fiat_amount_str:
|
if fiat_amount_str:
|
||||||
fiat_amount = Decimal(str(fiat_amount_str))
|
fiat_amount = Decimal(str(fiat_amount_str))
|
||||||
|
|
||||||
|
if not fiat_currency or not fiat_amount:
|
||||||
|
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}")
|
logger.info(f"Final fiat values for payment entry - currency: {fiat_currency}, amount: {fiat_amount}")
|
||||||
|
|
||||||
# Get user's receivable account (what user owes)
|
# Get user's current balance to determine receivables and payables
|
||||||
|
balance = await fava.get_user_balance(user_id)
|
||||||
|
fiat_balances = balance.get("fiat_balances", {})
|
||||||
|
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
||||||
|
|
||||||
|
logger.info(f"User {user_id[:8]} current balance: {total_fiat_balance} {fiat_currency}")
|
||||||
|
|
||||||
|
# Determine receivables and payables based on balance
|
||||||
|
# Positive balance = user owes castle (receivable)
|
||||||
|
# Negative balance = castle owes user (payable)
|
||||||
|
if total_fiat_balance > 0:
|
||||||
|
# User owes castle
|
||||||
|
total_receivable = total_fiat_balance
|
||||||
|
total_payable = Decimal(0)
|
||||||
|
else:
|
||||||
|
# Castle owes user
|
||||||
|
total_receivable = Decimal(0)
|
||||||
|
total_payable = abs(total_fiat_balance)
|
||||||
|
|
||||||
|
logger.info(f"Settlement amounts - Receivable: {total_receivable}, Payable: {total_payable}, Net: {fiat_amount}")
|
||||||
|
|
||||||
|
# Get account names
|
||||||
user_receivable = await get_or_create_user_account(
|
user_receivable = await get_or_create_user_account(
|
||||||
user_id, AccountType.ASSET, "Accounts Receivable"
|
user_id, AccountType.ASSET, "Accounts Receivable"
|
||||||
)
|
)
|
||||||
|
user_payable = await get_or_create_user_account(
|
||||||
# Get lightning account
|
user_id, AccountType.LIABILITY, "Accounts Payable"
|
||||||
|
)
|
||||||
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
||||||
if not lightning_account:
|
if not lightning_account:
|
||||||
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Format as Beancount transaction
|
# Format as net settlement transaction
|
||||||
entry = format_payment_entry(
|
entry = format_net_settlement_entry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
payment_account=lightning_account.name, # "Assets:Bitcoin:Lightning"
|
payment_account=lightning_account.name,
|
||||||
payable_or_receivable_account=user_receivable.name, # "Assets:Receivable:User-{id}"
|
receivable_account=user_receivable.name,
|
||||||
|
payable_account=user_payable.name,
|
||||||
amount_sats=amount_sats,
|
amount_sats=amount_sats,
|
||||||
description=f"Lightning payment from user {user_id[:8]}",
|
net_fiat_amount=fiat_amount,
|
||||||
entry_date=datetime.now().date(),
|
total_receivable_fiat=total_receivable,
|
||||||
is_payable=False, # User paying castle (receivable settlement)
|
total_payable_fiat=total_payable,
|
||||||
fiat_currency=fiat_currency,
|
fiat_currency=fiat_currency,
|
||||||
fiat_amount=fiat_amount,
|
description=f"Lightning payment settlement from user {user_id[:8]}",
|
||||||
|
entry_date=datetime.now().date(),
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
reference=payment.payment_hash
|
reference=payment.payment_hash
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue