Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.
Make the entry-id transaction metadata the single canonical identity:
- _extract_entry_id() resolves metadata-first (libra- link parsing kept
only for pre-dfdcc44 ledger history); used by /entries/user,
/entries/pending, approve, and reject.
- Creation endpoints no longer fuse the reference with the entry id —
the user reference becomes its own sanitized link and round-trips
verbatim in API responses. Typed exp-/rcv-/inc- links stay as the
settlement-tracking handles.
- format_revenue_entry now writes entry-id metadata like its siblings
and sanitizes its reference link (was appended raw); generic
POST /entries sanitizes its reference link too.
- User-journal reference extraction skips all system link prefixes
(typed links used to leak into the reference field).
Contract documented in CLAUDE.md (Data Integrity → Entry Identity &
Links), pinned by tests/test_entry_identity_api.py and formatter
contract tests in test_unit.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1091 lines
36 KiB
Python
1091 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,
|
|
entry_id: 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 (invoice ID, etc.) — stored as its own link
|
|
entry_id: Optional unique entry ID (generated if not provided)
|
|
|
|
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")
|
|
)
|
|
"""
|
|
if not entry_id:
|
|
entry_id = generate_entry_id()
|
|
|
|
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",
|
|
"entry-id": entry_id
|
|
}
|
|
|
|
links = []
|
|
if reference:
|
|
links.append(sanitize_link(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,
|
|
)
|