libra/beancount_format.py
Padreug 116df46d38 Net settlement + credit overflow on /receivables/settle (libra-#33, libra-#41)
When the caller omits settled_entry_links (the default), the endpoint
auto-detects open entries across both directions for the user and writes
a single transaction that:

  - Zeros every per-user account that has an open balance, not just the
    net (the libra-#33 bug — previously the 2-leg form left both Payable
    and Receivable carrying non-zero balances after a complete cash
    settlement, while only netting the cash side).
  - Routes any cash above the net obligation to Liabilities:Credit:User-X
    (libra-#41), so over-payment lands on a real liability account
    instead of silently drifting.
  - Attaches every reconciled source entry's link
    (exp-..., rcv-...) so a reader scanning the settlement transaction
    can trace what it cleared.

Cash less than the net obligation, with no explicit links, returns 400
with a structured diff (cash_paid, net_obligation, receivable_total,
payable_total). The operator either pays the exact net or passes
settled_entry_links to settle a specific subset; partial settlement
without a coherent target is not silently absorbed.

The legacy explicit-links code path is unchanged — callers that pass
settled_entry_links keep the 2-leg shape with no auto-detection. None
of the callers in libra or aiolabs/webapp currently use that field, but
the contract is preserved for the partial-settle-of-specific-entries
flow.

format_fiat_net_settlement_entry is the new helper for the 2/3/4-leg
shape; it enforces the cash-balance constraint inline so callers can't
accidentally produce an unbalanced transaction.

tests/test_settlement_api.py (6 tests) locks in:
  - Nancy's #33 scenario: receivable 100 + payable 50 + cash 50
    zeros both per-user accounts, links both source entries
  - Overpay: cash 70 against net 50 → credit balance 20
  - Pure receivable overpay → credit appears
  - Underpay without explicit links → 400 with diff
  - No open receivables → 400 with hint pointing at /payables/pay
  - Explicit settled_entry_links uses legacy 2-leg path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00

1085 lines
36 KiB
Python

"""
Format Libra entries as Beancount transactions for Fava API.
All entries submitted to Fava must follow Beancount syntax.
This module converts Libra data models to Fava API format.
Key concepts:
- Amounts are strings: "200000 SATS" or "100.00 EUR"
- Cost basis syntax: "200000 SATS {100.00 EUR}"
- Flags: "*" (cleared), "!" (pending), "#" (flagged), "?" (unknown)
- Entry type: "t": "Transaction" (required by Fava)
"""
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:
"""
Sanitize a string to make it valid for Beancount links.
Beancount links can only contain: A-Z, a-z, 0-9, -, _, /, .
All other characters are replaced with hyphens.
Examples:
>>> sanitize_link("Test (pending)")
'Test-pending'
>>> sanitize_link("Invoice #123")
'Invoice-123'
>>> sanitize_link("libra-abc123")
'libra-abc123'
"""
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
# Remove consecutive hyphens
sanitized = re.sub(r'-+', '-', sanitized)
# Remove leading/trailing hyphens
sanitized = sanitized.strip('-')
return sanitized
def format_transaction(
date_val: date,
flag: str,
narration: str,
postings: List[Dict[str, Any]],
payee: str = "",
tags: Optional[List[str]] = None,
links: Optional[List[str]] = None,
meta: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Format a transaction for Fava's add_entries API.
Args:
date_val: Transaction date
flag: Beancount flag (* = cleared, ! = pending, # = flagged)
narration: Description
postings: List of posting dicts (formatted by format_posting)
payee: Optional payee
tags: Optional tags (e.g., ["expense-entry", "approved"])
links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"])
meta: Optional transaction metadata
Returns:
Fava API entry dict
Example:
entry = format_transaction(
date_val=date.today(),
flag="*",
narration="Grocery shopping",
postings=[
format_posting_with_cost(
account="Expenses:Food",
amount_sats=36930,
fiat_currency="EUR",
fiat_amount=Decimal("36.93")
),
format_posting_with_cost(
account="Liabilities:Payable:User-abc",
amount_sats=-36930,
fiat_currency="EUR",
fiat_amount=Decimal("36.93")
)
],
tags=["expense-entry"],
links=["libra-abc123"],
meta={"user-id": "abc123", "source": "libra-expense-entry"}
)
"""
return {
"t": "Transaction", # REQUIRED by Fava API
"date": str(date_val),
"flag": flag,
"payee": payee or "", # Empty string, not None
"narration": narration,
"tags": tags or [],
"links": links or [],
"postings": postings,
"meta": meta or {}
}
def format_balance(
date_val: date,
account: str,
amount: int,
currency: str = "SATS"
) -> str:
"""
Format a balance assertion directive for Beancount.
Balance assertions verify that an account has an expected balance on a specific date.
They are checked automatically by Beancount when the file is loaded.
Args:
date_val: Date of the balance assertion
account: Account name (e.g., "Assets:Bitcoin:Lightning")
amount: Expected balance amount
currency: Currency code (default: "SATS")
Returns:
Beancount balance directive as a string
Example:
>>> format_balance(date(2025, 11, 10), "Assets:Bitcoin:Lightning", 1500000, "SATS")
'2025-11-10 balance Assets:Bitcoin:Lightning 1500000 SATS'
"""
date_str = date_val.strftime('%Y-%m-%d')
# Two spaces between account and amount (Beancount convention)
return f"{date_str} balance {account} {amount} {currency}"
def format_posting_with_cost(
account: str,
amount_sats: int,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Format a posting with cost basis for Fava API.
This is the RECOMMENDED format for all Libra 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 TOTAL (Decimal, unsigned) - will be converted to per-unit
metadata: Optional posting metadata
Returns:
Fava API posting dict
Example:
posting = format_posting_with_cost(
account="Expenses:Food",
amount_sats=200000,
fiat_currency="EUR",
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 {0.0005 EUR}",
# "meta": {}
# }
"""
# Build amount string with cost basis
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 - 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 {
"account": account,
"amount": amount_str,
"meta": posting_meta
}
def format_posting_simple(
account: str,
amount_sats: int,
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Format a simple posting (SATS only, no cost basis).
Use this for:
- Lightning payments (no fiat conversion)
- SATS-only transactions
- Internal transfers
Args:
account: Account name
amount_sats: Amount in satoshis (signed)
metadata: Optional posting metadata
Returns:
Fava API posting dict
Example:
posting = format_posting_simple(
account="Assets:Bitcoin:Lightning",
amount_sats=200000
)
# Returns: {
# "account": "Assets:Bitcoin:Lightning",
# "amount": "200000 SATS",
# "meta": {}
# }
"""
return {
"account": account,
"amount": f"{amount_sats} SATS",
"meta": metadata or {}
}
def format_expense_entry(
user_id: str,
expense_account: str,
user_account: str,
amount_sats: int,
description: str,
entry_date: date,
is_equity: bool = False,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Format an expense entry for submission to Fava.
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
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 with unique expense link (^exp-{entry_id})
Example:
entry = format_expense_entry(
user_id="abc123",
expense_account="Expenses:Food:Groceries",
user_account="Liabilities:Payable:User-abc123",
amount_sats=200000,
description="Grocery shopping",
entry_date=date.today(),
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 using price notation (@@ SATS) for BQL queryability
sats_abs = abs(amount_sats)
postings = [
{
"account": expense_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
},
{
"account": user_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
}
]
# Build entry metadata
entry_meta = {
"user-id": user_id,
"source": "libra-api",
"entry-id": entry_id
}
# Build links - include expense link for settlement tracking
links = [f"exp-{entry_id}"]
if reference:
links.append(sanitize_link(reference))
# Build tags
tags = ["expense-entry"]
if is_equity:
tags.append("equity-contribution")
return format_transaction(
date_val=entry_date,
flag="!", # Pending - requires admin approval
narration=narration,
postings=postings,
tags=tags,
links=links,
meta=entry_meta
)
def format_receivable_entry(
user_id: str,
revenue_account: str,
receivable_account: str,
amount_sats: int,
description: str,
entry_date: date,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a receivable entry (user owes libra).
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
Args:
user_id: User ID
revenue_account: Revenue account name
receivable_account: User's receivable account name (Assets:Receivable:User-{id})
amount_sats: Amount in satoshis (unsigned)
description: Entry description
entry_date: Date of 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 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 using price notation (@@ SATS) for BQL queryability
sats_abs = abs(amount_sats)
postings = [
{
"account": receivable_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
},
{
"account": revenue_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS"
}
]
entry_meta = {
"user-id": user_id,
"source": "libra-api",
"entry-id": entry_id
}
# Build links - include receivable link for settlement tracking
links = [f"rcv-{entry_id}"]
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="*", # Receivables are immediately cleared (approved)
narration=narration,
postings=postings,
tags=["receivable-entry"],
links=links,
meta=entry_meta
)
def format_payment_entry(
user_id: str,
payment_account: str,
payable_or_receivable_account: str,
amount_sats: int,
description: str,
entry_date: date,
is_payable: bool = True,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
payment_hash: Optional[str] = None,
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Format a payment entry (Lightning payment recorded).
Creates a cleared transaction (flag="*") since payment already happened.
Args:
user_id: User ID
payment_account: Payment method account (e.g., "Assets:Bitcoin:Lightning")
payable_or_receivable_account: User's account being settled
amount_sats: Amount in satoshis (unsigned)
description: Payment description
entry_date: Date of payment
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned)
payment_hash: Lightning payment hash
reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123"])
Returns:
Fava API entry dict
"""
amount_sats_abs = abs(amount_sats)
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:
# Libra 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 libra: 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:
# 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
entry_meta = {
"user-id": user_id,
"source": "lightning_payment"
}
if payment_hash:
entry_meta["payment-hash"] = payment_hash
links = []
if settled_entry_links:
links.extend(settled_entry_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", "settlement"],
links=links,
meta=entry_meta
)
def format_fiat_settlement_entry(
user_id: str,
payment_account: str,
payable_or_receivable_account: str,
fiat_amount: Decimal,
fiat_currency: str,
amount_sats: int,
description: str,
entry_date: date,
is_payable: bool = True,
payment_method: str = "cash",
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Format a fiat (cash/bank) settlement entry.
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Includes links to the expense/receivable entries being settled.
Args:
user_id: User ID
payment_account: Payment method account (e.g., "Assets:Cash", "Assets:Bank")
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
description: Payment description
entry_date: Date of settlement
is_payable: True if libra paying user (payable), False if user paying libra (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 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:
# Libra paying user: DR Payable, CR Cash/Bank
postings = [
{
"account": payable_or_receivable_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
},
{
"account": payment_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {amount_sats_abs} SATS"
}
]
else:
# User paying libra: DR Cash/Bank, CR Receivable
postings = [
{
"account": payment_account,
"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} @@ {amount_sats_abs} SATS"
}
]
# Map payment method to appropriate source and tag
payment_method_map = {
"cash": ("cash_settlement", "cash-payment"),
"bank_transfer": ("bank_settlement", "bank-transfer"),
"check": ("check_settlement", "check-payment"),
"btc_onchain": ("onchain_settlement", "onchain-payment"),
"other": ("manual_settlement", "manual-payment")
}
source, tag = payment_method_map.get(payment_method.lower(), ("manual_settlement", "manual-payment"))
entry_meta = {
"user-id": user_id,
"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(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="*", # Cleared (payment already happened)
narration=description,
postings=postings,
tags=[tag, "settlement"],
links=links,
meta=entry_meta
)
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,
settled_entry_links: Optional[List[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
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"])
Returns:
Fava API entry dict
"""
# Build postings for net settlement
# Note: We use @@ (total price) syntax for cleaner formatting, but Fava's API
# will convert this to @ (per-unit price) with a long decimal when writing to file.
# This is Fava's internal normalization behavior and cannot be changed via API.
# The accounting is still 100% correct, just not as visually clean.
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 settled_entry_links:
links.extend(settled_entry_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_fiat_net_settlement_entry(
user_id: str,
cash_account: str,
receivable_account: str,
payable_account: Optional[str],
credit_account: Optional[str],
cash_paid_fiat: Decimal,
total_receivable_fiat: Decimal,
total_payable_fiat: Decimal,
credit_overflow_fiat: Decimal,
fiat_currency: str,
description: str,
entry_date: date,
payment_method: str = "cash",
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Fiat cash settlement that nets receivable and payable for one user.
Implements the contract from libra-#33 (settlement netting) and
libra-#41 (credit-balance overflow). Builds a 2- to 4-leg transaction
depending on what the user has open:
- Cash + Receivable only (2-leg) — pure receivable, exact pay
- Cash + Receivable + Credit (3-leg) — overpay against pure receivable
- Cash + Receivable + Payable (3-leg) — nancy's #33 scenario, exact pay
- Cash + Receivable + Payable + Credit (4-leg) — net + overpay
The receivable leg is always present (this endpoint is `/receivables/settle`).
The payable leg appears when the user has open expenses being netted against
the receivable. The credit leg appears when cash > settle target, absorbing
the overflow as a liability libra owes the user going forward.
Constraint enforced inline:
cash_paid_fiat = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
Args:
user_id: User ID
cash_account: Payment-method account name (e.g. "Assets:Cash")
receivable_account: User's receivable account being cleared
payable_account: User's payable account being cleared (omit when no payable)
credit_account: User's credit account receiving overflow (omit when no overflow)
cash_paid_fiat: What the user paid in cash, unsigned
total_receivable_fiat: Gross receivable being cleared (unsigned, 0 if none)
total_payable_fiat: Gross payable being cleared (unsigned, 0 if none)
credit_overflow_fiat: Excess cash going to credit (unsigned, 0 if none)
fiat_currency: Currency code (EUR, USD, etc.)
description: Entry narration
entry_date: Date of settlement
payment_method: cash / bank_transfer / check / other
reference: Optional caller-supplied reference (becomes an extra link)
settled_entry_links: Source entry links being cleared
(e.g. `["exp-abc", "rcv-def"]`). The audit trail for which
originals this settlement reconciles.
Returns:
Fava API entry dict ready for `fava.add_entry`.
Raises:
ValueError: if any amount is negative, or if the cash-balance
constraint above is not satisfied.
"""
for label, value in (
("cash_paid_fiat", cash_paid_fiat),
("total_receivable_fiat", total_receivable_fiat),
("total_payable_fiat", total_payable_fiat),
("credit_overflow_fiat", credit_overflow_fiat),
):
if value < 0:
raise ValueError(f"{label} must be non-negative; got {value}")
expected_cash = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
if abs(cash_paid_fiat - expected_cash) > Decimal("0.01"):
raise ValueError(
f"cash_paid_fiat {cash_paid_fiat} does not match expected "
f"{expected_cash} (= receivable {total_receivable_fiat} "
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat})"
)
if total_payable_fiat > 0 and not payable_account:
raise ValueError("payable_account required when total_payable_fiat > 0")
if credit_overflow_fiat > 0 and not credit_account:
raise ValueError("credit_account required when credit_overflow_fiat > 0")
postings: List[Dict[str, Any]] = [
{"account": cash_account, "amount": f"{cash_paid_fiat:.2f} {fiat_currency}"},
{"account": receivable_account, "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}"},
]
if total_payable_fiat > 0:
postings.append({
"account": payable_account,
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
})
if credit_overflow_fiat > 0:
postings.append({
"account": credit_account,
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
})
payment_method_map = {
"cash": ("cash_settlement", "cash-payment"),
"bank_transfer": ("bank_settlement", "bank-transfer"),
"check": ("check_settlement", "check-payment"),
"btc_onchain": ("onchain_settlement", "onchain-payment"),
"other": ("manual_settlement", "manual-payment"),
}
source, tag = payment_method_map.get(
payment_method.lower(), ("manual_settlement", "manual-payment"),
)
entry_meta: Dict[str, Any] = {
"user-id": user_id,
"source": source,
"payment-type": "net-settlement",
}
links: List[str] = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="*",
narration=description,
postings=postings,
tags=[tag, "settlement", "net-settlement"],
links=links,
meta=entry_meta,
)
def format_revenue_entry(
payment_account: str,
revenue_account: str,
amount_sats: int,
description: str,
entry_date: date,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a revenue entry (libra receives payment directly).
Creates a cleared transaction (flag="*") since payment was received.
Example: Cash sale, Lightning payment received, bank transfer received.
Args:
payment_account: Payment method account (e.g., "Assets:Bitcoin:Lightning", "Assets:Cash")
revenue_account: Revenue account name (e.g., "Income:Sales", "Income:Services")
amount_sats: Amount in satoshis (unsigned)
description: Entry description
entry_date: Date of payment
fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned)
reference: Optional reference
Returns:
Fava API entry dict
Example:
entry = format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date.today(),
fiat_currency="EUR",
fiat_amount=Decimal("50.00")
)
"""
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})"
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
),
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
)
]
# Note: created-via is redundant with #revenue-entry tag
entry_meta = {
"source": "libra-api"
}
links = []
if reference:
links.append(reference)
return format_transaction(
date_val=entry_date,
flag="*", # Cleared (payment received)
narration=narration,
postings=postings,
tags=["revenue-entry"],
links=links,
meta=entry_meta
)
def format_income_entry(
user_id: str,
user_account: str,
revenue_account: str,
amount_sats: int,
description: str,
entry_date: date,
fiat_currency: str,
fiat_amount: Decimal,
reference: Optional[str] = None,
entry_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Format a user-submitted income/revenue entry for Fava (pending approval).
Mirrors format_expense_entry: pending flag (!) for super-user review,
fiat-first price notation (@@ SATS) for BQL queryability, unique link
(^inc-{entry_id}) for tracking through the approve/reject flow.
Postings: DR user_account (Assets:Receivable:User-{id} — user owes
the entity until they hand the cash over), CR revenue_account.
"""
if not fiat_currency or not fiat_amount or fiat_amount <= 0:
raise ValueError("fiat_currency and a positive fiat_amount are required for income entries")
if not entry_id:
entry_id = generate_entry_id()
fiat_amount_abs = abs(fiat_amount)
sats_abs = abs(amount_sats)
narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})"
postings = [
{
"account": user_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS",
},
{
"account": revenue_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS",
},
]
entry_meta = {
"user-id": user_id,
"source": "libra-api",
"entry-id": entry_id,
}
links = [f"inc-{entry_id}"]
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="!", # Pending - requires admin approval
narration=narration,
postings=postings,
tags=["income-entry"],
links=links,
meta=entry_meta,
)