Compare commits

..

No commits in common. "refactor/views-api-package" and "main" have entirely different histories.

40 changed files with 7260 additions and 6296 deletions

1
.gitignore vendored
View file

@ -2,4 +2,3 @@ __pycache__
node_modules
.venv
.mypy_cache
data/

View file

@ -169,12 +169,6 @@ User-specific accounts are created automatically with format:
Use `get_or_create_user_account()` in crud.py to ensure consistency.
### Pydantic version
LNbits pins **Pydantic v1** (`pydantic~=1.10`) — keep `.dict()` /
`.parse_obj()` v1 APIs. Do NOT "modernize" to `.model_dump()` etc.;
it would crash at runtime until upstream migrates.
### Currency Handling
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`.

218
MIGRATION_SQUASH_SUMMARY.md Normal file
View file

@ -0,0 +1,218 @@
# Libra Migration Squash Summary
**Date:** November 10, 2025
**Action:** Squashed 16 incremental migrations into a single clean initial migration
## Overview
The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
## Files Changed
- **migrations.py** - Replaced with squashed single migration (651 → 327 lines)
- **migrations_old.py.bak** - Backup of original 16 migrations for reference
## Final Database Schema
The squashed migration creates **7 tables**:
### 1. libra_accounts
- Core chart of accounts with hierarchical Beancount-style names
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
- User-specific accounts: "Assets:Receivable:User-af983632"
- Includes comprehensive default account set (40+ accounts)
### 2. libra_extension_settings
- Libra-wide configuration
- Stores libra_wallet_id for Lightning payments
### 3. libra_user_wallet_settings
- Per-user wallet configuration
- Allows users to have separate wallet preferences
### 4. libra_manual_payment_requests
- User-submitted payment requests to Libra
- Reviewed by admins before processing
- Includes notes field for additional context
### 5. libra_balance_assertions
- Reconciliation and balance checking at specific dates
- Multi-currency support (satoshis + fiat)
- Tolerance checking for small discrepancies
- Includes notes field for reconciliation comments
### 6. libra_user_equity_status
- Manages equity contribution eligibility
- Equity-eligible users can convert expenses to equity
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
### 7. libra_account_permissions
- Granular access control for accounts
- Permission types: read, submit_expense, manage
- Supports hierarchical inheritance (parent permissions cascade)
- Time-based expiration support
## What Was Removed
The following tables were **intentionally NOT included** in the final schema (they were dropped in m016):
- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
- **libra_entry_lines** - Entry lines now managed by Fava/Beancount
Libra now uses Fava as the single source of truth for accounting data. Journal operations:
- **Write:** Submit to Fava via FavaClient.add_entry()
- **Read:** Query Fava via FavaClient.get_entries()
## Key Schema Decisions
1. **Hierarchical Account Names** - Beancount-style colon-separated hierarchy (e.g., "Assets:Bitcoin:Lightning")
2. **No Journal Tables** - Fava/Beancount is the source of truth for journal entries
3. **Dynamic User Accounts** - User-specific accounts created on-demand (Assets:Receivable:User-xxx, Equity:User-xxx)
4. **No Parent-Only Accounts** - Hierarchy is implicit in names (no "Assets:Bitcoin" parent account needed)
5. **Multi-Currency Support** - Balance assertions support both satoshis and fiat currencies
6. **Notes Fields** - Added notes to balance_assertions and manual_payment_requests for better documentation
## Migration History (Original 16 Migrations)
For reference, the original migration sequence (preserved in migrations_old.py.bak):
1. **m001** - Initial accounts, journal_entries, entry_lines tables
2. **m002** - Extension settings table
3. **m003** - User wallet settings table
4. **m004** - Manual payment requests table
5. **m005** - Added flag/meta columns to journal_entries
6. **m006** - Migrated to hierarchical account names
7. **m007** - Balance assertions table
8. **m008** - Renamed Lightning account (Assets:Lightning:Balance → Assets:Bitcoin:Lightning)
9. **m009** - Added OnChain Bitcoin account (Assets:Bitcoin:OnChain)
10. **m010** - User equity status table
11. **m011** - Account permissions table
12. **m012** - Updated default accounts with detailed hierarchy (40+ accounts)
13. **m013** - Removed parent-only accounts (Assets:Bitcoin, Equity)
14. **m014** - Removed legacy equity accounts (MemberEquity, RetainedEarnings)
15. **m015** - Converted entry_lines from debit/credit to single amount field
16. **m016** - Dropped journal_entries and entry_lines tables (Fava integration)
## Benefits of Squashing
1. **Cleaner Codebase** - Single 327-line migration vs 651 lines across 16 functions
2. **Easier to Understand** - New developers see final schema immediately
3. **Faster Fresh Installs** - One migration run instead of 16
4. **Better Documentation** - Comprehensive comments explain design decisions
5. **No Migration Artifacts** - No intermediate states, data conversions, or temporary columns
## Fresh Install Process
For new installations:
```bash
# Libra's migration system will run m001_initial automatically
# No manual intervention needed
```
The migration will:
1. Create all 7 tables with proper indexes and foreign keys
2. Insert 40+ default accounts with hierarchical names
3. Set up proper constraints and defaults
4. Complete in a single transaction
## Default Accounts Created
The migration automatically creates a comprehensive chart of accounts:
**Assets (12 accounts):**
- Assets:Bank
- Assets:Bitcoin:Lightning
- Assets:Bitcoin:OnChain
- Assets:Cash
- Assets:FixedAssets:Equipment
- Assets:FixedAssets:FarmEquipment
- Assets:FixedAssets:Network
- Assets:FixedAssets:ProductionFacility
- Assets:Inventory
- Assets:Livestock
- Assets:Receivable
- Assets:Tools
**Liabilities (1 account):**
- Liabilities:Payable
**Income (3 accounts):**
- Income:Accommodation:Guests
- Income:Service
- Income:Other
**Expenses (24 accounts):**
- Expenses:Administrative
- Expenses:Construction:Materials
- Expenses:Furniture
- Expenses:Garden
- Expenses:Gas:Kitchen
- Expenses:Gas:Vehicle
- Expenses:Groceries
- Expenses:Hardware
- Expenses:Housewares
- Expenses:Insurance
- Expenses:Kitchen
- Expenses:Maintenance:Car
- Expenses:Maintenance:Garden
- Expenses:Maintenance:Property
- Expenses:Membership
- Expenses:Supplies
- Expenses:Tools
- Expenses:Utilities:Electric
- Expenses:Utilities:Internet
- Expenses:WebHosting:Domain
- Expenses:WebHosting:Wix
**Equity:**
- Created dynamically as Equity:User-{user_id} when granting equity eligibility
## Testing
After squashing, verify the migration works:
```bash
# 1. Backup existing database (if any)
cp libra.sqlite3 libra.sqlite3.backup
# 2. Drop and recreate database to test fresh install
rm libra.sqlite3
# 3. Start LNbits - migration should run automatically
poetry run lnbits
# 4. Verify tables created
sqlite3 libra.sqlite3 ".tables"
# Should show: libra_accounts, libra_extension_settings, etc.
# 5. Verify default accounts
sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;"
# Should show: 40 (default accounts)
```
## Rollback Plan
If issues are discovered:
```bash
# Restore original migrations
cp migrations_old.py.bak migrations.py
# Restore database
cp libra.sqlite3.backup libra.sqlite3
```
## Notes
- This squash is safe because Libra has not been released yet
- No existing production databases need migration
- Historical migrations preserved in migrations_old.py.bak
- All functionality preserved in final schema
- No data loss concerns (no production data exists)
---
**Signed off by:** Claude Code
**Reviewed by:** Human operator
**Status:** Complete

View file

@ -30,17 +30,6 @@ def libra_stop():
except Exception as ex:
logger.warning(ex)
# Close the Fava client's shared HTTP connection pool. libra_stop is
# synchronous, so schedule the close; if the loop is already gone the
# sockets die with the process anyway.
from .fava_client import _fava_client
if _fava_client is not None:
try:
asyncio.get_event_loop().create_task(_fava_client.aclose())
except Exception as ex:
logger.warning(f"Could not close Fava HTTP client: {ex}")
def libra_start():
"""Initialize Libra extension background tasks"""

View file

@ -285,11 +285,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
return stats
async def sync_single_account_from_beancount(
account_name: str,
description: Optional[str] = None,
assume_exists: bool = False,
) -> bool:
async def sync_single_account_from_beancount(account_name: str) -> bool:
"""
Sync a single account from Beancount to Libra DB.
@ -298,13 +294,6 @@ async def sync_single_account_from_beancount(
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food")
description: Description for the Libra DB row (only used with
assume_exists otherwise read from Beancount metadata)
assume_exists: Skip the Fava existence lookup. Pass when the
caller just wrote the Open directive itself verifying via
a second serialized get_all_accounts round-trip doubles the
latency of every account create for no information gain
(libra-#53).
Returns:
True if account was created/updated, False if it already existed or failed
@ -317,22 +306,6 @@ async def sync_single_account_from_beancount(
logger.debug(f"Account already exists: {account_name}")
return False
if assume_exists:
try:
await create_account(
CreateAccount(
name=account_name,
account_type=infer_account_type_from_name(account_name),
description=description,
user_id=extract_user_id_from_account_name(account_name),
)
)
logger.info(f"Created account (writer-asserted): {account_name}")
return True
except Exception as e:
logger.error(f"Failed to sync account {account_name}: {e}")
return False
# Get from Beancount
fava = get_fava_client()
try:

View file

@ -17,58 +17,6 @@ ACCOUNT_TYPE_ROOTS = {
AccountType.EXPENSE: "Expenses",
}
VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:")
def is_valid_account_component(component: str, *, is_root: bool) -> bool:
"""Validate one ':'-separated account component against Beancount's grammar.
Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*``
(must start with an uppercase letter); a sub component matches
``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body
chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware
str methods (libra's runtime has no beancount — Fava is a separate service),
so non-ASCII letters are accepted exactly as Beancount accepts them.
"""
if not component:
return False
first, rest = component[0], component[1:]
first_ok = (first.isalpha() and first.isupper()) or (
not is_root and first.isdecimal()
)
if not first_ok:
return False
return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest)
def validate_account_name(name: str, *, allow_root_only: bool = False) -> None:
"""Raise ValueError if ``name`` is not a syntactically valid Beancount account.
The single source of truth for account-name validation (libra-#51):
every path that writes an account name admin add-account, direct
account create, user-account derivation funnels through here before
the name can reach the ledger source.
Args:
name: Hierarchical account name (e.g. "Expenses:Food").
allow_root_only: Accept a bare root component ("Expenses")
only virtual parent accounts are allowed this shape.
"""
parts = name.split(":")
min_parts = 1 if allow_root_only else 2
valid = (
len(parts) >= min_parts
and is_valid_account_component(parts[0], is_root=True)
and all(is_valid_account_component(p, is_root=False) for p in parts[1:])
)
if not valid:
raise ValueError(
f"Invalid account name {name!r}: each ':'-separated part must be "
"letters/digits/hyphens, the root starting with an uppercase "
"letter (sub-accounts may start with a digit), with at least one "
"sub-account (e.g. Expenses:Food)."
)
def format_hierarchical_account_name(
account_type: AccountType,

18
auth.py
View file

@ -172,14 +172,11 @@ async def can_access_account(
if auth.is_super_user:
return True
# Check if this is the user's own account. Match the User-{short}
# segment exactly — a substring test also matched account names that
# merely CONTAIN it (e.g. "Expenses:Misc-User-deadbeef"), granting
# access to unrelated accounts.
# Check if this is the user's own account
account = await get_account(account_id)
if account:
user_segment = f"User-{auth.user_id[:8]}"
if user_segment in account.name.split(":"):
user_short = auth.user_id[:8]
if f"User-{user_short}" in account.name:
return True
# Check explicit permissions
@ -245,13 +242,14 @@ async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool:
if auth.is_super_user:
return True
# Users can access their own data. Full-ID equality ONLY: an 8-char
# prefix comparison is a 32-bit space, and any prefix collision (or a
# deliberately crafted short target id) let one user read another's
# data. Callers must pass full user ids.
# Users can access their own data - compare full ID or short ID
if auth.user_id == target_user_id:
return True
# Also allow if short IDs match (8 char prefix)
if auth.user_id[:8] == target_user_id[:8]:
return True
return False

View file

@ -115,18 +115,13 @@ def format_balance(
account: str,
amount: int,
currency: str = "SATS"
) -> Dict[str, Any]:
) -> str:
"""
Format a balance assertion directive for Fava's JSON API.
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.
Fava's `deserialise` (fava/serialisation.py) expects
`{"t": "Balance", "amount": {"number", "currency"}, ...}` the
previous source-string return 500'd on every assertion create
(libra-#39).
Args:
date_val: Date of the balance assertion
account: Account name (e.g., "Assets:Bitcoin:Lightning")
@ -134,15 +129,15 @@ def format_balance(
currency: Currency code (default: "SATS")
Returns:
Fava API Balance entry dict ready for `fava.add_entry`.
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'
"""
return {
"t": "Balance",
"date": date_val.strftime('%Y-%m-%d'),
"account": account,
"amount": {"number": str(amount), "currency": currency},
"meta": {},
}
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(
@ -257,10 +252,9 @@ def format_posting_at_average_cost(
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 basis — omit the braces entirely. Empty "{}" is not
# valid Beancount syntax and fails to parse on ledger load.
amount_str = f"{amount_sats} SATS"
logger.warning(f"format_posting_at_average_cost: cost_currency is None, omitting cost basis")
# 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 {}
@ -310,57 +304,6 @@ def format_posting_simple(
}
_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-")
def _extract_entry_id(entry: dict) -> Optional[str]:
"""Resolve the canonical libra entry id for a Fava transaction.
The ``entry-id`` transaction metadata is the single source of truth
written by every libra entry formatter since dfdcc44. Ledger history
predating it carries only a ``libra-{id}`` link; parse that as a
fallback so old entries still resolve.
Returns None when no id can be determined (e.g. settlement/payment
transactions, which are not approvable).
"""
meta = entry.get("meta", {})
entry_id = meta.get("entry-id")
if entry_id:
return str(entry_id)
# Legacy fallback: pre-entry-id ledger history (single libra-{id} link)
links = entry.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if link_clean.startswith("libra-"):
return link_clean[len("libra-"):]
return None
def fiat_rate_metadata(amount_sats: int, fiat_amount: Decimal) -> Dict[str, str]:
"""Exchange-rate metadata (sats per fiat unit, fiat per BTC) as exact
Decimal strings.
These values become the cost-basis record for the entry, so they must
not carry float drift CLAUDE.md mandates Decimal for all fiat math.
Returns:
{"fiat_rate": "<sats per fiat unit>", "btc_rate": "<fiat per BTC>"}
"""
if amount_sats <= 0 or fiat_amount <= 0:
return {"fiat_rate": "0", "btc_rate": "0"}
fiat_rate = (Decimal(amount_sats) / fiat_amount).quantize(Decimal("0.000001"))
btc_rate = (
fiat_amount / Decimal(amount_sats) * Decimal(100_000_000)
).quantize(Decimal("0.01"))
return {"fiat_rate": str(fiat_rate), "btc_rate": str(btc_rate)}
def format_expense_entry(
user_id: str,
expense_account: str,
@ -775,18 +718,15 @@ def format_net_settlement_entry(
entry_date: date,
payment_hash: Optional[str] = None,
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None,
credit_account: Optional[str] = None,
credit_overflow_fiat: Decimal = Decimal(0),
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Format a net settlement payment entry (user paying net balance).
Creates a three- to four-posting transaction:
Creates a three-posting transaction:
1. Lightning payment in SATS with @@ total price notation
2. Clear receivables in EUR
3. Clear payables in EUR
4. Credit overflow when the payment exceeds what it clears
Example:
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@ -794,61 +734,25 @@ def format_net_settlement_entry(
Liabilities:Payable:User 38.00 EUR
= 517 - 555 + 38 = 0
Constraint enforced inline (same contract as
`format_fiat_net_settlement_entry`):
net_fiat_amount = total_receivable_fiat - total_payable_fiat
+ credit_overflow_fiat
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: Fiat value of the payment being recorded
total_receivable_fiat: Receivables cleared by this payment
total_payable_fiat: Payables cleared by this payment
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"])
credit_account: User's credit account receiving overflow (required
when credit_overflow_fiat > 0)
credit_overflow_fiat: Payment excess beyond what it clears, absorbed
as a liability libra owes the user going forward
Returns:
Fava API entry dict
Raises:
ValueError: if any amount is negative, or the payment doesn't
balance against what it clears an unbalanced settlement
must never reach the ledger.
"""
for label, value in (
("net_fiat_amount", net_fiat_amount),
("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_payment = (
total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
)
if abs(net_fiat_amount - expected_payment) > Decimal("0.01"):
raise ValueError(
f"net_fiat_amount {net_fiat_amount} does not match expected "
f"{expected_payment} (= receivable {total_receivable_fiat} "
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat}); "
f"refusing to write an unbalanced settlement"
)
if credit_overflow_fiat > 0 and not credit_account:
raise ValueError("credit_account required when credit_overflow_fiat > 0")
# 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.
@ -857,26 +761,20 @@ def format_net_settlement_entry(
postings = [
{
"account": payment_account,
"amount": f"{abs(amount_sats)} SATS @@ {net_fiat_amount:.2f} {fiat_currency}",
"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"-{total_receivable_fiat:.2f} {fiat_currency}",
"amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))}
},
{
"account": payable_account,
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
"amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
"meta": {}
}
]
if credit_overflow_fiat > 0:
postings.append({
"account": credit_account,
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
"meta": {}
})
entry_meta = {
"user-id": user_id,

View file

@ -16,9 +16,10 @@ Note: Balance calculation and inventory tracking have been migrated to Fava/Bean
All accounting calculations are now performed via Fava's query API.
"""
from .validation import ValidationError, validate_balance
from .validation import ValidationError, validate_journal_entry, validate_balance
__all__ = [
"ValidationError",
"validate_journal_entry",
"validate_balance",
]

View file

@ -5,7 +5,7 @@ Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly.
"""
from decimal import Decimal, InvalidOperation
from decimal import Decimal
from typing import Any, Dict, List, Optional
@ -18,6 +18,81 @@ class ValidationError(Exception):
self.details = details or {}
def validate_journal_entry(
entry: Dict[str, Any],
entry_lines: List[Dict[str, Any]]
) -> None:
"""
Validate a journal entry and its lines (Beancount-style with single amount field).
Checks:
1. Entry must have at least 2 lines (double-entry requirement)
2. Entry must be balanced (sum of amounts = 0)
3. All lines must have account_id
4. No line should have amount = 0 (would serve no purpose)
Args:
entry: Journal entry dict with keys:
- id: str
- description: str
- entry_date: datetime
entry_lines: List of entry line dicts with keys:
- account_id: str
- amount: int (positive = debit, negative = credit)
Raises:
ValidationError: If validation fails
"""
# Check minimum number of lines
if len(entry_lines) < 2:
raise ValidationError(
"Journal entry must have at least 2 lines",
{
"entry_id": entry.get("id"),
"line_count": len(entry_lines),
}
)
# Validate each line
for i, line in enumerate(entry_lines):
# Check account_id exists
if not line.get("account_id"):
raise ValidationError(
f"Entry line {i + 1} missing account_id",
{
"entry_id": entry.get("id"),
"line_index": i,
}
)
# Get amount (Beancount-style: positive = debit, negative = credit)
amount = line.get("amount", 0)
# Check that amount is non-zero (zero amounts serve no purpose)
if amount == 0:
raise ValidationError(
f"Entry line {i + 1} has amount = 0 (serves no purpose)",
{
"entry_id": entry.get("id"),
"line_index": i,
}
)
# Check entry is balanced (sum of amounts must equal 0)
# Beancount-style: positive amounts cancel out negative amounts
total_amount = sum(line.get("amount", 0) for line in entry_lines)
if total_amount != 0:
raise ValidationError(
"Journal entry is not balanced (sum of amounts must equal 0)",
{
"entry_id": entry.get("id"),
"total_amount": total_amount,
"line_count": len(entry_lines),
}
)
def validate_balance(
account_id: str,
expected_balance_sats: int,
@ -203,14 +278,11 @@ def validate_metadata(
}
)
# Validate fiat amount is valid Decimal. InvalidOperation is what
# Decimal actually raises on garbage input ("abc") — it is not a
# ValueError subclass, so without it the raw exception leaked to
# callers (libra-#38).
# Validate fiat amount is valid Decimal
if has_fiat_amount:
try:
Decimal(str(metadata["fiat_amount"]))
except (ValueError, TypeError, InvalidOperation) as e:
except (ValueError, TypeError) as e:
raise ValidationError(
f"Invalid fiat_amount: {metadata['fiat_amount']}",
{"error": str(e)}

178
crud.py
View file

@ -1,3 +1,4 @@
import json
from datetime import datetime
from typing import Optional
@ -17,9 +18,13 @@ from .models import (
CreateAccount,
CreateAccountPermission,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
CreateRole,
CreateRolePermission,
CreateUserEquityStatus,
EntryLine,
JournalEntry,
PermissionType,
Role,
RolePermission,
@ -34,6 +39,16 @@ from .models import (
UserWithRoles,
)
# Import core accounting logic
from .core.validation import (
ValidationError,
validate_journal_entry,
validate_balance,
validate_receivable_entry,
validate_expense_entry,
validate_payment_entry,
)
db = Database("ext_libra")
# ===== CACHING =====
@ -51,21 +66,7 @@ PERMISSION_CACHE_TTL = 60 # 1 minute
# ===== ACCOUNT OPERATIONS =====
class AccountExistsError(Exception):
"""Raised when creating an account whose name is already taken."""
def __init__(self, name: str):
super().__init__(f"Account already exists: {name}")
self.name = name
async def create_account(data: CreateAccount) -> Account:
# Single validation choke point for every account-creation path
# (libra-#51). Virtual parents may be a bare root ("Expenses").
from .account_utils import validate_account_name
validate_account_name(data.name, allow_root_only=data.is_virtual)
account_id = urlsafe_short_hash()
account = Account(
id=account_id,
@ -76,17 +77,7 @@ async def create_account(data: CreateAccount) -> Account:
is_virtual=data.is_virtual,
created_at=datetime.now(),
)
try:
await db.insert("accounts", account)
except Exception as e:
# Translate backend-specific unique-violation errors (SQLite:
# "UNIQUE constraint failed", Postgres: "duplicate key value")
# into a domain error instead of leaking sqlalchemy internals
# (libra-#36).
msg = str(e).lower()
if "unique" in msg or "duplicate" in msg:
raise AccountExistsError(data.name) from e
raise
# Invalidate cache for this account (Cache class doesn't have delete method, use pop)
account_cache._values.pop(f"account:id:{account_id}", None)
@ -314,8 +305,10 @@ async def get_or_create_user_account(
user_id=user_id,
)
)
except AccountExistsError:
logger.warning(f"[LIBRA DB] Account already exists, fetching by name: {account_name}")
except Exception as e:
# Handle UNIQUE constraint error - account already exists
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e):
logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
# Fetch existing account by name only (ignore user_id in query)
account = await db.fetchone(
"""
@ -347,6 +340,9 @@ async def get_or_create_user_account(
{"name": account_name},
Account,
)
else:
# Re-raise if it's a different error
raise
else:
logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}")
@ -567,20 +563,14 @@ async def get_all_manual_payment_requests(
async def approve_manual_payment_request(
request_id: str, reviewed_by: str, journal_entry_id: str
) -> Optional["ManualPaymentRequest"]:
"""Approve a manual payment request.
"""Approve a manual payment request"""
from .models import ManualPaymentRequest
Status-guarded: only a 'pending' request can be approved, so two
concurrent admins can't both win (the loser gets None and must not
create a second journal entry).
Returns:
The approved request, or None if it wasn't pending anymore.
"""
result = await db.execute(
await db.execute(
"""
UPDATE manual_payment_requests
SET status = 'approved', reviewed_at = :reviewed_at, reviewed_by = :reviewed_by, journal_entry_id = :journal_entry_id
WHERE id = :id AND status = 'pending'
WHERE id = :id
""",
{
"id": request_id,
@ -589,42 +579,21 @@ async def approve_manual_payment_request(
"journal_entry_id": journal_entry_id,
},
)
if result.rowcount == 0:
return None
return await get_manual_payment_request(request_id)
async def revert_manual_payment_request(request_id: str) -> None:
"""Roll an approved request back to pending.
Compensation for the approve flow: the status is claimed BEFORE the
journal entry is written (so concurrent admins can't double-book);
if the ledger write then fails, the claim must be released.
"""
await db.execute(
"""
UPDATE manual_payment_requests
SET status = 'pending', reviewed_at = NULL, reviewed_by = NULL, journal_entry_id = NULL
WHERE id = :id AND status = 'approved'
""",
{"id": request_id},
)
async def reject_manual_payment_request(
request_id: str, reviewed_by: str
) -> Optional["ManualPaymentRequest"]:
"""Reject a manual payment request.
"""Reject a manual payment request"""
from .models import ManualPaymentRequest
Status-guarded like approve_manual_payment_request; returns None when
the request wasn't pending anymore.
"""
result = await db.execute(
await db.execute(
"""
UPDATE manual_payment_requests
SET status = 'rejected', reviewed_at = :reviewed_at, reviewed_by = :reviewed_by
WHERE id = :id AND status = 'pending'
WHERE id = :id
""",
{
"id": request_id,
@ -632,8 +601,6 @@ async def reject_manual_payment_request(
"reviewed_by": reviewed_by,
},
)
if result.rowcount == 0:
return None
return await get_manual_payment_request(request_id)
@ -1525,15 +1492,10 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
notes=data.notes,
)
# The unique index on (user_id, role_id) makes this insert the
# arbiter against concurrent assignments (e.g. two simultaneous
# logins both auto-assigning the default role). rowcount 0 means
# the assignment already exists — return it (idempotent).
result = await db.execute(
await db.execute(
"""
INSERT INTO user_roles (id, user_id, role_id, granted_by, granted_at, expires_at, notes)
VALUES (:id, :user_id, :role_id, :granted_by, :granted_at, :expires_at, :notes)
ON CONFLICT (user_id, role_id) DO NOTHING
""",
{
"id": user_role.id,
@ -1545,14 +1507,6 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
"notes": user_role.notes,
},
)
if result.rowcount == 0:
existing = await db.fetchone(
"SELECT * FROM user_roles WHERE user_id = :user_id AND role_id = :role_id",
{"user_id": data.user_id, "role_id": data.role_id},
UserRole,
)
if existing:
return existing
return user_role
@ -1742,73 +1696,3 @@ async def check_user_has_role_permission(
return True
return False
# =============================================================================
# PROCESSED PAYMENTS (Lightning payment idempotency gate)
# =============================================================================
# The Fava-side duplicate checks are read-then-write races; this table's
# primary key on payment_hash makes exactly one claimant win. Shared by the
# background invoice listener (tasks.on_invoice_paid) and the client-driven
# /record-payment endpoint.
async def claim_payment(payment_hash: str) -> bool:
"""Atomically claim a Lightning payment for recording.
Returns True when this caller owns the claim; False when the payment
is already recorded or another coroutine is recording it right now.
"""
result = await db.execute(
"""
INSERT INTO processed_payments (payment_hash, status)
VALUES (:payment_hash, 'processing')
ON CONFLICT (payment_hash) DO NOTHING
""",
{"payment_hash": payment_hash},
)
return result.rowcount == 1
async def get_processed_payment(payment_hash: str) -> Optional[dict]:
row = await db.fetchone(
"SELECT payment_hash, status, entry_id FROM processed_payments"
" WHERE payment_hash = :payment_hash",
{"payment_hash": payment_hash},
)
return dict(row) if row else None
async def mark_payment_done(payment_hash: str, entry_id: Optional[str] = None) -> None:
await db.execute(
"""
UPDATE processed_payments SET status = 'done', entry_id = :entry_id
WHERE payment_hash = :payment_hash
""",
{"payment_hash": payment_hash, "entry_id": entry_id},
)
async def release_payment_claim(payment_hash: str) -> None:
"""Compensating delete after a failed recording, so redelivery retries.
Only removes an in-flight claim a 'done' row is permanent.
"""
await db.execute(
"DELETE FROM processed_payments"
" WHERE payment_hash = :payment_hash AND status = 'processing'",
{"payment_hash": payment_hash},
)
async def clear_stale_payment_claims() -> int:
"""Drop 'processing' claims left behind by a previous process life.
A live claim only exists inside a running coroutine, so anything
still 'processing' at listener startup belongs to a crashed or
restarted process and would otherwise block that payment forever.
"""
result = await db.execute(
"DELETE FROM processed_payments WHERE status = 'processing'"
)
return result.rowcount

View file

@ -0,0 +1,953 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="" xml:lang="">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title>ACCOUNTING-ANALYSIS-NET-SETTLEMENT</title>
<style>
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
/* CSS for syntax highlighting */
html { -webkit-text-size-adjust: 100%; }
pre > code.sourceCode { white-space: pre; position: relative; }
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
pre > code.sourceCode > span:empty { height: 1.2em; }
.sourceCode { overflow: visible; }
code.sourceCode > span { color: inherit; text-decoration: inherit; }
div.sourceCode { margin: 1em 0; }
pre.sourceCode { margin: 0; }
@media screen {
div.sourceCode { overflow: auto; }
}
@media print {
pre > code.sourceCode { white-space: pre-wrap; }
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
}
pre.numberSource code
{ counter-reset: source-line 0; }
pre.numberSource code > span
{ position: relative; left: -4em; counter-increment: source-line; }
pre.numberSource code > span > a:first-child::before
{ content: counter(source-line);
position: relative; left: -1em; text-align: right; vertical-align: baseline;
border: none; display: inline-block;
-webkit-touch-callout: none; -webkit-user-select: none;
-khtml-user-select: none; -moz-user-select: none;
-ms-user-select: none; user-select: none;
padding: 0 4px; width: 4em;
color: #aaaaaa;
}
pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; }
div.sourceCode
{ }
@media screen {
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
}
code span.al { color: #ff0000; font-weight: bold; } /* Alert */
code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */
code span.at { color: #7d9029; } /* Attribute */
code span.bn { color: #40a070; } /* BaseN */
code span.bu { color: #008000; } /* BuiltIn */
code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
code span.ch { color: #4070a0; } /* Char */
code span.cn { color: #880000; } /* Constant */
code span.co { color: #60a0b0; font-style: italic; } /* Comment */
code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */
code span.do { color: #ba2121; font-style: italic; } /* Documentation */
code span.dt { color: #902000; } /* DataType */
code span.dv { color: #40a070; } /* DecVal */
code span.er { color: #ff0000; font-weight: bold; } /* Error */
code span.ex { } /* Extension */
code span.fl { color: #40a070; } /* Float */
code span.fu { color: #06287e; } /* Function */
code span.im { color: #008000; font-weight: bold; } /* Import */
code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */
code span.kw { color: #007020; font-weight: bold; } /* Keyword */
code span.op { color: #666666; } /* Operator */
code span.ot { color: #007020; } /* Other */
code span.pp { color: #bc7a00; } /* Preprocessor */
code span.sc { color: #4070a0; } /* SpecialChar */
code span.ss { color: #bb6688; } /* SpecialString */
code span.st { color: #4070a0; } /* String */
code span.va { color: #19177c; } /* Variable */
code span.vs { color: #4070a0; } /* VerbatimString */
code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */
</style>
<link rel="stylesheet" href="https://latex.now.sh/style.css" />
</head>
<body>
<nav id="TOC" role="doc-toc">
<ul>
<li><a href="#accounting-analysis-net-settlement-entry-pattern"
id="toc-accounting-analysis-net-settlement-entry-pattern">Accounting
Analysis: Net Settlement Entry Pattern</a>
<ul>
<li><a href="#executive-summary" id="toc-executive-summary">Executive
Summary</a></li>
<li><a href="#background-the-technical-challenge"
id="toc-background-the-technical-challenge">Background: The Technical
Challenge</a></li>
<li><a href="#current-implementation"
id="toc-current-implementation">Current Implementation</a>
<ul>
<li><a href="#transaction-example"
id="toc-transaction-example">Transaction Example</a></li>
<li><a href="#code-implementation" id="toc-code-implementation">Code
Implementation</a></li>
</ul></li>
<li><a href="#accounting-issues-identified"
id="toc-accounting-issues-identified">Accounting Issues Identified</a>
<ul>
<li><a href="#issue-1-zero-amount-postings"
id="toc-issue-1-zero-amount-postings">Issue 1: Zero-Amount
Postings</a></li>
<li><a href="#issue-2-redundant-satoshi-tracking"
id="toc-issue-2-redundant-satoshi-tracking">Issue 2: Redundant Satoshi
Tracking</a></li>
<li><a href="#issue-3-no-exchange-gainloss-recognition"
id="toc-issue-3-no-exchange-gainloss-recognition">Issue 3: No Exchange
Gain/Loss Recognition</a></li>
<li><a href="#issue-4-semantic-misuse-of-price-notation"
id="toc-issue-4-semantic-misuse-of-price-notation">Issue 4: Semantic
Misuse of Price Notation</a></li>
<li><a href="#issue-5-misnamed-function-and-incorrect-usage"
id="toc-issue-5-misnamed-function-and-incorrect-usage">Issue 5: Misnamed
Function and Incorrect Usage</a></li>
</ul></li>
<li><a href="#traditional-accounting-approaches"
id="toc-traditional-accounting-approaches">Traditional Accounting
Approaches</a>
<ul>
<li><a
href="#approach-1-record-bitcoin-at-fair-market-value-tax-compliant"
id="toc-approach-1-record-bitcoin-at-fair-market-value-tax-compliant">Approach
1: Record Bitcoin at Fair Market Value (Tax Compliant)</a></li>
<li><a href="#approach-2-simplified-eur-only-ledger-no-sats-positions"
id="toc-approach-2-simplified-eur-only-ledger-no-sats-positions">Approach
2: Simplified EUR-Only Ledger (No SATS Positions)</a></li>
<li><a
href="#approach-3-true-net-settlement-when-both-obligations-exist"
id="toc-approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)</a></li>
</ul></li>
<li><a href="#recommendations"
id="toc-recommendations">Recommendations</a>
<ul>
<li><a href="#priority-1-immediate-fixes-easy-wins"
id="toc-priority-1-immediate-fixes-easy-wins">Priority 1: Immediate
Fixes (Easy Wins)</a></li>
<li><a href="#priority-2-medium-term-improvements-compliance"
id="toc-priority-2-medium-term-improvements-compliance">Priority 2:
Medium-Term Improvements (Compliance)</a></li>
<li><a href="#priority-3-long-term-architectural-decisions"
id="toc-priority-3-long-term-architectural-decisions">Priority 3:
Long-Term Architectural Decisions</a></li>
</ul></li>
<li><a href="#code-files-requiring-changes"
id="toc-code-files-requiring-changes">Code Files Requiring Changes</a>
<ul>
<li><a href="#high-priority-immediate-fixes"
id="toc-high-priority-immediate-fixes">High Priority (Immediate
Fixes)</a></li>
<li><a href="#medium-priority-compliance"
id="toc-medium-priority-compliance">Medium Priority
(Compliance)</a></li>
</ul></li>
<li><a href="#testing-requirements"
id="toc-testing-requirements">Testing Requirements</a>
<ul>
<li><a href="#test-case-1-simple-receivable-payment-no-payable"
id="toc-test-case-1-simple-receivable-payment-no-payable">Test Case 1:
Simple Receivable Payment (No Payable)</a></li>
<li><a href="#test-case-2-true-net-settlement"
id="toc-test-case-2-true-net-settlement">Test Case 2: True Net
Settlement</a></li>
<li><a href="#test-case-3-exchange-gainloss-future"
id="toc-test-case-3-exchange-gainloss-future">Test Case 3: Exchange
Gain/Loss (Future)</a></li>
</ul></li>
<li><a href="#conclusion" id="toc-conclusion">Conclusion</a>
<ul>
<li><a href="#summary-of-issues" id="toc-summary-of-issues">Summary of
Issues</a></li>
<li><a href="#professional-assessment"
id="toc-professional-assessment">Professional Assessment</a></li>
<li><a href="#next-steps" id="toc-next-steps">Next Steps</a></li>
</ul></li>
<li><a href="#references" id="toc-references">References</a></li>
</ul></li>
</ul>
</nav>
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
Analysis: Net Settlement Entry Pattern</h1>
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
Senior Accounting Review <strong>Subject</strong>: Libra Extension -
Lightning Payment Settlement Entries <strong>Status</strong>: Technical
Review</p>
<hr />
<h2 id="executive-summary">Executive Summary</h2>
<p>This document provides a professional accounting assessment of
Libras net settlement entry pattern used for recording Lightning
Network payments that settle fiat-denominated receivables. The analysis
identifies areas where the implementation deviates from traditional
accounting best practices and provides specific recommendations for
improvement.</p>
<p><strong>Key Findings</strong>: - ✅ Double-entry integrity maintained
- ✅ Functional for intended purpose - ❌ Zero-amount postings violate
accounting principles - ❌ Redundant satoshi tracking - ❌ No exchange
gain/loss recognition - ⚠️ Mixed currency approach lacks clear
hierarchy</p>
<hr />
<h2 id="background-the-technical-challenge">Background: The Technical
Challenge</h2>
<p>Libra operates as a Lightning Network-integrated accounting system
for collectives (co-living spaces, makerspaces). It faces a unique
accounting challenge:</p>
<p><strong>Scenario</strong>: User creates a receivable in EUR (e.g.,
€200 for room rent), then pays via Lightning Network in satoshis
(225,033 sats).</p>
<p><strong>Challenge</strong>: Record the payment while: 1. Clearing the
exact EUR receivable amount 2. Recording the exact satoshi amount
received 3. Handling cases where users have both receivables (owe
Libra) and payables (Libra owes them) 4. Maintaining Beancount
double-entry balance</p>
<hr />
<h2 id="current-implementation">Current Implementation</h2>
<h3 id="transaction-example">Transaction Example</h3>
<pre class="beancount"><code>; Step 1: Receivable Created
2025-11-12 * &quot;room (200.00 EUR)&quot; #receivable-entry
user-id: &quot;375ec158&quot;
source: &quot;libra-api&quot;
sats-amount: &quot;225033&quot;
Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: &quot;225033&quot;
Income:Accommodation:Guests -200.00 EUR
sats-equivalent: &quot;225033&quot;
; Step 2: Lightning Payment Received
2025-11-12 * &quot;Lightning payment settlement from user 375ec158&quot;
#lightning-payment #net-settlement
user-id: &quot;375ec158&quot;
source: &quot;lightning_payment&quot;
payment-type: &quot;net-settlement&quot;
payment-hash: &quot;8d080ec4cc4301715535004156085dd50c159185...&quot;
Assets:Bitcoin:Lightning 225033 SATS @ 0.0008887585... EUR
payment-hash: &quot;8d080ec4cc4301715535004156085dd50c159185...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR
sats-equivalent: &quot;225033&quot;
Liabilities:Payable:User-375ec158 0.00 EUR</code></pre>
<h3 id="code-implementation">Code Implementation</h3>
<p><strong>Location</strong>:
<code>beancount_format.py:739-760</code></p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Build postings for net settlement</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payment_account,</span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;payment-hash&quot;</span>: payment_hash} <span class="cf">if</span> payment_hash <span class="cf">else</span> {}</span>
<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a> },</span>
<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: receivable_account,</span>
<span id="cb2-10"><a href="#cb2-10" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb2-11"><a href="#cb2-11" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;sats-equivalent&quot;</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
<span id="cb2-12"><a href="#cb2-12" aria-hidden="true" tabindex="-1"></a> },</span>
<span id="cb2-13"><a href="#cb2-13" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb2-14"><a href="#cb2-14" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payable_account,</span>
<span id="cb2-15"><a href="#cb2-15" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb2-16"><a href="#cb2-16" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {}</span>
<span id="cb2-17"><a href="#cb2-17" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb2-18"><a href="#cb2-18" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Three-Posting Structure</strong>: 1. <strong>Lightning
Account</strong>: Records SATS received with <code>@@</code> total price
notation 2. <strong>Receivable Account</strong>: Clears EUR receivable
with sats-equivalent metadata 3. <strong>Payable Account</strong>:
Clears any outstanding EUR payables (often 0.00)</p>
<hr />
<h2 id="accounting-issues-identified">Accounting Issues Identified</h2>
<h3 id="issue-1-zero-amount-postings">Issue 1: Zero-Amount Postings</h3>
<p><strong>Problem</strong>: The third posting often records
<code>0.00 EUR</code> when no payable exists.</p>
<pre class="beancount"><code>Liabilities:Payable:User-375ec158 0.00 EUR</code></pre>
<p><strong>Why This Is Wrong</strong>: - Zero-amount postings have no
economic substance - Clutters the journal with non-events - Violates the
principle of materiality (GAAP Concept Statement 2) - Makes auditing
more difficult (reviewers must verify why zero amounts exist)</p>
<p><strong>Accounting Principle Violated</strong>: &gt; “Transactions
should only include postings that represent actual economic events or
changes in account balances.”</p>
<p><strong>Impact</strong>: Low severity, but unprofessional
presentation</p>
<p><strong>Recommendation</strong>:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Make payable posting conditional</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> {<span class="st">&quot;account&quot;</span>: payment_account, <span class="st">&quot;amount&quot;</span>: ...},</span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> {<span class="st">&quot;account&quot;</span>: receivable_account, <span class="st">&quot;amount&quot;</span>: ...}</span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a>]</span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a><span class="co"># Only add payable posting if there&#39;s actually a payable</span></span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> total_payable_fiat <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> postings.append({</span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payable_account,</span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {}</span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> })</span></code></pre></div>
<hr />
<h3 id="issue-2-redundant-satoshi-tracking">Issue 2: Redundant Satoshi
Tracking</h3>
<p><strong>Problem</strong>: Satoshis are tracked in TWO places in the
same transaction:</p>
<ol type="1">
<li><p><strong>Position Amount</strong> (via <code>@@</code>
notation):</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR</code></pre></li>
<li><p><strong>Metadata</strong> (sats-equivalent):</p>
<pre class="beancount"><code>Assets:Receivable:User-375ec158 -200.00 EUR
sats-equivalent: &quot;225033&quot;</code></pre></li>
</ol>
<p><strong>Why This Is Problematic</strong>: - The <code>@@</code>
notation already records the exact satoshi amount - Beancounts price
database stores this relationship - Metadata becomes redundant for this
specific posting - Increases storage and potential for inconsistency</p>
<p><strong>Technical Detail</strong>:</p>
<p>The <code>@@</code> notation means “total price” and Beancount
converts it to per-unit price:</p>
<pre class="beancount"><code>; You write:
Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
; Beancount stores:
Assets:Bitcoin:Lightning 225033 SATS @ 0.0008887585... EUR
; (where 200.00 / 225033 = 0.0008887585...)</code></pre>
<p>Beancount can query this:</p>
<div class="sourceCode" id="cb8"><pre
class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="kw">account</span>, <span class="fu">sum</span>(<span class="fu">convert</span>(position, SATS))</span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> <span class="kw">account</span> <span class="op">=</span> <span class="st">&#39;Assets:Bitcoin:Lightning&#39;</span></span></code></pre></div>
<p><strong>Recommendation</strong>:</p>
<p>Choose ONE approach consistently:</p>
<p><strong>Option A - Use @ notation</strong> (Beancount standard):</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR
; No sats-equivalent needed here</code></pre>
<p><strong>Option B - Use EUR positions with metadata</strong> (Libras
current approach):</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR
sats-cleared: &quot;225033&quot;</code></pre>
<p><strong>Dont</strong>: Mix both in the same transaction (current
implementation)</p>
<hr />
<h3 id="issue-3-no-exchange-gainloss-recognition">Issue 3: No Exchange
Gain/Loss Recognition</h3>
<p><strong>Problem</strong>: When receivables are denominated in one
currency (EUR) and paid in another (SATS), exchange rate fluctuations
create gains or losses that should be recognized.</p>
<p><strong>Example Scenario</strong>:</p>
<pre><code>Day 1 - Receivable Created:
200 EUR = 225,033 SATS (rate: 1,125.165 sats/EUR)
Day 5 - Payment Received:
225,033 SATS = 199.50 EUR (rate: 1,127.682 sats/EUR)
Exchange rate moved unfavorably
Economic Reality: 0.50 EUR LOSS</code></pre>
<p><strong>Current Implementation</strong>: Forces balance by
calculating the <code>@</code> rate to make it exactly 200 EUR:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR ; = exactly 200.00 EUR</code></pre>
<p>This <strong>hides the exchange variance</strong> by treating the
payment as if it was worth exactly the receivable amount.</p>
<p><strong>GAAP/IFRS Requirement</strong>:</p>
<p>Under both US GAAP (ASC 830) and IFRS (IAS 21), exchange gains and
losses on monetary items (like receivables) should be recognized in the
period they occur.</p>
<p><strong>Proper Accounting Treatment</strong>:</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment with exchange loss&quot;
Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
; Market rate at payment time = 199.50 EUR
Expenses:Foreign-Exchange-Loss 0.50 EUR
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Impact</strong>: Moderate severity - affects financial
statement accuracy</p>
<p><strong>Why This Matters</strong>: - Tax reporting may require
exchange gain/loss recognition - Financial statements misstate true
economic results - Auditors would flag this as a compliance issue -
Cannot accurately calculate ROI or performance metrics</p>
<hr />
<h3 id="issue-4-semantic-misuse-of-price-notation">Issue 4: Semantic
Misuse of Price Notation</h3>
<p><strong>Problem</strong>: The <code>@</code> notation in Beancount
represents <strong>acquisition cost</strong>, not <strong>settlement
value</strong>.</p>
<p><strong>Current Usage</strong>:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR</code></pre>
<p><strong>What this notation means in accounting</strong>: “We
<strong>purchased</strong> 225,033 satoshis at a cost of 0.000888 EUR
per satoshi”</p>
<p><strong>What actually happened</strong>: “We
<strong>received</strong> 225,033 satoshis as payment for a debt”</p>
<p><strong>Economic Difference</strong>: - <strong>Purchase</strong>:
You exchange cash for an asset (buying Bitcoin) - <strong>Payment
Receipt</strong>: You receive an asset in settlement of a receivable</p>
<p><strong>Accounting Substance vs. Form</strong>: -
<strong>Form</strong>: The transaction looks like a Bitcoin purchase -
<strong>Substance</strong>: The transaction is actually a receivable
collection</p>
<p><strong>GAAP Principle (ASC 105-10-05)</strong>: &gt; “Accounting
should reflect the economic substance of transactions, not merely their
legal form.”</p>
<p><strong>Why This Creates Issues</strong>:</p>
<ol type="1">
<li><strong>Cost Basis Tracking</strong>: For tax purposes, the “cost”
of Bitcoin received as payment should be its fair market value at
receipt, not the receivable amount</li>
<li><strong>Price Database Pollution</strong>: Beancounts price
database now contains “prices” that arent real market prices</li>
<li><strong>Auditor Confusion</strong>: An auditor reviewing this would
question why purchase prices dont match market rates</li>
</ol>
<p><strong>Proper Accounting Approach</strong>:</p>
<pre class="beancount"><code>; Approach 1: Record at fair market value
Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
; Using actual market price at time of receipt
acquisition-type: &quot;payment-received&quot;
Revenue:Exchange-Gain 0.50 EUR
Assets:Receivable:User-375ec158 -200.00 EUR
; Approach 2: Don&#39;t use @ notation at all
Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
fmv-at-receipt: &quot;199.50 EUR&quot;
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<hr />
<h3 id="issue-5-misnamed-function-and-incorrect-usage">Issue 5: Misnamed
Function and Incorrect Usage</h3>
<p><strong>Problem</strong>: Function is called
<code>format_net_settlement_entry</code>, but its used for simple
payments that arent true net settlements.</p>
<p><strong>Example from Users Transaction</strong>: - Receivable:
200.00 EUR - Payable: 0.00 EUR - Net: 200.00 EUR (this is just a
<strong>payment</strong>, not a <strong>settlement</strong>)</p>
<p><strong>Accounting Terminology</strong>:</p>
<ul>
<li><strong>Payment</strong>: Settling a single obligation (receivable
OR payable)</li>
<li><strong>Net Settlement</strong>: Offsetting multiple obligations
(receivable AND payable)</li>
</ul>
<p><strong>When Net Settlement is Appropriate</strong>:</p>
<pre><code>User owes Libra: 555.00 EUR (receivable)
Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)</code></pre>
<p>Proper three-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
<p><strong>When Two Postings Suffice</strong>:</p>
<pre><code>User owes Libra: 200.00 EUR (receivable)
Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)</code></pre>
<p>Simpler two-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
Assets:Receivable:User -200.00 EUR</code></pre>
<p><strong>Best Practice</strong>: Use the simplest journal entry
structure that accurately represents the transaction.</p>
<p><strong>Recommendation</strong>: 1. Rename function to
<code>format_payment_entry</code> or
<code>format_receivable_payment_entry</code> 2. Create separate
<code>format_net_settlement_entry</code> for true netting scenarios 3.
Use conditional logic to choose 2-posting vs 3-posting based on whether
both receivables AND payables exist</p>
<hr />
<h2 id="traditional-accounting-approaches">Traditional Accounting
Approaches</h2>
<h3
id="approach-1-record-bitcoin-at-fair-market-value-tax-compliant">Approach
1: Record Bitcoin at Fair Market Value (Tax Compliant)</h3>
<pre class="beancount"><code>2025-11-12 * &quot;Bitcoin payment from user 375ec158&quot;
Assets:Bitcoin:Lightning 199.50 EUR
sats-received: &quot;225033&quot;
fmv-per-sat: &quot;0.000886 EUR&quot;
cost-basis: &quot;199.50 EUR&quot;
payment-hash: &quot;8d080ec4...&quot;
Revenue:Exchange-Gain 0.50 EUR
source: &quot;cryptocurrency-receipt&quot;
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Pros</strong>: - ✅ Tax compliant (establishes cost basis) -
✅ Recognizes exchange gain/loss - ✅ Uses actual market rates - ✅
Audit trail for cryptocurrency receipts</p>
<p><strong>Cons</strong>: - ❌ Requires real-time price feeds - ❌
Creates taxable events</p>
<hr />
<h3
id="approach-2-simplified-eur-only-ledger-no-sats-positions">Approach 2:
Simplified EUR-Only Ledger (No SATS Positions)</h3>
<pre class="beancount"><code>2025-11-12 * &quot;Bitcoin payment from user 375ec158&quot;
Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
sats-rate: &quot;1125.165&quot;
payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Pros</strong>: - ✅ Simple and clean - ✅ EUR positions match
accounting reality - ✅ SATS tracked in metadata for reference - ✅ No
artificial price notation</p>
<p><strong>Cons</strong>: - ❌ SATS not queryable via Beancount
positions - ❌ Requires metadata parsing for SATS balances</p>
<hr />
<h3
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)</h3>
<pre class="beancount"><code>2025-11-12 * &quot;Net settlement via Lightning&quot;
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: &quot;565251&quot;
Assets:Receivable:User-375ec158 -555.00 EUR
Liabilities:Payable:User-375ec158 38.00 EUR</code></pre>
<p><strong>When to Use</strong>: Only when <strong>both</strong>
receivables and payables exist and youre truly netting them.</p>
<hr />
<h2 id="recommendations">Recommendations</h2>
<h3 id="priority-1-immediate-fixes-easy-wins">Priority 1: Immediate
Fixes (Easy Wins)</h3>
<h4 id="remove-zero-amount-postings">1.1 Remove Zero-Amount
Postings</h4>
<p><strong>File</strong>: <code>beancount_format.py:739-760</code></p>
<p><strong>Current Code</strong>:</p>
<div class="sourceCode" id="cb23"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb23-1"><a href="#cb23-1" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb23-2"><a href="#cb23-2" aria-hidden="true" tabindex="-1"></a> {...}, <span class="co"># Lightning</span></span>
<span id="cb23-3"><a href="#cb23-3" aria-hidden="true" tabindex="-1"></a> {...}, <span class="co"># Receivable</span></span>
<span id="cb23-4"><a href="#cb23-4" aria-hidden="true" tabindex="-1"></a> { <span class="co"># Payable (always included, even if 0.00)</span></span>
<span id="cb23-5"><a href="#cb23-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payable_account,</span>
<span id="cb23-6"><a href="#cb23-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb23-7"><a href="#cb23-7" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {}</span>
<span id="cb23-8"><a href="#cb23-8" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb23-9"><a href="#cb23-9" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Fixed Code</strong>:</p>
<div class="sourceCode" id="cb24"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb24-1"><a href="#cb24-1" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb24-2"><a href="#cb24-2" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb24-3"><a href="#cb24-3" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payment_account,</span>
<span id="cb24-4"><a href="#cb24-4" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb24-5"><a href="#cb24-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;payment-hash&quot;</span>: payment_hash} <span class="cf">if</span> payment_hash <span class="cf">else</span> {}</span>
<span id="cb24-6"><a href="#cb24-6" aria-hidden="true" tabindex="-1"></a> },</span>
<span id="cb24-7"><a href="#cb24-7" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb24-8"><a href="#cb24-8" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: receivable_account,</span>
<span id="cb24-9"><a href="#cb24-9" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb24-10"><a href="#cb24-10" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;sats-equivalent&quot;</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
<span id="cb24-11"><a href="#cb24-11" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb24-12"><a href="#cb24-12" aria-hidden="true" tabindex="-1"></a>]</span>
<span id="cb24-13"><a href="#cb24-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-14"><a href="#cb24-14" aria-hidden="true" tabindex="-1"></a><span class="co"># Only add payable posting if there&#39;s actually a payable to clear</span></span>
<span id="cb24-15"><a href="#cb24-15" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> total_payable_fiat <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb24-16"><a href="#cb24-16" aria-hidden="true" tabindex="-1"></a> postings.append({</span>
<span id="cb24-17"><a href="#cb24-17" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payable_account,</span>
<span id="cb24-18"><a href="#cb24-18" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb24-19"><a href="#cb24-19" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {}</span>
<span id="cb24-20"><a href="#cb24-20" aria-hidden="true" tabindex="-1"></a> })</span></code></pre></div>
<p><strong>Impact</strong>: Cleaner journal, professional presentation,
easier auditing</p>
<hr />
<h4 id="choose-one-sats-tracking-method">1.2 Choose One SATS Tracking
Method</h4>
<p><strong>Decision Required</strong>: Select either position-based OR
metadata-based satoshi tracking.</p>
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
Libra):</p>
<div class="sourceCode" id="cb25"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb25-1"><a href="#cb25-1" aria-hidden="true" tabindex="-1"></a><span class="co"># In format_net_settlement_entry()</span></span>
<span id="cb25-2"><a href="#cb25-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb25-3"><a href="#cb25-3" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb25-4"><a href="#cb25-4" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payment_account,</span>
<span id="cb25-5"><a href="#cb25-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>, <span class="co"># EUR only</span></span>
<span id="cb25-6"><a href="#cb25-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {</span>
<span id="cb25-7"><a href="#cb25-7" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;sats-received&quot;</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats)),</span>
<span id="cb25-8"><a href="#cb25-8" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;payment-hash&quot;</span>: payment_hash</span>
<span id="cb25-9"><a href="#cb25-9" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb25-10"><a href="#cb25-10" aria-hidden="true" tabindex="-1"></a> },</span>
<span id="cb25-11"><a href="#cb25-11" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb25-12"><a href="#cb25-12" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: receivable_account,</span>
<span id="cb25-13"><a href="#cb25-13" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb25-14"><a href="#cb25-14" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;sats-cleared&quot;</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
<span id="cb25-15"><a href="#cb25-15" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb25-16"><a href="#cb25-16" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Option B - Use Position-Based Tracking</strong>:</p>
<div class="sourceCode" id="cb26"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb26-1"><a href="#cb26-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Remove sats-equivalent metadata entirely</span></span>
<span id="cb26-2"><a href="#cb26-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
<span id="cb26-3"><a href="#cb26-3" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb26-4"><a href="#cb26-4" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: payment_account,</span>
<span id="cb26-5"><a href="#cb26-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb26-6"><a href="#cb26-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {<span class="st">&quot;payment-hash&quot;</span>: payment_hash}</span>
<span id="cb26-7"><a href="#cb26-7" aria-hidden="true" tabindex="-1"></a> },</span>
<span id="cb26-8"><a href="#cb26-8" aria-hidden="true" tabindex="-1"></a> {</span>
<span id="cb26-9"><a href="#cb26-9" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: receivable_account,</span>
<span id="cb26-10"><a href="#cb26-10" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb26-11"><a href="#cb26-11" aria-hidden="true" tabindex="-1"></a> <span class="co"># No sats-equivalent needed - queryable via price database</span></span>
<span id="cb26-12"><a href="#cb26-12" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb26-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Recommendation</strong>: Choose Option A (metadata) for
consistency with Libras architecture.</p>
<hr />
<h4 id="rename-function-for-clarity">1.3 Rename Function for
Clarity</h4>
<p><strong>File</strong>: <code>beancount_format.py</code></p>
<p><strong>Current</strong>:
<code>format_net_settlement_entry()</code></p>
<p><strong>New</strong>: <code>format_receivable_payment_entry()</code>
or <code>format_payment_settlement_entry()</code></p>
<p><strong>Rationale</strong>: More accurately describes what the
function does (processes payments, not always net settlements)</p>
<hr />
<h3 id="priority-2-medium-term-improvements-compliance">Priority 2:
Medium-Term Improvements (Compliance)</h3>
<h4 id="add-exchange-gainloss-tracking">2.1 Add Exchange Gain/Loss
Tracking</h4>
<p><strong>File</strong>: <code>tasks.py:259-276</code> (get balance and
calculate settlement)</p>
<p><strong>New Logic</strong>:</p>
<div class="sourceCode" id="cb27"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb27-1"><a href="#cb27-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Get user&#39;s current balance</span></span>
<span id="cb27-2"><a href="#cb27-2" aria-hidden="true" tabindex="-1"></a>balance <span class="op">=</span> <span class="cf">await</span> fava.get_user_balance(user_id)</span>
<span id="cb27-3"><a href="#cb27-3" aria-hidden="true" tabindex="-1"></a>fiat_balances <span class="op">=</span> balance.get(<span class="st">&quot;fiat_balances&quot;</span>, {})</span>
<span id="cb27-4"><a href="#cb27-4" aria-hidden="true" tabindex="-1"></a>total_fiat_balance <span class="op">=</span> fiat_balances.get(fiat_currency, Decimal(<span class="dv">0</span>))</span>
<span id="cb27-5"><a href="#cb27-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb27-6"><a href="#cb27-6" aria-hidden="true" tabindex="-1"></a><span class="co"># Calculate expected fiat value of SATS payment at current market rate</span></span>
<span id="cb27-7"><a href="#cb27-7" aria-hidden="true" tabindex="-1"></a>market_rate <span class="op">=</span> <span class="cf">await</span> get_current_sats_eur_rate() <span class="co"># New function needed</span></span>
<span id="cb27-8"><a href="#cb27-8" aria-hidden="true" tabindex="-1"></a>market_value <span class="op">=</span> Decimal(amount_sats) <span class="op">*</span> market_rate</span>
<span id="cb27-9"><a href="#cb27-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb27-10"><a href="#cb27-10" aria-hidden="true" tabindex="-1"></a><span class="co"># Calculate exchange variance</span></span>
<span id="cb27-11"><a href="#cb27-11" aria-hidden="true" tabindex="-1"></a>receivable_amount <span class="op">=</span> <span class="bu">abs</span>(total_fiat_balance) <span class="cf">if</span> total_fiat_balance <span class="op">&gt;</span> <span class="dv">0</span> <span class="cf">else</span> Decimal(<span class="dv">0</span>)</span>
<span id="cb27-12"><a href="#cb27-12" aria-hidden="true" tabindex="-1"></a>exchange_variance <span class="op">=</span> market_value <span class="op">-</span> receivable_amount</span>
<span id="cb27-13"><a href="#cb27-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb27-14"><a href="#cb27-14" aria-hidden="true" tabindex="-1"></a><span class="co"># If variance is material (&gt; 1 cent), create exchange gain/loss posting</span></span>
<span id="cb27-15"><a href="#cb27-15" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> <span class="bu">abs</span>(exchange_variance) <span class="op">&gt;</span> Decimal(<span class="st">&quot;0.01&quot;</span>):</span>
<span id="cb27-16"><a href="#cb27-16" aria-hidden="true" tabindex="-1"></a> <span class="co"># Add exchange gain/loss to postings</span></span>
<span id="cb27-17"><a href="#cb27-17" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> exchange_variance <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb27-18"><a href="#cb27-18" aria-hidden="true" tabindex="-1"></a> <span class="co"># Gain: payment worth more than receivable</span></span>
<span id="cb27-19"><a href="#cb27-19" aria-hidden="true" tabindex="-1"></a> exchange_account <span class="op">=</span> <span class="st">&quot;Revenue:Foreign-Exchange-Gain&quot;</span></span>
<span id="cb27-20"><a href="#cb27-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
<span id="cb27-21"><a href="#cb27-21" aria-hidden="true" tabindex="-1"></a> <span class="co"># Loss: payment worth less than receivable</span></span>
<span id="cb27-22"><a href="#cb27-22" aria-hidden="true" tabindex="-1"></a> exchange_account <span class="op">=</span> <span class="st">&quot;Expenses:Foreign-Exchange-Loss&quot;</span></span>
<span id="cb27-23"><a href="#cb27-23" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb27-24"><a href="#cb27-24" aria-hidden="true" tabindex="-1"></a> <span class="co"># Include in entry creation</span></span>
<span id="cb27-25"><a href="#cb27-25" aria-hidden="true" tabindex="-1"></a> exchange_posting <span class="op">=</span> {</span>
<span id="cb27-26"><a href="#cb27-26" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;account&quot;</span>: exchange_account,</span>
<span id="cb27-27"><a href="#cb27-27" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;amount&quot;</span>: <span class="ss">f&quot;</span><span class="sc">{</span><span class="bu">abs</span>(exchange_variance)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">&quot;</span>,</span>
<span id="cb27-28"><a href="#cb27-28" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;meta&quot;</span>: {</span>
<span id="cb27-29"><a href="#cb27-29" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;sats-amount&quot;</span>: <span class="bu">str</span>(amount_sats),</span>
<span id="cb27-30"><a href="#cb27-30" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;market-rate&quot;</span>: <span class="bu">str</span>(market_rate),</span>
<span id="cb27-31"><a href="#cb27-31" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;receivable-amount&quot;</span>: <span class="bu">str</span>(receivable_amount)</span>
<span id="cb27-32"><a href="#cb27-32" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb27-33"><a href="#cb27-33" aria-hidden="true" tabindex="-1"></a> }</span></code></pre></div>
<p><strong>Benefits</strong>: - ✅ Tax compliance - ✅ Accurate
financial reporting - ✅ Audit trail for cryptocurrency gains/losses -
✅ Regulatory compliance (GAAP/IFRS)</p>
<hr />
<h4 id="implement-true-net-settlement-vs.-simple-payment-logic">2.2
Implement True Net Settlement vs. Simple Payment Logic</h4>
<p><strong>File</strong>: <code>tasks.py</code> or new
<code>payment_logic.py</code></p>
<div class="sourceCode" id="cb28"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb28-1"><a href="#cb28-1" aria-hidden="true" tabindex="-1"></a><span class="cf">async</span> <span class="kw">def</span> create_payment_entry(</span>
<span id="cb28-2"><a href="#cb28-2" aria-hidden="true" tabindex="-1"></a> user_id: <span class="bu">str</span>,</span>
<span id="cb28-3"><a href="#cb28-3" aria-hidden="true" tabindex="-1"></a> amount_sats: <span class="bu">int</span>,</span>
<span id="cb28-4"><a href="#cb28-4" aria-hidden="true" tabindex="-1"></a> fiat_amount: Decimal,</span>
<span id="cb28-5"><a href="#cb28-5" aria-hidden="true" tabindex="-1"></a> fiat_currency: <span class="bu">str</span>,</span>
<span id="cb28-6"><a href="#cb28-6" aria-hidden="true" tabindex="-1"></a> payment_hash: <span class="bu">str</span></span>
<span id="cb28-7"><a href="#cb28-7" aria-hidden="true" tabindex="-1"></a>):</span>
<span id="cb28-8"><a href="#cb28-8" aria-hidden="true" tabindex="-1"></a> <span class="co">&quot;&quot;&quot;</span></span>
<span id="cb28-9"><a href="#cb28-9" aria-hidden="true" tabindex="-1"></a><span class="co"> Create appropriate payment entry based on user&#39;s balance situation.</span></span>
<span id="cb28-10"><a href="#cb28-10" aria-hidden="true" tabindex="-1"></a><span class="co"> Uses 2-posting for simple payments, 3-posting for net settlements.</span></span>
<span id="cb28-11"><a href="#cb28-11" aria-hidden="true" tabindex="-1"></a><span class="co"> &quot;&quot;&quot;</span></span>
<span id="cb28-12"><a href="#cb28-12" aria-hidden="true" tabindex="-1"></a> <span class="co"># Get user balance</span></span>
<span id="cb28-13"><a href="#cb28-13" aria-hidden="true" tabindex="-1"></a> balance <span class="op">=</span> <span class="cf">await</span> fava.get_user_balance(user_id)</span>
<span id="cb28-14"><a href="#cb28-14" aria-hidden="true" tabindex="-1"></a> fiat_balances <span class="op">=</span> balance.get(<span class="st">&quot;fiat_balances&quot;</span>, {})</span>
<span id="cb28-15"><a href="#cb28-15" aria-hidden="true" tabindex="-1"></a> total_balance <span class="op">=</span> fiat_balances.get(fiat_currency, Decimal(<span class="dv">0</span>))</span>
<span id="cb28-16"><a href="#cb28-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb28-17"><a href="#cb28-17" aria-hidden="true" tabindex="-1"></a> receivable_amount <span class="op">=</span> Decimal(<span class="dv">0</span>)</span>
<span id="cb28-18"><a href="#cb28-18" aria-hidden="true" tabindex="-1"></a> payable_amount <span class="op">=</span> Decimal(<span class="dv">0</span>)</span>
<span id="cb28-19"><a href="#cb28-19" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb28-20"><a href="#cb28-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> total_balance <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb28-21"><a href="#cb28-21" aria-hidden="true" tabindex="-1"></a> receivable_amount <span class="op">=</span> total_balance</span>
<span id="cb28-22"><a href="#cb28-22" aria-hidden="true" tabindex="-1"></a> <span class="cf">elif</span> total_balance <span class="op">&lt;</span> <span class="dv">0</span>:</span>
<span id="cb28-23"><a href="#cb28-23" aria-hidden="true" tabindex="-1"></a> payable_amount <span class="op">=</span> <span class="bu">abs</span>(total_balance)</span>
<span id="cb28-24"><a href="#cb28-24" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb28-25"><a href="#cb28-25" aria-hidden="true" tabindex="-1"></a> <span class="co"># Determine entry type</span></span>
<span id="cb28-26"><a href="#cb28-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> receivable_amount <span class="op">&gt;</span> <span class="dv">0</span> <span class="kw">and</span> payable_amount <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb28-27"><a href="#cb28-27" aria-hidden="true" tabindex="-1"></a> <span class="co"># TRUE NET SETTLEMENT: Both obligations exist</span></span>
<span id="cb28-28"><a href="#cb28-28" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_net_settlement_entry(</span>
<span id="cb28-29"><a href="#cb28-29" aria-hidden="true" tabindex="-1"></a> user_id<span class="op">=</span>user_id,</span>
<span id="cb28-30"><a href="#cb28-30" aria-hidden="true" tabindex="-1"></a> amount_sats<span class="op">=</span>amount_sats,</span>
<span id="cb28-31"><a href="#cb28-31" aria-hidden="true" tabindex="-1"></a> receivable_amount<span class="op">=</span>receivable_amount,</span>
<span id="cb28-32"><a href="#cb28-32" aria-hidden="true" tabindex="-1"></a> payable_amount<span class="op">=</span>payable_amount,</span>
<span id="cb28-33"><a href="#cb28-33" aria-hidden="true" tabindex="-1"></a> fiat_amount<span class="op">=</span>fiat_amount,</span>
<span id="cb28-34"><a href="#cb28-34" aria-hidden="true" tabindex="-1"></a> fiat_currency<span class="op">=</span>fiat_currency,</span>
<span id="cb28-35"><a href="#cb28-35" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
<span id="cb28-36"><a href="#cb28-36" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb28-37"><a href="#cb28-37" aria-hidden="true" tabindex="-1"></a> <span class="cf">elif</span> receivable_amount <span class="op">&gt;</span> <span class="dv">0</span>:</span>
<span id="cb28-38"><a href="#cb28-38" aria-hidden="true" tabindex="-1"></a> <span class="co"># SIMPLE RECEIVABLE PAYMENT: Only receivable exists</span></span>
<span id="cb28-39"><a href="#cb28-39" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_receivable_payment_entry(</span>
<span id="cb28-40"><a href="#cb28-40" aria-hidden="true" tabindex="-1"></a> user_id<span class="op">=</span>user_id,</span>
<span id="cb28-41"><a href="#cb28-41" aria-hidden="true" tabindex="-1"></a> amount_sats<span class="op">=</span>amount_sats,</span>
<span id="cb28-42"><a href="#cb28-42" aria-hidden="true" tabindex="-1"></a> receivable_amount<span class="op">=</span>receivable_amount,</span>
<span id="cb28-43"><a href="#cb28-43" aria-hidden="true" tabindex="-1"></a> fiat_amount<span class="op">=</span>fiat_amount,</span>
<span id="cb28-44"><a href="#cb28-44" aria-hidden="true" tabindex="-1"></a> fiat_currency<span class="op">=</span>fiat_currency,</span>
<span id="cb28-45"><a href="#cb28-45" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
<span id="cb28-46"><a href="#cb28-46" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb28-47"><a href="#cb28-47" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
<span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Libra paying user (different flow)</span></span>
<span id="cb28-49"><a href="#cb28-49" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_payable_payment_entry(...)</span></code></pre></div>
<hr />
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
Long-Term Architectural Decisions</h3>
<h4 id="establish-primary-currency-hierarchy">3.1 Establish Primary
Currency Hierarchy</h4>
<p><strong>Current Issue</strong>: Mixed approach (EUR positions with
SATS metadata, but also SATS positions with @ notation)</p>
<p><strong>Decision Required</strong>: Choose ONE of the following
architectures:</p>
<p><strong>Architecture A - EUR Primary, SATS Secondary</strong>
(recommended):</p>
<pre class="beancount"><code>; All positions in EUR, SATS in metadata
2025-11-12 * &quot;Payment&quot;
Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
Assets:Receivable:User -200.00 EUR
sats-cleared: &quot;225033&quot;</code></pre>
<p><strong>Architecture B - SATS Primary, EUR Secondary</strong>:</p>
<pre class="beancount"><code>; All positions in SATS, EUR in metadata
2025-11-12 * &quot;Payment&quot;
Assets:Bitcoin:Lightning 225033 SATS
eur-value: &quot;200.00&quot;
Assets:Receivable:User -225033 SATS
eur-cleared: &quot;200.00&quot;</code></pre>
<p><strong>Recommendation</strong>: Architecture A (EUR primary)
because: 1. Most receivables created in EUR 2. Financial reporting
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
Aligns with current Libra metadata approach</p>
<hr />
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
Consider Separate Ledger for Cryptocurrency Holdings</h4>
<p><strong>Advanced Approach</strong>: Separate cryptocurrency movements
from fiat accounting</p>
<p><strong>Main Ledger</strong> (EUR-denominated):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Payment received from user&quot;
Assets:Bitcoin-Custody:User-375ec158 200.00 EUR
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment received&quot;
Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
Cryptocurrency movements tracked independently - ✅ Fiat accounting
unaffected by Bitcoin volatility - ✅ Can generate separate financial
statements</p>
<p><strong>Drawbacks</strong>: - ❌ Increased complexity - ❌
Reconciliation between ledgers required - ❌ Two sets of books to
maintain</p>
<hr />
<h2 id="code-files-requiring-changes">Code Files Requiring Changes</h2>
<h3 id="high-priority-immediate-fixes">High Priority (Immediate
Fixes)</h3>
<ol type="1">
<li><strong><code>beancount_format.py:739-760</code></strong>
<ul>
<li>Remove zero-amount postings</li>
<li>Make payable posting conditional</li>
</ul></li>
<li><strong><code>beancount_format.py:692</code></strong>
<ul>
<li>Rename function to <code>format_receivable_payment_entry</code></li>
</ul></li>
</ol>
<h3 id="medium-priority-compliance">Medium Priority (Compliance)</h3>
<ol start="3" type="1">
<li><strong><code>tasks.py:235-310</code></strong>
<ul>
<li>Add exchange gain/loss calculation</li>
<li>Implement payment vs. settlement logic</li>
</ul></li>
<li><strong>New file: <code>exchange_rates.py</code></strong>
<ul>
<li>Create <code>get_current_sats_eur_rate()</code> function</li>
<li>Implement price feed integration</li>
</ul></li>
<li><strong><code>beancount_format.py</code></strong>
<ul>
<li>Create new <code>format_net_settlement_entry()</code> for true
netting</li>
<li>Create <code>format_receivable_payment_entry()</code> for simple
payments</li>
</ul></li>
</ol>
<hr />
<h2 id="testing-requirements">Testing Requirements</h2>
<h3 id="test-case-1-simple-receivable-payment-no-payable">Test Case 1:
Simple Receivable Payment (No Payable)</h3>
<p><strong>Setup</strong>: - User has receivable: 200.00 EUR - User has
payable: 0.00 EUR - User pays: 225,033 SATS</p>
<p><strong>Expected Entry</strong> (after fixes):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment from user&quot;
Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User -200.00 EUR
sats-cleared: &quot;225033&quot;</code></pre>
<p><strong>Verify</strong>: - ✅ Only 2 postings (no zero-amount
payable) - ✅ Entry balances - ✅ SATS tracked in metadata - ✅ User
balance becomes 0 (both EUR and SATS)</p>
<hr />
<h3 id="test-case-2-true-net-settlement">Test Case 2: True Net
Settlement</h3>
<p><strong>Setup</strong>: - User has receivable: 555.00 EUR - User has
payable: 38.00 EUR - Net owed: 517.00 EUR - User pays: 565,251 SATS
(worth 517.00 EUR)</p>
<p><strong>Expected Entry</strong>:</p>
<pre class="beancount"><code>2025-11-12 * &quot;Net settlement via Lightning&quot;
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: &quot;565251&quot;
payment-hash: &quot;abc123...&quot;
Assets:Receivable:User -555.00 EUR
sats-portion: &quot;565251&quot;
Liabilities:Payable:User 38.00 EUR</code></pre>
<p><strong>Verify</strong>: - ✅ 3 postings (receivable + payable
cleared) - ✅ Net amount = receivable - payable - ✅ Both balances
become 0 - ✅ Mathematically balanced</p>
<hr />
<h3 id="test-case-3-exchange-gainloss-future">Test Case 3: Exchange
Gain/Loss (Future)</h3>
<p><strong>Setup</strong>: - User has receivable: 200.00 EUR (created at
1,125 sats/EUR) - User pays: 225,033 SATS (now worth 199.50 EUR at
market) - Exchange loss: 0.50 EUR</p>
<p><strong>Expected Entry</strong> (with exchange tracking):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment with exchange loss&quot;
Assets:Bitcoin:Lightning 199.50 EUR
sats-received: &quot;225033&quot;
market-rate: &quot;0.000886&quot;
Expenses:Foreign-Exchange-Loss 0.50 EUR
Assets:Receivable:User -200.00 EUR</code></pre>
<p><strong>Verify</strong>: - ✅ Bitcoin recorded at fair market value -
✅ Exchange loss recognized - ✅ Receivable cleared at book value - ✅
Entry balances</p>
<hr />
<h2 id="conclusion">Conclusion</h2>
<h3 id="summary-of-issues">Summary of Issues</h3>
<table>
<colgroup>
<col style="width: 12%" />
<col style="width: 18%" />
<col style="width: 34%" />
<col style="width: 34%" />
</colgroup>
<thead>
<tr>
<th>Issue</th>
<th>Severity</th>
<th>Accounting Impact</th>
<th>Recommended Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Zero-amount postings</td>
<td>Low</td>
<td>Presentation only</td>
<td>Remove immediately</td>
</tr>
<tr>
<td>Redundant SATS tracking</td>
<td>Low</td>
<td>Storage/efficiency</td>
<td>Choose one method</td>
</tr>
<tr>
<td>No exchange gain/loss</td>
<td><strong>High</strong></td>
<td>Financial accuracy</td>
<td>Implement for compliance</td>
</tr>
<tr>
<td>Semantic misuse of @</td>
<td>Medium</td>
<td>Audit clarity</td>
<td>Consider EUR-only positions</td>
</tr>
<tr>
<td>Misnamed function</td>
<td>Low</td>
<td>Code clarity</td>
<td>Rename function</td>
</tr>
</tbody>
</table>
<h3 id="professional-assessment">Professional Assessment</h3>
<p><strong>Is this “best practice” accounting?</strong>
<strong>No</strong>, this implementation deviates from traditional
accounting standards in several ways.</p>
<p><strong>Is it acceptable for Libras use case?</strong> <strong>Yes,
with modifications</strong>, its a reasonable pragmatic solution for a
novel problem (cryptocurrency payments of fiat debts).</p>
<p><strong>Critical improvements needed</strong>: 1. ✅ Remove
zero-amount postings (easy fix, professional presentation) 2. ✅
Implement exchange gain/loss tracking (required for compliance) 3. ✅
Separate payment vs. settlement logic (accuracy and clarity)</p>
<p><strong>The fundamental challenge</strong>: Traditional accounting
wasnt designed for this scenario. There is no established “standard”
for recording cryptocurrency payments of fiat-denominated receivables.
Libras approach is functional, but should be refined to align better
with accounting principles where possible.</p>
<h3 id="next-steps">Next Steps</h3>
<ol type="1">
<li><strong>Week 1</strong>: Implement Priority 1 fixes (remove zero
postings, rename function)</li>
<li><strong>Week 2-3</strong>: Design and implement exchange gain/loss
tracking</li>
<li><strong>Week 4</strong>: Add payment vs. settlement logic</li>
<li><strong>Ongoing</strong>: Monitor regulatory guidance on
cryptocurrency accounting</li>
</ol>
<hr />
<h2 id="references">References</h2>
<ul>
<li><strong>FASB ASC 830</strong>: Foreign Currency Matters</li>
<li><strong>IAS 21</strong>: The Effects of Changes in Foreign Exchange
Rates</li>
<li><strong>FASB Concept Statement No. 2</strong>: Qualitative
Characteristics of Accounting Information</li>
<li><strong>ASC 105-10-05</strong>: Substance Over Form</li>
<li><strong>Beancount Documentation</strong>:
http://furius.ca/beancount/doc/index</li>
<li><strong>Libra Extension</strong>:
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
<li><strong>BQL Analysis</strong>:
<code>docs/BQL-BALANCE-QUERIES.md</code></li>
</ul>
<hr />
<p><strong>Document Version</strong>: 1.0 <strong>Last Updated</strong>:
2025-01-12 <strong>Next Review</strong>: After Priority 1 fixes
implemented</p>
<hr />
<p><em>This analysis was prepared for internal review and development
planning. It represents a professional accounting assessment of the
current implementation and should be used to guide improvements to
Libras payment recording system.</em></p>
</body>
</html>

View file

@ -1,260 +0,0 @@
# Code review — 2026-06-05
Findings from a deep review of the Libra LNbits extension (12k LOC,
14 files). Each finding has `file:line` references, a one-line fix
proposal, and a status tag:
- ✅ **fixed** — merged in commit listed
- ⏳ **outstanding** — still needs work
- 🚫 **downgraded** — initially flagged, verified not a bug on closer read
Triage order at the bottom prioritises blast radius over file location.
> **2026-07-12 refactor series:** findings #2#19 fixed across PRs
> #55#59 + the chore/hygiene branch (stacked; merge in order). LOW
> items fixed in chore/hygiene except `parse_legacy_account_name`
> fragility (documented assumption, internal input only) and the
> `is_active`/`is_virtual` filter inconsistency (still open).
---
## CRITICAL
### ✅ #1 — Mass `require_admin_key` mis-use → cross-user privilege escalation
**Status:** fixed in `1201557` (`aiolabs/libra` main, 2026-06-05) +
`4c704e5` (`aiolabs/webapp` dev).
27 endpoints documented "(admin only)" used `require_admin_key`, which
only checks the caller owns *some* wallet with its admin key — i.e.
any authenticated user. Cluster included `receivable`/`revenue`
creation, equity-eligibility grant/revoke, account-permission CRUD
(grant yourself MANAGE on any account → ledger god mode), role and
user-role CRUD, account-sync admin, cross-user reports.
Also deleted the duplicate `api_pay_user` at `views_api.py:1937` (the
correctly-gated `/api/v1/payables/pay` at L2144 replaces it).
Webapp side: deleted orphaned `PermissionManager.vue` +
`GrantPermissionDialog.vue` admin components that were never imported
or routed and whose backing API methods pointed at non-existent paths.
### ✅ #2`format_net_settlement_entry` ships unbalanced postings on partial payments
`beancount_format.py:761-777` emits three postings whose weights sum to
`net_fiat total_receivable + total_payable`. The docstring example
assumes `net_fiat == total_receivable total_payable`. But
`tasks.py:251-258` sets `total_receivable = total_prior_balance` and
`net_fiat = invoice_fiat_amount` — only equal when the user paid the
full balance. Any partial payment ships unbalanced postings; Beancount
will reject or apply tolerance silently.
**Fix:** add `assert abs(net_fiat (receivable payable)) <= 0.005`
inside the formatter and raise on violation. Then fix the caller in
`tasks.py:218-322` to settle only what the payment covers (likely a
two-posting `DR Lightning / CR Receivable`-for-payment-amount, not
net-settlement).
### ✅ #3 — Migrations not idempotent (violates fork-migrations contract)
`migrations.py:347, 377` (`ALTER TABLE accounts ADD COLUMN
is_active/is_virtual`) and `migrations.py:441, 472, 510` (`CREATE TABLE
roles/role_permissions/user_roles`) lack idempotency guards. Seed
`INSERT`s at `migrations.py:319-330, 400-412, 587-597` have no `ON
CONFLICT DO NOTHING`. Per CLAUDE.md, the cross-DB write between
`ext_libra` and core `dbversions` is non-atomic — a failed version-bump
leaves the migration to re-run on boot and crash with `duplicate
column` / `table exists` / UNIQUE violation. Bricks the extension until
manual `dbversions` surgery.
**Fix:** wrap ALTERs with `_alter_add_column_safe`, switch CREATEs to
`CREATE TABLE IF NOT EXISTS`, gate seed INSERTs with `INSERT ... ON
CONFLICT DO NOTHING`.
### ✅ #4 — Lightning payment recording has no local idempotency gate
`tasks.py:218-322` relies entirely on `fava.add_entry_idempotent` for
dedup, which itself does a read-then-write race on the Fava ledger. On
lnbits restart with a persisted invoice queue, the same `payment_hash`
can re-fire; the per-user lock is in-process only and doesn't survive
restart. Webhook + poller hitting concurrently both pass the
"not present" check and both insert.
**Fix:** add a `processed_payments(payment_hash TEXT PRIMARY KEY)`
table; `INSERT OR IGNORE` at the top of `on_invoice_paid`; only
proceed if `rowcount == 1`.
---
## HIGH
### ✅ #5 — Auth prefix-match in `can_access_user_data`
`auth.py:248-251` uses `caller.user_id[:8] == target.user_id[:8]`.
Eight hex chars = 32 bits; birthday collision at ~65k users.
**Fix:** require full UUID equality; never resolve by prefix in an
authorisation decision.
### ✅ #6`can_access_account` substring match
`auth.py:178-180` does `f"User-{short}" in account.name` — matches
`Expenses:Misc-User-deadbeef` too.
**Fix:** split on `:`, require segment equality.
### ✅ #7`ChecksumConflictError` never raised by update/delete
`fava_client.py:1392-1482`: Fava 409/412 propagates as raw
`HTTPStatusError`. The `ChecksumConflictError` type exists but isn't
raised by these methods; callers see stack-trace 500s instead of a
clean retry path.
**Fix:** `if status in (409, 412): raise ChecksumConflictError(...)`.
### ✅ #8`float()` arithmetic in fiat-rate metadata
`views_api.py:1061-1062, 1263-1264, 1364-1365, 1759-1760` compute
`fiat_rate` / `btc_rate` via `float()`, then persist into Beancount
metadata as the cost basis for that entry. Float drift cascades
through reporting.
**Fix:** keep `Decimal` end-to-end; only stringify at JSON-serialise
time.
### ✅ #9 — Background loop swallows `raise` in `on_invoice_paid`
`tasks.py:320-322` does `logger.error(...); raise`.
`wait_for_paid_invoices` at `tasks.py:178-180` has no surrounding
try/except, so one unhandled exception kills the listener for the
rest of the process lifetime — no further Lightning payments get
recorded, no alarm.
**Fix:** wrap the iteration body in
`try/except Exception: logger.exception(...)`; never `raise` from
`on_invoice_paid`.
### ✅ #10`record-payment` dedup is non-atomic AND exception-swallowing
`views_api.py:1841-1924` (per subagent report — needs verification)
catches all exceptions in the dedup window with a 5-second timeout
and treats Fava errors as "not duplicate", producing double-entries
on transient Fava blips.
**Fix:** fail closed on transport error; narrow the exception type
catch to `httpx.HTTPError` only.
### ✅ #11`validate_journal_entry` is stale
`core/validation.py:21-93` validates the pre-string-amount model —
sums one bag of integers, doesn't balance per currency. Doesn't match
production data shape post-Fava migration.
**Fix:** rewrite to parse `"X CCY"` strings and balance per currency,
or delete if Beancount-side validation is now considered sufficient.
---
## MEDIUM
### ✅ #12`m001_initial` seed `INSERT` into `accounts` non-idempotent
`migrations.py:319-330` — same shape as #3.
### ✅ #13`format_posting_at_average_cost` emits `{}` when `cost_currency=None`
`beancount_format.py:256``<sats> SATS {}` isn't valid Beancount
syntax; drop the braces when cost is unset.
### ✅ #14 — Per-call `httpx.AsyncClient` instantiation in fava_client
~30 sites build a new `httpx.AsyncClient` per call. TCP handshake every
time.
**Fix:** construct once on `FavaClient.__init__`, expose `aclose()`.
### ✅ #15 — BQL string interpolation without quoting
`fava_client.py:250, 621, 770-775, 853-858` interpolate
`account_name` / user-id-prefix raw into BQL `WHERE account = '{...}'`.
The 8-char hex prefix is safe in practice; arbitrary `account_name`
input is not.
**Fix:** validate against `^[A-Za-z0-9:_-]+$` before interpolation.
### ✅ #16`approve_manual_payment_request` not status-guarded
`crud.py:559-579` overwrites `status='approved'` regardless of current
state. Two concurrent admins → two journal entries.
**Fix:** `UPDATE ... WHERE id=:id AND status='pending'`, check
`rowcount == 1`.
### ✅ #17 — Account name not validated on receivable/revenue/expense
`views_api.py:1066-1074, 1224-1236, 1369-1376, 1477-1494` accept
free-string `data.expense_account` (etc.) with no Beancount-syntax
check before lookup.
**Fix:** enforce `^[A-Z][A-Za-z0-9:-]*$`.
### ✅ #18`_get_username_from_user_id` creates a fresh LNbits DB per call
`views_api.py:697-708` opens an LNbits DB inside a per-row hot path.
**Fix:** cache the Database instance at module load; batch-load
usernames once per request via single `IN (…)` query.
### ✅ #19`get_user_balance` regex rejects decimal SATS
`fava_client.py:346, 503` patterns require `(-?\d+)` SATS. Fava's
`@@→@` normalisation can emit decimal SATS.
**Fix:** `(-?[\d.]+)`.
### ⏳ #20`fava_url` default `http://localhost:3333` and sandboxed lnbits
Loopback breaks if the lnbits service unit gains `PrivateNetwork=true`.
Not a code bug — worth documenting in deploy assumptions.
---
## LOW
- ⏳ `tasks.py` mixes `print()` with `logger.*` (`:61, 65-69, 81, 89,
94, 162`).
- ⏳ Dead model imports in `crud.py:21-27` (`JournalEntry`,
`EntryLine`) after `entry_lines` table dropped.
- ⏳ `auto_assign_default_role` check-then-act race
(`crud.py:1609-1641`) — add UNIQUE constraint on
`user_roles(user_id, role_id)`.
- ⏳ Pydantic v1 `.dict()` calls (`crud.py:381, 400, 411, 450`) if
upstream is on v2.
- ⏳ `account_utils.parse_legacy_account_name` splits on ` - `
fragile if ever called on user input.
- ⏳ `Account.is_active` vs `is_virtual` default-filter inconsistency
hides virtual parents in permission-grant UI (`crud.py:146-167`).
---
## 🚫 Downgraded (initially flagged, verified not a bug)
### Subagent A — "inverted balance sign in tasks.py:251-258"
`fava_client.py:303` docstring says positive = user owes libra
(bookkeeper perspective). `tasks.py:249-250` matches. CLAUDE.md
describes the *user's* perspective (positive = libra owes user), which
is consistent at the UI layer. Both representations are internally
coherent — no bug, just a doc-vs-code perspective collision.
### Subagent A — "fava_ledger_slug default doesn't match deploy"
Verified empirically by user: Fava in single-ledger mode appears to
accept arbitrary slugs against the JSON API, and the deploy's seed
title `"Libra Ledger"` → slugify → `libra-ledger` matches the
extension default in `models.py:159` anyway. False alarm.
---
## Triage order (when picking the next item)
1. **#2 (unbalanced net settlement) + #4 (idempotency)** — silently
corrupts the ledger on every partial Lightning payment + every
restart with a persisted invoice queue. Real-money blast radius.
2. **#3 (migrations) + #12** — guaranteed boot crash on the documented
failure mode; bricks the extension.
3. **#8 (float in fiat metadata)** — every entry written today carries
float-drift cost basis into Beancount.
4. **#9 (silent listener death)** — operational; the kind of bug
discovered when nobody can pay for a week.
5. **#5, #6 (auth narrowing)** — residual privilege risk; smaller blast
than #1 (already fixed) but worth closing.
6. Everything else, in arbitrary order; mostly hygiene.
---
## Commits applied
| Commit | Repo / branch | What |
|---|---|---|
| `1201557` | `aiolabs/libra` `main` | Gate cross-user admin endpoints behind `require_super_user`; delete duplicate `api_pay_user` |
| `4c704e5` | `aiolabs/webapp` `dev` | Delete orphaned `PermissionManager.vue` + `GrantPermissionDialog.vue` + 3 API methods + 4 dead types |

200
docs/PHASE1_COMPLETE.md Normal file
View file

@ -0,0 +1,200 @@
# Phase 1 Implementation - Complete ✅
## Summary
We've successfully implemented the core improvements from Phase 1 of the Beancount patterns adoption:
## ✅ Completed
### 1. **Decimal Instead of Float for Fiat Amounts**
- **Files Changed:**
- `models.py`: Changed all fiat amount fields from `float` to `Decimal`
- `ExpenseEntry.amount`
- `ReceivableEntry.amount`
- `RevenueEntry.amount`
- `UserBalance.fiat_balances` dictionary values
- `crud.py`: Updated fiat balance calculations to use `Decimal`
- `views_api.py`: Store fiat amounts as strings with `str(amount.quantize(Decimal("0.001")))`
- **Benefits:**
- Prevents floating point rounding errors
- Exact decimal arithmetic
- Financial-grade precision
### 2. **Meta Field for Journal Entries**
- **Database Migration:** `m005_add_flag_and_meta`
- Added `meta TEXT DEFAULT '{}'` column to `journal_entries` table
- **Model Changes:**
- Added `meta: dict = {}` to `JournalEntry` and `CreateJournalEntry`
- Meta stores: source, created_via, user_id, payment_hash, etc.
- **CRUD Updates:**
- `create_journal_entry()` now stores meta as JSON
- `get_journal_entries_by_user()` parses meta from JSON
- **API Integration:**
- Expense entries: `{"source": "api", "created_via": "expense_entry", "user_id": "...", "is_equity": false}`
- Receivable entries: `{"source": "api", "created_via": "receivable_entry", "debtor_user_id": "..."}`
- Payment entries: `{"source": "lightning_payment", "created_via": "record_payment", "payment_hash": "...", "payer_user_id": "..."}`
- **Benefits:**
- Full audit trail for every transaction
- Source tracking (where did this entry come from?)
- Can add tags, links, notes in future
- Essential for compliance and debugging
### 3. **Flag Field for Transaction Status**
- **Database Migration:** `m005_add_flag_and_meta`
- Added `flag TEXT DEFAULT '*'` column to `journal_entries` table
- **Model Changes:**
- Created `JournalEntryFlag` enum:
- `*` = CLEARED (confirmed/reconciled)
- `!` = PENDING (awaiting confirmation)
- `#` = FLAGGED (needs review)
- `x` = VOID (cancelled)
- Added `flag: JournalEntryFlag` to `JournalEntry` and `CreateJournalEntry`
- **CRUD Updates:**
- `create_journal_entry()` stores flag as string value
- `get_journal_entries_by_user()` converts string to enum
- **API Logic:**
- Expense entries: Default to CLEARED (immediately confirmed)
- Receivable entries: Start as PENDING (unpaid debt)
- Payment entries: Mark as CLEARED (payment received)
- **Benefits:**
- Visual indication of transaction status in UI
- Filter transactions by status
- Supports reconciliation workflows
- Standard accounting practice (Beancount-style)
## 📊 Migration Details
**Migration `m005_add_flag_and_meta`:**
```sql
ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
```
**To Apply:**
1. Stop LNbits server (if running)
2. Restart LNbits - migration runs automatically
3. Check logs for "m005_add_flag_and_meta" success message
## 🔧 Technical Implementation Details
### Decimal Handling
```python
# Store as string for precision
metadata = {
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
}
# Parse back to Decimal
fiat_decimal = Decimal(str(fiat_amount))
```
### Flag Handling
```python
# Set flag on creation
entry_data = CreateJournalEntry(
flag=JournalEntryFlag.PENDING, # or CLEARED
# ...
)
# Parse from database
flag = JournalEntryFlag(entry_data.get("flag", "*"))
```
### Meta Handling
```python
# Create with meta
entry_meta = {
"source": "api",
"created_via": "expense_entry",
"user_id": wallet.wallet.user,
}
entry_data = CreateJournalEntry(
meta=entry_meta,
# ...
)
# Parse from database
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
```
## 🎯 What's Next (Remaining Phase 1 Items)
### Hierarchical Account Naming (In Progress)
Implement Beancount-style account hierarchy:
- Current: `"Accounts Receivable - af983632"`
- Better: `"Assets:Receivable:User-af983632"`
### UI Updates for Flags
Display flag icons in transaction list:
- ✅ `*` = Green checkmark (cleared)
- ⚠️ `!` = Yellow/Orange badge (pending)
- 🚩 `#` = Red flag (needs review)
- ❌ `x` = Strikethrough (voided)
## 🧪 Testing Recommendations
1. **Test Decimal Precision:**
```python
# Create expense with fiat amount
POST /api/v1/entries/expense
{"amount": "36.93", "currency": "EUR", ...}
# Verify stored as exact string
SELECT metadata FROM entry_lines WHERE ...
# Should see: {"fiat_amount": "36.930", ...}
```
2. **Test Flag Workflow:**
```python
# Create receivable (should be PENDING)
POST /api/v1/entries/receivable
# Check: flag = '!'
# Pay receivable (creates CLEARED entry)
POST /api/v1/record-payment
# Check: payment entry flag = '*'
```
3. **Test Meta Audit Trail:**
```python
# Create any entry
# Check database:
SELECT meta FROM journal_entries WHERE ...
# Should see: {"source": "api", "created_via": "...", ...}
```
## 🎉 Success Metrics
- ✅ No more floating point errors in fiat calculations
- ✅ Every transaction has source tracking
- ✅ Transaction status is visible (pending vs cleared)
- ✅ Database migration successful
- ✅ All API endpoints updated
- ✅ CRUD operations handle new fields
## 📝 Notes
- **Backward Compatibility:** Old entries will have default values (`flag='*'`, `meta='{}'`)
- **Performance:** No impact - added columns have defaults and indexes not needed yet
- **Storage:** Minimal increase (meta typically < 200 bytes per entry)
## ✅ Phase 1 Complete!
All Phase 1 tasks have been completed:
1. ✅ Decimal instead of float for fiat amounts
2. ✅ Meta field for journal entries (audit trail)
3. ✅ Flag field for transaction status
4. ✅ Hierarchical account naming (Beancount-style)
5. ✅ UI updated to display flags and metadata
**Next:** Move to Phase 2 (Core logic refactoring) when ready.

273
docs/PHASE2_COMPLETE.md Normal file
View file

@ -0,0 +1,273 @@
# Phase 2: Reconciliation - COMPLETE ✅
## Summary
Phase 2 of the Beancount-inspired refactor focused on **reconciliation and automated balance checking**. This phase builds on Phase 1's foundation to provide robust reconciliation tools that ensure accounting accuracy and catch discrepancies early.
## Completed Features
### 1. Balance Assertions ✅
**Purpose**: Verify account balances match expected values at specific points in time (like Beancount's `balance` directive)
**Implementation**:
- **Models** (`models.py:184-219`):
- `AssertionStatus` enum (pending, passed, failed)
- `BalanceAssertion` model with sats and optional fiat checks
- `CreateBalanceAssertion` request model
- **Database** (`migrations.py:275-320`):
- `balance_assertions` table with expected/actual balance tracking
- Tolerance levels for flexible matching
- Status tracking and timestamps
- Indexes for performance
- **CRUD** (`crud.py:773-981`):
- `create_balance_assertion()` - Create and store assertion
- `get_balance_assertion()` - Fetch single assertion
- `get_balance_assertions()` - List with filters
- `check_balance_assertion()` - Compare expected vs actual
- `delete_balance_assertion()` - Remove assertion
- **API Endpoints** (`views_api.py:1067-1230`):
- `POST /api/v1/assertions` - Create and check assertion
- `GET /api/v1/assertions` - List assertions with filters
- `GET /api/v1/assertions/{id}` - Get specific assertion
- `POST /api/v1/assertions/{id}/check` - Re-check assertion
- `DELETE /api/v1/assertions/{id}` - Delete assertion
- **UI** (`templates/libra/index.html:254-378`):
- Balance Assertions card (super user only)
- Failed assertions prominently displayed with red banner
- Passed assertions in collapsible panel
- Create assertion dialog with validation
- Re-check and delete buttons
- **Frontend** (`static/js/index.js:70-79, 602-726`):
- Data properties and computed values
- CRUD methods for assertions
- Automatic loading on page load
### 2. Reconciliation API Endpoints ✅
**Purpose**: Provide comprehensive reconciliation tools and reporting
**Implementation**:
- **Summary Endpoint** (`views_api.py:1236-1287`):
- `GET /api/v1/reconciliation/summary`
- Returns counts of assertions by status
- Returns counts of journal entries by flag
- Total accounts count
- Last checked timestamp
- **Check All Endpoint** (`views_api.py:1290-1325`):
- `POST /api/v1/reconciliation/check-all`
- Re-checks all balance assertions
- Returns summary of results (passed/failed/errors)
- Useful for manual reconciliation runs
- **Discrepancies Endpoint** (`views_api.py:1328-1357`):
- `GET /api/v1/reconciliation/discrepancies`
- Returns all failed assertions
- Returns all flagged journal entries
- Returns all pending entries
- Total discrepancy count
### 3. Reconciliation UI Dashboard ✅
**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
**Implementation** (`templates/libra/index.html:380-499`):
- **Summary Cards**:
- Balance Assertions stats (total, passed, failed, pending)
- Journal Entries stats (total, cleared, pending, flagged)
- Total Accounts count with last checked timestamp
- **Discrepancies Alert**:
- Warning banner when discrepancies found
- Shows count of failed assertions and flagged entries
- "View Details" button to expand discrepancy list
- **Discrepancy Details**:
- Failed assertions list with expected vs actual balances
- Flagged entries list
- Quick access to problematic transactions
- **Actions**:
- "Check All" button to run full reconciliation
- Loading states during checks
- Success message when all accounts reconciled
**Frontend** (`static/js/index.js:80-85, 727-779, 933-934`):
- Reconciliation data properties
- Methods to load summary and discrepancies
- `runFullReconciliation()` method with notifications
- Automatic loading on page load for super users
### 4. Automated Daily Balance Checks ✅
**Purpose**: Run balance checks automatically on a schedule to catch discrepancies early
**Implementation**:
- **Tasks Module** (`tasks.py`):
- `check_all_balance_assertions()` - Core checking logic
- `scheduled_daily_reconciliation()` - Scheduled wrapper
- Results logging and reporting
- Error handling
- **API Endpoint** (`views_api.py:1363-1390`):
- `POST /api/v1/tasks/daily-reconciliation`
- Can be triggered manually or via cron
- Returns detailed results
- Super user only
- **Documentation** (`DAILY_RECONCILIATION.md`):
- Comprehensive setup guide
- Multiple scheduling options (cron, systemd, k8s)
- Monitoring and troubleshooting
- Best practices
- Example scripts
## Benefits
### Accounting Accuracy
- ✅ Catch data entry errors early
- ✅ Verify balances at critical checkpoints
- ✅ Build confidence in accounting accuracy
- ✅ Required for external audits
### Operational Excellence
- ✅ Automated daily checks reduce manual work
- ✅ Dashboard provides at-a-glance reconciliation status
- ✅ Discrepancies are immediately visible
- ✅ Historical tracking of assertions
### Developer Experience
- ✅ Clean API for programmatic reconciliation
- ✅ Well-documented scheduling options
- ✅ Flexible tolerance levels
- ✅ Comprehensive error reporting
## File Changes
### New Files Created
1. `tasks.py` - Background tasks for automated reconciliation
2. `DAILY_RECONCILIATION.md` - Setup and scheduling documentation
3. `PHASE2_COMPLETE.md` - This file
### Modified Files
1. `models.py` - Added `BalanceAssertion`, `CreateBalanceAssertion`, `AssertionStatus`
2. `migrations.py` - Added `m007_balance_assertions` migration
3. `crud.py` - Added balance assertion CRUD operations
4. `views_api.py` - Added assertion, reconciliation, and task endpoints
5. `templates/libra/index.html` - Added assertions and reconciliation UI
6. `static/js/index.js` - Added assertion and reconciliation functionality
7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
## API Endpoints Summary
### Balance Assertions
- `POST /api/v1/assertions` - Create assertion
- `GET /api/v1/assertions` - List assertions
- `GET /api/v1/assertions/{id}` - Get assertion
- `POST /api/v1/assertions/{id}/check` - Re-check assertion
- `DELETE /api/v1/assertions/{id}` - Delete assertion
### Reconciliation
- `GET /api/v1/reconciliation/summary` - Get reconciliation summary
- `POST /api/v1/reconciliation/check-all` - Check all assertions
- `GET /api/v1/reconciliation/discrepancies` - Get discrepancies
### Automated Tasks
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation check
## Usage Examples
### Create a Balance Assertion
```bash
curl -X POST http://localhost:5000/libra/api/v1/assertions \
-H "X-Api-Key: ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "lightning",
"expected_balance_sats": 268548,
"tolerance_sats": 100
}'
```
### Get Reconciliation Summary
```bash
curl http://localhost:5000/libra/api/v1/reconciliation/summary \
-H "X-Api-Key: ADMIN_KEY"
```
### Run Full Reconciliation
```bash
curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
-H "X-Api-Key: ADMIN_KEY"
```
### Schedule Daily Reconciliation (Cron)
```bash
# Add to crontab
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
```
## Testing Checklist
- [x] Create balance assertion (UI)
- [x] Create balance assertion (API)
- [x] Assertion passes when balance matches
- [x] Assertion fails when balance doesn't match
- [x] Tolerance levels work correctly
- [x] Fiat balance assertions work
- [x] Re-check assertion updates status
- [x] Delete assertion removes it
- [x] Reconciliation summary shows correct stats
- [x] Check all assertions endpoint works
- [x] Discrepancies endpoint returns correct data
- [x] Dashboard displays summary correctly
- [x] Discrepancy alert shows when issues exist
- [x] "Check All" button triggers reconciliation
- [x] Daily reconciliation task executes successfully
- [x] Failed assertions are logged
- [x] All endpoints require super user access
## Next Steps
**Phase 3: Core Logic Refactoring (Medium Priority)**
- Create `core/` module with pure accounting logic
- Implement `LibraInventory` for position tracking
- Move balance calculation to `core/balance.py`
- Add comprehensive validation in `core/validation.py`
**Phase 4: Validation Plugins (Medium Priority)**
- Create plugin system architecture
- Implement `check_balanced` plugin
- Implement `check_receivables` plugin
- Add plugin configuration UI
**Phase 5: Advanced Features (Low Priority)**
- Add tags and links to entries
- Implement query language
- Add lot tracking to inventory
- Support multi-currency in single entry
## Conclusion
Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
- **Trust their data** with automated verification
- **Catch errors early** through regular reconciliation
- **Save time** with automated daily checks
- **Gain confidence** in their accounting accuracy
The implementation follows Beancount's best practices while adapting to LNbits' architecture and use case. All reconciliation features are admin-only, ensuring proper access control for sensitive accounting operations.
**Phase 2 Status**: ✅ COMPLETE
---
*Generated: 2025-10-23*
*Next: Phase 3 - Core Logic Refactoring*

365
docs/PHASE3_COMPLETE.md Normal file
View file

@ -0,0 +1,365 @@
# Phase 3: Core Logic Refactoring - COMPLETE ✅
## Summary
Phase 3 of the Beancount-inspired refactor focused on **separating business logic from database operations** and creating a clean, testable core module. This phase improves code quality, maintainability, and follows best practices from Beancount's architecture.
## Completed Features
### 1. Core Module Structure ✅
**Purpose**: Separate pure accounting logic from database and API concerns
**Implementation** (`core/__init__.py`):
- Created `core/` module package
- Exports main classes and functions
- Clean separation of concerns
**Benefits**:
- Testable without database
- Reusable across different storage backends
- Easier to audit and verify
- Clear architecture
### 2. LibraInventory for Position Tracking ✅
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
**Implementation** (`core/inventory.py`):
**LibraPosition** (Lines 11-84):
- Immutable dataclass representing a single position
- Tracks currency, amount, cost basis, and metadata
- Supports addition and negation operations
- Automatic Decimal conversion in `__post_init__`
```python
@dataclass(frozen=True)
class LibraPosition:
currency: str # "SATS", "EUR", "USD"
amount: Decimal
cost_currency: Optional[str] = None
cost_amount: Optional[Decimal] = None
date: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
```
**LibraInventory** (Lines 87-201):
- Container for multiple positions
- Positions keyed by `(currency, cost_currency)` tuple
- Methods for querying balances:
- `get_balance_sats()` - Total satoshis
- `get_balance_fiat(currency)` - Fiat balance for specific currency
- `get_all_fiat_balances()` - All fiat balances
- Utility methods:
- `is_empty()` - Check if no positions
- `is_zero()` - Check if all positions sum to zero
- `to_dict()` - Export to dictionary
### 3. BalanceCalculator ✅
**Purpose**: Pure logic for calculating balances from journal entries
**Implementation** (`core/balance.py`):
**AccountType Enum** (Lines 13-19):
```python
class AccountType(str, Enum):
ASSET = "asset"
LIABILITY = "liability"
EQUITY = "equity"
REVENUE = "revenue"
EXPENSE = "expense"
```
**BalanceCalculator Class** (Lines 22-217):
**Static Methods**:
1. **`calculate_account_balance()`** (Lines 29-54):
- Calculate balance based on account type
- Normal balances:
- Assets/Expenses: Debit balance (debit - credit)
- Liabilities/Equity/Revenue: Credit balance (credit - debit)
2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
- Build LibraInventory from journal entry lines
- Handles both sats and fiat currency tracking
- Accounts for account type when determining sign
3. **`calculate_user_balance()`** (Lines 119-168):
- Calculate user's total balance across all accounts
- Returns both sats balance and fiat balances by currency
- Properly handles asset (receivable) vs liability (payable) accounts
4. **`check_balance_matches()`** (Lines 170-187):
- Verify balance assertion for sats
5. **`check_fiat_balance_matches()`** (Lines 189-202):
- Verify balance assertion for fiat currency
### 4. Comprehensive Validation ✅
**Purpose**: Validation rules for accounting operations
**Implementation** (`core/validation.py`):
**ValidationError Exception** (Lines 10-18):
- Custom exception for validation failures
- Includes detailed error information
**Validation Functions**:
1. **`validate_journal_entry()`** (Lines 21-124):
- Checks:
- At least 2 lines (double-entry requirement)
- Entry is balanced (debits = credits)
- Valid amounts (non-negative)
- No line has both debit and credit
- All lines have account_id
2. **`validate_balance()`** (Lines 127-177):
- Validates balance assertions
- Checks both sats and fiat within tolerance
3. **`validate_receivable_entry()`** (Lines 180-199):
- Validates receivable (user owes libra) entries
- Ensures positive amount
- Ensures revenue account type
4. **`validate_expense_entry()`** (Lines 202-227):
- Validates expense entries
- Ensures positive amount
- Checks account type (expense or equity)
5. **`validate_payment_entry()`** (Lines 230-245):
- Validates payment entries
- Ensures positive amount
6. **`validate_metadata()`** (Lines 248-284):
- Validates entry line metadata
- Checks for required keys
- Validates fiat currency/amount consistency
- Validates Decimal conversion
### 5. Refactored CRUD Operations ✅
**Purpose**: Use core logic in database operations
**Modified Files**: `crud.py`
**Changes**:
1. **Imports** (Lines 26-36):
- Import core accounting logic
- Import validation functions
2. **`get_account_balance()`** (Lines 347-377):
- Refactored to use `BalanceCalculator.calculate_account_balance()`
- Removed duplicate logic
3. **`get_user_balance()`** (Lines 380-435):
- Completely refactored to use:
- `BalanceCalculator.build_inventory_from_entry_lines()`
- `BalanceCalculator.calculate_user_balance()`
- Cleaner separation of database queries vs business logic
4. **`get_all_user_balances()`** (Lines 438-459):
- Simplified to call `get_user_balance()` for each user
- Eliminates code duplication
## Architecture
### Before Phase 3
```
views_api.py → crud.py (mixed DB + logic)
database
```
All accounting logic was embedded in crud.py alongside database operations.
### After Phase 3
```
views_api.py → crud.py → core/
↓ ↓
database Pure Logic
(testable)
```
**Separation of Concerns**:
- `core/` - Pure accounting logic (no DB dependencies)
- `crud.py` - Database operations + orchestration
- `views_api.py` - HTTP API layer
## Benefits
### Code Quality
- ✅ **Testability**: Core logic can be tested without database
- ✅ **Maintainability**: Clear separation makes code easier to understand
- ✅ **Reusability**: Core logic can be used in different contexts
- ✅ **Consistency**: Centralized accounting rules
### Developer Experience
- ✅ **Type Safety**: Immutable dataclasses with proper types
- ✅ **Documentation**: Well-documented core functions
- ✅ **Debugging**: Easier to trace accounting logic
- ✅ **Refactoring**: Safer to make changes
### Reliability
- ✅ **Validation**: Comprehensive validation rules
- ✅ **Correctness**: Pure functions easier to verify
- ✅ **Auditability**: Clear accounting rules
## File Structure
```
lnbits/extensions/libra/
├── core/
│ ├── __init__.py # Module exports
│ ├── inventory.py # LibraInventory, LibraPosition
│ ├── balance.py # BalanceCalculator
│ └── validation.py # Validation functions
├── crud.py # DB operations (refactored to use core/)
├── models.py # Pydantic models
├── views_api.py # API endpoints
└── PHASE3_COMPLETE.md # This file
```
## Usage Examples
### Using LibraInventory
```python
from decimal import Decimal
from libra.core.inventory import LibraInventory, LibraPosition
# Create inventory
inv = LibraInventory()
# Add positions
inv.add_position(LibraPosition(
currency="SATS",
amount=Decimal("100000")
))
inv.add_position(LibraPosition(
currency="SATS",
amount=Decimal("50000"),
cost_currency="EUR",
cost_amount=Decimal("25.00")
))
# Query balances
total_sats = inv.get_balance_sats() # Decimal("150000")
eur_balance = inv.get_balance_fiat("EUR") # Decimal("25.00")
# Export
data = inv.to_dict()
# {"sats": 150000, "fiat": {"EUR": 25.00}}
```
### Using BalanceCalculator
```python
from libra.core.balance import BalanceCalculator, AccountType
# Calculate account balance
balance = BalanceCalculator.calculate_account_balance(
total_debit=100000,
total_credit=50000,
account_type=AccountType.ASSET
)
# Returns: 50000 (debit balance for asset)
# Build inventory from entry lines
entry_lines = [
{"amount": 100000, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'}, # Positive = debit
{"amount": -50000, "metadata": "{}"} # Negative = credit
]
inventory = BalanceCalculator.build_inventory_from_entry_lines(
entry_lines,
AccountType.ASSET
)
# Check balance matches
is_valid = BalanceCalculator.check_balance_matches(
actual_balance_sats=100000,
expected_balance_sats=99900,
tolerance_sats=100
)
# Returns: True (within tolerance)
```
### Using Validation
```python
from libra.core.validation import validate_journal_entry, ValidationError
entry = {
"id": "abc123",
"description": "Test entry",
"entry_date": datetime.now()
}
entry_lines = [
{"account_id": "acc1", "amount": 100000}, # Positive = debit
{"account_id": "acc2", "amount": -100000} # Negative = credit
]
try:
validate_journal_entry(entry, entry_lines)
print("Valid!")
except ValidationError as e:
print(f"Invalid: {e.message}")
print(f"Details: {e.details}")
```
## Testing Checklist
- [x] LibraInventory created and tested
- [x] LibraPosition addition works
- [x] Inventory balance calculations work
- [x] BalanceCalculator account balance calculation works
- [x] BalanceCalculator inventory building works
- [x] BalanceCalculator user balance calculation works
- [x] Validation functions work
- [x] crud.py refactored to use core logic
- [x] Existing balance calculations still work
- [ ] Unit tests for core module (future work)
## Next Steps
**Phase 4: Validation Plugins** (Medium Priority)
- Create plugin system architecture
- Implement `check_balanced` plugin
- Implement `check_receivables` plugin
- Add plugin configuration UI
**Future Enhancements**:
- Add unit tests for core/ module
- Add integration tests
- Add lot tracking to inventory
- Support multi-currency in single entry
- Add more validation plugins
## Conclusion
Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
- **Pure accounting logic** separated from database concerns
- **LibraInventory** for position tracking across currencies
- **BalanceCalculator** for consistent balance calculations
- **Comprehensive validation** for data integrity
The refactoring improves code quality, maintainability, and sets the foundation for Phase 4's plugin system.
**Phase 3 Status**: ✅ COMPLETE
---
*Generated: 2025-10-23*
*Next: Phase 4 - Validation Plugins*

View file

@ -20,8 +20,7 @@ See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
import asyncio
import re
import httpx
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
from typing import Any, Dict, List, Optional
from decimal import Decimal
from datetime import date, datetime
from loguru import logger
@ -45,34 +44,6 @@ def _infer_target_file(account_name: str) -> str:
return "accounts/chart.beancount"
# Posting amount-string patterns shared by the balance parsers. Fava's
# @@ → @ normalisation can emit decimal SATS values, so every SATS group
# must tolerate decimals (the old integer-only pattern silently dropped
# those postings from balances).
_TOTAL_PRICE_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?[\d.]+)\s+SATS$')
_UNIT_PRICE_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$')
_FIAT_AMOUNT_RE = re.compile(r'^(-?[\d.]+)\s+([A-Z]{3})$')
_SATS_AMOUNT_RE = re.compile(r'^(-?[\d.]+)\s+SATS')
def _sats_to_int(value: str) -> int:
"""Parse a (possibly decimal) SATS amount string to whole sats."""
return int(Decimal(value))
# Account names/patterns are interpolated into BQL string literals; restrict
# them to Beancount account characters so caller-supplied input can't break
# out of the quoted literal.
_BQL_ACCOUNT_RE = re.compile(r'^[A-Za-z0-9:_-]+$')
def _validate_bql_account(value: str) -> str:
"""Validate a value bound for interpolation into a BQL string literal."""
if not _BQL_ACCOUNT_RE.match(value):
raise ValueError(f"Invalid account name for BQL query: {value!r}")
return value
def _escape_beancount_string(value: str) -> str:
"""Escape a value for safe inclusion in a Beancount string literal.
@ -165,27 +136,6 @@ class FavaClient:
self._main_dir_cache: Optional[str] = None
self._main_dir_lock = asyncio.Lock()
# Shared HTTP client, created lazily on first use. One client
# means one connection pool instead of a TCP handshake per call.
self._http: Optional[httpx.AsyncClient] = None
@asynccontextmanager
async def _client(self) -> AsyncIterator[httpx.AsyncClient]:
"""Yield the shared HTTP client (lazily created).
Kept as a context manager so call sites read the same as the
per-call clients they replace; the client itself is NOT closed on
exit call `aclose()` at extension shutdown.
"""
if self._http is None or self._http.is_closed:
self._http = httpx.AsyncClient(timeout=self.timeout)
yield self._http
async def aclose(self) -> None:
"""Close the shared HTTP client (extension shutdown)."""
if self._http is not None and not self._http.is_closed:
await self._http.aclose()
async def _resolve_target_file(self, target_file: str) -> str:
"""
Turn a relative include path into the absolute path fava expects.
@ -210,7 +160,7 @@ class FavaClient:
if self._main_dir_cache is None:
async with self._main_dir_lock:
if self._main_dir_cache is None:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(f"{self.base_url}/options")
resp.raise_for_status()
main_file = resp.json()["data"]["beancount_options"]["filename"]
@ -286,7 +236,7 @@ class FavaClient:
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/add_entries",
json={"entries": [entry]},
@ -400,11 +350,10 @@ class FavaClient:
# Use sum(weight) for SATS and sum(number) for fiat
# Note: BQL doesn't support != operator, so use flag = '*' to exclude pending
_validate_bql_account(account_name)
query = f"SELECT sum(number), sum(weight) WHERE account = '{account_name}' AND flag = '*'"
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/query",
params={"query_string": query}
@ -497,14 +446,14 @@ class FavaClient:
import re
# Try total price notation: "50.00 EUR @@ 50000 SATS"
total_price_match = _TOTAL_PRICE_RE.match(amount_str)
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
unit_price_match = _UNIT_PRICE_RE.match(amount_str)
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
if total_price_match:
fiat_amount = Decimal(total_price_match.group(1))
fiat_currency = total_price_match.group(2)
sats_amount = _sats_to_int(total_price_match.group(3))
sats_amount = int(total_price_match.group(3))
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0)
@ -531,8 +480,8 @@ class FavaClient:
accounts_dict[account_name]["sats"] += sats_amount
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
elif _FIAT_AMOUNT_RE.match(amount_str):
fiat_match = _FIAT_AMOUNT_RE.match(amount_str)
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
fiat_amount = Decimal(fiat_match.group(1))
fiat_currency = fiat_match.group(2)
@ -553,9 +502,9 @@ class FavaClient:
else:
# Old format: SATS with cost/price notation - extract SATS amount
sats_match = _SATS_AMOUNT_RE.match(amount_str)
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = _sats_to_int(sats_match.group(1))
sats_amount = int(sats_match.group(1))
total_sats += sats_amount
# Track per account
@ -654,14 +603,14 @@ class FavaClient:
import re
# Try total price notation: "50.00 EUR @@ 50000 SATS"
total_price_match = _TOTAL_PRICE_RE.match(amount_str)
total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(-?\d+)\s+SATS$', amount_str)
# Try per-unit price notation: "50.00 EUR @ 1000.5 SATS"
unit_price_match = _UNIT_PRICE_RE.match(amount_str)
unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str)
if total_price_match:
fiat_amount = Decimal(total_price_match.group(1))
fiat_currency = total_price_match.group(2)
sats_amount = _sats_to_int(total_price_match.group(3))
sats_amount = int(total_price_match.group(3))
if fiat_currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
@ -680,8 +629,8 @@ class FavaClient:
user_data[user_id]["balance"] += sats_amount
# Try simple fiat format: "50.00 EUR" (check metadata for sats)
elif _FIAT_AMOUNT_RE.match(amount_str):
fiat_match = _FIAT_AMOUNT_RE.match(amount_str)
elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str):
fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
fiat_amount = Decimal(fiat_match.group(1))
fiat_currency = fiat_match.group(2)
@ -699,9 +648,9 @@ class FavaClient:
else:
# Old format: SATS with cost/price notation
sats_match = _SATS_AMOUNT_RE.match(amount_str)
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match:
sats_amount = _sats_to_int(sats_match.group(1))
sats_amount = int(sats_match.group(1))
user_data[user_id]["balance"] += sats_amount
# Extract fiat from cost syntax or metadata (backward compatibility)
@ -734,12 +683,9 @@ class FavaClient:
True if Fava responds, False otherwise
"""
try:
async with self._client() as client:
# Health probes stay fast regardless of the configured
# request timeout.
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(
f"{self.base_url}/changed",
timeout=2.0
f"{self.base_url}/changed"
)
return response.status_code == 200
except Exception as e:
@ -775,13 +721,12 @@ class FavaClient:
"""
# Build Beancount query
if account_pattern:
_validate_bql_account(account_pattern)
query = f"SELECT * WHERE account ~ '{account_pattern}' ORDER BY date DESC LIMIT {limit}"
else:
query = f"SELECT * ORDER BY date DESC LIMIT {limit}"
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/query",
params={"query_string": query}
@ -862,7 +807,7 @@ class FavaClient:
https://beancount.github.io/docs/beancount_query_language.html
"""
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/query",
params={"query_string": query_string}
@ -1396,7 +1341,7 @@ class FavaClient:
# (BQL's SELECT DISTINCT account only returns accounts with postings)
account_names: set[str] = set()
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
for endpoint in ("balance_sheet", "income_statement"):
try:
response = await client.get(f"{self.base_url}/{endpoint}")
@ -1409,12 +1354,9 @@ class FavaClient:
logger.warning(f"Failed to fetch {endpoint}: {e}")
# Filter out synthetic entries like "Net Profit"
from .account_utils import ACCOUNT_TYPE_ROOTS
valid_roots = set(ACCOUNT_TYPE_ROOTS.values())
account_names = {
name for name in account_names
if ":" in name or name in valid_roots
if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses")
}
if account_names:
@ -1498,7 +1440,7 @@ class FavaClient:
params["time"] = f"{cutoff_date.isoformat()} - {today.isoformat()}"
logger.info(f"Querying journal for last {days} days (from {cutoff_date})")
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/journal", params=params)
response.raise_for_status()
result = response.json()
@ -1540,7 +1482,7 @@ class FavaClient:
sha256sum = context["sha256sum"]
"""
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/context",
params={"entry_hash": entry_hash}
@ -1587,7 +1529,7 @@ class FavaClient:
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/source_slice",
json={
@ -1602,78 +1544,11 @@ class FavaClient:
except httpx.HTTPStatusError as e:
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
if e.response.status_code in (409, 412):
raise ChecksumConflictError(
f"Entry {entry_hash} changed concurrently"
) from e
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def transform_source_line(
self,
filename: str,
lineno: int,
transform: Callable[[str], str],
) -> bool:
"""Atomically read-modify-write one line of a ledger source file.
Holds the global write lock across the whole read-modify-write, so
another writer can't slip in between the checksum read and the
write (libra-#23: the approve/reject endpoints used to do this
read-then-write with raw httpx and no lock).
The transform receives the current line and returns the new one;
returning it unchanged skips the write.
Returns:
True when the line was changed and written, False on a no-op.
Raises:
ValueError: lineno is outside the file.
ChecksumConflictError: an out-of-process writer changed the
file between read and write (409/412 from Fava).
"""
async with self._write_lock:
async with self._client() as client:
response = await client.get(
f"{self.base_url}/source",
params={"filename": filename},
)
response.raise_for_status()
source_data = response.json()["data"]
sha256sum = source_data["sha256sum"]
lines = source_data["source"].split("\n")
idx = lineno - 1
if idx < 0 or idx >= len(lines):
raise ValueError(f"Line {lineno} not found in {filename}")
new_line = transform(lines[idx])
if new_line == lines[idx]:
return False
lines[idx] = new_line
try:
update = await client.put(
f"{self.base_url}/source",
json={
"file_path": filename,
"source": "\n".join(lines),
"sha256sum": sha256sum,
},
headers={"Content-Type": "application/json"},
)
update.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code in (409, 412):
raise ChecksumConflictError(
f"{filename} changed concurrently"
) from e
raise
return True
async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
"""
Delete an entry from the Beancount file.
@ -1696,7 +1571,7 @@ class FavaClient:
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/source_slice",
params={
@ -1710,10 +1585,6 @@ class FavaClient:
except httpx.HTTPStatusError as e:
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
if e.response.status_code in (409, 412):
raise ChecksumConflictError(
f"Entry {entry_hash} changed concurrently"
) from e
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
@ -1778,13 +1649,6 @@ class FavaClient:
"""
from datetime import date as date_type
# Defense in depth at the writer boundary (libra-#52): the name is
# written verbatim into ledger source below, so validate it HERE,
# not only in the endpoints that happen to call this today.
from .account_utils import validate_account_name
validate_account_name(account_name, allow_root_only=True)
if opening_date is None:
opening_date = date_type.today()
@ -1800,7 +1664,7 @@ class FavaClient:
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with self._client() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get current source file (fresh read on each attempt)
response = await client.get(
f"{self.base_url}/source",
@ -1953,7 +1817,7 @@ class FavaClient:
# Query 1: Get all original expense/receivable entries for this user
# These are entries with the expense-entry or receivable-entry tag
original_query = f"""
SELECT date, narration, account, number, currency, weight, links,
SELECT date, narration, account, number, weight, links,
any_meta('entry-id') as entry_id
WHERE account ~ '{account_pattern}'
AND '{entry_tag}' IN tags
@ -1987,10 +1851,7 @@ class FavaClient:
entries_by_link: Dict[str, Dict[str, Any]] = {}
for row in original_result["rows"]:
(
date_val, narration, account, number, currency,
weight, links, entry_id,
) = row
date_val, narration, account, number, weight, links, entry_id = row
# Skip if no links
if not links or not isinstance(links, list):
@ -2014,11 +1875,9 @@ class FavaClient:
if entry_link in entries_by_link:
continue
# Parse amounts. The posting's real currency matters: callers
# net these totals per currency, and the old hardcoded "EUR"
# let USD (or SATS-only) entries be summed as if they were EUR.
fiat_amount = str(abs(Decimal(str(number)))) if number else "0"
fiat_currency = currency or "EUR"
# Parse amounts
fiat_amount = abs(float(number)) if number else 0.0
fiat_currency = "EUR" # Default, could be extracted from posting
# Parse SATS from weight column
sats_amount = 0

View file

@ -34,33 +34,9 @@ Original migration sequence (Nov 2025):
- m014: Removed legacy equity accounts (MemberEquity, RetainedEarnings)
- m015: Converted entry_lines to single amount field
- m016: Dropped journal_entries and entry_lines tables (Fava integration)
IDEMPOTENCY CONTRACT:
Every statement here must be a silent no-op on re-run. The migration
version bump lands in the core LNbits DB (`dbversions`) while the DDL
lands in `ext_libra` the two writes are not atomic. If the version
bump fails after the DDL commits, the whole migration re-runs on next
boot; a bare CREATE/ALTER/INSERT then crashes the extension until
manual dbversions surgery.
"""
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
Neither SQLite nor Postgres supports ADD COLUMN IF NOT EXISTS
portably, so re-runs are made no-ops by swallowing the error both
backends raise for an existing column.
"""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
async def m001_initial(db):
"""
Initial Libra database schema (squashed from m001-m016).
@ -87,7 +63,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS accounts (
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
account_type TEXT NOT NULL,
@ -100,13 +76,13 @@ async def m001_initial(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts (user_id);
CREATE INDEX idx_accounts_user_id ON accounts (user_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_type ON accounts (account_type);
CREATE INDEX idx_accounts_type ON accounts (account_type);
"""
)
@ -117,7 +93,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS extension_settings (
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY,
libra_wallet_id TEXT,
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
@ -135,7 +111,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS user_wallet_settings (
CREATE TABLE user_wallet_settings (
id TEXT NOT NULL PRIMARY KEY,
user_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
@ -150,7 +126,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS manual_payment_requests (
CREATE TABLE manual_payment_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
@ -167,14 +143,14 @@ async def m001_initial(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_manual_payment_requests_user_id
CREATE INDEX idx_manual_payment_requests_user_id
ON manual_payment_requests (user_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_manual_payment_requests_status
CREATE INDEX idx_manual_payment_requests_status
ON manual_payment_requests (status);
"""
)
@ -187,7 +163,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS balance_assertions (
CREATE TABLE balance_assertions (
id TEXT PRIMARY KEY,
date TIMESTAMP NOT NULL,
account_id TEXT NOT NULL,
@ -212,21 +188,21 @@ async def m001_initial(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_balance_assertions_account_id
CREATE INDEX idx_balance_assertions_account_id
ON balance_assertions (account_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_balance_assertions_status
CREATE INDEX idx_balance_assertions_status
ON balance_assertions (status);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_balance_assertions_date
CREATE INDEX idx_balance_assertions_date
ON balance_assertions (date);
"""
)
@ -240,7 +216,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS user_equity_status (
CREATE TABLE user_equity_status (
user_id TEXT PRIMARY KEY,
is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE,
equity_account_name TEXT,
@ -254,7 +230,7 @@ async def m001_initial(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_equity_status_eligible
CREATE INDEX idx_user_equity_status_eligible
ON user_equity_status (is_equity_eligible)
WHERE is_equity_eligible = TRUE;
"""
@ -269,7 +245,7 @@ async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS account_permissions (
CREATE TABLE account_permissions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
@ -286,7 +262,7 @@ async def m001_initial(db):
# Index for looking up permissions by user
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_permissions_user_id
CREATE INDEX idx_account_permissions_user_id
ON account_permissions (user_id);
"""
)
@ -294,7 +270,7 @@ async def m001_initial(db):
# Index for looking up permissions by account
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_permissions_account_id
CREATE INDEX idx_account_permissions_account_id
ON account_permissions (account_id);
"""
)
@ -302,7 +278,7 @@ async def m001_initial(db):
# Composite index for checking specific user+account permissions
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_permissions_user_account
CREATE INDEX idx_account_permissions_user_account
ON account_permissions (user_id, account_id);
"""
)
@ -310,7 +286,7 @@ async def m001_initial(db):
# Index for finding permissions by type
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_permissions_type
CREATE INDEX idx_account_permissions_type
ON account_permissions (permission_type);
"""
)
@ -318,7 +294,7 @@ async def m001_initial(db):
# Index for finding expired permissions
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_permissions_expires
CREATE INDEX idx_account_permissions_expires
ON account_permissions (expires_at)
WHERE expires_at IS NOT NULL;
"""
@ -344,7 +320,6 @@ async def m001_initial(db):
f"""
INSERT INTO accounts (id, name, account_type, description, created_at)
VALUES (:id, :name, :type, :description, {db.timestamp_now})
ON CONFLICT (name) DO NOTHING
""",
{
"id": str(uuid.uuid4()),
@ -367,18 +342,17 @@ async def m002_add_account_is_active(db):
Default: All existing accounts are marked as active (TRUE).
"""
await _alter_add_column_safe(
db,
await db.execute(
"""
ALTER TABLE accounts
ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE
""",
"""
)
# Create index for faster queries filtering by is_active
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_is_active ON accounts (is_active)
CREATE INDEX idx_accounts_is_active ON accounts (is_active)
"""
)
@ -398,18 +372,17 @@ async def m003_add_account_is_virtual(db):
Default: All existing accounts are real (is_virtual = FALSE).
"""
await _alter_add_column_safe(
db,
await db.execute(
"""
ALTER TABLE accounts
ADD COLUMN is_virtual BOOLEAN NOT NULL DEFAULT FALSE
""",
"""
)
# Create index for faster queries filtering by is_virtual
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_is_virtual ON accounts (is_virtual)
CREATE INDEX idx_accounts_is_virtual ON accounts (is_virtual)
"""
)
@ -429,7 +402,6 @@ async def m003_add_account_is_virtual(db):
f"""
INSERT INTO accounts (id, name, account_type, description, is_active, is_virtual, created_at)
VALUES (:id, :name, :type, :description, TRUE, TRUE, {db.timestamp_now})
ON CONFLICT (name) DO NOTHING
""",
{
"id": str(uuid.uuid4()),
@ -466,7 +438,7 @@ async def m004_add_rbac_tables(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS roles (
CREATE TABLE roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
@ -479,13 +451,13 @@ async def m004_add_rbac_tables(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles (name);
CREATE INDEX idx_roles_name ON roles (name);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_roles_is_default ON roles (is_default)
CREATE INDEX idx_roles_is_default ON roles (is_default)
WHERE is_default = TRUE;
"""
)
@ -497,7 +469,7 @@ async def m004_add_rbac_tables(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS role_permissions (
CREATE TABLE role_permissions (
id TEXT PRIMARY KEY,
role_id TEXT NOT NULL,
account_id TEXT NOT NULL,
@ -512,19 +484,19 @@ async def m004_add_rbac_tables(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions (role_id);
CREATE INDEX idx_role_permissions_role_id ON role_permissions (role_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_role_permissions_account_id ON role_permissions (account_id);
CREATE INDEX idx_role_permissions_account_id ON role_permissions (account_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_role_permissions_type ON role_permissions (permission_type);
CREATE INDEX idx_role_permissions_type ON role_permissions (permission_type);
"""
)
@ -535,7 +507,7 @@ async def m004_add_rbac_tables(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS user_roles (
CREATE TABLE user_roles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
@ -550,19 +522,19 @@ async def m004_add_rbac_tables(db):
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles (user_id);
CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles (role_id);
CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
"""
)
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_roles_expires ON user_roles (expires_at)
CREATE INDEX idx_user_roles_expires ON user_roles (expires_at)
WHERE expires_at IS NOT NULL;
"""
)
@ -570,7 +542,7 @@ async def m004_add_rbac_tables(db):
# Composite index for checking specific user+role assignments
await db.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_roles_user_role ON user_roles (user_id, role_id);
CREATE INDEX idx_user_roles_user_role ON user_roles (user_id, role_id);
"""
)
@ -614,7 +586,6 @@ async def m004_add_rbac_tables(db):
f"""
INSERT INTO roles (id, name, description, is_default, created_by, created_at)
VALUES (:id, :name, :description, :is_default, :created_by, {db.timestamp_now})
ON CONFLICT (name) DO NOTHING
""",
{
"id": str(uuid.uuid4()),
@ -624,57 +595,3 @@ async def m004_add_rbac_tables(db):
"created_by": "system", # System-created default roles
},
)
async def m005_add_processed_payments(db):
"""
Local idempotency gate for Lightning payment recording.
The Fava-side duplicate check (`add_entry_idempotent`, journal-link
scan) is a read-then-write race: the background invoice listener and
the client-driven /record-payment endpoint can both pass the "not
present" check for the same payment_hash and both insert. The
primary key on payment_hash makes exactly one claimant win.
status lifecycle: 'processing' (claimed, write in flight) 'done'
(entry recorded). Failed claims are deleted so redelivery retries;
'processing' rows from a crashed process are cleared at listener
startup.
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS processed_payments (
payment_hash TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'processing',
entry_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m006_unique_user_roles(db):
"""
Enforce one assignment per (user, role).
auto_assign_default_role's check-then-act let two concurrent logins
both pass the "no roles yet" check and insert twice. The unique
index makes the insert itself the arbiter (assign_user_role now
uses ON CONFLICT DO NOTHING against it).
"""
# Remove duplicate assignments before creating the index (keep one
# deterministic row per pair).
await db.execute(
"""
DELETE FROM user_roles
WHERE id NOT IN (
SELECT min(id) FROM user_roles GROUP BY user_id, role_id
)
"""
)
await db.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_roles_unique
ON user_roles (user_id, role_id)
"""
)

651
migrations_old.py.bak Normal file
View file

@ -0,0 +1,651 @@
async def m001_initial(db):
"""
Initial migration for Castle accounting extension.
Creates tables for double-entry bookkeeping system.
"""
await db.execute(
f"""
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
account_type TEXT NOT NULL,
description TEXT,
user_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
"""
CREATE INDEX idx_accounts_user_id ON accounts (user_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_accounts_type ON accounts (account_type);
"""
)
await db.execute(
f"""
CREATE TABLE journal_entries (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
entry_date TIMESTAMP NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
reference TEXT
);
"""
)
await db.execute(
"""
CREATE INDEX idx_journal_entries_created_by ON journal_entries (created_by);
"""
)
await db.execute(
"""
CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
"""
)
await db.execute(
f"""
CREATE TABLE entry_lines (
id TEXT PRIMARY KEY,
journal_entry_id TEXT NOT NULL,
account_id TEXT NOT NULL,
debit INTEGER NOT NULL DEFAULT 0,
credit INTEGER NOT NULL DEFAULT 0,
description TEXT,
metadata TEXT DEFAULT '{{}}'
);
"""
)
await db.execute(
"""
CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
"""
)
# Insert default chart of accounts
default_accounts = [
# Assets
("cash", "Cash", "asset", "Cash on hand"),
("bank", "Bank Account", "asset", "Bank account"),
("lightning", "Lightning Balance", "asset", "Lightning Network balance"),
("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"),
# Liabilities
("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"),
# Equity
("member_equity", "Member Equity", "equity", "Member contributions"),
("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"),
# Revenue
("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"),
("service_revenue", "Service Revenue", "revenue", "Revenue from services"),
("other_revenue", "Other Revenue", "revenue", "Other revenue"),
# Expenses
("utilities", "Utilities", "expense", "Electricity, water, internet"),
("food", "Food & Supplies", "expense", "Food and supplies"),
("maintenance", "Maintenance", "expense", "Repairs and maintenance"),
("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"),
]
for acc_id, name, acc_type, desc in default_accounts:
await db.execute(
"""
INSERT INTO accounts (id, name, account_type, description)
VALUES (:id, :name, :type, :description)
""",
{"id": acc_id, "name": name, "type": acc_type, "description": desc}
)
async def m002_extension_settings(db):
"""
Create extension_settings table for Castle configuration.
"""
await db.execute(
f"""
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY,
castle_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m003_user_wallet_settings(db):
"""
Create user_wallet_settings table for per-user wallet configuration.
"""
await db.execute(
f"""
CREATE TABLE user_wallet_settings (
id TEXT NOT NULL PRIMARY KEY,
user_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m004_manual_payment_requests(db):
"""
Create manual_payment_requests table for user payment requests to Castle.
"""
await db.execute(
f"""
CREATE TABLE manual_payment_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
reviewed_at TIMESTAMP,
reviewed_by TEXT,
journal_entry_id TEXT
);
"""
)
await db.execute(
"""
CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
"""
)
async def m005_add_flag_and_meta(db):
"""
Add flag and meta columns to journal_entries table.
- flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
- meta: JSON metadata for audit trail (source, tags, links, notes)
"""
await db.execute(
"""
ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
"""
)
await db.execute(
"""
ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
"""
)
async def m006_hierarchical_account_names(db):
"""
Migrate account names to hierarchical Beancount-style format.
- "Cash" "Assets:Cash"
- "Accounts Receivable" "Assets:Receivable"
- "Food & Supplies" "Expenses:Food:Supplies"
- "Accounts Receivable - af983632" "Assets:Receivable:User-af983632"
"""
from .account_utils import migrate_account_name
from .models import AccountType
# Get all existing accounts
accounts = await db.fetchall("SELECT * FROM accounts")
# Mapping of old names to new names
name_mappings = {
# Assets
"cash": "Assets:Cash",
"bank": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"accounts_receivable": "Assets:Receivable",
# Liabilities
"accounts_payable": "Liabilities:Payable",
# Equity
"member_equity": "Equity:MemberEquity",
"retained_earnings": "Equity:RetainedEarnings",
# Revenue → Income
"accommodation_revenue": "Income:Accommodation",
"service_revenue": "Income:Service",
"other_revenue": "Income:Other",
# Expenses
"utilities": "Expenses:Utilities",
"food": "Expenses:Food:Supplies",
"maintenance": "Expenses:Maintenance",
"other_expense": "Expenses:Other",
}
# Update default accounts using ID-based mapping
for old_id, new_name in name_mappings.items():
await db.execute(
"""
UPDATE accounts
SET name = :new_name
WHERE id = :old_id
""",
{"new_name": new_name, "old_id": old_id}
)
# Update user-specific accounts (those with user_id set)
user_accounts = await db.fetchall(
"SELECT * FROM accounts WHERE user_id IS NOT NULL"
)
for account in user_accounts:
# Parse account type
account_type = AccountType(account["account_type"])
# Migrate name
new_name = migrate_account_name(account["name"], account_type)
await db.execute(
"""
UPDATE accounts
SET name = :new_name
WHERE id = :id
""",
{"new_name": new_name, "id": account["id"]}
)
async def m007_balance_assertions(db):
"""
Create balance_assertions table for reconciliation.
Allows admins to assert expected balances at specific dates.
"""
await db.execute(
f"""
CREATE TABLE balance_assertions (
id TEXT PRIMARY KEY,
date TIMESTAMP NOT NULL,
account_id TEXT NOT NULL,
expected_balance_sats INTEGER NOT NULL,
expected_balance_fiat TEXT,
fiat_currency TEXT,
tolerance_sats INTEGER DEFAULT 0,
tolerance_fiat TEXT DEFAULT '0',
checked_balance_sats INTEGER,
checked_balance_fiat TEXT,
difference_sats INTEGER,
difference_fiat TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
checked_at TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);
"""
)
await db.execute(
"""
CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_balance_assertions_status ON balance_assertions (status);
"""
)
await db.execute(
"""
CREATE INDEX idx_balance_assertions_date ON balance_assertions (date);
"""
)
async def m008_rename_lightning_account(db):
"""
Rename Lightning account from Assets:Lightning:Balance to Assets:Bitcoin:Lightning
for better naming consistency.
"""
await db.execute(
"""
UPDATE accounts
SET name = 'Assets:Bitcoin:Lightning'
WHERE name = 'Assets:Lightning:Balance'
"""
)
async def m009_add_onchain_bitcoin_account(db):
"""
Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
This allows tracking on-chain Bitcoin separately from Lightning Network payments.
"""
import uuid
# Check if the account already exists
existing = await db.fetchone(
"""
SELECT id FROM accounts
WHERE name = 'Assets:Bitcoin:OnChain'
"""
)
if not existing:
# Create the on-chain Bitcoin asset account
await db.execute(
f"""
INSERT INTO accounts (id, name, account_type, description, created_at)
VALUES (:id, :name, :type, :description, {db.timestamp_now})
""",
{
"id": str(uuid.uuid4()),
"name": "Assets:Bitcoin:OnChain",
"type": "asset",
"description": "On-chain Bitcoin wallet"
}
)
async def m010_user_equity_status(db):
"""
Create user_equity_status table for managing equity contribution eligibility.
Only equity-eligible users can convert their expenses to equity contributions.
"""
await db.execute(
f"""
CREATE TABLE user_equity_status (
user_id TEXT PRIMARY KEY,
is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE,
equity_account_name TEXT,
notes TEXT,
granted_by TEXT NOT NULL,
granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
revoked_at TIMESTAMP
);
"""
)
await db.execute(
"""
CREATE INDEX idx_user_equity_status_eligible
ON user_equity_status (is_equity_eligible)
WHERE is_equity_eligible = TRUE;
"""
)
async def m011_account_permissions(db):
"""
Create account_permissions table for granular account access control.
Allows admins to grant specific permissions (read, submit_expense, manage) to users for specific accounts.
Supports hierarchical permission inheritance (permissions on parent accounts cascade to children).
"""
await db.execute(
f"""
CREATE TABLE account_permissions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
permission_type TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
expires_at TIMESTAMP,
notes TEXT,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);
"""
)
# Index for looking up permissions by user
await db.execute(
"""
CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
"""
)
# Index for looking up permissions by account
await db.execute(
"""
CREATE INDEX idx_account_permissions_account_id ON account_permissions (account_id);
"""
)
# Composite index for checking specific user+account permissions
await db.execute(
"""
CREATE INDEX idx_account_permissions_user_account
ON account_permissions (user_id, account_id);
"""
)
# Index for finding permissions by type
await db.execute(
"""
CREATE INDEX idx_account_permissions_type ON account_permissions (permission_type);
"""
)
# Index for finding expired permissions
await db.execute(
"""
CREATE INDEX idx_account_permissions_expires
ON account_permissions (expires_at)
WHERE expires_at IS NOT NULL;
"""
)
async def m012_update_default_accounts(db):
"""
Update default chart of accounts to include more detailed hierarchical structure.
Adds new accounts for fixed assets, livestock, equity contributions, and detailed expenses.
Only adds accounts that don't already exist.
"""
import uuid
from .account_utils import DEFAULT_HIERARCHICAL_ACCOUNTS
for name, account_type, description in DEFAULT_HIERARCHICAL_ACCOUNTS:
# Check if account already exists
existing = await db.fetchone(
"""
SELECT id FROM accounts WHERE name = :name
""",
{"name": name}
)
if not existing:
# Create new account
await db.execute(
f"""
INSERT INTO accounts (id, name, account_type, description, created_at)
VALUES (:id, :name, :type, :description, {db.timestamp_now})
""",
{
"id": str(uuid.uuid4()),
"name": name,
"type": account_type.value,
"description": description
}
)
async def m013_remove_parent_only_accounts(db):
"""
Remove parent-only accounts from the database.
Since Castle doesn't interface directly with Beancount (only exports to it),
we don't need parent accounts that exist only for organizational hierarchy.
The hierarchy is implicit in the colon-separated account names.
When exporting to Beancount, the parent accounts will be inferred from the
hierarchical naming (e.g., "Assets:Bitcoin:Lightning" implies "Assets:Bitcoin" exists).
This keeps our database clean and prevents accidentally posting to parent accounts.
Removes:
- Assets:Bitcoin (parent of Lightning and OnChain)
- Equity (parent of user equity accounts like Equity:User-xxx)
"""
# Remove Assets:Bitcoin (parent account)
await db.execute(
"DELETE FROM accounts WHERE name = :name",
{"name": "Assets:Bitcoin"}
)
# Remove Equity (parent account)
await db.execute(
"DELETE FROM accounts WHERE name = :name",
{"name": "Equity"}
)
async def m014_remove_legacy_equity_accounts(db):
"""
Remove legacy generic equity accounts that don't fit the user-specific equity model.
The castle extension uses dynamic user-specific equity accounts (Equity:User-{user_id})
created automatically when granting equity eligibility. Generic equity accounts like
MemberEquity and RetainedEarnings are not needed.
Removes:
- Equity:MemberEquity
- Equity:RetainedEarnings
"""
# Remove Equity:MemberEquity
await db.execute(
"DELETE FROM accounts WHERE name = :name",
{"name": "Equity:MemberEquity"}
)
# Remove Equity:RetainedEarnings
await db.execute(
"DELETE FROM accounts WHERE name = :name",
{"name": "Equity:RetainedEarnings"}
)
async def m015_convert_to_single_amount_field(db):
"""
Convert entry_lines from separate debit/credit columns to single amount field.
This aligns Castle with Beancount's elegant design:
- Positive amount = debit (increase assets/expenses, decrease liabilities/equity/revenue)
- Negative amount = credit (decrease assets/expenses, increase liabilities/equity/revenue)
Benefits:
- Simpler model (one field instead of two)
- Direct compatibility with Beancount import/export
- Eliminates invalid states (both debit and credit non-zero)
- More intuitive for programmers (positive/negative instead of accounting conventions)
Migration formula: amount = debit - credit
Examples:
- Expense transaction:
* Expenses:Food:Groceries amount=+100 (debit)
* Liabilities:Payable:User amount=-100 (credit)
- Payment transaction:
* Liabilities:Payable:User amount=+100 (debit)
* Assets:Bitcoin:Lightning amount=-100 (credit)
"""
from sqlalchemy.exc import OperationalError
# Step 1: Add new amount column (nullable for migration)
try:
await db.execute(
"ALTER TABLE entry_lines ADD COLUMN amount INTEGER"
)
except OperationalError:
# Column might already exist if migration was partially run
pass
# Step 2: Populate amount from existing debit/credit
# Formula: amount = debit - credit
await db.execute(
"""
UPDATE entry_lines
SET amount = debit - credit
WHERE amount IS NULL
"""
)
# Step 3: Create new table with amount field as NOT NULL
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
await db.execute(
"""
CREATE TABLE entry_lines_new (
id TEXT PRIMARY KEY,
journal_entry_id TEXT NOT NULL,
account_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT,
metadata TEXT DEFAULT '{}'
)
"""
)
# Step 4: Copy data from old table to new
await db.execute(
"""
INSERT INTO entry_lines_new (id, journal_entry_id, account_id, amount, description, metadata)
SELECT id, journal_entry_id, account_id, amount, description, metadata
FROM entry_lines
"""
)
# Step 5: Drop old table and rename new one
await db.execute("DROP TABLE entry_lines")
await db.execute("ALTER TABLE entry_lines_new RENAME TO entry_lines")
# Step 6: Recreate indexes
await db.execute(
"""
CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id)
"""
)
await db.execute(
"""
CREATE INDEX idx_entry_lines_account ON entry_lines (account_id)
"""
)
async def m016_drop_obsolete_journal_tables(db):
"""
Drop journal_entries and entry_lines tables.
Castle now uses Fava/Beancount as the single source of truth for accounting data.
These tables are no longer written to or read from.
All journal entry operations now:
- Write: Submit to Fava via FavaClient.add_entry()
- Read: Query Fava via FavaClient.get_entries()
Migration completed as part of Castle extension cleanup (Nov 2025).
No backwards compatibility concerns - user explicitly approved.
"""
# Drop entry_lines first (has foreign key to journal_entries)
await db.execute("DROP TABLE IF EXISTS entry_lines")
# Drop journal_entries
await db.execute("DROP TABLE IF EXISTS journal_entries")

111
tasks.py
View file

@ -58,15 +58,15 @@ async def check_all_balance_assertions() -> dict:
})
except Exception as e:
results["errors"] += 1
logger.error(f"Error checking assertion {assertion.id}: {e}")
print(f"Error checking assertion {assertion.id}: {e}")
# Log results
if results["failed"] > 0:
logger.warning(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
for failed in results["failed_assertions"]:
logger.warning(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
else:
logger.info(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
return results
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
or by LNbits background task system.
"""
logger.info(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
try:
results = await check_all_balance_assertions()
@ -86,12 +86,12 @@ async def scheduled_daily_reconciliation():
# TODO: Send notifications if there are failures
# This could send email, webhook, or in-app notification
if results["failed"] > 0:
logger.warning(f"[LIBRA] {results['failed']} balance assertions failed!")
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
# Future: Send alert notification
return results
except Exception as e:
logger.error(f"[LIBRA] Error in scheduled reconciliation: {e}")
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
raise
@ -166,7 +166,7 @@ def start_daily_reconciliation_task():
# Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
"""
logger.info("[LIBRA] Daily reconciliation task registered")
print("[LIBRA] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint
@ -179,31 +179,12 @@ async def wait_for_paid_invoices():
This ensures payments are recorded even if the user closes their browser
before the payment is detected by client-side polling.
"""
from .crud import clear_stale_payment_claims
invoice_queue = Queue()
register_invoice_listener(invoice_queue, "ext_libra")
# Claims from a previous process life can't be live anymore — clear
# them so those payments aren't blocked forever.
cleared = await clear_stale_payment_claims()
if cleared:
logger.warning(
f"[LIBRA] Cleared {cleared} stale in-flight payment claim(s) "
"from a previous run"
)
while True:
payment = await invoice_queue.get()
try:
await on_invoice_paid(payment)
except Exception:
# One bad payment must not kill the listener for the rest of
# the process lifetime; its claim was released, so redelivery
# can retry it.
logger.exception(
f"[LIBRA] Failed to record payment {payment.payment_hash}"
)
async def on_invoice_paid(payment: Payment) -> None:
@ -229,20 +210,8 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata")
return
from .crud import claim_payment, mark_payment_done, release_payment_claim
from .fava_client import get_fava_client
# Local idempotency gate: exactly one claimant (this listener or the
# /record-payment endpoint) gets to record a given payment_hash. The
# Fava-side idempotent write below stays as a second layer for
# entries recorded before this table existed.
if not await claim_payment(payment.payment_hash):
logger.info(
f"Payment {payment.payment_hash} already recorded or being "
"recorded; skipping"
)
return
fava = get_fava_client()
# Use idempotency key based on payment hash - this ensures duplicate
@ -276,36 +245,26 @@ async def on_invoice_paid(payment: Payment) -> None:
if not fiat_currency or not fiat_amount:
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
await release_payment_claim(payment.payment_hash)
return
# Get user's current balance to determine what this payment clears
# 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))
# Settle only what this payment covers. The balance is already
# net (positive = user owes libra); a partial payment clears
# that much receivable, and any excess — or the whole payment
# when nothing is owed — becomes credit libra owes the user.
# (Previously partial payments cleared the FULL balance against
# a smaller payment, shipping unbalanced postings.)
tolerance = Decimal("0.01")
open_receivable = (
total_fiat_balance if total_fiat_balance > 0 else Decimal(0)
)
total_receivable = min(open_receivable, fiat_amount)
# Determine receivables and payables based on balance
# Positive balance = user owes libra (receivable)
# Negative balance = libra owes user (payable)
if total_fiat_balance > 0:
# User owes libra
total_receivable = total_fiat_balance
total_payable = Decimal(0)
credit_overflow = fiat_amount - total_receivable
if credit_overflow < tolerance:
# Absorb sub-cent rounding into the receivable leg.
credit_overflow = Decimal(0)
total_receivable = fiat_amount
else:
# Libra owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
logger.info(
f"Settlement: {fiat_amount} {fiat_currency} "
f"(clears receivable: {total_receivable}, credit: {credit_overflow})"
)
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
# Get account names
user_receivable = await get_or_create_user_account(
@ -314,32 +273,19 @@ async def on_invoice_paid(payment: Payment) -> None:
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
user_credit = None
if credit_overflow > 0:
user_credit = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Credit"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
await release_payment_claim(payment.payment_hash)
return
# Link the source entries this settlement reconciles — but only
# when the payment clears the full open balance. On a partial
# payment we can't know which entries are covered, and linking
# them would make get_unsettled_entries_bql treat them as
# settled. Only same-currency entries qualify either way.
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
settled_links = []
if open_receivable > 0 and fiat_amount + tolerance >= open_receivable:
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend(
e["link"]
for e in unsettled_expenses + unsettled_receivables
if e.get("link") and e.get("fiat_currency") == fiat_currency
)
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
@ -359,9 +305,7 @@ async def on_invoice_paid(payment: Payment) -> None:
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None,
credit_account=user_credit.name if user_credit else None,
credit_overflow_fiat=credit_overflow,
settled_entry_links=settled_links if settled_links else None
)
# Submit to Fava using idempotent method to prevent duplicates
@ -380,11 +324,6 @@ async def on_invoice_paid(payment: Payment) -> None:
f"{result.get('data', 'Unknown')}"
)
await mark_payment_done(payment.payment_hash, idempotency_key)
except Exception as e:
logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}")
# Release the claim so redelivery (or the /record-payment
# endpoint) can retry this payment.
await release_payment_claim(payment.payment_hash)
raise

View file

@ -108,6 +108,9 @@ def _settings_cleanup(settings: Settings) -> None:
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session")

View file

@ -1,207 +0,0 @@
"""Auth narrowing + input validation.
Covers the PR-5 fixes:
- `can_access_user_data`: full-ID equality only (an 8-char prefix
comparison let prefix-colliding users read each other's data).
- `can_access_account`: exact User-{short} SEGMENT match (a substring
test also matched accounts merely containing it).
- Manual-payment approve/reject: status-guarded claim concurrent
admins can't double-book (CODE-REVIEW-2026-06 #16).
- POST /accounts: duplicate 409 instead of a leaked
IntegrityError 500 (libra-#36); malformed name → 400 (libra-#51).
"""
import asyncio
import importlib
from uuid import uuid4
import pytest
from .helpers import submit_manual_payment_request
pytestmark = pytest.mark.anyio
def _module(name: str):
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
auth = _module("auth")
libra_crud = _module("crud")
mdl = _module("models")
def _ctx(user_id: str) -> "auth.AuthContext":
return auth.AuthContext(
user_id=user_id, wallet_id="w", is_super_user=False, wallet=None,
)
# ---------------------------------------------------------------------------
# can_access_user_data — full-ID equality
# ---------------------------------------------------------------------------
async def test_prefix_colliding_user_cannot_access_other_users_data(client):
caller = "deadbeef" + uuid4().hex[8:]
victim = "deadbeef" + uuid4().hex[8:] # same 8-char prefix, different id
assert victim != caller
assert await auth.can_access_user_data(_ctx(caller), caller) is True
assert await auth.can_access_user_data(_ctx(caller), victim) is False
# A crafted short target id must not match either.
assert await auth.can_access_user_data(_ctx(caller), caller[:8]) is False
# ---------------------------------------------------------------------------
# can_access_account — exact segment match
# ---------------------------------------------------------------------------
async def test_account_access_requires_exact_user_segment(client):
user_id = "deadbeef" + uuid4().hex[8:]
suffix = uuid4().hex[:6]
# An account whose LAST SEGMENT merely contains "User-deadbeef".
lookalike = await libra_crud.create_account(
mdl.CreateAccount(
name=f"Expenses:Misc-User-deadbeef-{suffix}",
account_type=mdl.AccountType.EXPENSE,
description="substring-match bait",
)
)
owned = await libra_crud.create_account(
mdl.CreateAccount(
name=f"Assets:Receivable-{suffix}:User-deadbeef",
account_type=mdl.AccountType.ASSET,
description="genuinely owned",
user_id=user_id,
)
)
ctx = _ctx(user_id)
assert await auth.can_access_account(
ctx, lookalike.id, mdl.PermissionType.READ
) is False, "substring-only match must not grant access"
assert await auth.can_access_account(
ctx, owned.id, mdl.PermissionType.READ
) is True
# ---------------------------------------------------------------------------
# Manual payment approve/reject — status-guarded claim
# ---------------------------------------------------------------------------
async def test_concurrent_approvals_create_exactly_one_entry(
client, super_user_headers, configured_user,
):
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=10_000,
description=f"race {uuid4().hex[:6]}",
)
r1, r2 = await asyncio.gather(
client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
),
client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
),
)
statuses = sorted([r1.status_code, r2.status_code])
assert statuses[0] == 200, f"one approval must win: {statuses} {r1.text} {r2.text}"
assert statuses[1] in (400, 409), (
f"the losing approval must fail cleanly, got {statuses}"
)
# Exactly one ledger entry references this request.
listing = await client.get(
"/libra/api/v1/entries/user",
headers={"X-Api-Key": wallet.inkey},
)
assert listing.status_code == 200
link = f"MPR-{submitted['id']}"
matching = [
e for e in listing.json()["entries"] if link in (e.get("links") or [])
]
assert len(matching) == 1, (
f"expected exactly one journal entry for {link}, got {len(matching)}"
)
async def test_reject_after_approve_conflicts(
client, super_user_headers, configured_user,
):
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=5_000,
description=f"approve-then-reject {uuid4().hex[:6]}",
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
)
assert r.status_code == 200, r.text
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code in (400, 409), (
f"rejecting an approved request must fail, got {r.status_code}"
)
# ---------------------------------------------------------------------------
# POST /accounts — duplicate and malformed names
# ---------------------------------------------------------------------------
async def test_create_duplicate_account_returns_409(
client, super_user_headers,
):
name = f"Expenses:DupTest-{uuid4().hex[:6]}"
body = {"name": name, "account_type": "expense", "description": "dup test"}
r = await client.post(
"/libra/api/v1/accounts", headers=super_user_headers, json=body,
)
assert r.status_code == 201, r.text
r = await client.post(
"/libra/api/v1/accounts", headers=super_user_headers, json=body,
)
assert r.status_code == 409, (
f"duplicate create must 409, not leak an IntegrityError: "
f"{r.status_code} {r.text}"
)
async def test_create_account_with_invalid_name_returns_400(
client, super_user_headers,
):
r = await client.post(
"/libra/api/v1/accounts",
headers=super_user_headers,
json={
"name": 'Expenses:bad"name\nfoo',
"account_type": "expense",
},
)
assert r.status_code == 400, (
f"malformed account name must 400 before reaching the ledger: "
f"{r.status_code} {r.text}"
)

View file

@ -1,110 +0,0 @@
"""Migration idempotency tests.
The migration version bump lands in the core LNbits DB (`dbversions`)
while the DDL lands in `ext_libra` the two writes are not atomic. If
the bump fails after the DDL commits, the whole migration re-runs on
next boot, so every statement must be a silent no-op on re-run instead
of crashing with `duplicate column` / `table exists` / UNIQUE
violations (which bricks the extension until manual dbversions
surgery).
These tests run the full migration chain twice against a fresh SQLite
database the second pass simulates the lost-version-bump re-run.
"""
import importlib
import re
from uuid import uuid4
import pytest
from lnbits.db import Database
pytestmark = pytest.mark.anyio
def _module(name: str):
"""Import a libra submodule under whichever path the active LNbits layout
uses (default `lnbits.extensions.libra` or bare `libra`)."""
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
migrations = _module("migrations")
# Same discovery as lnbits.core.helpers.run_migration: m### prefix,
# module definition order.
_MIGRATION_RE = re.compile(r"^m(\d\d\d)_")
MIGRATION_FUNCTIONS = [
fn for name, fn in vars(migrations).items() if _MIGRATION_RE.match(name)
]
# Tables the chain must leave behind — one probe row read per table
# proves both existence and queryability after a double run.
EXPECTED_TABLES = [
"accounts",
"extension_settings",
"user_wallet_settings",
"manual_payment_requests",
"balance_assertions",
"user_equity_status",
"account_permissions",
"roles",
"role_permissions",
"user_roles",
"processed_payments",
]
async def _run_all_migrations(db: Database) -> None:
async with db.connect() as conn:
for migrate in MIGRATION_FUNCTIONS:
await migrate(conn)
async def _seed_counts(db: Database) -> dict:
async with db.connect() as conn:
accounts = await conn.fetchall("SELECT id, name FROM accounts")
roles = await conn.fetchall("SELECT id, name FROM roles")
return {
"account_names": sorted(r["name"] for r in accounts),
"account_ids": sorted(r["id"] for r in accounts),
"role_names": sorted(r["name"] for r in roles),
"role_ids": sorted(r["id"] for r in roles),
}
async def test_migrations_rerun_is_noop():
"""Full chain twice: second run must not raise and not re-seed."""
db = Database(f"ext_libra_migtest_{uuid4().hex[:8]}")
await _run_all_migrations(db)
first = await _seed_counts(db)
# Simulate the lost dbversions bump: everything runs again.
await _run_all_migrations(db)
second = await _seed_counts(db)
# Seeds must not duplicate (names) and must not be replaced (ids).
assert second == first
assert first["account_names"], "seed accounts missing after migration"
assert "Employee" in first["role_names"]
# Every table exists and is queryable after the double run.
async with db.connect() as conn:
for table in EXPECTED_TABLES:
await conn.fetchall(f"SELECT * FROM {table} LIMIT 1") # noqa: S608
async def test_single_migration_rerun_is_noop():
"""Each migration individually survives an immediate re-run (the
version bump fails right after that one migration committed)."""
db = Database(f"ext_libra_migtest_{uuid4().hex[:8]}")
async with db.connect() as conn:
for migrate in MIGRATION_FUNCTIONS:
await migrate(conn)
await migrate(conn) # re-run before "bumping" to the next

View file

@ -1,281 +0,0 @@
"""Lightning payment idempotency — the `processed_payments` claim gate.
The background invoice listener (`tasks.on_invoice_paid`) and the
client-driven `POST /record-payment` endpoint can both fire for the
same `payment_hash` (queue redelivery after restart, webhook + poller).
The Fava-side duplicate checks are read-then-write races; the local
`processed_payments` primary key makes exactly one claimant win.
These tests bypass invoice generation (blocked by libra/issues/40) by
delivering synthetic paid `Payment` objects straight to
`on_invoice_paid` and by inserting paid payment rows via the LNbits
core crud for the endpoint tests.
"""
import asyncio
import importlib
from uuid import uuid4
import pytest
from lnbits.core.crud.payments import create_payment
from lnbits.core.models.payments import CreatePayment, Payment, PaymentState
from .helpers import list_user_entries, post_receivable
pytestmark = pytest.mark.anyio
def _module(name: str):
"""Import a libra submodule under whichever path the active LNbits layout
uses (default `lnbits.extensions.libra` or bare `libra`)."""
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
tasks = _module("tasks")
libra_crud = _module("crud")
def _paid_payment(
wallet_id: str,
user_id: str,
*,
fiat_amount: str = "100.00",
fiat_currency: str = "EUR",
sats: int = 100_000,
) -> Payment:
payment_hash = uuid4().hex + uuid4().hex[:32]
return Payment(
checking_id=payment_hash,
payment_hash=payment_hash,
wallet_id=wallet_id,
amount=sats * 1000,
fee=0,
bolt11="lnbcfake",
status=PaymentState.SUCCESS,
extra={
"tag": "libra",
"user_id": user_id,
"fiat_currency": fiat_currency,
"fiat_amount": fiat_amount,
},
)
async def _setup_receivable(
client, super_user_headers, configured_user, standard_accounts,
amount: str = "100.00",
):
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount=amount,
description=f"Idempotency setup {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
# Force a Fava reload before downstream balance reads (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
return user, wallet
async def _entries_with_link(client, wallet_inkey: str, link: str) -> list:
payload = await list_user_entries(client, wallet_inkey=wallet_inkey)
return [
e for e in payload["entries"] if link in (e.get("links") or [])
]
# ---------------------------------------------------------------------------
# on_invoice_paid — the background listener path
# ---------------------------------------------------------------------------
async def test_double_delivery_records_exactly_once(
client, super_user_headers, configured_user, standard_accounts
):
"""Same payment delivered twice (queue redelivery) → one ledger entry."""
user, wallet = await _setup_receivable(
client, super_user_headers, configured_user, standard_accounts
)
payment = _paid_payment(wallet.id, user.id)
await tasks.on_invoice_paid(payment)
await tasks.on_invoice_paid(payment)
link = f"ln-{payment.payment_hash[:16]}"
assert len(await _entries_with_link(client, wallet.inkey, link)) == 1
row = await libra_crud.get_processed_payment(payment.payment_hash)
assert row is not None and row["status"] == "done"
async def test_failed_recording_releases_claim_and_retry_succeeds(
client, super_user_headers, configured_user, standard_accounts, monkeypatch
):
"""A Fava failure mid-write must not permanently block the payment."""
user, wallet = await _setup_receivable(
client, super_user_headers, configured_user, standard_accounts
)
payment = _paid_payment(wallet.id, user.id)
fava_client = _module("fava_client")
fava = fava_client.get_fava_client()
async def _boom(*args, **kwargs):
raise RuntimeError("fava down")
monkeypatch.setattr(fava, "add_entry_idempotent", _boom)
with pytest.raises(RuntimeError):
await tasks.on_invoice_paid(payment)
monkeypatch.undo()
# Claim released → nothing recorded, retry allowed.
assert await libra_crud.get_processed_payment(payment.payment_hash) is None
await tasks.on_invoice_paid(payment)
row = await libra_crud.get_processed_payment(payment.payment_hash)
assert row is not None and row["status"] == "done"
link = f"ln-{payment.payment_hash[:16]}"
assert len(await _entries_with_link(client, wallet.inkey, link)) == 1
async def test_listener_survives_poison_payment_and_clears_stale_claims(
client, super_user_headers, configured_user, standard_accounts, monkeypatch
):
"""One bad payment must not kill the listener; stale 'processing'
claims from a previous process life are cleared at startup."""
user, wallet = await _setup_receivable(
client, super_user_headers, configured_user, standard_accounts
)
# A claim left behind by a "crashed" previous run.
stale_hash = uuid4().hex + uuid4().hex[:32]
assert await libra_crud.claim_payment(stale_hash)
captured: dict = {}
monkeypatch.setattr(
tasks,
"register_invoice_listener",
lambda queue, name: captured.update(queue=queue),
)
listener = asyncio.create_task(tasks.wait_for_paid_invoices())
try:
for _ in range(50):
if "queue" in captured:
break
await asyncio.sleep(0.05)
assert "queue" in captured, "listener never registered its queue"
poison = _paid_payment(wallet.id, user.id, fiat_amount="not-a-number")
good = _paid_payment(wallet.id, user.id)
captured["queue"].put_nowait(poison)
captured["queue"].put_nowait(good)
row = None
for _ in range(100):
row = await libra_crud.get_processed_payment(good.payment_hash)
if row and row["status"] == "done":
break
await asyncio.sleep(0.1)
assert row is not None and row["status"] == "done", (
"good payment was not recorded after the poison payment"
)
finally:
listener.cancel()
# Startup cleared the stale claim; the poison payment's claim was
# released on failure so redelivery could retry it.
assert await libra_crud.get_processed_payment(stale_hash) is None
assert await libra_crud.get_processed_payment(poison.payment_hash) is None
# ---------------------------------------------------------------------------
# POST /record-payment — the client-driven path
# ---------------------------------------------------------------------------
async def _insert_paid_payment_row(wallet_id: str, user_id: str) -> str:
payment_hash = uuid4().hex + uuid4().hex[:32]
await create_payment(
checking_id=payment_hash,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash,
bolt11="lnbcfake",
amount_msat=100_000_000,
memo="idempotency test",
extra={
"tag": "libra",
"user_id": user_id,
"fiat_currency": "EUR",
"fiat_amount": "100.00",
},
),
status=PaymentState.SUCCESS,
)
return payment_hash
async def test_record_payment_conflicts_while_in_flight(
client, super_user_headers, configured_user, standard_accounts
):
user, wallet = await _setup_receivable(
client, super_user_headers, configured_user, standard_accounts
)
payment_hash = await _insert_paid_payment_row(wallet.id, user.id)
# Another claimant (e.g. the background listener) is mid-recording.
assert await libra_crud.claim_payment(payment_hash)
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey},
json={"payment_hash": payment_hash},
)
assert r.status_code == 409, r.text
# Once that claimant finishes, a replay reports "already recorded"
# instead of writing a second entry.
await libra_crud.mark_payment_done(payment_hash, f"ln-{payment_hash[:16]}")
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey},
json={"payment_hash": payment_hash},
)
assert r.status_code == 200, r.text
assert "already recorded" in r.json()["message"].lower()
async def test_record_payment_records_once_then_replays_safely(
client, super_user_headers, configured_user, standard_accounts
):
user, wallet = await _setup_receivable(
client, super_user_headers, configured_user, standard_accounts
)
payment_hash = await _insert_paid_payment_row(wallet.id, user.id)
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey},
json={"payment_hash": payment_hash},
)
assert r.status_code == 200, r.text
assert r.json()["message"] == "Payment recorded successfully"
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey},
json={"payment_hash": payment_hash},
)
assert r.status_code == 200, r.text
assert "already recorded" in r.json()["message"].lower()
link = f"ln-{payment_hash[:16]}"
assert len(await _entries_with_link(client, wallet.inkey, link)) == 1

View file

@ -18,6 +18,19 @@ from uuid import uuid4
import pytest
# Tests that try to actually create + check an assertion all hit issue #39:
# `format_balance` returns a Beancount source string but `fava.add_entry`
# expects a dict, so Fava 500s on every assertion-create call. The contract
# violation is on libra's side; mark these strict-xfail so they go green
# automatically once #39 lands and the format_balance return shape is fixed.
ASSERTION_CREATE_BROKEN = pytest.mark.xfail(
reason="libra/issues/39 — POST /assertions submits a Beancount source string "
"to Fava's JSON API and 500s. Drop this marker when the format_balance "
"return type is changed to a dict.",
strict=True,
)
# ---------------------------------------------------------------------------
# helpers (local — assertion endpoints don't have wrapper helpers yet)
# ---------------------------------------------------------------------------
@ -45,6 +58,7 @@ async def _create_assertion(
# ---------------------------------------------------------------------------
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_against_empty_account_passes(
client, super_user_headers, standard_accounts,
@ -65,6 +79,7 @@ async def test_assertion_against_empty_account_passes(
assert body.get("difference_sats", 0) == 0
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_wrong_balance_returns_409(
client, super_user_headers, standard_accounts,
@ -87,6 +102,7 @@ async def test_assertion_with_wrong_balance_returns_409(
assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_tolerance_accepts_small_diff(
client, super_user_headers, standard_accounts,
@ -103,6 +119,7 @@ async def test_assertion_with_tolerance_accepts_small_diff(
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_list_assertions_returns_created(
client, super_user_headers, standard_accounts,
@ -128,6 +145,7 @@ async def test_list_assertions_returns_created(
assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_get_assertion_by_id(
client, super_user_headers, standard_accounts,
@ -149,6 +167,7 @@ async def test_get_assertion_by_id(
assert r.json().get("id") == assertion_id
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_recheck_assertion_via_check_endpoint(
client, super_user_headers, standard_accounts,
@ -171,6 +190,7 @@ async def test_recheck_assertion_via_check_endpoint(
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_delete_assertion_removes_it(
client, super_user_headers, standard_accounts,

View file

@ -1,147 +0,0 @@
"""Route-table snapshot guard for the views_api package split.
FastAPI matches routes in registration order, so a mechanical move of
endpoints between modules can silently change which endpoint answers a
path (literal vs {param} siblings). This snapshot pins the ordered
(methods, path, endpoint) table; the split must reproduce it exactly.
Regenerate after a DELIBERATE routing change with:
from libra.views_api import libra_api_router
for r in libra_api_router.routes:
print((",".join(sorted(r.methods)), r.path, r.endpoint.__name__))
"""
import importlib
import pytest
def _module(name: str):
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
views_api = _module("views_api")
EXPECTED_ROUTES = [
("GET", "/api/v1/currencies", "api_get_currencies"),
("GET", "/api/v1/accounts", "api_get_accounts"),
("POST", "/api/v1/accounts", "api_create_account"),
("GET", "/api/v1/accounts/hierarchy", "api_get_account_hierarchy"),
("GET", "/api/v1/accounts/{account_id}", "api_get_account"),
("GET", "/api/v1/accounts/{account_id}/balance", "api_get_account_balance"),
("GET", "/api/v1/accounts/{account_id}/transactions", "api_get_account_transactions"),
("GET", "/api/v1/entries", "api_get_journal_entries"),
("GET", "/api/v1/entries/user", "api_get_user_entries"),
("GET", "/api/v1/entries/pending", "api_get_pending_entries"),
("POST", "/api/v1/entries", "api_create_journal_entry"),
("POST", "/api/v1/entries/expense", "api_create_expense_entry"),
("POST", "/api/v1/entries/income", "api_create_income_entry"),
("POST", "/api/v1/entries/receivable", "api_create_receivable_entry"),
("POST", "/api/v1/entries/revenue", "api_create_revenue_entry"),
("POST", "/api/v1/entries/{entry_id}/approve", "api_approve_expense_entry"),
("POST", "/api/v1/entries/{entry_id}/reject", "api_reject_expense_entry"),
("GET", "/api/v1/balance", "api_get_my_balance"),
("GET", "/api/v1/balance/{user_id}", "api_get_user_balance"),
("GET", "/api/v1/balances/all", "api_get_all_balances"),
("POST", "/api/v1/generate-payment-invoice", "api_generate_payment_invoice"),
("POST", "/api/v1/record-payment", "api_record_payment"),
("POST", "/api/v1/receivables/settle", "api_settle_receivable"),
("POST", "/api/v1/payables/pay", "api_pay_user"),
("POST", "/api/v1/manual-payment-request", "api_create_manual_payment_request"),
("GET", "/api/v1/manual-payment-requests", "api_get_manual_payment_requests"),
("GET", "/api/v1/manual-payment-requests/all", "api_get_all_manual_payment_requests"),
("POST", "/api/v1/manual-payment-requests/{request_id}/approve", "api_approve_manual_payment_request"),
("POST", "/api/v1/manual-payment-requests/{request_id}/reject", "api_reject_manual_payment_request"),
("GET", "/api/v1/settings", "api_get_settings"),
("PUT", "/api/v1/settings", "api_update_settings"),
("GET", "/api/v1/user-wallet/{user_id}", "api_get_user_wallet"),
("GET", "/api/v1/users", "api_get_all_users"),
("GET", "/api/v1/admin/libra-users", "api_get_libra_users"),
("GET", "/api/v1/reports/expenses", "api_expense_report"),
("GET", "/api/v1/reports/contributions", "api_contributions_report"),
("GET", "/api/v1/users/{user_id}/unsettled-entries", "api_get_unsettled_entries"),
("GET", "/api/v1/user/wallet", "api_get_user_wallet"),
("PUT", "/api/v1/user/wallet", "api_update_user_wallet"),
("GET", "/api/v1/user/info", "api_get_user_info"),
("POST", "/api/v1/assertions", "api_create_balance_assertion"),
("GET", "/api/v1/assertions", "api_get_balance_assertions"),
("GET", "/api/v1/assertions/{assertion_id}", "api_get_balance_assertion"),
("POST", "/api/v1/assertions/{assertion_id}/check", "api_check_balance_assertion"),
("DELETE", "/api/v1/assertions/{assertion_id}", "api_delete_balance_assertion"),
("GET", "/api/v1/reconciliation/summary", "api_get_reconciliation_summary"),
("POST", "/api/v1/reconciliation/check-all", "api_check_all_assertions"),
("GET", "/api/v1/reconciliation/discrepancies", "api_get_discrepancies"),
("POST", "/api/v1/tasks/daily-reconciliation", "api_run_daily_reconciliation"),
("POST", "/api/v1/admin/equity-eligibility", "api_grant_equity_eligibility"),
("DELETE", "/api/v1/admin/equity-eligibility/{user_id}", "api_revoke_equity_eligibility"),
("GET", "/api/v1/admin/equity-eligibility", "api_list_equity_eligible_users"),
("POST", "/api/v1/admin/permissions", "api_grant_permission"),
("GET", "/api/v1/admin/permissions", "api_list_permissions"),
("DELETE", "/api/v1/admin/permissions/{permission_id}", "api_revoke_permission"),
("POST", "/api/v1/admin/permissions/bulk", "api_bulk_grant_permissions"),
("POST", "/api/v1/admin/permissions/bulk-grant", "api_bulk_grant_permission_to_users"),
("GET", "/api/v1/users/me/permissions", "api_get_user_permissions"),
("POST", "/api/v1/admin/accounts", "api_admin_add_chart_account"),
("POST", "/api/v1/admin/accounts/sync", "api_sync_all_accounts"),
("POST", "/api/v1/admin/accounts/sync/{account_name:path}", "api_sync_single_account"),
("GET", "/api/v1/admin/roles", "api_get_all_roles"),
("POST", "/api/v1/admin/roles", "api_create_role"),
("GET", "/api/v1/admin/roles/{role_id}", "api_get_role"),
("PUT", "/api/v1/admin/roles/{role_id}", "api_update_role"),
("DELETE", "/api/v1/admin/roles/{role_id}", "api_delete_role"),
("POST", "/api/v1/admin/roles/{role_id}/permissions", "api_add_role_permission"),
("DELETE", "/api/v1/admin/roles/{role_id}/permissions/{permission_id}", "api_delete_role_permission"),
("POST", "/api/v1/admin/user-roles", "api_assign_user_role"),
("GET", "/api/v1/admin/user-roles/{user_id}", "api_get_user_roles"),
("DELETE", "/api/v1/admin/user-roles/{user_role_id}", "api_revoke_user_role"),
("GET", "/api/v1/admin/users/roles", "api_get_all_user_roles"),
("GET", "/api/v1/users/me/roles", "api_get_my_roles"),
]
def test_route_table_matches_snapshot():
actual = [
(",".join(sorted(r.methods)), r.path, r.endpoint.__name__)
for r in views_api.libra_api_router.routes
]
assert actual == EXPECTED_ROUTES
def _index(path: str) -> int:
paths = [p for _, p, _ in EXPECTED_ROUTES]
return paths.index(path)
def test_overlapping_route_order_is_preserved():
"""Only relative order among OVERLAPPING patterns is behavior;
these are the two overlap families in the table. The package split
keeps each family inside one module so include order can't reorder
them."""
# The literal must precede the {param} sibling or it is unreachable.
assert _index("/api/v1/accounts/hierarchy") < _index(
"/api/v1/accounts/{account_id}"
)
assert _index("/api/v1/admin/accounts/sync") < _index(
"/api/v1/admin/accounts/sync/{account_name:path}"
)
@pytest.mark.anyio
async def test_accounts_hierarchy_is_reachable(
client, configured_user, standard_accounts,
):
"""GET /accounts/hierarchy must reach the hierarchy endpoint. It was
registered after /accounts/{account_id} since it was added, so every
request resolved as account_id="hierarchy" and 404'd."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/accounts/hierarchy",
headers={"X-Api-Key": wallet.inkey},
)
assert r.status_code == 200, f"hierarchy shadowed again? {r.status_code} {r.text}"
assert isinstance(r.json(), list)

View file

@ -10,7 +10,6 @@ Underpay without explicit entry-picks returns 400 with diff details so
the operator can either pay the exact net or specify `settled_entry_links`.
"""
import importlib
from decimal import Decimal
from uuid import uuid4
import pytest
@ -269,12 +268,10 @@ async def test_underpay_without_explicit_links_returns_400(
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
payload = r.json().get("detail")
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
# Amounts are exact Decimal strings (not floats) so the operator can
# act on them without precision loss.
assert Decimal(payload.get("cash_paid")) == Decimal("30.00")
assert Decimal(payload.get("net_obligation")) == Decimal("100.00")
assert Decimal(payload.get("receivable_total")) == Decimal("100.00")
assert Decimal(payload.get("payable_total")) == Decimal("0")
assert payload.get("cash_paid") == 30.0
assert payload.get("net_obligation") == 100.0
assert payload.get("receivable_total") == 100.0
assert payload.get("payable_total") == 0.0
@pytest.mark.anyio

View file

@ -394,6 +394,66 @@ def test_migrate_account_name_expense_with_ampersand():
)
# ---------------------------------------------------------------------------
# core.validation — validate_journal_entry
# ---------------------------------------------------------------------------
def test_validate_journal_entry_balanced_passes():
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -100},
],
)
def test_validate_journal_entry_unbalanced_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -50},
],
)
assert "not balanced" in str(exc.value)
def test_validate_journal_entry_single_line_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[{"account_id": "a", "amount": 100}],
)
assert "at least 2 lines" in str(exc.value)
def test_validate_journal_entry_zero_amount_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 0},
{"account_id": "b", "amount": 0},
],
)
assert "amount = 0" in str(exc.value)
def test_validate_journal_entry_missing_account_id_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"amount": 100},
{"account_id": "b", "amount": -100},
],
)
assert "missing account_id" in str(exc.value)
# ---------------------------------------------------------------------------
# core.validation — validate_balance
# ---------------------------------------------------------------------------
@ -493,6 +553,11 @@ def test_validate_metadata_fiat_amount_without_currency_raises():
val.validate_metadata({"fiat_amount": "10.00"})
@pytest.mark.xfail(
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
strict=True,
)
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
@ -505,111 +570,3 @@ def test_validate_metadata_both_present_passes():
def test_validate_metadata_neither_present_passes():
val.validate_metadata({"source": "api"})
# ---------------------------------------------------------------------------
# format_net_settlement_entry — balance guard + credit overflow
# ---------------------------------------------------------------------------
def _net_settlement(**overrides):
kwargs = dict(
user_id="abc12345",
payment_account="Assets:Bitcoin:Lightning",
receivable_account="Assets:Receivable:User-abc12345",
payable_account="Liabilities:Payable:User-abc12345",
amount_sats=565251,
net_fiat_amount=Decimal("517.00"),
total_receivable_fiat=Decimal("555.00"),
total_payable_fiat=Decimal("38.00"),
fiat_currency="EUR",
description="test settlement",
entry_date=date(2026, 7, 12),
payment_hash="ff" * 32,
)
kwargs.update(overrides)
return bf.format_net_settlement_entry(**kwargs)
def test_net_settlement_balanced_passes():
entry = _net_settlement()
amounts = [p["amount"] for p in entry["postings"]]
assert any("@@ 517.00 EUR" in a for a in amounts)
assert "-555.00 EUR" in amounts
assert "38.00 EUR" in amounts
def test_net_settlement_unbalanced_partial_payment_raises():
# Payment of 300 can't clear a 555 receivable net of 38 payable —
# this is the pre-fix partial-payment shape that shipped unbalanced
# postings to the ledger.
with pytest.raises(ValueError, match="unbalanced"):
_net_settlement(net_fiat_amount=Decimal("300.00"))
def test_net_settlement_negative_amount_raises():
with pytest.raises(ValueError, match="non-negative"):
_net_settlement(total_receivable_fiat=Decimal("-1.00"))
def test_net_settlement_credit_overflow_adds_leg():
entry = _net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_account="Liabilities:Credit:User-abc12345",
credit_overflow_fiat=Decimal("83.00"),
)
amounts = [p["amount"] for p in entry["postings"]]
assert "-83.00 EUR" in amounts
def test_net_settlement_credit_overflow_without_account_raises():
with pytest.raises(ValueError, match="credit_account"):
_net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_overflow_fiat=Decimal("83.00"),
)
# ---------------------------------------------------------------------------
# format_posting_at_average_cost — no empty cost braces
# ---------------------------------------------------------------------------
def test_average_cost_posting_without_currency_omits_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc", amount_sats=-996896,
)
assert posting["amount"] == "-996896 SATS"
assert "{" not in posting["amount"]
def test_average_cost_posting_with_currency_keeps_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc",
amount_sats=-996896,
cost_currency="EUR",
)
assert posting["amount"] == "-996896 SATS {EUR}"
# ---------------------------------------------------------------------------
# fiat_rate_metadata — Decimal-exact rate strings
# ---------------------------------------------------------------------------
def test_fiat_rate_metadata_is_exact():
meta = bf.fiat_rate_metadata(107419, Decimal("100.00"))
assert meta["fiat_rate"] == "1074.190000"
assert meta["btc_rate"] == "93093.40"
# No float artifacts like 1074.1899999999998
Decimal(meta["fiat_rate"])
Decimal(meta["btc_rate"])
def test_fiat_rate_metadata_zero_amounts():
assert bf.fiat_rate_metadata(0, Decimal("100.00")) == {
"fiat_rate": "0", "btc_rate": "0",
}
assert bf.fiat_rate_metadata(1000, Decimal("0")) == {
"fiat_rate": "0", "btc_rate": "0",
}

View file

@ -210,69 +210,3 @@ async def test_double_reject_returns_404_on_second_call(
assert r.status_code in (200, 404), (
f"second reject should be deterministic, got {r.status_code}: {r.text}"
)
@pytest.mark.anyio
async def test_concurrent_approve_and_reject_are_serialized(
client, super_user_headers, configured_user, standard_accounts,
):
"""Two mutations of the same ledger source file fired concurrently must
BOTH land. Before libra-#23 each endpoint did its own read-modify-write
with raw httpx and no lock, so one writer overwrote the other's change
(or 412'd on the stale checksum). Now both route through
FavaClient.transform_source_line under the global write lock.
"""
import asyncio
_, wallet = configured_user
approve_tag = f"conc-approve-{uuid4().hex[:6]}"
reject_tag = f"conc-reject-{uuid4().hex[:6]}"
posted = {}
for tag in (approve_tag, reject_tag):
posted[tag] = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="10.00",
currency="EUR",
description=tag,
expense_account=standard_accounts["expense_food"]["name"],
)
# Force a Fava reload so the approve/reject lookups see both fresh
# pending entries (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
r_approve, r_reject = await asyncio.gather(
client.post(
f"/libra/api/v1/entries/{posted[approve_tag]['id']}/approve",
headers=super_user_headers,
),
client.post(
f"/libra/api/v1/entries/{posted[reject_tag]['id']}/reject",
headers=super_user_headers,
),
)
assert r_approve.status_code == 200, f"approve: {r_approve.text}"
assert r_reject.status_code == 200, f"reject: {r_reject.text}"
# Both mutations must be visible: one entry voided, the other cleared
# (a cleared entry no longer matches the pending-only reject lookup).
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
rejected = next(
(e for e in entries if reject_tag in (e.get("description") or "")), None,
)
assert rejected is not None and "voided" in rejected.get("tags", []), (
f"rejected entry lost its #voided tag: {rejected}"
)
second_reject = await client.post(
f"/libra/api/v1/entries/{posted[approve_tag]['id']}/reject",
headers=super_user_headers,
)
assert second_reject.status_code == 404, (
"approved entry should no longer match the pending-only reject "
f"lookup, got {second_reject.status_code}"
)

View file

@ -1,126 +0,0 @@
"""Username resolution for UI display.
Extracted from views_api (CODE-REVIEW-2026-06 #18): the old helper
constructed a fresh LNbits `Database` per call inside per-row hot paths
(entry listings, all-user balances). This module keeps one shared core-DB
handle and a short TTL cache, so listing N rows for the same few users
costs one lookup per unique user per TTL window instead of one per row.
Accepted id shapes (they all occur in ledger data):
- Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d"
- Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d"
- Partial id from account names (8 chars): "375ec158"
"""
from typing import Dict, Iterable, Optional
from lnbits.core.crud.users import get_user
from lnbits.db import Database
from lnbits.utils.cache import Cache
from loguru import logger
# One shared handle to the LNbits core DB (username lives on core
# `accounts`, not in libra's extension DB).
_core_db = Database("database")
_username_cache = Cache()
_USERNAME_CACHE_TTL = 60 # seconds — usernames change rarely
def _dashed(user_id: str) -> str:
return (
f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}"
f"-{user_id[16:20]}-{user_id[20:32]}"
)
async def _resolve(user_id: str) -> str:
from .crud import get_all_user_wallet_settings
# Case 1: full UUID with dashes
if len(user_id) == 36 and user_id.count('-') == 4:
user = await get_user(user_id)
return user.username if user and user.username else f"User-{user_id[:8]}"
# Case 2: dashless 32-char UUID — libra user settings first, then
# the LNbits core DB directly
if len(user_id) == 32 and '-' not in user_id:
try:
user_id_with_dashes = _dashed(user_id)
user_settings = await get_all_user_wallet_settings()
for setting in user_settings:
if setting.id == user_id_with_dashes:
user = await get_user(setting.id)
return (
user.username
if user and user.username
else f"User-{user_id[:8]}"
)
async with _core_db.connect() as conn:
row = await conn.fetchone(
"SELECT id, username FROM accounts WHERE id = :user_id LIMIT 1",
{"user_id": user_id_with_dashes},
)
if row and row["username"]:
return row["username"]
return f"User-{user_id[:8]}"
except Exception as e:
logger.error(f"Error looking up user by dashless UUID {user_id}: {e}")
return f"User-{user_id[:8]}"
# Case 3: 8-char partial id from an account name — resolve to a full
# id via libra user settings
if len(user_id) == 8:
try:
user_settings = await get_all_user_wallet_settings()
for setting in user_settings:
if setting.id.startswith(user_id):
user = await get_user(setting.id)
return (
user.username
if user and user.username
else f"User-{user_id}"
)
return f"User-{user_id}"
except Exception as e:
logger.error(f"Error looking up user by partial ID {user_id}: {e}")
return f"User-{user_id}"
# Case 4: unknown shape — try as-is, fall back
try:
user = await get_user(user_id)
return user.username if user and user.username else f"User-{user_id[:8]}"
except Exception:
return f"User-{user_id[:8]}"
async def get_username(user_id: str) -> Optional[str]:
"""Resolve a user id (any accepted shape) to a display username.
Returns a "User-{short}" fallback when no username exists, or None
for falsy input.
"""
if not user_id:
return None
cache_key = f"username:{user_id}"
cached = _username_cache.get(cache_key)
if cached is not None:
return cached
result = await _resolve(user_id)
_username_cache.set(cache_key, result, _USERNAME_CACHE_TTL)
return result
async def get_usernames(user_ids: Iterable[str]) -> Dict[str, str]:
"""Resolve many user ids at once, deduplicated and cache-backed."""
result: Dict[str, str] = {}
for user_id in {u for u in user_ids if u}:
username = await get_username(user_id)
if username is not None:
result[user_id] = username
return result

4230
views_api.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
"""Libra API endpoints, split by domain.
Each module registers full literal paths on its own APIRouter; this
package includes them in a canonical order pinned by
tests/test_route_table.py. Route ORDER matters only for overlapping
patterns (literal vs {param} siblings) those live within a single
module so their relative order is stable regardless of include order.
"""
from fastapi import APIRouter
from . import (
accounts,
entries,
payments,
settings_reports,
reconciliation,
permissions,
admin,
)
libra_api_router = APIRouter()
for _module in (
accounts,
entries,
payments,
settings_reports,
reconciliation,
permissions,
admin,
):
libra_api_router.include_router(_module.router)

View file

@ -1,177 +0,0 @@
"""Shared imports and helpers for the views_api package.
Everything module-level that the pre-split views_api.py defined above
its first route lives here; endpoint modules pull it in with a
wildcard import plus explicit imports for underscore-prefixed names
(which `import *` does not export).
"""
from datetime import datetime
from decimal import Decimal
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import (
check_super_user,
check_user_exists,
require_invoice_key,
)
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from ..account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
from ..beancount_format import (
_SYSTEM_LINK_PREFIXES,
_extract_entry_id,
fiat_rate_metadata,
)
from ..user_lookup import get_username
from ..crud import (
approve_manual_payment_request,
check_balance_assertion,
create_account,
create_account_permission,
create_balance_assertion,
create_manual_payment_request,
db,
delete_account_permission,
delete_balance_assertion,
get_account,
get_account_by_name,
get_account_permission,
get_account_permissions,
get_all_accounts,
get_all_manual_payment_requests,
get_all_user_wallet_settings,
get_balance_assertion,
get_balance_assertions,
get_manual_payment_request,
get_or_create_user_account,
get_user_manual_payment_requests,
get_user_permissions,
get_user_permissions_with_inheritance,
reject_manual_payment_request,
)
from ..models import (
Account,
AccountPermission,
AccountType,
AccountWithPermissions,
AssertionStatus,
AssignUserRole,
BalanceAssertion,
BulkGrantPermission,
BulkGrantResult,
LibraSettings,
CreateAccount,
CreateAccountPermission,
CreateChartAccount,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
CreateManualPaymentRequest,
CreateRole,
CreateRolePermission,
CreateUserEquityStatus,
ExpenseEntry,
GeneratePaymentInvoice,
IncomeEntry,
JournalEntry,
JournalEntryFlag,
ManualPaymentRequest,
PayUser,
PermissionType,
ReceivableEntry,
RecordPayment,
RevenueEntry,
Role,
RolePermission,
RoleWithPermissions,
SettleReceivable,
UpdateRole,
UserBalance,
UserEquityStatus,
UserInfo,
UserRole,
UserWalletSettings,
UserWithRoles,
)
from ..services import get_settings, get_user_wallet, update_settings, update_user_wallet
from ..auth import (
AuthContext,
require_authenticated,
require_authenticated_write,
require_super_user,
require_account_access,
require_user_data_access,
)
# Synthetic Beancount flags marking auto-generated entries (summarization,
# padding, transfers, conversions, unrealized gains, returns, merging) that
# should not appear in user-facing transaction lists. Mirrors Fava's
# _EXCL_FLAGS in fava/core/file.py.
_SYNTHETIC_FLAGS = frozenset({"S", "T", "C", "P", "U", "R", "M"})
# ===== HELPER FUNCTIONS =====
async def check_libra_wallet_configured() -> str:
"""Ensure libra wallet is configured, return wallet_id"""
settings = await get_settings("admin")
if not settings or not settings.libra_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet not configured. Please contact the super user to configure the Libra wallet in settings.",
)
return settings.libra_wallet_id
async def check_user_wallet_configured(user_id: str) -> str:
"""Ensure user has configured their wallet, return wallet_id"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, use the libra wallet
if user_id == lnbits_settings.super_user:
libra_settings = await get_settings("admin")
if libra_settings and libra_settings.libra_wallet_id:
return libra_settings.libra_wallet_id
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet not configured. Please configure the Libra wallet in settings.",
)
# For regular users, check their personal wallet
user_wallet = await get_user_wallet(user_id)
if not user_wallet or not user_wallet.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You must configure your wallet in settings before using this feature.",
)
return user_wallet.user_wallet_id
# ===== UTILITY ENDPOINTS =====
_VALID_ACCOUNT_PREFIXES = VALID_ACCOUNT_PREFIXES
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
Thin HTTP wrapper around account_utils.validate_account_name the
single source of truth for account-name syntax (libra-#51).
"""
try:
validate_account_name(name)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(e),
)

View file

@ -1,333 +0,0 @@
"""Libra API — accounts endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/currencies")
async def api_get_currencies() -> list[str]:
"""Get list of allowed currencies for fiat conversion"""
return allowed_currencies()
# ===== ACCOUNT ENDPOINTS =====
@router.get("/api/v1/accounts")
async def api_get_accounts(
filter_by_user: bool = False,
exclude_virtual: bool = True,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Account] | list[AccountWithPermissions]:
"""
Get all accounts in the chart of accounts.
- filter_by_user: If true, only return accounts the user has permissions for
- exclude_virtual: If true, exclude virtual parent accounts (default True)
- Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects
"""
from lnbits.settings import settings as lnbits_settings
from .. import crud
all_accounts = await get_all_accounts()
user_id = wallet.wallet.user
is_super_user = user_id == lnbits_settings.super_user
# Auto-assign default role if user has no roles (only for non-super users)
if not is_super_user:
assigned_role = await crud.auto_assign_default_role(user_id, "system")
if assigned_role:
logger.info(f"[ACCOUNTS] Auto-assigned role to user {user_id}")
# Super users bypass permission filtering - they see everything
if not filter_by_user or is_super_user:
# Filter out virtual accounts if requested (default behavior for user views)
if exclude_virtual:
all_accounts = [acc for acc in all_accounts if not acc.is_virtual]
# Return all accounts without filtering by permissions
return all_accounts
# Filter by user permissions
# NOTE: Do NOT filter out virtual accounts yet - they're needed for inheritance logic
# Get direct user permissions
user_permissions = await get_user_permissions(user_id)
# Get role-based permissions
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
# Flatten role permissions into a single list
role_perms = []
for role, perms in role_permissions_list:
role_perms.extend(perms)
# Combine direct and role-based permissions
all_permissions = list(user_permissions) + role_perms
logger.info(f"[ACCOUNTS] User {user_id} has {len(user_permissions)} direct permissions and {len(role_perms)} role permissions (total: {len(all_permissions)})")
if role_perms:
logger.info(f"[ACCOUNTS] Role permissions: {[(p.account_id, p.permission_type) for p in role_perms]}")
logger.info(f"[ACCOUNTS] Total accounts in system: {len(all_accounts)}")
if len(all_accounts) > 0:
logger.info(f"[ACCOUNTS] Sample account IDs: {[acc.id for acc in all_accounts[:5]]}")
# Get set of account IDs the user has any permission on
permitted_account_ids = {perm.account_id for perm in all_permissions}
# Build list of accounts with permission metadata
accounts_with_permissions = []
for account in all_accounts:
# Check if user has permission on this account (direct or from role)
account_perms = [
perm for perm in all_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account (using combined permissions)
# Check both direct and role-based permissions for parent accounts
inherited_perms = []
for perm in all_permissions:
# Get the account for this permission
perm_account = await get_account(perm.account_id)
if not perm_account:
continue
# Check if this permission's account is a parent of the current account
# e.g., "Expenses:Supplies" is parent of "Expenses:Supplies:Food"
if account.name.startswith(perm_account.name + ":"):
# Inherited permission from parent account
inherited_perms.append((perm, perm_account.name))
# Determine if account should be included
has_access = bool(account_perms) or bool(inherited_perms)
if has_access:
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_permissions.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
is_active=account.is_active,
is_virtual=account.is_virtual,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
# Filter out virtual accounts if requested (after permission inheritance logic)
if exclude_virtual:
accounts_with_permissions = [
acc for acc in accounts_with_permissions if not acc.is_virtual
]
logger.info(f"[ACCOUNTS] Returning {len(accounts_with_permissions)} accounts for user {user_id}")
return accounts_with_permissions
@router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account(
data: CreateAccount,
auth: AuthContext = Depends(require_super_user),
) -> Account:
"""Create a new account (super user only)"""
from ..crud import AccountExistsError
try:
return await create_account(data)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(e)
)
except AccountExistsError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {data.name} already exists",
)
# NOTE: hierarchy must be registered BEFORE /accounts/{account_id} —
# FastAPI matches in registration order, and the param route otherwise
# swallows the literal path (it resolved as account_id="hierarchy" and
# 404'd for as long as the endpoint existed).
@router.get("/api/v1/accounts/hierarchy")
async def api_get_account_hierarchy(
root_account: str | None = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountWithPermissions]:
"""
Get hierarchical account structure with user permissions.
Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts).
"""
all_accounts = await get_all_accounts()
user_id = wallet.wallet.user
user_permissions = await get_user_permissions(user_id)
# Filter by root account if specified
if root_account:
all_accounts = [
acc for acc in all_accounts
if acc.name == root_account or acc.name.startswith(root_account + ":")
]
# Build hierarchy with permission metadata
accounts_with_hierarchy = []
for account in all_accounts:
# Check if user has direct permission on this account
account_perms = [
perm for perm in user_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account
inherited_perms = await get_user_permissions_with_inheritance(
user_id, account.name, PermissionType.READ
)
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_hierarchy.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
# Sort by hierarchical name for natural ordering
accounts_with_hierarchy.sort(key=lambda a: a.name)
return accounts_with_hierarchy
# ===== ACCOUNT SYNC ENDPOINTS =====
@router.get("/api/v1/accounts/{account_id}")
async def api_get_account(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> Account:
"""Get a specific account (requires authentication and account access)"""
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
return account
@router.get("/api/v1/accounts/{account_id}/balance")
async def api_get_account_balance(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> dict:
"""Get account balance from Fava/Beancount (requires authentication and account access)"""
from ..fava_client import get_fava_client
# Get account to retrieve its name
account = await get_account(account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for balance
fava = get_fava_client()
balance_data = await fava.get_account_balance(account.name)
return {
"account_id": account_id,
"balance": balance_data["sats"], # Balance in satoshis
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
"fiat_currency": balance_data.get("fiat_currency", "EUR")
}
@router.get("/api/v1/accounts/{account_id}/transactions")
async def api_get_account_transactions(
account_id: str,
limit: int = 100,
auth: AuthContext = Depends(require_authenticated),
) -> list[dict]:
"""
Get all transactions for an account from Fava/Beancount.
Returns transactions affecting this account in reverse chronological order.
Requires authentication and account access.
"""
from ..fava_client import get_fava_client
# Get account details
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account {account_id} not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for transactions
fava = get_fava_client()
transactions = await fava.get_account_transactions(account.name, limit)
return transactions
# ===== JOURNAL ENTRY ENDPOINTS =====

View file

@ -1,535 +0,0 @@
"""Libra API — admin endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
from ._shared import _VALID_ACCOUNT_PREFIXES, _validate_account_name # noqa: F401
router = APIRouter()
@router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account(
payload: CreateChartAccount,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Add a chart-of-accounts entry (super-user only).
Writes an Open directive to accounts/chart.beancount via Fava's /api/source,
then syncs the account into Libra's DB so permissions can be granted on it.
Per-user accounts (matching :User-xxxxxxxx) take a different code path via
crud.get_or_create_user_account and are not created through this endpoint.
"""
from ..fava_client import get_fava_client
if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Account name must start with one of "
f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})"
),
)
_validate_account_name(payload.name)
logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}"
)
fava = get_fava_client()
metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"}
if payload.description:
metadata["description"] = payload.description
result = await fava.add_account(
account_name=payload.name,
currencies=payload.currencies,
target_file="accounts/chart.beancount",
metadata=metadata,
)
from ..account_sync import sync_single_account_from_beancount
if result.get("already_existed"):
# The Open directive is already in the ledger. If it's also already
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
# sync failed — there's no cross-DB atomicity — or it was opened out of
# band), mirror it now so it becomes grantable instead of being stranded
# with no recovery path.
from ..crud import get_account_by_name
if await get_account_by_name(payload.name) is not None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {payload.name} already exists",
)
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it. We just
# wrote the Open directive ourselves, so skip the verification
# round-trip through Fava (libra-#53).
synced = await sync_single_account_from_beancount(
payload.name,
description=payload.description,
assume_exists=True,
)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
}
@router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts(
force_full_sync: bool = False,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync all accounts from Beancount to Libra DB (admin only).
This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
Args:
force_full_sync: If True, re-check all accounts. If False, only add new ones.
Returns:
Sync statistics: {total_beancount_accounts, accounts_added, accounts_skipped, errors}
"""
from ..account_sync import sync_accounts_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
try:
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
logger.info(f"Account sync complete: {stats['accounts_added']} added, {stats['accounts_skipped']} skipped")
return stats
except Exception as e:
logger.error(f"Account sync failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
@router.post("/api/v1/admin/accounts/sync/{account_name:path}")
async def api_sync_single_account(
account_name: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync a single account from Beancount to Libra DB (admin only).
Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food:Groceries")
Returns:
{success: bool, account_name: str, message: str}
"""
from ..account_sync import sync_single_account_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
try:
created = await sync_single_account_from_beancount(account_name)
if created:
return {
"success": True,
"account_name": account_name,
"message": f"Account '{account_name}' synced successfully"
}
else:
return {
"success": False,
"account_name": account_name,
"message": f"Account '{account_name}' already exists or not found in Beancount"
}
except Exception as e:
logger.error(f"Single account sync failed for {account_name}: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
# ===== RBAC (ROLE-BASED ACCESS CONTROL) ENDPOINTS =====
@router.get("/api/v1/admin/roles")
async def api_get_all_roles(
auth: AuthContext = Depends(require_super_user),
) -> list:
"""Get all roles (admin only)"""
from .. import crud
roles = await crud.get_all_roles()
# Enrich each role with user count and permission count
enriched_roles = []
for role in roles:
user_count = await crud.get_user_count_for_role(role.id)
permissions = await crud.get_role_permissions(role.id)
enriched_roles.append({
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"user_count": user_count,
"permission_count": len(permissions),
})
return enriched_roles
@router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
async def api_create_role(
data: CreateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Create a new role (admin only)"""
from .. import crud
try:
role = await crud.create_role(data, created_by=auth.user_id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to create role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to create role: {str(e)}"
)
@router.get("/api/v1/admin/roles/{role_id}")
async def api_get_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get a specific role with its permissions and users (admin only)"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
permissions = await crud.get_role_permissions(role.id)
user_roles = await crud.get_role_users(role.id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"notes": p.notes,
"created_at": p.created_at.isoformat(),
}
for p in permissions
],
"users": [
{
"id": ur.id,
"user_id": ur.user_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
],
}
@router.put("/api/v1/admin/roles/{role_id}")
async def api_update_role(
role_id: str,
data: UpdateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Update a role (admin only)"""
from .. import crud
role = await crud.update_role(role_id, data)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
@router.delete("/api/v1/admin/roles/{role_id}")
async def api_delete_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Delete a role (admin only) - cascades to role_permissions and user_roles"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
await crud.delete_role(role_id)
return {"success": True, "message": f"Role '{role.name}' deleted successfully"}
# ===== ROLE PERMISSION ENDPOINTS =====
@router.post("/api/v1/admin/roles/{role_id}/permissions", status_code=HTTPStatus.CREATED)
async def api_add_role_permission(
role_id: str,
data: CreateRolePermission,
auth: AuthContext = Depends(require_super_user),
):
"""Add a permission to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
# Ensure data has correct role_id
data.role_id = role_id
try:
permission = await crud.create_role_permission(data)
return {
"id": permission.id,
"role_id": permission.role_id,
"account_id": permission.account_id,
"permission_type": permission.permission_type.value,
"notes": permission.notes,
"created_at": permission.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to add role permission: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to add permission: {str(e)}"
)
@router.delete("/api/v1/admin/roles/{role_id}/permissions/{permission_id}")
async def api_delete_role_permission(
role_id: str,
permission_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Remove a permission from a role (admin only)"""
from .. import crud
await crud.delete_role_permission(permission_id)
return {"success": True, "message": "Permission removed from role"}
# ===== USER ROLE ASSIGNMENT ENDPOINTS =====
@router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
async def api_assign_user_role(
data: AssignUserRole,
auth: AuthContext = Depends(require_super_user),
):
"""Assign a user to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(data.role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {data.role_id} not found"
)
try:
user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
return {
"id": user_role.id,
"user_id": user_role.user_id,
"role_id": user_role.role_id,
"granted_by": user_role.granted_by,
"granted_at": user_role.granted_at.isoformat(),
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
"notes": user_role.notes,
}
except Exception as e:
logger.error(f"Failed to assign user role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to assign role: {str(e)}"
)
@router.get("/api/v1/admin/user-roles/{user_id}")
async def api_get_user_roles(
user_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get all roles assigned to a user (admin only)"""
from .. import crud
user_roles = await crud.get_user_roles(user_id)
# Enrich with role details
enriched = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
enriched.append({
"user_role_id": ur.id,
"user_id": ur.user_id,
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
},
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
})
return enriched
@router.delete("/api/v1/admin/user-roles/{user_role_id}")
async def api_revoke_user_role(
user_role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Revoke a user's role assignment (admin only)"""
from .. import crud
await crud.revoke_user_role(user_role_id)
return {"success": True, "message": "Role assignment revoked"}
@router.get("/api/v1/admin/users/roles")
async def api_get_all_user_roles(
auth: AuthContext = Depends(require_super_user),
):
"""Get all user role assignments (admin only)"""
from .. import crud
user_roles = await crud.get_all_user_roles()
return [
{
"id": ur.id,
"user_id": ur.user_id,
"role_id": ur.role_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
]
@router.get("/api/v1/users/me/roles")
async def api_get_my_roles(
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
"""Get current user's roles and effective permissions"""
from .. import crud
user_id = wallet.wallet.user
# Get user's roles
user_roles = await crud.get_user_roles(user_id)
# Get permissions from roles
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
# Get direct permissions
direct_permissions = await crud.get_user_permissions(user_id)
# Build response
roles_data = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
permissions = await crud.get_role_permissions(role.id)
roles_data.append({
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
},
"permissions": [
{
"account_id": p.account_id,
"permission_type": p.permission_type.value,
}
for p in permissions
],
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
})
return {
"roles": roles_data,
"direct_permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"granted_at": p.granted_at.isoformat(),
"expires_at": p.expires_at.isoformat() if p.expires_at else None,
"notes": p.notes,
}
for p in direct_permissions
],
}

File diff suppressed because it is too large Load diff

View file

@ -1,979 +0,0 @@
"""Libra API — payments endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/balance")
async def api_get_my_balance(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserBalance:
"""Get current user's balance with the organization (from Fava/Beancount)"""
from lnbits.settings import settings as lnbits_settings
from ..fava_client import get_fava_client
fava = get_fava_client()
# If super user, show total libra position
if wallet.wallet.user == lnbits_settings.super_user:
all_balances = await fava.get_all_user_balances_bql()
# Calculate total:
# From get_user_balance_bql(): positive = user owes libra, negative = libra owes user
# Positive balances = Users owe Libra (receivables for Libra)
# Negative balances = Libra owes users (liabilities for Libra)
# Net: positive means libra is owed money, negative means libra owes money
total_receivables = sum(b["balance"] for b in all_balances if b["balance"] > 0)
total_liabilities = sum(abs(b["balance"]) for b in all_balances if b["balance"] < 0)
net_balance = total_receivables - total_liabilities
# Aggregate fiat balances from all users
total_fiat_balances = {}
for user_balance in all_balances:
for currency, amount in user_balance["fiat_balances"].items():
if currency not in total_fiat_balances:
total_fiat_balances[currency] = Decimal("0")
# Add all balances (positive and negative)
total_fiat_balances[currency] += amount
# Super-user totals reflect their personal submissions (if any), not org-wide
super_totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
# Return net position
return UserBalance(
user_id=wallet.wallet.user,
balance=net_balance,
accounts=[],
fiat_balances=total_fiat_balances,
total_expenses_sats=super_totals["total_expenses_sats"],
total_expenses_fiat=super_totals["total_expenses_fiat"],
total_income_sats=super_totals["total_income_sats"],
total_income_fiat=super_totals["total_income_fiat"],
)
# For regular users, show their individual balance from Fava
balance_data = await fava.get_user_balance_bql(wallet.wallet.user)
totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
return UserBalance(
user_id=wallet.wallet.user,
balance=balance_data["balance"],
accounts=[],
account_balances=balance_data.get("accounts", []),
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(
user_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> UserBalance:
"""
Get a specific user's balance with the organization (from Fava/Beancount).
Users can only access their own balance. Super users can access any user's balance.
"""
# Check access: must be own data or super user
await require_user_data_access(auth, user_id)
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(user_id)
totals = await fava.get_user_lifetime_totals_bql(user_id)
return UserBalance(
user_id=user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balances/all")
async def api_get_all_balances(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all user balances (super user only) from Fava/Beancount"""
from ..fava_client import get_fava_client
fava = get_fava_client()
balances = await fava.get_all_user_balances_bql()
# Enrich with username information using helper function
result = []
for balance in balances:
username = await get_username(balance["user_id"])
result.append({
"user_id": balance["user_id"],
"username": username,
"balance": balance["balance"],
"fiat_balances": balance["fiat_balances"],
"accounts": balance["accounts"],
})
return result
# ===== PAYMENT ENDPOINTS =====
@router.post("/api/v1/generate-payment-invoice")
async def api_generate_payment_invoice(
data: GeneratePaymentInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Generate an invoice on the Libra wallet for user to pay their balance.
User can then pay this invoice to settle their debt.
If user_id is provided (admin only), the invoice is generated for that specific user.
"""
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.settings import settings as lnbits_settings
# Determine which user this invoice is for
if data.user_id:
# Admin generating invoice for a specific user
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can generate invoices for other users",
)
target_user_id = data.user_id
else:
# User generating invoice for themselves
target_user_id = wallet.wallet.user
# Get libra wallet ID
libra_wallet_id = await check_libra_wallet_configured()
# Get user's balance from Fava to calculate fiat metadata
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(target_user_id)
# Build UserBalance object for compatibility
user_balance = UserBalance(
user_id=target_user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"]
)
# Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "libra", "user_id": target_user_id}
logger.info(f"User balance for invoice generation - sats: {user_balance.balance}, fiat_balances: {user_balance.fiat_balances}")
if user_balance.fiat_balances:
# Simple single-currency solution: use the first (and should be only) currency
currencies = list(user_balance.fiat_balances.keys())
if len(currencies) > 1:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
)
if len(currencies) == 1:
fiat_currency = currencies[0]
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
total_sat_balance = abs(user_balance.balance) # Use absolute value
if total_sat_balance > 0:
# Calculate proportional fiat amount for this invoice
# fiat_amount = (invoice_amount / total_sats) * total_fiat
from decimal import Decimal
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
invoice_fiat_amount = abs(total_fiat_balance) * proportion
invoice_extra.update({
"fiat_currency": fiat_currency,
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
**fiat_rate_metadata(data.amount, invoice_fiat_amount),
})
logger.info(f"Invoice extra metadata: {invoice_extra}")
# Create invoice on libra wallet
invoice_data = CreateInvoice(
out=False,
amount=data.amount,
memo=f"Payment from user {target_user_id[:8]} to Libra",
unit="sat",
extra=invoice_extra,
)
payment = await create_payment_request(libra_wallet_id, invoice_data)
# Get libra wallet to return its inkey for payment checking
libra_wallet = await get_wallet(libra_wallet_id)
if not libra_wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Libra wallet not found"
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": data.amount,
"memo": invoice_data.memo,
"check_wallet_key": libra_wallet.inkey, # Key to check payment status
}
@router.post("/api/v1/record-payment")
async def api_record_payment(
data: RecordPayment,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Record a lightning payment in accounting after invoice is paid.
This reduces what the user owes to the libra.
The user_id is extracted from the payment metadata (set during invoice generation).
"""
from lnbits.core.crud.payments import get_standalone_payment
# Get the payment details (incoming=True to get the invoice, not the payment)
payment = await get_standalone_payment(data.payment_hash, incoming=True)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment not found"
)
if payment.pending:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet settled"
)
# Get user_id from payment metadata (set during invoice generation)
target_user_id = None
if payment.extra and isinstance(payment.extra, dict):
target_user_id = payment.extra.get("user_id")
if not target_user_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
)
# Check if payment already recorded in Fava (idempotency)
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
import httpx
fava = get_fava_client()
# Check if payment already recorded by fetching recent entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
link_to_find = f"ln-{data.payment_hash[:16]}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Get recent entries from Fava's journal endpoint. base_url
# already ends in /api — the previous "/api/journal" path
# 404'd, so this duplicate check silently never ran.
response = await client.get(
f"{fava.base_url}/journal",
params={"time": ""} # Get all entries
)
response.raise_for_status()
response_data = response.json()
entries = response_data.get('entries', [])
# Check if any entry has our payment link
for entry in entries:
entry_links = entry.get('links', [])
if link_to_find in entry_links:
# Payment already recorded, return existing entry
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
except httpx.HTTPError as e:
# Fail CLOSED: if Fava can't confirm the payment isn't already
# recorded, refuse to write — proceeding on a transient blip is
# how double entries happen. The client can simply retry.
logger.warning(f"Could not check Fava for duplicate payment: {e}")
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Cannot verify payment duplicate status; try again shortly",
)
# Local idempotency gate shared with the background invoice listener
# (tasks.on_invoice_paid): exactly one claimant records a payment_hash.
from ..crud import (
claim_payment,
get_processed_payment,
mark_payment_done,
release_payment_claim,
)
if not await claim_payment(data.payment_hash):
existing = await get_processed_payment(data.payment_hash)
if existing and existing["status"] == "done":
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": existing.get("entry_id")
or f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Payment is being recorded; check balance shortly",
)
# Convert amount from millisatoshis to satoshis
try:
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra and isinstance(payment.extra, dict):
logger.info(f"Payment.extra contents: {payment.extra}")
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
from decimal import Decimal
fiat_amount = Decimal(str(fiat_amount_str))
logger.info(f"Extracted fiat metadata - currency: {fiat_currency}, amount: {fiat_amount}")
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
target_user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Get unsettled receivable entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(target_user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
# Format payment entry and submit to Fava
entry = format_payment_entry(
user_id=target_user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_sats,
description=f"Lightning payment from user {target_user_id[:8]}",
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.payment_hash,
settled_entry_links=settled_links
)
logger.info(f"Formatted payment entry: {entry}")
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")
entry_id = f"ln-{data.payment_hash[:16]}"
await mark_payment_done(data.payment_hash, entry_id)
except BaseException:
# Release the claim so a retry (client or background listener)
# can record this payment.
await release_payment_claim(data.payment_hash)
raise
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": entry_id,
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually settle a receivable (record when user pays libra in person).
This endpoint is for non-lightning payments like:
- Cash payments
- Bank transfers
- Other manual settlements
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
data.user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
# If account doesn't exist, try to find or create a generic one
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format settlement entry and submit to Fava
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt
from ..fava_client import get_fava_client
from ..beancount_format import (
format_payment_entry,
format_fiat_settlement_entry,
format_fiat_net_settlement_entry,
)
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat or lightning payment
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment and data.settled_entry_links is None:
# Auto-detect netting + credit-overflow path (libra-#33 + libra-#41).
# The operator hasn't picked specific entries — backend nets all
# open balances in both directions, validates cash matches the net
# obligation (or absorbs excess into credit), and writes a single
# transaction that links every reconciled source entry.
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
# Net only entries denominated in the settlement currency — summing
# mixed currencies as if they were one silently mis-states the net
# obligation and links entries this settlement doesn't actually clear.
settle_currency = data.currency.upper()
unsettled_payables = [
e for e in unsettled_payables
if e.get("fiat_currency") == settle_currency
]
unsettled_receivables = [
e for e in unsettled_receivables
if e.get("fiat_currency") == settle_currency
]
payable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
Decimal(0),
)
receivable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_receivables),
Decimal(0),
)
all_links = (
[e["link"] for e in unsettled_payables if e.get("link")]
+ [e["link"] for e in unsettled_receivables if e.get("link")]
)
if receivable_total <= 0:
# Endpoint is `/receivables/settle` — user paying off something
# they owe. With no open receivable there's nothing this endpoint
# can settle. Operator should use `/payables/pay` (libra pays user)
# or wait until the user has open receivables.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"User {data.user_id[:8]} has no open receivables to settle. "
f"If libra owes them, use `/payables/pay`. If they want to "
f"deposit credit without an open obligation, that's a future "
f"feature (libra-#41 follow-up)."
),
)
cash_paid = Decimal(str(data.amount))
net_obligation = receivable_total - payable_total
tolerance = Decimal("0.01") # forex rounding slack
if cash_paid + tolerance < net_obligation:
# Under-pay without explicit entry-picks — backend can't guess
# which receivable(s) the operator means to settle.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"message": (
"Cash paid is less than net obligation. Pay the exact "
"net to clear all open entries, or pass "
"`settled_entry_links` to settle a specific subset."
),
"cash_paid": str(cash_paid),
"net_obligation": str(net_obligation),
"receivable_total": str(receivable_total),
"payable_total": str(payable_total),
"currency": data.currency.upper(),
},
)
credit_overflow = cash_paid - net_obligation
if credit_overflow < tolerance:
credit_overflow = Decimal(0)
# Auto-create the user-side accounts as needed.
user_payable = None
if payable_total > 0:
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable",
)
user_credit = None
if credit_overflow > 0:
user_credit = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Credit",
)
entry = format_fiat_net_settlement_entry(
user_id=data.user_id,
cash_account=payment_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name if user_payable else None,
credit_account=user_credit.name if user_credit else None,
cash_paid_fiat=cash_paid,
total_receivable_fiat=receivable_total,
total_payable_fiat=payable_total,
credit_overflow_fiat=credit_overflow,
fiat_currency=data.currency.upper(),
description=data.description,
entry_date=datetime.now().date(),
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=all_links,
)
elif is_fiat_payment:
# Legacy fiat path — operator provided `settled_entry_links` explicitly,
# meaning they're settling a specific subset. Backwards-compatible
# 2-leg behaviour: trust the caller's list, no auto-netting, no
# credit-overflow validation. Use the auto-detect path above (omit
# settled_entry_links) to get netting + credit handling.
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when settling with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
# Record in SATS with optional fiat metadata
amount_in_sats = data.amount_sats if data.amount_sats else int(data.amount)
fiat_currency = data.currency.upper() if data.currency else None
fiat_amount = Decimal(str(data.amount)) if data.currency else None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_in_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Receivable settlement submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_settled": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"Receivable settled successfully via {data.payment_method}",
}
@router.post("/api/v1/payables/pay")
async def api_pay_user(
data: PayUser,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Pay a user (libra pays user for expense/liability).
This endpoint is for both lightning and manual payments:
- Lightning payments: already executed, just record the payment
- Cash/Bank/Check: record manual payment that was made
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's payable account (what libra owes)
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format payment entry and submit to Fava
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that libra paid its debt
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry, format_fiat_settlement_entry
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat payment that should use format_fiat_settlement_entry
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment:
# Fiat currency payment (cash, bank transfer, etc.)
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
payment_method=data.payment_method,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
if data.currency:
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
amount_in_sats = data.amount_sats
fiat_currency = data.currency.upper()
fiat_amount = data.amount
else:
amount_in_sats = int(data.amount)
fiat_currency = None
fiat_amount = None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount_in_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payable payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_paid": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"User paid successfully via {data.payment_method}",
}
# ===== SETTINGS ENDPOINTS =====
@router.post("/api/v1/manual-payment-request")
async def api_create_manual_payment_request(
data: CreateManualPaymentRequest,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> ManualPaymentRequest:
"""Create a manual payment request for an admin to review"""
return await create_manual_payment_request(
wallet.wallet.user, data.amount, data.description
)
@router.get("/api/v1/manual-payment-requests")
async def api_get_manual_payment_requests(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[ManualPaymentRequest]:
"""Get manual payment requests for the current user"""
return await get_user_manual_payment_requests(wallet.wallet.user)
@router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
auth: AuthContext = Depends(require_super_user),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (super user only)"""
return await get_all_manual_payment_requests(status)
@router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
# Get libra wallet from settings
libra_wallet_id = await check_libra_wallet_configured()
# Get or create liability account for user (libra owes the user)
liability_account = await get_or_create_user_account(
request.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the Lightning asset account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Lightning account not found",
)
# Format payment entry and submit to Fava
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
fava = get_fava_client()
# Get unsettled expense entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(request.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=request.user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=liability_account.name,
amount_sats=request.amount,
description=f"Manual payment to user: {request.description}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user
reference=f"MPR-{request.id}",
settled_entry_links=settled_links
)
# Claim the request BEFORE writing the ledger entry — the
# status-guarded UPDATE makes exactly one concurrent admin win, so
# only one journal entry can ever be created for this request.
approved = await approve_manual_payment_request(
request_id, auth.user_id, f"MPR-{request.id}"
)
if approved is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
try:
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
except BaseException:
# Ledger write failed — release the claim so the request can be
# approved again.
from ..crud import revert_manual_payment_request
await revert_manual_payment_request(request_id)
raise
return approved
@router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Reject a manual payment request (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
rejected = await reject_manual_payment_request(request_id, auth.user_id)
if rejected is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
return rejected
# ===== EXPENSE APPROVAL ENDPOINTS =====

View file

@ -1,210 +0,0 @@
"""Libra API — permissions endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED)
async def api_grant_equity_eligibility(
data: CreateUserEquityStatus,
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Grant equity contribution eligibility to a user (admin only)"""
from ..crud import create_or_update_user_equity_status
return await create_or_update_user_equity_status(data, auth.user_id)
@router.delete("/api/v1/admin/equity-eligibility/{user_id}")
async def api_revoke_equity_eligibility(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Revoke equity contribution eligibility from a user (admin only)"""
from ..crud import revoke_user_equity_eligibility
result = await revoke_user_equity_eligibility(user_id)
if not result:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"User {user_id} not found in equity status records",
)
return result
@router.get("/api/v1/admin/equity-eligibility")
async def api_list_equity_eligible_users(
auth: AuthContext = Depends(require_super_user),
) -> list[UserEquityStatus]:
"""List all equity-eligible users (admin only)"""
from ..crud import get_all_equity_eligible_users
return await get_all_equity_eligible_users()
# ===== ACCOUNT PERMISSION ADMIN ENDPOINTS =====
@router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED)
async def api_grant_permission(
data: CreateAccountPermission,
auth: AuthContext = Depends(require_super_user),
) -> AccountPermission:
"""Grant account permission to a user (admin only)"""
# Validate that account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{data.account_id}' not found",
)
return await create_account_permission(data, auth.user_id)
@router.get("/api/v1/admin/permissions")
async def api_list_permissions(
user_id: str | None = None,
account_id: str | None = None,
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""
List account permissions (admin only).
Can filter by user_id or account_id.
"""
if user_id:
return await get_user_permissions(user_id)
elif account_id:
return await get_account_permissions(account_id)
else:
# Get all permissions (get all users' permissions)
# This is a bit inefficient but works for admin overview
all_accounts = await get_all_accounts()
all_permissions = []
for account in all_accounts:
account_perms = await get_account_permissions(account.id)
all_permissions.extend(account_perms)
# Deduplicate by permission ID
seen_ids = set()
unique_permissions = []
for perm in all_permissions:
if perm.id not in seen_ids:
seen_ids.add(perm.id)
unique_permissions.append(perm)
return unique_permissions
@router.delete("/api/v1/admin/permissions/{permission_id}")
async def api_revoke_permission(
permission_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Revoke (delete) an account permission (admin only)"""
# Verify permission exists
permission = await get_account_permission(permission_id)
if not permission:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Permission with ID '{permission_id}' not found",
)
await delete_account_permission(permission_id)
return {
"success": True,
"message": f"Permission {permission_id} revoked successfully",
}
@router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permissions(
permissions: list[CreateAccountPermission],
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""Grant multiple account permissions at once (admin only)"""
created_permissions = []
for perm_data in permissions:
# Validate that account exists
account = await get_account(perm_data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{perm_data.account_id}' not found",
)
perm = await create_account_permission(perm_data, auth.user_id)
created_permissions.append(perm)
return created_permissions
@router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permission_to_users(
data: "BulkGrantPermission",
auth: AuthContext = Depends(require_super_user),
) -> "BulkGrantResult":
"""
Grant the same permission to multiple users at once (admin only).
This is a convenience endpoint that grants the same account permission
to multiple users in one operation. Useful for onboarding teams or
granting access to a shared expense account.
Returns detailed results including successes and failures.
"""
from ..models import BulkGrantResult
granted = []
failed = []
# Validate account exists and is active
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{data.account_id}' not found",
)
# Grant permission to each user
for user_id in data.user_ids:
try:
perm_data = CreateAccountPermission(
user_id=user_id,
account_id=data.account_id,
permission_type=data.permission_type,
expires_at=data.expires_at,
notes=data.notes,
)
perm = await create_account_permission(perm_data, auth.user_id)
granted.append(perm)
except Exception as e:
failed.append({
"user_id": user_id,
"error": str(e),
})
return BulkGrantResult(
granted=granted,
failed=failed,
total=len(data.user_ids),
success_count=len(granted),
failure_count=len(failed),
)
# ===== USER PERMISSION ENDPOINTS =====
@router.get("/api/v1/users/me/permissions")
async def api_get_user_permissions(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountPermission]:
"""Get current user's account permissions"""
return await get_user_permissions(wallet.wallet.user)
# ===== ACCOUNT HIERARCHY ENDPOINT =====

View file

@ -1,304 +0,0 @@
"""Libra API — reconciliation endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.post("/api/v1/assertions")
async def api_create_balance_assertion(
data: CreateBalanceAssertion,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""
Create a balance assertion for reconciliation (super user only).
Uses hybrid approach:
1. Writes balance assertion to Beancount (via Fava) - source of truth
2. Stores metadata in Libra DB for UI convenience (created_by, notes, tolerance)
3. Lets Beancount validate the assertion automatically
The assertion will be checked immediately upon creation.
"""
from ..fava_client import get_fava_client
from ..beancount_format import format_balance
# Verify account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account {data.account_id} not found",
)
assertion_date = data.date or datetime.now()
# HYBRID APPROACH: Write to Beancount first (source of truth)
balance_directive = format_balance(
date_val=assertion_date.date() if isinstance(assertion_date, datetime) else assertion_date,
account=account.name,
amount=data.expected_balance_sats,
currency="SATS"
)
# Submit to Fava/Beancount
try:
fava = get_fava_client()
result = await fava.add_entry(balance_directive)
logger.info(f"Balance assertion submitted to Fava: {result}")
except Exception as e:
logger.error(f"Failed to write balance assertion to Fava: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to write balance assertion to Beancount: {str(e)}"
)
# Store metadata in Libra DB for UI convenience
assertion = await create_balance_assertion(data, auth.user_id)
# Check it immediately (queries Fava for actual balance)
try:
assertion = await check_balance_assertion(assertion.id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(e),
)
# If assertion failed, return 409 Conflict with details
if assertion.status == AssertionStatus.FAILED:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail={
"message": "Balance assertion failed (validated by Beancount)",
"expected_sats": assertion.expected_balance_sats,
"actual_sats": assertion.checked_balance_sats,
"difference_sats": assertion.difference_sats,
"expected_fiat": float(assertion.expected_balance_fiat) if assertion.expected_balance_fiat else None,
"actual_fiat": float(assertion.checked_balance_fiat) if assertion.checked_balance_fiat else None,
"difference_fiat": float(assertion.difference_fiat) if assertion.difference_fiat else None,
"fiat_currency": assertion.fiat_currency,
},
)
return assertion
@router.get("/api/v1/assertions")
async def api_get_balance_assertions(
account_id: str = None,
status: str = None,
limit: int = 100,
auth: AuthContext = Depends(require_super_user),
) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters (super user only)"""
# Parse status enum if provided
status_enum = None
if status:
try:
status_enum = AssertionStatus(status)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid status: {status}. Must be one of: pending, passed, failed",
)
return await get_balance_assertions(
account_id=account_id,
status=status_enum,
limit=limit,
)
@router.get("/api/v1/assertions/{assertion_id}")
async def api_get_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Get a specific balance assertion (super user only)"""
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
return assertion
@router.post("/api/v1/assertions/{assertion_id}/check")
async def api_check_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Re-check a balance assertion (super user only)"""
try:
assertion = await check_balance_assertion(assertion_id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=str(e),
)
return assertion
@router.delete("/api/v1/assertions/{assertion_id}")
async def api_delete_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Delete a balance assertion (super user only)"""
# Verify it exists
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
await delete_balance_assertion(assertion_id)
return {"success": True, "message": "Balance assertion deleted"}
# ===== RECONCILIATION ENDPOINTS =====
@router.get("/api/v1/reconciliation/summary")
async def api_get_reconciliation_summary(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get reconciliation summary (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
# Count by status
passed = len([a for a in all_assertions if a.status == AssertionStatus.PASSED])
failed = len([a for a in all_assertions if a.status == AssertionStatus.FAILED])
pending = len([a for a in all_assertions if a.status == AssertionStatus.PENDING])
# Get all journal entries from Fava
from ..fava_client import get_fava_client
fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
# Count entries by flag (Beancount only supports * and !)
cleared = len([e for e in all_entries if e.get("flag") == "*"])
pending_entries = len([e for e in all_entries if e.get("flag") == "!"])
# Count entries with special tags
voided = len([e for e in all_entries if "voided" in e.get("tags", [])])
flagged = len([e for e in all_entries if "review" in e.get("tags", []) or "flagged" in e.get("tags", [])])
# Get all accounts
accounts = await get_all_accounts()
return {
"assertions": {
"total": len(all_assertions),
"passed": passed,
"failed": failed,
"pending": pending,
},
"entries": {
"total": len(all_entries),
"cleared": cleared,
"pending": pending_entries,
"flagged": flagged,
"voided": voided,
},
"accounts": {
"total": len(accounts),
},
"last_checked": datetime.now().isoformat(),
}
@router.post("/api/v1/reconciliation/check-all")
async def api_check_all_assertions(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Re-check all balance assertions (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
results = {
"total": len(all_assertions),
"checked": 0,
"passed": 0,
"failed": 0,
"errors": 0,
}
for assertion in all_assertions:
try:
checked = await check_balance_assertion(assertion.id)
results["checked"] += 1
if checked.status == AssertionStatus.PASSED:
results["passed"] += 1
elif checked.status == AssertionStatus.FAILED:
results["failed"] += 1
except Exception as e:
results["errors"] += 1
return results
@router.get("/api/v1/reconciliation/discrepancies")
async def api_get_discrepancies(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get all discrepancies (failed assertions, flagged entries) (super user only)"""
# Get failed assertions
failed_assertions = await get_balance_assertions(
status=AssertionStatus.FAILED,
limit=1000,
)
# Get flagged entries from Fava
from ..fava_client import get_fava_client
fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
flagged_entries = [e for e in all_entries if e.get("flag") == "#"]
pending_entries = [e for e in all_entries if e.get("flag") == "!"]
return {
"failed_assertions": failed_assertions,
"flagged_entries": flagged_entries,
"pending_entries": pending_entries,
"total_discrepancies": len(failed_assertions) + len(flagged_entries),
}
# ===== AUTOMATED TASKS ENDPOINTS =====
@router.post("/api/v1/tasks/daily-reconciliation")
async def api_run_daily_reconciliation(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually trigger the daily reconciliation check (super user only).
This endpoint can also be called via cron job.
Returns a summary of the reconciliation check results.
"""
from ..tasks import check_all_balance_assertions
try:
results = await check_all_balance_assertions()
return results
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Error running daily reconciliation: {str(e)}",
)
# ===== USER EQUITY ELIGIBILITY ENDPOINTS =====

View file

@ -1,399 +0,0 @@
"""Libra API — settings reports endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_user_exists),
) -> LibraSettings:
"""Get Libra settings"""
user_id = "admin"
settings = await get_settings(user_id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return LibraSettings()
return settings
@router.put("/api/v1/settings")
async def api_update_settings(
data: LibraSettings,
user: User = Depends(check_super_user),
) -> LibraSettings:
"""Update Libra settings (super user only)"""
if not data.libra_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet ID is required",
)
user_id = "admin"
return await update_settings(user_id, data)
# ===== USER WALLET ENDPOINTS =====
@router.get("/api/v1/user-wallet/{user_id}")
async def api_get_user_wallet(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get user's wallet settings (super user only)
Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
"""
from ..crud import get_user_wallet_settings_by_prefix
# First try exact match
user_wallet = await get_user_wallet(user_id)
# If not found and user_id looks like a truncated ID (8 chars), try prefix match
if not user_wallet or not user_wallet.user_wallet_id:
if len(user_id) <= 8:
stored_wallet = await get_user_wallet_settings_by_prefix(user_id)
if stored_wallet and stored_wallet.user_wallet_id:
user_wallet = stored_wallet
user_id = stored_wallet.id # Use the full ID
if not user_wallet or not user_wallet.user_wallet_id:
return {"user_id": user_id, "user_wallet_id": None}
# Get invoice key for the user's wallet (needed to generate invoices)
from lnbits.core.crud import get_wallet
wallet_obj = await get_wallet(user_wallet.user_wallet_id)
if not wallet_obj:
return {"user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id}
return {
"user_id": user_id,
"user_wallet_id": user_wallet.user_wallet_id,
"user_wallet_id_invoice_key": wallet_obj.inkey,
}
@router.get("/api/v1/users")
async def api_get_all_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all users who have configured their wallet (super user only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise truncate user_id
username = user.username if user and user.username else setting.id[:16] + "..."
users.append({
"user_id": setting.id,
"user_wallet_id": setting.user_wallet_id,
"username": username,
})
return users
@router.get("/api/v1/admin/libra-users")
async def api_get_libra_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all users who have configured their wallet in Libra.
These are users who can interact with Libra (submit expenses, receive permissions, etc.).
Super user only.
"""
from lnbits.core.crud.users import get_user
# Get all users who have configured their wallet
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise use user_id
username = user.username if user and user.username else None
users.append({
"id": setting.id,
"user_id": setting.id, # Compatibility with existing code
"username": username,
"user_wallet_id": setting.user_wallet_id,
})
# Sort by username (None values last)
users.sort(key=lambda x: (x["username"] is None, x["username"] or "", x["user_id"]))
return users
@router.get("/api/v1/reports/expenses")
async def api_expense_report(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
group_by: str = "account",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get expense summary report using BQL. Super user only.
Args:
start_date: Filter from this date (YYYY-MM-DD), optional
end_date: Filter to this date (YYYY-MM-DD), optional
group_by: "account" (by expense category) or "month" (by month)
Returns:
{
"summary": [
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
...
],
"total_fiat": 1500.00,
"total_sats": 1650000,
"fiat_currency": "EUR",
"group_by": "account",
"start_date": "2025-01-01",
"end_date": "2025-12-31"
}
Admin only.
"""
from ..fava_client import get_fava_client
if group_by not in ["account", "month"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="group_by must be 'account' or 'month'"
)
fava = get_fava_client()
summaries = await fava.get_expense_summary_bql(
start_date=start_date,
end_date=end_date,
group_by=group_by
)
# Calculate totals
total_fiat = sum(s.get("fiat", 0) for s in summaries)
total_sats = sum(s.get("sats", 0) for s in summaries)
return {
"summary": summaries,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"group_by": group_by,
"start_date": start_date,
"end_date": end_date,
"count": len(summaries)
}
@router.get("/api/v1/reports/contributions")
async def api_contributions_report(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get user contribution report using BQL.
Shows total expenses submitted by each user (creating payables).
Returns:
{
"contributions": [
{
"user_id": "cfe378b3",
"username": "alice",
"total_fiat": 1500.00,
"total_sats": 1650000,
"entry_count": 25
},
...
],
"total_fiat": 5000.00,
"total_sats": 5500000,
"fiat_currency": "EUR",
"user_count": 5
}
Admin only.
"""
from lnbits.core.crud.users import get_user
from ..fava_client import get_fava_client
fava = get_fava_client()
contributions = await fava.get_user_contributions_bql()
# Enrich with usernames
for contrib in contributions:
user_id = contrib["user_id"]
# Try to find full user_id from wallet settings
settings = await get_all_user_wallet_settings()
full_user_id = None
for s in settings:
if s.id.startswith(user_id):
full_user_id = s.id
break
if full_user_id:
user = await get_user(full_user_id)
contrib["username"] = user.username if user and user.username else None
contrib["full_user_id"] = full_user_id
else:
contrib["username"] = None
contrib["full_user_id"] = None
# Calculate totals
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
total_sats = sum(c.get("total_sats", 0) for c in contributions)
return {
"contributions": contributions,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"user_count": len(contributions)
}
@router.get("/api/v1/users/{user_id}/unsettled-entries")
async def api_get_unsettled_entries(
user_id: str,
entry_type: str = "expense",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get unsettled expense or receivable entries for a user.
Returns entries that have unique links (exp-xxx or rcv-xxx) which
have not yet appeared in a settlement transaction.
Args:
user_id: The user's ID
entry_type: "expense" (payables - libra owes user) or
"receivable" (user owes libra)
Returns:
{
"user_id": "abc123...",
"entry_type": "expense",
"unsettled_entries": [
{
"link": "exp-abc123",
"date": "2025-12-01",
"narration": "Groceries at Biocoop",
"fiat_amount": 50.00,
"fiat_currency": "EUR",
"sats_amount": 47000,
"flag": "!" # pending or "*" cleared
},
...
],
"total_fiat": 150.00,
"total_fiat_currency": "EUR",
"total_sats": 141000,
"count": 3
}
Admin only - used when settling user balances.
"""
from ..fava_client import get_fava_client
if entry_type not in ["expense", "receivable"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="entry_type must be 'expense' or 'receivable'"
)
fava = get_fava_client()
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
# Calculate totals
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
total_sats = sum(e.get("sats_amount", 0) for e in unsettled)
# Get currency (assume all same currency for a user)
fiat_currency = unsettled[0].get("fiat_currency", "EUR") if unsettled else "EUR"
return {
"user_id": user_id,
"entry_type": entry_type,
"unsettled_entries": unsettled,
"total_fiat": total_fiat,
"total_fiat_currency": fiat_currency,
"total_sats": total_sats,
"count": len(unsettled)
}
@router.get("/api/v1/user/wallet")
async def api_get_user_wallet(
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Get current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, return the libra wallet
if user.id == lnbits_settings.super_user:
libra_settings = await get_settings("admin")
if libra_settings and libra_settings.libra_wallet_id:
return UserWalletSettings(user_wallet_id=libra_settings.libra_wallet_id)
return UserWalletSettings()
# For regular users, get their personal wallet
settings = await get_user_wallet(user.id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return UserWalletSettings()
return settings
@router.put("/api/v1/user/wallet")
async def api_update_user_wallet(
data: UserWalletSettings,
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Update current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# Super user cannot set their wallet separately - it's always the libra wallet
if user.id == lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Super user wallet is automatically set to the Libra wallet. Update Libra settings instead.",
)
if not data.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="User wallet ID is required",
)
return await update_user_wallet(user.id, data)
# ===== MANUAL PAYMENT REQUESTS =====
@router.get("/api/v1/user/info")
async def api_get_user_info(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserInfo:
"""Get current user's information including equity eligibility"""
from ..crud import get_user_equity_status
from ..models import UserInfo
equity_status = await get_user_equity_status(wallet.wallet.user)
return UserInfo(
user_id=wallet.wallet.user,
is_equity_eligible=equity_status.is_equity_eligible if equity_status else False,
equity_account_name=equity_status.equity_account_name if equity_status else None,
)