diff --git a/.gitignore b/.gitignore index 1ff904f..e68ab2e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ __pycache__ node_modules .venv .mypy_cache -data/ diff --git a/CLAUDE.md b/CLAUDE.md index 1016821..97e546f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/MIGRATION_SQUASH_SUMMARY.md b/MIGRATION_SQUASH_SUMMARY.md new file mode 100644 index 0000000..4b03ed0 --- /dev/null +++ b/MIGRATION_SQUASH_SUMMARY.md @@ -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 diff --git a/__init__.py b/__init__.py index 42a2648..614a8ba 100644 --- a/__init__.py +++ b/__init__.py @@ -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""" diff --git a/account_sync.py b/account_sync.py index 870d1d7..3d82381 100644 --- a/account_sync.py +++ b/account_sync.py @@ -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: diff --git a/account_utils.py b/account_utils.py index d797796..8520d59 100644 --- a/account_utils.py +++ b/account_utils.py @@ -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, diff --git a/auth.py b/auth.py index 34ba8c6..8cc9c17 100644 --- a/auth.py +++ b/auth.py @@ -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 diff --git a/beancount_format.py b/beancount_format.py index 1cba2c7..f233ee5 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -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": "", "btc_rate": ""} - """ - 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, diff --git a/core/__init__.py b/core/__init__.py index e8b8fa3..10c362c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -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", ] diff --git a/core/validation.py b/core/validation.py index 04416fc..c29f069 100644 --- a/core/validation.py +++ b/core/validation.py @@ -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)} diff --git a/crud.py b/crud.py index c28d685..0692806 100644 --- a/crud.py +++ b/crud.py @@ -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 + await db.insert("accounts", account) # 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,39 +305,44 @@ 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}") - # Fetch existing account by name only (ignore user_id in query) - account = await db.fetchone( - """ - SELECT * FROM accounts - WHERE name = :name - """, - {"name": account_name}, - Account, - ) - if account: - logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})") - # Update user_id if it's NULL or different - if account.user_id != user_id: - logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}") - await db.execute( - """ - UPDATE accounts - SET user_id = :user_id - WHERE name = :name - """, - {"user_id": user_id, "name": account_name} - ) - # Refresh account from DB - account = await db.fetchone( - """ - SELECT * FROM accounts - WHERE name = :name - """, - {"name": account_name}, - Account, - ) + 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( + """ + SELECT * FROM accounts + WHERE name = :name + """, + {"name": account_name}, + Account, + ) + if account: + logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})") + # Update user_id if it's NULL or different + if account.user_id != user_id: + logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}") + await db.execute( + """ + UPDATE accounts + SET user_id = :user_id + WHERE name = :name + """, + {"user_id": user_id, "name": account_name} + ) + # Refresh account from DB + account = await db.fetchone( + """ + SELECT * FROM accounts + WHERE name = :name + """, + {"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 diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html new file mode 100644 index 0000000..6271865 --- /dev/null +++ b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html @@ -0,0 +1,953 @@ + + + + + + + ACCOUNTING-ANALYSIS-NET-SETTLEMENT + + + + + +

Accounting +Analysis: Net Settlement Entry Pattern

+

Date: 2025-01-12 Prepared By: +Senior Accounting Review Subject: Libra Extension - +Lightning Payment Settlement Entries Status: Technical +Review

+
+

Executive Summary

+

This document provides a professional accounting assessment of +Libra’s 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.

+

Key Findings: - ✅ 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

+
+

Background: The Technical +Challenge

+

Libra operates as a Lightning Network-integrated accounting system +for collectives (co-living spaces, makerspaces). It faces a unique +accounting challenge:

+

Scenario: User creates a receivable in EUR (e.g., +€200 for room rent), then pays via Lightning Network in satoshis +(225,033 sats).

+

Challenge: 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

+
+

Current Implementation

+

Transaction Example

+
; Step 1: Receivable Created
+2025-11-12 * "room (200.00 EUR)" #receivable-entry
+  user-id: "375ec158"
+  source: "libra-api"
+  sats-amount: "225033"
+  Assets:Receivable:User-375ec158     200.00 EUR
+    sats-equivalent: "225033"
+  Income:Accommodation:Guests        -200.00 EUR
+    sats-equivalent: "225033"
+
+; Step 2: Lightning Payment Received
+2025-11-12 * "Lightning payment settlement from user 375ec158"
+  #lightning-payment #net-settlement
+  user-id: "375ec158"
+  source: "lightning_payment"
+  payment-type: "net-settlement"
+  payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
+  Assets:Bitcoin:Lightning            225033 SATS @ 0.0008887585... EUR
+    payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
+  Assets:Receivable:User-375ec158    -200.00 EUR
+    sats-equivalent: "225033"
+  Liabilities:Payable:User-375ec158     0.00 EUR
+

Code Implementation

+

Location: +beancount_format.py:739-760

+
# Build postings for net settlement
+postings = [
+    {
+        "account": payment_account,
+        "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
+        "meta": {"payment-hash": payment_hash} if payment_hash else {}
+    },
+    {
+        "account": receivable_account,
+        "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
+        "meta": {"sats-equivalent": str(abs(amount_sats))}
+    },
+    {
+        "account": payable_account,
+        "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
+        "meta": {}
+    }
+]
+

Three-Posting Structure: 1. Lightning +Account: Records SATS received with @@ total price +notation 2. Receivable Account: Clears EUR receivable +with sats-equivalent metadata 3. Payable Account: +Clears any outstanding EUR payables (often 0.00)

+
+

Accounting Issues Identified

+

Issue 1: Zero-Amount Postings

+

Problem: The third posting often records +0.00 EUR when no payable exists.

+
Liabilities:Payable:User-375ec158     0.00 EUR
+

Why This Is Wrong: - 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)

+

Accounting Principle Violated: > “Transactions +should only include postings that represent actual economic events or +changes in account balances.”

+

Impact: Low severity, but unprofessional +presentation

+

Recommendation:

+
# Make payable posting conditional
+postings = [
+    {"account": payment_account, "amount": ...},
+    {"account": receivable_account, "amount": ...}
+]
+
+# Only add payable posting if there's actually a payable
+if total_payable_fiat > 0:
+    postings.append({
+        "account": payable_account,
+        "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
+        "meta": {}
+    })
+
+

Issue 2: Redundant Satoshi +Tracking

+

Problem: Satoshis are tracked in TWO places in the +same transaction:

+
    +
  1. Position Amount (via @@ +notation):

    +
    Assets:Bitcoin:Lightning  225033 SATS @@ 200.00 EUR
  2. +
  3. Metadata (sats-equivalent):

    +
    Assets:Receivable:User-375ec158  -200.00 EUR
    +  sats-equivalent: "225033"
  4. +
+

Why This Is Problematic: - The @@ +notation already records the exact satoshi amount - Beancount’s price +database stores this relationship - Metadata becomes redundant for this +specific posting - Increases storage and potential for inconsistency

+

Technical Detail:

+

The @@ notation means “total price” and Beancount +converts it to per-unit price:

+
; 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...)
+

Beancount can query this:

+
SELECT account, sum(convert(position, SATS))
+WHERE account = 'Assets:Bitcoin:Lightning'
+

Recommendation:

+

Choose ONE approach consistently:

+

Option A - Use @ notation (Beancount standard):

+
Assets:Bitcoin:Lightning           225033 SATS @@ 200.00 EUR
+  payment-hash: "8d080ec4..."
+Assets:Receivable:User-375ec158   -200.00 EUR
+  ; No sats-equivalent needed here
+

Option B - Use EUR positions with metadata (Libra’s +current approach):

+
Assets:Bitcoin:Lightning           200.00 EUR
+  sats-received: "225033"
+  payment-hash: "8d080ec4..."
+Assets:Receivable:User-375ec158   -200.00 EUR
+  sats-cleared: "225033"
+

Don’t: Mix both in the same transaction (current +implementation)

+
+

Issue 3: No Exchange +Gain/Loss Recognition

+

Problem: When receivables are denominated in one +currency (EUR) and paid in another (SATS), exchange rate fluctuations +create gains or losses that should be recognized.

+

Example Scenario:

+
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
+

Current Implementation: Forces balance by +calculating the @ rate to make it exactly 200 EUR:

+
Assets:Bitcoin:Lightning  225033 SATS @ 0.000888... EUR  ; = exactly 200.00 EUR
+

This hides the exchange variance by treating the +payment as if it was worth exactly the receivable amount.

+

GAAP/IFRS Requirement:

+

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.

+

Proper Accounting Treatment:

+
2025-11-12 * "Lightning payment with exchange loss"
+  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
+

Impact: Moderate severity - affects financial +statement accuracy

+

Why This Matters: - 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

+
+

Issue 4: Semantic +Misuse of Price Notation

+

Problem: The @ notation in Beancount +represents acquisition cost, not settlement +value.

+

Current Usage:

+
Assets:Bitcoin:Lightning  225033 SATS @ 0.000888... EUR
+

What this notation means in accounting: “We +purchased 225,033 satoshis at a cost of 0.000888 EUR +per satoshi”

+

What actually happened: “We +received 225,033 satoshis as payment for a debt”

+

Economic Difference: - Purchase: +You exchange cash for an asset (buying Bitcoin) - Payment +Receipt: You receive an asset in settlement of a receivable

+

Accounting Substance vs. Form: - +Form: The transaction looks like a Bitcoin purchase - +Substance: The transaction is actually a receivable +collection

+

GAAP Principle (ASC 105-10-05): > “Accounting +should reflect the economic substance of transactions, not merely their +legal form.”

+

Why This Creates Issues:

+
    +
  1. Cost Basis Tracking: For tax purposes, the “cost” +of Bitcoin received as payment should be its fair market value at +receipt, not the receivable amount
  2. +
  3. Price Database Pollution: Beancount’s price +database now contains “prices” that aren’t real market prices
  4. +
  5. Auditor Confusion: An auditor reviewing this would +question why purchase prices don’t match market rates
  6. +
+

Proper Accounting Approach:

+
; 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: "payment-received"
+Revenue:Exchange-Gain              0.50 EUR
+Assets:Receivable:User-375ec158   -200.00 EUR
+
+; Approach 2: Don't use @ notation at all
+Assets:Bitcoin:Lightning           200.00 EUR
+  sats-received: "225033"
+  fmv-at-receipt: "199.50 EUR"
+Assets:Receivable:User-375ec158   -200.00 EUR
+
+

Issue 5: Misnamed +Function and Incorrect Usage

+

Problem: Function is called +format_net_settlement_entry, but it’s used for simple +payments that aren’t true net settlements.

+

Example from User’s Transaction: - Receivable: +200.00 EUR - Payable: 0.00 EUR - Net: 200.00 EUR (this is just a +payment, not a settlement)

+

Accounting Terminology:

+ +

When Net Settlement is Appropriate:

+
User owes Libra:    555.00 EUR (receivable)
+Libra owes User:     38.00 EUR (payable)
+Net amount due:      517.00 EUR (true settlement)
+

Proper three-posting entry:

+
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 ✓
+

When Two Postings Suffice:

+
User owes Libra:    200.00 EUR (receivable)
+Libra owes User:      0.00 EUR (no payable)
+Amount due:          200.00 EUR (simple payment)
+

Simpler two-posting entry:

+
Assets:Bitcoin:Lightning           225033 SATS @@ 200.00 EUR
+Assets:Receivable:User            -200.00 EUR
+

Best Practice: Use the simplest journal entry +structure that accurately represents the transaction.

+

Recommendation: 1. Rename function to +format_payment_entry or +format_receivable_payment_entry 2. Create separate +format_net_settlement_entry for true netting scenarios 3. +Use conditional logic to choose 2-posting vs 3-posting based on whether +both receivables AND payables exist

+
+

Traditional Accounting +Approaches

+

Approach +1: Record Bitcoin at Fair Market Value (Tax Compliant)

+
2025-11-12 * "Bitcoin payment from user 375ec158"
+  Assets:Bitcoin:Lightning           199.50 EUR
+    sats-received: "225033"
+    fmv-per-sat: "0.000886 EUR"
+    cost-basis: "199.50 EUR"
+    payment-hash: "8d080ec4..."
+  Revenue:Exchange-Gain              0.50 EUR
+    source: "cryptocurrency-receipt"
+  Assets:Receivable:User-375ec158   -200.00 EUR
+

Pros: - ✅ Tax compliant (establishes cost basis) - +✅ Recognizes exchange gain/loss - ✅ Uses actual market rates - ✅ +Audit trail for cryptocurrency receipts

+

Cons: - ❌ Requires real-time price feeds - ❌ +Creates taxable events

+
+

Approach 2: +Simplified EUR-Only Ledger (No SATS Positions)

+
2025-11-12 * "Bitcoin payment from user 375ec158"
+  Assets:Bitcoin:Lightning           200.00 EUR
+    sats-received: "225033"
+    sats-rate: "1125.165"
+    payment-hash: "8d080ec4..."
+  Assets:Receivable:User-375ec158   -200.00 EUR
+

Pros: - ✅ Simple and clean - ✅ EUR positions match +accounting reality - ✅ SATS tracked in metadata for reference - ✅ No +artificial price notation

+

Cons: - ❌ SATS not queryable via Beancount +positions - ❌ Requires metadata parsing for SATS balances

+
+

Approach +3: True Net Settlement (When Both Obligations Exist)

+
2025-11-12 * "Net settlement via Lightning"
+  ; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
+  Assets:Bitcoin:Lightning           517.00 EUR
+    sats-received: "565251"
+  Assets:Receivable:User-375ec158   -555.00 EUR
+  Liabilities:Payable:User-375ec158   38.00 EUR
+

When to Use: Only when both +receivables and payables exist and you’re truly netting them.

+
+

Recommendations

+

Priority 1: Immediate +Fixes (Easy Wins)

+

1.1 Remove Zero-Amount +Postings

+

File: beancount_format.py:739-760

+

Current Code:

+
postings = [
+    {...},  # Lightning
+    {...},  # Receivable
+    {       # Payable (always included, even if 0.00)
+        "account": payable_account,
+        "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
+        "meta": {}
+    }
+]
+

Fixed Code:

+
postings = [
+    {
+        "account": payment_account,
+        "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
+        "meta": {"payment-hash": payment_hash} if payment_hash else {}
+    },
+    {
+        "account": receivable_account,
+        "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
+        "meta": {"sats-equivalent": str(abs(amount_sats))}
+    }
+]
+
+# Only add payable posting if there's actually a payable to clear
+if total_payable_fiat > 0:
+    postings.append({
+        "account": payable_account,
+        "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
+        "meta": {}
+    })
+

Impact: Cleaner journal, professional presentation, +easier auditing

+
+

1.2 Choose One SATS Tracking +Method

+

Decision Required: Select either position-based OR +metadata-based satoshi tracking.

+

Option A - Keep Metadata Approach (recommended for +Libra):

+
# In format_net_settlement_entry()
+postings = [
+    {
+        "account": payment_account,
+        "amount": f"{abs(net_fiat_amount):.2f} {fiat_currency}",  # EUR only
+        "meta": {
+            "sats-received": str(abs(amount_sats)),
+            "payment-hash": payment_hash
+        }
+    },
+    {
+        "account": receivable_account,
+        "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
+        "meta": {"sats-cleared": str(abs(amount_sats))}
+    }
+]
+

Option B - Use Position-Based Tracking:

+
# Remove sats-equivalent metadata entirely
+postings = [
+    {
+        "account": payment_account,
+        "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
+        "meta": {"payment-hash": payment_hash}
+    },
+    {
+        "account": receivable_account,
+        "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
+        # No sats-equivalent needed - queryable via price database
+    }
+]
+

Recommendation: Choose Option A (metadata) for +consistency with Libra’s architecture.

+
+

1.3 Rename Function for +Clarity

+

File: beancount_format.py

+

Current: +format_net_settlement_entry()

+

New: format_receivable_payment_entry() +or format_payment_settlement_entry()

+

Rationale: More accurately describes what the +function does (processes payments, not always net settlements)

+
+

Priority 2: +Medium-Term Improvements (Compliance)

+

2.1 Add Exchange Gain/Loss +Tracking

+

File: tasks.py:259-276 (get balance and +calculate settlement)

+

New Logic:

+
# Get user's current balance
+balance = await fava.get_user_balance(user_id)
+fiat_balances = balance.get("fiat_balances", {})
+total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
+
+# Calculate expected fiat value of SATS payment at current market rate
+market_rate = await get_current_sats_eur_rate()  # New function needed
+market_value = Decimal(amount_sats) * market_rate
+
+# Calculate exchange variance
+receivable_amount = abs(total_fiat_balance) if total_fiat_balance > 0 else Decimal(0)
+exchange_variance = market_value - receivable_amount
+
+# If variance is material (> 1 cent), create exchange gain/loss posting
+if abs(exchange_variance) > Decimal("0.01"):
+    # Add exchange gain/loss to postings
+    if exchange_variance > 0:
+        # Gain: payment worth more than receivable
+        exchange_account = "Revenue:Foreign-Exchange-Gain"
+    else:
+        # Loss: payment worth less than receivable
+        exchange_account = "Expenses:Foreign-Exchange-Loss"
+
+    # Include in entry creation
+    exchange_posting = {
+        "account": exchange_account,
+        "amount": f"{abs(exchange_variance):.2f} {fiat_currency}",
+        "meta": {
+            "sats-amount": str(amount_sats),
+            "market-rate": str(market_rate),
+            "receivable-amount": str(receivable_amount)
+        }
+    }
+

Benefits: - ✅ Tax compliance - ✅ Accurate +financial reporting - ✅ Audit trail for cryptocurrency gains/losses - +✅ Regulatory compliance (GAAP/IFRS)

+
+

2.2 +Implement True Net Settlement vs. Simple Payment Logic

+

File: tasks.py or new +payment_logic.py

+
async def create_payment_entry(
+    user_id: str,
+    amount_sats: int,
+    fiat_amount: Decimal,
+    fiat_currency: str,
+    payment_hash: str
+):
+    """
+    Create appropriate payment entry based on user's balance situation.
+    Uses 2-posting for simple payments, 3-posting for net settlements.
+    """
+    # Get user balance
+    balance = await fava.get_user_balance(user_id)
+    fiat_balances = balance.get("fiat_balances", {})
+    total_balance = fiat_balances.get(fiat_currency, Decimal(0))
+
+    receivable_amount = Decimal(0)
+    payable_amount = Decimal(0)
+
+    if total_balance > 0:
+        receivable_amount = total_balance
+    elif total_balance < 0:
+        payable_amount = abs(total_balance)
+
+    # Determine entry type
+    if receivable_amount > 0 and payable_amount > 0:
+        # TRUE NET SETTLEMENT: Both obligations exist
+        return await format_net_settlement_entry(
+            user_id=user_id,
+            amount_sats=amount_sats,
+            receivable_amount=receivable_amount,
+            payable_amount=payable_amount,
+            fiat_amount=fiat_amount,
+            fiat_currency=fiat_currency,
+            payment_hash=payment_hash
+        )
+    elif receivable_amount > 0:
+        # SIMPLE RECEIVABLE PAYMENT: Only receivable exists
+        return await format_receivable_payment_entry(
+            user_id=user_id,
+            amount_sats=amount_sats,
+            receivable_amount=receivable_amount,
+            fiat_amount=fiat_amount,
+            fiat_currency=fiat_currency,
+            payment_hash=payment_hash
+        )
+    else:
+        # PAYABLE PAYMENT: Libra paying user (different flow)
+        return await format_payable_payment_entry(...)
+
+

Priority 3: +Long-Term Architectural Decisions

+

3.1 Establish Primary +Currency Hierarchy

+

Current Issue: Mixed approach (EUR positions with +SATS metadata, but also SATS positions with @ notation)

+

Decision Required: Choose ONE of the following +architectures:

+

Architecture A - EUR Primary, SATS Secondary +(recommended):

+
; All positions in EUR, SATS in metadata
+2025-11-12 * "Payment"
+  Assets:Bitcoin:Lightning           200.00 EUR
+    sats-received: "225033"
+  Assets:Receivable:User            -200.00 EUR
+    sats-cleared: "225033"
+

Architecture B - SATS Primary, EUR Secondary:

+
; All positions in SATS, EUR in metadata
+2025-11-12 * "Payment"
+  Assets:Bitcoin:Lightning           225033 SATS
+    eur-value: "200.00"
+  Assets:Receivable:User            -225033 SATS
+    eur-cleared: "200.00"
+

Recommendation: 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

+
+

3.2 +Consider Separate Ledger for Cryptocurrency Holdings

+

Advanced Approach: Separate cryptocurrency movements +from fiat accounting

+

Main Ledger (EUR-denominated):

+
2025-11-12 * "Payment received from user"
+  Assets:Bitcoin-Custody:User-375ec158  200.00 EUR
+  Assets:Receivable:User-375ec158      -200.00 EUR
+

Cryptocurrency Sub-Ledger (SATS-denominated):

+
2025-11-12 * "Lightning payment received"
+  Assets:Bitcoin:Lightning:Libra    225033 SATS
+  Assets:Bitcoin:Custody:User-375ec  225033 SATS
+

Benefits: - ✅ Clean separation of concerns - ✅ +Cryptocurrency movements tracked independently - ✅ Fiat accounting +unaffected by Bitcoin volatility - ✅ Can generate separate financial +statements

+

Drawbacks: - ❌ Increased complexity - ❌ +Reconciliation between ledgers required - ❌ Two sets of books to +maintain

+
+

Code Files Requiring Changes

+

High Priority (Immediate +Fixes)

+
    +
  1. beancount_format.py:739-760 +
      +
    • Remove zero-amount postings
    • +
    • Make payable posting conditional
    • +
  2. +
  3. beancount_format.py:692 +
      +
    • Rename function to format_receivable_payment_entry
    • +
  4. +
+

Medium Priority (Compliance)

+
    +
  1. tasks.py:235-310 +
      +
    • Add exchange gain/loss calculation
    • +
    • Implement payment vs. settlement logic
    • +
  2. +
  3. New file: exchange_rates.py +
      +
    • Create get_current_sats_eur_rate() function
    • +
    • Implement price feed integration
    • +
  4. +
  5. beancount_format.py +
      +
    • Create new format_net_settlement_entry() for true +netting
    • +
    • Create format_receivable_payment_entry() for simple +payments
    • +
  6. +
+
+

Testing Requirements

+

Test Case 1: +Simple Receivable Payment (No Payable)

+

Setup: - User has receivable: 200.00 EUR - User has +payable: 0.00 EUR - User pays: 225,033 SATS

+

Expected Entry (after fixes):

+
2025-11-12 * "Lightning payment from user"
+  Assets:Bitcoin:Lightning           200.00 EUR
+    sats-received: "225033"
+    payment-hash: "8d080ec4..."
+  Assets:Receivable:User            -200.00 EUR
+    sats-cleared: "225033"
+

Verify: - ✅ Only 2 postings (no zero-amount +payable) - ✅ Entry balances - ✅ SATS tracked in metadata - ✅ User +balance becomes 0 (both EUR and SATS)

+
+

Test Case 2: True Net +Settlement

+

Setup: - 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)

+

Expected Entry:

+
2025-11-12 * "Net settlement via Lightning"
+  Assets:Bitcoin:Lightning           517.00 EUR
+    sats-received: "565251"
+    payment-hash: "abc123..."
+  Assets:Receivable:User            -555.00 EUR
+    sats-portion: "565251"
+  Liabilities:Payable:User            38.00 EUR
+

Verify: - ✅ 3 postings (receivable + payable +cleared) - ✅ Net amount = receivable - payable - ✅ Both balances +become 0 - ✅ Mathematically balanced

+
+

Test Case 3: Exchange +Gain/Loss (Future)

+

Setup: - 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

+

Expected Entry (with exchange tracking):

+
2025-11-12 * "Lightning payment with exchange loss"
+  Assets:Bitcoin:Lightning           199.50 EUR
+    sats-received: "225033"
+    market-rate: "0.000886"
+  Expenses:Foreign-Exchange-Loss     0.50 EUR
+  Assets:Receivable:User            -200.00 EUR
+

Verify: - ✅ Bitcoin recorded at fair market value - +✅ Exchange loss recognized - ✅ Receivable cleared at book value - ✅ +Entry balances

+
+

Conclusion

+

Summary of Issues

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IssueSeverityAccounting ImpactRecommended Action
Zero-amount postingsLowPresentation onlyRemove immediately
Redundant SATS trackingLowStorage/efficiencyChoose one method
No exchange gain/lossHighFinancial accuracyImplement for compliance
Semantic misuse of @MediumAudit clarityConsider EUR-only positions
Misnamed functionLowCode clarityRename function
+

Professional Assessment

+

Is this “best practice” accounting? +No, this implementation deviates from traditional +accounting standards in several ways.

+

Is it acceptable for Libra’s use case? Yes, +with modifications, it’s a reasonable pragmatic solution for a +novel problem (cryptocurrency payments of fiat debts).

+

Critical improvements needed: 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)

+

The fundamental challenge: Traditional accounting +wasn’t designed for this scenario. There is no established “standard” +for recording cryptocurrency payments of fiat-denominated receivables. +Libra’s approach is functional, but should be refined to align better +with accounting principles where possible.

+

Next Steps

+
    +
  1. Week 1: Implement Priority 1 fixes (remove zero +postings, rename function)
  2. +
  3. Week 2-3: Design and implement exchange gain/loss +tracking
  4. +
  5. Week 4: Add payment vs. settlement logic
  6. +
  7. Ongoing: Monitor regulatory guidance on +cryptocurrency accounting
  8. +
+
+

References

+ +
+

Document Version: 1.0 Last Updated: +2025-01-12 Next Review: After Priority 1 fixes +implemented

+
+

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 +Libra’s payment recording system.

+ + diff --git a/docs/CODE-REVIEW-2026-06.md b/docs/CODE-REVIEW-2026-06.md deleted file mode 100644 index a200c5c..0000000 --- a/docs/CODE-REVIEW-2026-06.md +++ /dev/null @@ -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 {}` 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 | diff --git a/docs/PHASE1_COMPLETE.md b/docs/PHASE1_COMPLETE.md new file mode 100644 index 0000000..c152ef1 --- /dev/null +++ b/docs/PHASE1_COMPLETE.md @@ -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. diff --git a/docs/PHASE2_COMPLETE.md b/docs/PHASE2_COMPLETE.md new file mode 100644 index 0000000..cc45614 --- /dev/null +++ b/docs/PHASE2_COMPLETE.md @@ -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* diff --git a/docs/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md new file mode 100644 index 0000000..b53625a --- /dev/null +++ b/docs/PHASE3_COMPLETE.md @@ -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* diff --git a/fava_client.py b/fava_client.py index 1cca139..eaed06b 100644 --- a/fava_client.py +++ b/fava_client.py @@ -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 diff --git a/migrations.py b/migrations.py index 1e661c5..9c38c55 100644 --- a/migrations.py +++ b/migrations.py @@ -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) - """ - ) diff --git a/migrations_old.py.bak b/migrations_old.py.bak new file mode 100644 index 0000000..a412e3e --- /dev/null +++ b/migrations_old.py.bak @@ -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") diff --git a/tasks.py b/tasks.py index 0482708..8ed5a33 100644 --- a/tasks.py +++ b/tasks.py @@ -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}" - ) + await on_invoice_paid(payment) 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) - 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 + # 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) + 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,35 +273,22 @@ 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") - 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 - ) - except Exception as e: - logger.warning(f"Could not query unsettled entries for settlement links: {e}") - # Continue without links - settlement will still be recorded + 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_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 # Format as net settlement transaction entry = format_net_settlement_entry( @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 7b4bfe7..44b5c26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_auth_validation.py b/tests/test_auth_validation.py deleted file mode 100644 index 90d278f..0000000 --- a/tests/test_auth_validation.py +++ /dev/null @@ -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}" - ) diff --git a/tests/test_migrations.py b/tests/test_migrations.py deleted file mode 100644 index 1586473..0000000 --- a/tests/test_migrations.py +++ /dev/null @@ -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 diff --git a/tests/test_payment_idempotency.py b/tests/test_payment_idempotency.py deleted file mode 100644 index 969107b..0000000 --- a/tests/test_payment_idempotency.py +++ /dev/null @@ -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 diff --git a/tests/test_reconciliation_api.py b/tests/test_reconciliation_api.py index a168ff8..66757be 100644 --- a/tests/test_reconciliation_api.py +++ b/tests/test_reconciliation_api.py @@ -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, diff --git a/tests/test_route_table.py b/tests/test_route_table.py deleted file mode 100644 index 1d02b81..0000000 --- a/tests/test_route_table.py +++ /dev/null @@ -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) diff --git a/tests/test_settlement_api.py b/tests/test_settlement_api.py index f7de090..442a01e 100644 --- a/tests/test_settlement_api.py +++ b/tests/test_settlement_api.py @@ -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 diff --git a/tests/test_unit.py b/tests/test_unit.py index a25b436..1c7dabc 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -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", - } diff --git a/tests/test_void_reject_api.py b/tests/test_void_reject_api.py index 0f2b817..66e2180 100644 --- a/tests/test_void_reject_api.py +++ b/tests/test_void_reject_api.py @@ -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}" - ) diff --git a/user_lookup.py b/user_lookup.py deleted file mode 100644 index 040c09b..0000000 --- a/user_lookup.py +++ /dev/null @@ -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 diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..1b3149a --- /dev/null +++ b/views_api.py @@ -0,0 +1,4230 @@ +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 .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, +) + +libra_api_router = APIRouter() + +# 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 ===== + + +@libra_api_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 ===== + + +@libra_api_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 + + +@libra_api_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)""" + return await create_account(data) + + +@libra_api_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 + + +@libra_api_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") + } + + +@libra_api_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 ===== + + +@libra_api_router.get("/api/v1/entries") +async def api_get_journal_entries( + limit: int = 100, + auth: AuthContext = Depends(require_super_user), +) -> list[dict]: + """ + Get all journal entries from Fava/Beancount. + + Returns all transactions in reverse chronological order with username enrichment. + SUPER USER ONLY - exposes all transaction data. + """ + from lnbits.core.crud.users import get_user + from .fava_client import get_fava_client + + fava = get_fava_client() + all_entries = await fava.get_journal_entries() + + # Filter to transactions only and enrich with username + enriched_entries = [] + for e in all_entries: + if e.get("t") != "Transaction": + continue + if e.get("flag") in _SYNTHETIC_FLAGS: + continue + + # Extract user ID from metadata or account names + user_id = None + entry_meta = e.get("meta", {}) + if "user-id" in entry_meta: + user_id = entry_meta["user-id"] + else: + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + parts = account.split("User-") + if len(parts) > 1: + user_id = parts[1] + break + + # Look up username + username = None + if user_id: + user = await get_user(user_id) + username = user.username if user and user.username else f"User-{user_id[:8]}" + + # Add username to entry + enriched_entry = dict(e) + enriched_entry["user_id"] = user_id + enriched_entry["username"] = username + enriched_entries.append(enriched_entry) + + if len(enriched_entries) >= limit: + break + + return enriched_entries + + +# Link prefixes written by libra itself (vs user-supplied references): +# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the +# legacy libra-{id} identity link. +_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 + + +@libra_api_router.get("/api/v1/entries/user") +async def api_get_user_entries( + wallet: WalletTypeInfo = Depends(require_invoice_key), + limit: int = 20, + offset: int = 0, + filter_user_id: str = None, + filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable + days: int = 15, # Default 15 days, options: 15, 30, 60 + start_date: str = None, # ISO format: YYYY-MM-DD + end_date: str = None, # ISO format: YYYY-MM-DD +) -> dict: + """ + Get journal entries that affect the current user's accounts from Fava/Beancount. + + Returns transactions in reverse chronological order with optional filtering. + + Args: + days: Number of days to fetch (default: 15, options: 15, 30, 60) + start_date: Start date for custom range (YYYY-MM-DD). Requires end_date. + end_date: End date for custom range (YYYY-MM-DD). Requires start_date. + + Note: + If both days and start_date/end_date are provided, start_date/end_date takes precedence. + """ + from lnbits.settings import settings as lnbits_settings + from .fava_client import get_fava_client + + fava = get_fava_client() + + # Determine which user's entries to fetch + if wallet.wallet.user == lnbits_settings.super_user: + # Super user can view all or filter by user_id + target_user_id = filter_user_id + else: + # Regular user can only see their own entries + target_user_id = wallet.wallet.user + + # Get journal entries from Fava + # Priority: custom date range > days > default (5 days) + all_entries = await fava.get_journal_entries( + days=days, + start_date=start_date, + end_date=end_date + ) + + # Filter and transform entries + filtered_entries = [] + for e in all_entries: + if e.get("t") != "Transaction": + continue + if e.get("flag") in _SYNTHETIC_FLAGS: + continue + + # Extract user ID from metadata or account names + user_id_match = None + entry_meta = e.get("meta", {}) + if "user-id" in entry_meta: + user_id_match = entry_meta["user-id"] + else: + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") + parts = account.split("User-") + if len(parts) > 1: + user_id_match = parts[1] # Just the short ID after User- + break + + # Filter by target user if specified + if target_user_id and user_id_match: + if not user_id_match.startswith(target_user_id[:8]): + continue + + # Filter by account type if specified + if filter_account_type and user_id_match: + postings = e.get("postings", []) + has_matching_account = False + for posting in postings: + account = posting.get("account", "") + if filter_account_type.lower() == "asset" and "Receivable" in account: + has_matching_account = True + break + elif filter_account_type.lower() == "liability" and "Payable" in account: + has_matching_account = True + break + if not has_matching_account: + continue + + # Extract data for frontend + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) + links = e.get("links", []) + + # Extract amount from postings + amount_sats = 0 + fiat_amount = None + fiat_currency = None + + postings = e.get("postings", []) + if postings: + first_posting = postings[0] + if isinstance(first_posting, dict): + amount_str = first_posting.get("amount", "") + + # Parse amount string: price notation, simple fiat, or legacy SATS format + if isinstance(amount_str, str) and amount_str: + import re + + # Try total price notation: "50.00 EUR @@ 50000 SATS" + 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 = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) + + if total_price_match: + fiat_amount = abs(float(total_price_match.group(1))) + fiat_currency = total_price_match.group(2) + amount_sats = abs(int(total_price_match.group(3))) + elif unit_price_match: + fiat_amount = abs(float(unit_price_match.group(1))) + fiat_currency = unit_price_match.group(2) + sats_per_unit = float(unit_price_match.group(3)) + amount_sats = abs(int(fiat_amount * sats_per_unit)) + + # Try simple fiat format: "50.00 EUR" (check metadata for sats) + 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 = abs(float(fiat_match.group(1))) + fiat_currency = fiat_match.group(2) + + # Get SATS from metadata (legacy) + posting_meta = first_posting.get("meta", {}) + sats_equiv = posting_meta.get("sats-equivalent") + if sats_equiv: + amount_sats = abs(int(sats_equiv)) + + else: + # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" + sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) + if sats_match: + amount_sats = abs(int(sats_match.group(1))) + + # Extract fiat from cost syntax: {33.33 EUR, ...} + cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) + if cost_match: + fiat_amount = float(cost_match.group(1)) + fiat_currency = cost_match.group(2) + + # Extract reference from links (first link that isn't a + # libra-system link: typed entry/settlement links, lightning + # payment links, or the legacy libra-{id} identity link) + reference = None + if isinstance(links, (list, set)): + for link in links: + if isinstance(link, str): + link_clean = link.lstrip('^') + if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): + reference = link_clean + break + + # Look up actual username using helper function + username = await _get_username_from_user_id(user_id_match) if user_id_match else None + + entry_data = { + "id": entry_id or e.get("entry_hash", "unknown"), + "date": e.get("date", ""), + "entry_date": e.get("date", ""), + "flag": e.get("flag"), + "description": e.get("narration", ""), + "payee": e.get("payee"), + "tags": e.get("tags", []), + "links": links, + "amount": amount_sats, + "user_id": user_id_match, + "username": username, + "reference": reference, + "meta": entry_meta, # Include metadata for frontend + } + + if fiat_amount and fiat_currency: + entry_data["fiat_amount"] = fiat_amount + entry_data["fiat_currency"] = fiat_currency + + filtered_entries.append(entry_data) + + # Sort by date descending + filtered_entries.sort(key=lambda x: x.get("date", ""), reverse=True) + + # Apply pagination + total = len(filtered_entries) + paginated_entries = filtered_entries[offset:offset + limit] + + return { + "entries": paginated_entries, + "total": total, + "limit": limit, + "offset": offset, + "has_next": (offset + limit) < total, + "has_prev": offset > 0, + } + + +async def _get_username_from_user_id(user_id: str) -> str: + """ + Helper function to get username from user_id, handling various formats. + + Supports: + - Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d" + - Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d" + - Partial ID (8 chars from account names): "375ec158" + + Returns username or formatted fallback. + """ + from lnbits.core.crud.users import get_user + + logger.debug(f"[USERNAME] Called with: '{user_id}' (len={len(user_id) if user_id else 0})") + + if not user_id: + return None + + # Case 1: Already in standard UUID format (36 chars with dashes) + if len(user_id) == 36 and user_id.count('-') == 4: + logger.debug(f"[USERNAME] Case 1: Full UUID format") + user = await get_user(user_id) + result = user.username if user and user.username else f"User-{user_id[:8]}" + logger.debug(f"[USERNAME] Case 1 result: '{result}'") + return result + + # Case 2: Dashless 32-char UUID - lookup via Libra user settings, fallback to LNbits + elif len(user_id) == 32 and '-' not in user_id: + logger.debug(f"[USERNAME] Case 2: Dashless UUID format - looking up in Libra user settings") + try: + # Convert dashless to dashed format + user_id_with_dashes = f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}-{user_id[16:20]}-{user_id[20:32]}" + logger.debug(f"[USERNAME] Converted to dashed format: {user_id_with_dashes}") + + # Try Libra settings first + user_settings = await get_all_user_wallet_settings() + for setting in user_settings: + if setting.id == user_id_with_dashes: + logger.debug(f"[USERNAME] Found matching user in Libra settings") + user = await get_user(setting.id) + result = user.username if user and user.username else f"User-{user_id[:8]}" + logger.debug(f"[USERNAME] Case 2 result (from Libra): '{result}'") + return result + + # Not in Libra settings - try LNbits database directly + logger.debug(f"[USERNAME] Not in Libra settings, querying LNbits database directly") + from lnbits.db import Database + db = Database("database") + async with 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} + ) + logger.debug(f"[USERNAME] Database query result: {row}") + if row and row["username"]: + result = row["username"] + logger.debug(f"[USERNAME] Case 2 result (from LNbits DB): '{result}'") + return result + + # User doesn't exist anywhere + logger.debug(f"[USERNAME] User not found in LNbits database either") + result = f"User-{user_id[:8]}" + logger.debug(f"[USERNAME] Case 2 result (not found): '{result}'") + return result + + except Exception as e: + logger.error(f"Error looking up user by dashless UUID {user_id}: {e}") + result = f"User-{user_id[:8]}" + return result + + # Case 3: Partial ID (8 chars from account name) - lookup via Libra user settings + elif len(user_id) == 8: + logger.debug(f"[USERNAME] Case 3: Partial ID format - looking up in Libra user settings") + try: + # Get all Libra users (which have full user_ids) + user_settings = await get_all_user_wallet_settings() + + # Find matching user by first 8 chars + for setting in user_settings: + if setting.id.startswith(user_id): + logger.debug(f"[USERNAME] Found full user_id: {setting.id}") + # Now get username from LNbits with full ID + user = await get_user(setting.id) + result = user.username if user and user.username else f"User-{user_id}" + logger.debug(f"[USERNAME] Case 3 result (found): '{result}'") + return result + + # No matching user found in Libra settings + logger.debug(f"[USERNAME] No matching user found in Libra settings") + result = f"User-{user_id}" + logger.debug(f"[USERNAME] Case 3 result (not found): '{result}'") + return result + + except Exception as e: + logger.error(f"Error looking up user by partial ID {user_id}: {e}") + result = f"User-{user_id}" + return result + + # Case 4: Unknown format - try as-is and fall back + else: + logger.debug(f"[USERNAME] Case 4: Unknown format - trying as-is") + try: + user = await get_user(user_id) + result = user.username if user and user.username else f"User-{user_id[:8]}" + logger.debug(f"[USERNAME] Case 4 result: '{result}'") + return result + except Exception as e: + logger.debug(f"[USERNAME] Case 4 exception: {e}") + result = f"User-{user_id[:8]}" + logger.debug(f"[USERNAME] Case 4 fallback result: '{result}'") + return result + + +@libra_api_router.get("/api/v1/entries/pending") +async def api_get_pending_entries( + auth: AuthContext = Depends(require_super_user), +) -> list[dict]: + """ + Get all pending expense entries that need approval (super user only). + + Returns transactions with flag='!' from Fava/Beancount. + """ + from .fava_client import get_fava_client + + # Query Fava for all journal entries (includes links, tags, full metadata) + fava = get_fava_client() + all_entries = await fava.get_journal_entries() + + # Filter for pending transactions and extract info + pending_entries = [] + + for e in all_entries: + # Only include pending transactions that are NOT voided + if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) + links = e.get("links", []) + + # Extract user ID from metadata or account names + user_id = None + entry_meta = e.get("meta", {}) + logger.info(f"[EXTRACT] Entry metadata keys: {list(entry_meta.keys())}") + logger.info(f"[EXTRACT] Entry metadata: {entry_meta}") + if "user-id" in entry_meta: + user_id = entry_meta["user-id"] + logger.info(f"[EXTRACT] Found user-id in metadata: {user_id}") + else: + logger.info(f"[EXTRACT] No user-id in metadata, checking account names") + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") + parts = account.split("User-") + if len(parts) > 1: + user_id = parts[1] # Short ID after User- + logger.info(f"[EXTRACT] Extracted user_id from account name: {user_id}") + break + + # Look up username using helper function + username = await _get_username_from_user_id(user_id) if user_id else None + + # Extract amount from postings (sum of absolute values / 2) + amount_sats = 0 + fiat_amount = None + fiat_currency = None + + postings = e.get("postings", []) + if postings: + first_posting = postings[0] + if isinstance(first_posting, dict): + amount_str = first_posting.get("amount", "") + + # Parse amount string format + if isinstance(amount_str, str) and amount_str: + import re + + # Try total price notation: "50.00 EUR @@ 50000 SATS" + 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 = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) + + if total_price_match: + fiat_amount = abs(float(total_price_match.group(1))) + fiat_currency = total_price_match.group(2) + amount_sats = abs(int(total_price_match.group(3))) + elif unit_price_match: + fiat_amount = abs(float(unit_price_match.group(1))) + fiat_currency = unit_price_match.group(2) + sats_per_unit = float(unit_price_match.group(3)) + amount_sats = abs(int(fiat_amount * sats_per_unit)) + + # Try simple fiat format: "50.00 EUR" (check metadata for sats) + 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 = abs(float(fiat_match.group(1))) + fiat_currency = fiat_match.group(2) + + # Extract sats equivalent from metadata (legacy) + posting_meta = first_posting.get("meta", {}) + sats_equiv = posting_meta.get("sats-equivalent") + if sats_equiv: + amount_sats = abs(int(sats_equiv)) + + else: + # Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" + sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) + if sats_match: + amount_sats = abs(int(sats_match.group(1))) + + # Extract fiat from cost syntax: {33.33 EUR, ...} + cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) + if cost_match: + fiat_amount = float(cost_match.group(1)) + fiat_currency = cost_match.group(2) + + entry_data = { + "id": entry_id or "unknown", + "date": e.get("date", ""), + "entry_date": e.get("date", ""), + "flag": e.get("flag"), + "description": e.get("narration", ""), + "payee": e.get("payee"), + "tags": e.get("tags", []), + "links": links, + "amount": amount_sats, + "user_id": user_id, + "username": username, + } + + # Add fiat info if available + if fiat_amount and fiat_currency: + entry_data["fiat_amount"] = fiat_amount + entry_data["fiat_currency"] = fiat_currency + + pending_entries.append(entry_data) + + return pending_entries + + +@libra_api_router.post("/api/v1/entries", status_code=HTTPStatus.CREATED) +async def api_create_journal_entry( + data: CreateJournalEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create a new generic journal entry. + + Submits entry to Fava/Beancount. + """ + from .fava_client import get_fava_client + from .beancount_format import ( + format_transaction, + format_posting_with_cost, + sanitize_link, + ) + + # Validate that entry balances to zero + total = sum(line.amount for line in data.lines) + if total != 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Entry does not balance (total: {total}, expected: 0)" + ) + + # Get all accounts and validate they exist + account_map = {} + for line in data.lines: + account = await get_account(line.account_id) + if not account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Account '{line.account_id}' not found" + ) + account_map[line.account_id] = account + + # Format postings + postings = [] + for line in data.lines: + account = account_map[line.account_id] + + # Extract fiat info from metadata if present + fiat_currency = line.metadata.get("fiat_currency") + fiat_amount_str = line.metadata.get("fiat_amount") + fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None + + # Create posting metadata (excluding fiat fields that are used for primary amount) + posting_metadata = {k: v for k, v in line.metadata.items() + if k not in ["fiat_currency", "fiat_amount"]} + + # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) + # Otherwise, use SATS-based format + if fiat_currency and fiat_amount: + # EUR-based posting (current architecture) + posting_metadata["sats-equivalent"] = str(abs(line.amount)) + + # Apply the sign from line.amount to fiat_amount + # line.amount is positive for debits, negative for credits + signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount + + posting = { + "account": account.name, + "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", + "meta": posting_metadata if posting_metadata else None + } + else: + # SATS-based posting (legacy/fallback) + if line.description: + posting_metadata["description"] = line.description + + posting = format_posting_with_cost( + account=account.name, + amount_sats=line.amount, + fiat_currency=None, + fiat_amount=None, + metadata=posting_metadata if posting_metadata else None + ) + + postings.append(posting) + + # Extract tags and links from meta + tags = data.meta.get("tags", []) + links = data.meta.get("links", []) + if data.reference: + links.append(sanitize_link(data.reference)) + + # Entry metadata (excluding tags and links which go at transaction level) + entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} + entry_meta["source"] = "libra-api" + entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id + + # Format as Beancount entry + fava = get_fava_client() + + entry = format_transaction( + date_val=data.entry_date.date() if data.entry_date else datetime.now().date(), + flag=data.flag.value if data.flag else "*", + narration=data.description, + postings=postings, + tags=tags if tags else None, + links=links if links else None, + meta=entry_meta + ) + + # Submit to Fava + result = await fava.add_entry(entry) + logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") + + # Return simplified JournalEntry for API compatibility + # Note: Libra no longer stores entries in DB, Fava is the source of truth + timestamp = datetime.now().timestamp() + return JournalEntry( + id=f"fava-{timestamp}", + description=data.description, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_at=datetime.now(), + reference=data.reference, + flag=data.flag if data.flag else JournalEntryFlag.CLEARED, + lines=[], # Empty - entry is stored in Fava, not Libra DB + meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} + ) + + +# ===== SIMPLIFIED ENTRY ENDPOINTS ===== + + +@libra_api_router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED) +async def api_create_expense_entry( + data: ExpenseEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create an expense entry for a user. + If is_equity=True, records as equity contribution. + If is_equity=False, records as liability (libra owes user). + + If currency is provided, amount is converted from fiat to satoshis. + """ + # Check that libra wallet is configured + await check_libra_wallet_configured() + + # Check that user has configured their wallet + await check_user_wallet_configured(wallet.wallet.user) + # Handle currency conversion + amount_sats = int(data.amount) + metadata = {} + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Convert fiat to satoshis + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + # Store currency metadata (store fiat_amount as string to preserve Decimal precision) + metadata = { + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places + "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, + } + + # Get or create expense account + expense_account = await get_account_by_name(data.expense_account) + if not expense_account: + # Try to get it by ID + expense_account = await get_account(data.expense_account) + if not expense_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Expense account '{data.expense_account}' not found", + ) + + # Validate user has permission to submit expenses to this account + from .crud import get_user_permissions_with_inheritance + + submit_perms = await get_user_permissions_with_inheritance( + wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE + ) + + if not submit_perms: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.", + ) + + # Get or create user-specific account + if data.is_equity: + # Validate equity eligibility + from .crud import get_user_equity_status + + equity_status = await get_user_equity_status(wallet.wallet.user) + + if not equity_status or not equity_status.is_equity_eligible: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", + ) + + if not equity_status.equity_account_name: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="User equity account not configured. Contact administrator.", + ) + + # Equity contribution - use user's specific equity account + user_account = await get_account_by_name(equity_status.equity_account_name) + if not user_account: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", + ) + else: + # Liability (libra owes user) + user_account = await get_or_create_user_account( + wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable" + ) + + # Create journal entry + # DR Expense, CR User Account (Liability or Equity) + description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" + + # Add meta information for audit trail + entry_meta = { + "source": "api", + "created_via": "expense_entry", + "user_id": wallet.wallet.user, + "is_equity": data.is_equity, + } + + # Format as Beancount entry and submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_expense_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Format Beancount entry. Identity travels as entry-id metadata + + # exp-{entry_id} link; the user reference becomes its own link. + entry = format_expense_entry( + user_id=wallet.wallet.user, + expense_account=expense_account.name, + user_account=user_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), + is_equity=data.is_equity, + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id + ) + + # Submit to Fava + result = await fava.add_entry(entry) + + # Return a JournalEntry-like response for compatibility + from .models import EntryLine + return JournalEntry( + id=entry_id, # Use the generated libra entry ID + description=data.description + description_suffix, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=expense_account.id, + amount=amount_sats, + description=f"Expense paid by user {wallet.wallet.user[:8]}", + metadata=metadata or {} + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=user_account.id, + amount=-amount_sats, + description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", + metadata=metadata or {} + ), + ] + ) + + +@libra_api_router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) +async def api_create_income_entry( + data: IncomeEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create a user-submitted income/revenue entry (pending approval). + + Mirrors the expense submission flow: entry is created with '!' (pending) + flag and goes through the same /api/v1/entries/{id}/approve and /reject + endpoints. Requires SUBMIT_INCOME permission on the revenue account. + + Postings: DR payment_method_account (asset), CR revenue_account. + """ + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Resolve revenue account by name or ID + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + if revenue_account.account_type != AccountType.REVENUE: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", + ) + + # Permission check on the revenue account + from .crud import get_user_permissions_with_inheritance + + submit_perms = await get_user_permissions_with_inheritance( + wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME + ) + if not submit_perms: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", + ) + + # Income lands on the user as a receivable — they're holding cash on + # behalf of the entity until they hand it over via /settle-receivable. + user_account = await get_or_create_user_account( + wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" + ) + + # Convert fiat to sats + fiat_currency = data.currency.upper() + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + metadata = { + "fiat_currency": fiat_currency, + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), + "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, + } + + # Submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_income_entry + + fava = get_fava_client() + + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Identity travels as entry-id metadata + inc-{entry_id} link; the + # user reference becomes its own link. + entry = format_income_entry( + user_id=wallet.wallet.user, + user_account=user_account.name, + revenue_account=revenue_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=data.amount, + reference=data.reference, + entry_id=entry_id, + ) + + result = await fava.add_entry(entry) + logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") + + description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" + entry_meta = { + "source": "api", + "created_via": "income_entry", + "user_id": wallet.wallet.user, + } + + from .models import EntryLine + return JournalEntry( + id=entry_id, + description=data.description + description_suffix, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=user_account.id, + amount=amount_sats, + description=f"User holds cash receivable to entity ({user_account.name})", + metadata=metadata, + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=revenue_account.id, + amount=-amount_sats, + description="Revenue earned (pending approval)", + metadata=metadata, + ), + ], + ) + + +@libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) +async def api_create_receivable_entry( + data: ReceivableEntry, + auth: AuthContext = Depends(require_super_user), +) -> JournalEntry: + """ + Create an accounts receivable entry (user owes libra). + Admin only to prevent abuse. + + If currency is provided, amount is converted from fiat to satoshis. + """ + # Handle currency conversion + amount_sats = int(data.amount) + metadata = {} + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Convert fiat to satoshis + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + # Store currency metadata (store fiat_amount as string to preserve Decimal precision) + metadata = { + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places + "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, + } + + # Get or create revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get or create user-specific receivable account + user_receivable = await get_or_create_user_account( + data.user_id, AccountType.ASSET, "Accounts Receivable" + ) + + # Create journal entry + # DR Accounts Receivable (User), CR Revenue + description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" + + # Add meta information for audit trail + entry_meta = { + "source": "api", + "created_via": "receivable_entry", + "debtor_user_id": data.user_id, + } + + # Format as Beancount entry and submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_receivable_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Format Beancount entry. Identity travels as entry-id metadata + + # rcv-{entry_id} link; the user reference becomes its own link. + entry = format_receivable_entry( + user_id=data.user_id, + revenue_account=revenue_account.name, + receivable_account=user_receivable.name, + amount_sats=amount_sats, + description=data.description, + entry_date=datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id + ) + + # Submit to Fava + result = await fava.add_entry(entry) + + # Return a JournalEntry-like response for compatibility + from .models import EntryLine + return JournalEntry( + id=entry_id, # Use the generated libra entry ID + description=data.description + description_suffix, + entry_date=datetime.now(), + created_by=auth.user_id, + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=user_receivable.id, + amount=amount_sats, + description=f"Amount owed by user {data.user_id[:8]}", + metadata=metadata or {} + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=revenue_account.id, + amount=-amount_sats, + description="Revenue earned", + metadata=metadata or {} + ), + ] + ) + + +@libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) +async def api_create_revenue_entry( + data: RevenueEntry, + auth: AuthContext = Depends(require_super_user), +) -> JournalEntry: + """ + Create a revenue entry (libra receives payment). + Admin only. + + Submits entry to Fava/Beancount. + """ + from .fava_client import get_fava_client + from .beancount_format import format_revenue_entry + + # Get revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get payment method account + payment_account = await get_account_by_name(data.payment_method_account) + if not payment_account: + payment_account = await get_account(data.payment_method_account) + if not payment_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Payment account '{data.payment_method_account}' not found", + ) + + # Handle currency conversion if provided + amount_sats = int(data.amount) + fiat_currency = None + fiat_amount = None + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}", + ) + + # Store fiat info for cost basis + fiat_currency = data.currency.upper() + fiat_amount = data.amount # Original fiat amount + # In this case, data.amount should be the satoshi amount + # This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat + # For now, assume if currency is provided, amount is fiat and needs conversion + # TODO: Consider updating the API model to be clearer about this + + # Format as Beancount entry and submit to Fava + fava = get_fava_client() + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Identity travels as entry-id metadata; the user reference becomes + # its own link. + entry = format_revenue_entry( + payment_account=payment_account.name, + revenue_account=revenue_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id, + ) + + # Submit to Fava + result = await fava.add_entry(entry) + logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") + + # Return simplified JournalEntry for API compatibility + # Note: Libra no longer stores entries in DB, Fava is the source of truth + return JournalEntry( + id=entry_id, + description=data.description, + entry_date=datetime.now(), + created_by=auth.user_id, + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.CLEARED, + lines=[], # Empty - entry is stored in Fava, not Libra DB + meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} + ) + + +# ===== USER BALANCE ENDPOINTS ===== + + +@libra_api_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"], + ) + + +@libra_api_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"], + ) + + +@libra_api_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_from_user_id(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 ===== + + +@libra_api_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 + + # Calculate fiat rate (sats per fiat unit) + fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0 + btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0 + + invoice_extra.update({ + "fiat_currency": fiat_currency, + "fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))), + "fiat_rate": fiat_rate, + "btc_rate": btc_rate, + }) + + 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 + } + + +@libra_api_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 + response = await client.get( + f"{fava.base_url}/api/journal", + params={"time": ""} # Get all entries + ) + + if response.status_code == 200: + 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 Exception as e: + logger.warning(f"Could not check Fava for duplicate payment: {e}") + # Continue anyway - Fava/Beancount will catch duplicate if it exists + + # Convert amount from millisatoshis to satoshis + 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')}") + + # Get updated balance from Fava + balance_data = await fava.get_user_balance_bql(target_user_id) + + return { + "journal_entry_id": f"fava-{datetime.now().timestamp()}", + "new_balance": balance_data["balance"], + "message": "Payment recorded successfully", + } + + +@libra_api_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") + + 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": float(cash_paid), + "net_obligation": float(net_obligation), + "receivable_total": float(receivable_total), + "payable_total": float(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}", + } + + +@libra_api_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 ===== + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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, + } + + +@libra_api_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 + + +@libra_api_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 + + +@libra_api_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) + } + + +@libra_api_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) + } + + +@libra_api_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) + } + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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 + ) + + +@libra_api_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) + + +@libra_api_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) + + +@libra_api_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 + ) + + # Submit to Fava + result = await fava.add_entry(entry) + logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}") + + # Approve the request with Fava entry reference + entry_id = f"fava-{datetime.now().timestamp()}" + return await approve_manual_payment_request( + request_id, auth.user_id, entry_id + ) + + +@libra_api_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}", + ) + + return await reject_manual_payment_request(request_id, auth.user_id) + + +# ===== EXPENSE APPROVAL ENDPOINTS ===== + + +@libra_api_router.post("/api/v1/entries/{entry_id}/approve") +async def api_approve_expense_entry( + entry_id: str, + auth: AuthContext = Depends(require_super_user), +) -> dict: + """ + Approve a pending expense entry by changing flag from '!' to '*' (super user only). + + This updates the transaction in the Beancount file via Fava API. + """ + import httpx + from .fava_client import get_fava_client + + fava = get_fava_client() + + # 1. Get all journal entries from Fava + all_entries = await fava.get_journal_entries() + + # 2. Find the pending transaction with matching canonical entry id + target_entry = None + + for entry in all_entries: + # Only look at transactions with pending flag + if entry.get("t") == "Transaction" and entry.get("flag") == "!": + if _extract_entry_id(entry) == entry_id: + target_entry = entry + break + + if not target_entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Pending entry {entry_id} not found in Beancount ledger" + ) + + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") + date_str = target_entry.get("date", "") + + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) + + # 3. Get the source file from Fava + async with httpx.AsyncClient(timeout=fava.timeout) as client: + response = await client.get( + f"{fava.base_url}/source", + params={"filename": filename} + ) + response.raise_for_status() + source_data = response.json()["data"] + + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 4. Find and modify the entry at the specified line + # Line numbers are 1-indexed, list is 0-indexed + entry_line_idx = lineno - 1 + + if entry_line_idx >= len(lines): + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} not found in source file" + ) + + entry_line = lines[entry_line_idx] + + # Check if the line contains the pending flag pattern + old_pattern = f"{date_str} !" + if old_pattern not in entry_line: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}" + ) + + # Replace the flag + new_pattern = f"{date_str} *" + new_line = entry_line.replace(old_pattern, new_pattern, 1) + lines[entry_line_idx] = new_line + + # 5. Write back the modified source + new_source = '\n'.join(lines) + + update_response = await client.put( + f"{fava.base_url}/source", + json={ + "file_path": filename, + "source": new_source, + "sha256sum": sha256sum + }, + headers={"Content-Type": "application/json"} + ) + update_response.raise_for_status() + + logger.info(f"Entry {entry_id} approved (flag changed to *)") + + return { + "message": f"Entry {entry_id} approved successfully", + "entry_id": entry_id, + "date": date_str, + "description": target_entry.get("narration", "") + } + + +@libra_api_router.post("/api/v1/entries/{entry_id}/reject") +async def api_reject_expense_entry( + entry_id: str, + auth: AuthContext = Depends(require_super_user), +) -> dict: + """ + Reject a pending expense entry by marking it as voided (super user only). + + Adds #voided tag for audit trail while keeping the '!' flag. + Voided transactions are excluded from balances but preserved in the ledger. + """ + import httpx + from .fava_client import get_fava_client + + fava = get_fava_client() + + # 1. Get all journal entries from Fava + all_entries = await fava.get_journal_entries() + + # 2. Find the pending transaction with matching canonical entry id + target_entry = None + + for entry in all_entries: + # Only look at transactions with pending flag + if entry.get("t") == "Transaction" and entry.get("flag") == "!": + if _extract_entry_id(entry) == entry_id: + target_entry = entry + break + + if not target_entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Pending entry {entry_id} not found in Beancount ledger" + ) + + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") + date_str = target_entry.get("date", "") + + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) + + # 3. Get the source file from Fava + async with httpx.AsyncClient(timeout=fava.timeout) as client: + response = await client.get( + f"{fava.base_url}/source", + params={"filename": filename} + ) + response.raise_for_status() + source_data = response.json()["data"] + + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 4. Find and modify the entry at the specified line - add #voided tag + entry_line_idx = lineno - 1 + + if entry_line_idx >= len(lines): + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} not found in source file" + ) + + entry_line = lines[entry_line_idx] + + # Add #voided tag if not already present + if "#voided" not in entry_line: + # Add #voided tag to the transaction line + new_line = entry_line.rstrip() + ' #voided' + lines[entry_line_idx] = new_line + + # 5. Write back the modified source + new_source = '\n'.join(lines) + + update_response = await client.put( + f"{fava.base_url}/source", + json={ + "file_path": filename, + "source": new_source, + "sha256sum": sha256sum + }, + headers={"Content-Type": "application/json"} + ) + update_response.raise_for_status() + logger.info(f"Entry {entry_id} rejected (added #voided tag)") + + return { + "message": f"Entry {entry_id} rejected (marked as voided)", + "entry_id": entry_id, + "date": date_str, + "description": target_entry.get("narration", "") + } + + +# ===== BALANCE ASSERTION ENDPOINTS ===== + + +@libra_api_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 + + +@libra_api_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, + ) + + +@libra_api_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 + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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(), + } + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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 ===== + + +@libra_api_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, + ) + + +@libra_api_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) + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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) + + +@libra_api_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 + + +@libra_api_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", + } + + +@libra_api_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 + + +@libra_api_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 ===== + + +@libra_api_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 ===== + + +@libra_api_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 ===== + + +_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) -> None: + """Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account. + + The UI guards this client-side, but the endpoint is reachable directly via + API, so this is the load-bearing check before the name is written into the + ledger source. Requires a root plus at least one sub-component. + """ + parts = name.split(":") + valid = ( + len(parts) >= 2 + 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 HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + 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)." + ), + ) + + +@libra_api_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. + synced = await sync_single_account_from_beancount(payload.name) + + return { + "success": True, + "account_name": payload.name, + "synced_to_libra_db": synced, + } + + +@libra_api_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)}" + ) + + +@libra_api_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 ===== + + +@libra_api_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 + + +@libra_api_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)}" + ) + + +@libra_api_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 + ], + } + + +@libra_api_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(), + } + + +@libra_api_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 ===== + + +@libra_api_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)}" + ) + + +@libra_api_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 ===== + + +@libra_api_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)}" + ) + + +@libra_api_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 + + +@libra_api_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"} + + +@libra_api_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 + ] + + +@libra_api_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 + ], + } diff --git a/views_api/__init__.py b/views_api/__init__.py deleted file mode 100644 index bd79f14..0000000 --- a/views_api/__init__.py +++ /dev/null @@ -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) diff --git a/views_api/_shared.py b/views_api/_shared.py deleted file mode 100644 index 09b218a..0000000 --- a/views_api/_shared.py +++ /dev/null @@ -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), - ) - - diff --git a/views_api/accounts.py b/views_api/accounts.py deleted file mode 100644 index 3776f75..0000000 --- a/views_api/accounts.py +++ /dev/null @@ -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 ===== diff --git a/views_api/admin.py b/views_api/admin.py deleted file mode 100644 index 88114ec..0000000 --- a/views_api/admin.py +++ /dev/null @@ -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 - ], - } diff --git a/views_api/entries.py b/views_api/entries.py deleted file mode 100644 index 6df5545..0000000 --- a/views_api/entries.py +++ /dev/null @@ -1,1209 +0,0 @@ -"""Libra API — entries endpoints (moved verbatim from views_api.py).""" -from fastapi import APIRouter - -from ._shared import * # noqa: F401,F403 -from ._shared import ( # noqa: F401 - _SYNTHETIC_FLAGS, - _SYSTEM_LINK_PREFIXES, - _extract_entry_id, -) - -router = APIRouter() - -@router.get("/api/v1/entries") -async def api_get_journal_entries( - limit: int = 100, - auth: AuthContext = Depends(require_super_user), -) -> list[dict]: - """ - Get all journal entries from Fava/Beancount. - - Returns all transactions in reverse chronological order with username enrichment. - SUPER USER ONLY - exposes all transaction data. - """ - from lnbits.core.crud.users import get_user - from ..fava_client import get_fava_client - - fava = get_fava_client() - all_entries = await fava.get_journal_entries() - - # Filter to transactions only and enrich with username - enriched_entries = [] - for e in all_entries: - if e.get("t") != "Transaction": - continue - if e.get("flag") in _SYNTHETIC_FLAGS: - continue - - # Extract user ID from metadata or account names - user_id = None - entry_meta = e.get("meta", {}) - if "user-id" in entry_meta: - user_id = entry_meta["user-id"] - else: - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - parts = account.split("User-") - if len(parts) > 1: - user_id = parts[1] - break - - # Look up username - username = None - if user_id: - user = await get_user(user_id) - username = user.username if user and user.username else f"User-{user_id[:8]}" - - # Add username to entry - enriched_entry = dict(e) - enriched_entry["user_id"] = user_id - enriched_entry["username"] = username - enriched_entries.append(enriched_entry) - - if len(enriched_entries) >= limit: - break - - return enriched_entries - - -# Link prefixes written by libra itself (vs user-supplied references): -# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the -# legacy libra-{id} identity link. -@router.get("/api/v1/entries/user") -async def api_get_user_entries( - wallet: WalletTypeInfo = Depends(require_invoice_key), - limit: int = 20, - offset: int = 0, - filter_user_id: str = None, - filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable - days: int = 15, # Default 15 days, options: 15, 30, 60 - start_date: str = None, # ISO format: YYYY-MM-DD - end_date: str = None, # ISO format: YYYY-MM-DD -) -> dict: - """ - Get journal entries that affect the current user's accounts from Fava/Beancount. - - Returns transactions in reverse chronological order with optional filtering. - - Args: - days: Number of days to fetch (default: 15, options: 15, 30, 60) - start_date: Start date for custom range (YYYY-MM-DD). Requires end_date. - end_date: End date for custom range (YYYY-MM-DD). Requires start_date. - - Note: - If both days and start_date/end_date are provided, start_date/end_date takes precedence. - """ - from lnbits.settings import settings as lnbits_settings - from ..fava_client import get_fava_client - - fava = get_fava_client() - - # Determine which user's entries to fetch - if wallet.wallet.user == lnbits_settings.super_user: - # Super user can view all or filter by user_id - target_user_id = filter_user_id - else: - # Regular user can only see their own entries - target_user_id = wallet.wallet.user - - # Get journal entries from Fava - # Priority: custom date range > days > default (5 days) - all_entries = await fava.get_journal_entries( - days=days, - start_date=start_date, - end_date=end_date - ) - - # Filter and transform entries - filtered_entries = [] - for e in all_entries: - if e.get("t") != "Transaction": - continue - if e.get("flag") in _SYNTHETIC_FLAGS: - continue - - # Extract user ID from metadata or account names - user_id_match = None - entry_meta = e.get("meta", {}) - if "user-id" in entry_meta: - user_id_match = entry_meta["user-id"] - else: - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") - parts = account.split("User-") - if len(parts) > 1: - user_id_match = parts[1] # Just the short ID after User- - break - - # Filter by target user if specified - if target_user_id and user_id_match: - if not user_id_match.startswith(target_user_id[:8]): - continue - - # Filter by account type if specified - if filter_account_type and user_id_match: - postings = e.get("postings", []) - has_matching_account = False - for posting in postings: - account = posting.get("account", "") - if filter_account_type.lower() == "asset" and "Receivable" in account: - has_matching_account = True - break - elif filter_account_type.lower() == "liability" and "Payable" in account: - has_matching_account = True - break - if not has_matching_account: - continue - - # Extract data for frontend - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) - links = e.get("links", []) - - # Extract amount from postings - amount_sats = 0 - fiat_amount = None - fiat_currency = None - - postings = e.get("postings", []) - if postings: - first_posting = postings[0] - if isinstance(first_posting, dict): - amount_str = first_posting.get("amount", "") - - # Parse amount string: price notation, simple fiat, or legacy SATS format - if isinstance(amount_str, str) and amount_str: - import re - - # Try total price notation: "50.00 EUR @@ 50000 SATS" - 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 = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) - - if total_price_match: - fiat_amount = abs(float(total_price_match.group(1))) - fiat_currency = total_price_match.group(2) - amount_sats = abs(int(total_price_match.group(3))) - elif unit_price_match: - fiat_amount = abs(float(unit_price_match.group(1))) - fiat_currency = unit_price_match.group(2) - sats_per_unit = float(unit_price_match.group(3)) - amount_sats = abs(int(fiat_amount * sats_per_unit)) - - # Try simple fiat format: "50.00 EUR" (check metadata for sats) - 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 = abs(float(fiat_match.group(1))) - fiat_currency = fiat_match.group(2) - - # Get SATS from metadata (legacy) - posting_meta = first_posting.get("meta", {}) - sats_equiv = posting_meta.get("sats-equivalent") - if sats_equiv: - amount_sats = abs(int(sats_equiv)) - - else: - # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" - sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) - if sats_match: - amount_sats = abs(int(sats_match.group(1))) - - # Extract fiat from cost syntax: {33.33 EUR, ...} - cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) - if cost_match: - fiat_amount = float(cost_match.group(1)) - fiat_currency = cost_match.group(2) - - # Extract reference from links (first link that isn't a - # libra-system link: typed entry/settlement links, lightning - # payment links, or the legacy libra-{id} identity link) - reference = None - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - link_clean = link.lstrip('^') - if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): - reference = link_clean - break - - # Look up actual username using helper function - username = await get_username(user_id_match) if user_id_match else None - - entry_data = { - "id": entry_id or e.get("entry_hash", "unknown"), - "date": e.get("date", ""), - "entry_date": e.get("date", ""), - "flag": e.get("flag"), - "description": e.get("narration", ""), - "payee": e.get("payee"), - "tags": e.get("tags", []), - "links": links, - "amount": amount_sats, - "user_id": user_id_match, - "username": username, - "reference": reference, - "meta": entry_meta, # Include metadata for frontend - } - - if fiat_amount and fiat_currency: - entry_data["fiat_amount"] = fiat_amount - entry_data["fiat_currency"] = fiat_currency - - filtered_entries.append(entry_data) - - # Sort by date descending - filtered_entries.sort(key=lambda x: x.get("date", ""), reverse=True) - - # Apply pagination - total = len(filtered_entries) - paginated_entries = filtered_entries[offset:offset + limit] - - return { - "entries": paginated_entries, - "total": total, - "limit": limit, - "offset": offset, - "has_next": (offset + limit) < total, - "has_prev": offset > 0, - } - - -@router.get("/api/v1/entries/pending") -async def api_get_pending_entries( - auth: AuthContext = Depends(require_super_user), -) -> list[dict]: - """ - Get all pending expense entries that need approval (super user only). - - Returns transactions with flag='!' from Fava/Beancount. - """ - from ..fava_client import get_fava_client - - # Query Fava for all journal entries (includes links, tags, full metadata) - fava = get_fava_client() - all_entries = await fava.get_journal_entries() - - # Filter for pending transactions and extract info - pending_entries = [] - - for e in all_entries: - # Only include pending transactions that are NOT voided - if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) - links = e.get("links", []) - - # Extract user ID from metadata or account names - user_id = None - entry_meta = e.get("meta", {}) - logger.info(f"[EXTRACT] Entry metadata keys: {list(entry_meta.keys())}") - logger.info(f"[EXTRACT] Entry metadata: {entry_meta}") - if "user-id" in entry_meta: - user_id = entry_meta["user-id"] - logger.info(f"[EXTRACT] Found user-id in metadata: {user_id}") - else: - logger.info(f"[EXTRACT] No user-id in metadata, checking account names") - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") - parts = account.split("User-") - if len(parts) > 1: - user_id = parts[1] # Short ID after User- - logger.info(f"[EXTRACT] Extracted user_id from account name: {user_id}") - break - - # Look up username using helper function - username = await get_username(user_id) if user_id else None - - # Extract amount from postings (sum of absolute values / 2) - amount_sats = 0 - fiat_amount = None - fiat_currency = None - - postings = e.get("postings", []) - if postings: - first_posting = postings[0] - if isinstance(first_posting, dict): - amount_str = first_posting.get("amount", "") - - # Parse amount string format - if isinstance(amount_str, str) and amount_str: - import re - - # Try total price notation: "50.00 EUR @@ 50000 SATS" - 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 = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) - - if total_price_match: - fiat_amount = abs(float(total_price_match.group(1))) - fiat_currency = total_price_match.group(2) - amount_sats = abs(int(total_price_match.group(3))) - elif unit_price_match: - fiat_amount = abs(float(unit_price_match.group(1))) - fiat_currency = unit_price_match.group(2) - sats_per_unit = float(unit_price_match.group(3)) - amount_sats = abs(int(fiat_amount * sats_per_unit)) - - # Try simple fiat format: "50.00 EUR" (check metadata for sats) - 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 = abs(float(fiat_match.group(1))) - fiat_currency = fiat_match.group(2) - - # Extract sats equivalent from metadata (legacy) - posting_meta = first_posting.get("meta", {}) - sats_equiv = posting_meta.get("sats-equivalent") - if sats_equiv: - amount_sats = abs(int(sats_equiv)) - - else: - # Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" - sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) - if sats_match: - amount_sats = abs(int(sats_match.group(1))) - - # Extract fiat from cost syntax: {33.33 EUR, ...} - cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) - if cost_match: - fiat_amount = float(cost_match.group(1)) - fiat_currency = cost_match.group(2) - - entry_data = { - "id": entry_id or "unknown", - "date": e.get("date", ""), - "entry_date": e.get("date", ""), - "flag": e.get("flag"), - "description": e.get("narration", ""), - "payee": e.get("payee"), - "tags": e.get("tags", []), - "links": links, - "amount": amount_sats, - "user_id": user_id, - "username": username, - } - - # Add fiat info if available - if fiat_amount and fiat_currency: - entry_data["fiat_amount"] = fiat_amount - entry_data["fiat_currency"] = fiat_currency - - pending_entries.append(entry_data) - - return pending_entries - - -@router.post("/api/v1/entries", status_code=HTTPStatus.CREATED) -async def api_create_journal_entry( - data: CreateJournalEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create a new generic journal entry. - - Submits entry to Fava/Beancount. - """ - from ..fava_client import get_fava_client - from ..beancount_format import ( - format_transaction, - format_posting_with_cost, - sanitize_link, - ) - - # Validate that entry balances to zero - total = sum(line.amount for line in data.lines) - if total != 0: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Entry does not balance (total: {total}, expected: 0)" - ) - - # Get all accounts and validate they exist - account_map = {} - for line in data.lines: - account = await get_account(line.account_id) - if not account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Account '{line.account_id}' not found" - ) - account_map[line.account_id] = account - - # Format postings - postings = [] - for line in data.lines: - account = account_map[line.account_id] - - # Extract fiat info from metadata if present - fiat_currency = line.metadata.get("fiat_currency") - fiat_amount_str = line.metadata.get("fiat_amount") - fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None - - # Create posting metadata (excluding fiat fields that are used for primary amount) - posting_metadata = {k: v for k, v in line.metadata.items() - if k not in ["fiat_currency", "fiat_amount"]} - - # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) - # Otherwise, use SATS-based format - if fiat_currency and fiat_amount: - # EUR-based posting (current architecture) - posting_metadata["sats-equivalent"] = str(abs(line.amount)) - - # Apply the sign from line.amount to fiat_amount - # line.amount is positive for debits, negative for credits - signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount - - posting = { - "account": account.name, - "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", - "meta": posting_metadata if posting_metadata else None - } - else: - # SATS-based posting (legacy/fallback) - if line.description: - posting_metadata["description"] = line.description - - posting = format_posting_with_cost( - account=account.name, - amount_sats=line.amount, - fiat_currency=None, - fiat_amount=None, - metadata=posting_metadata if posting_metadata else None - ) - - postings.append(posting) - - # Extract tags and links from meta - tags = data.meta.get("tags", []) - links = data.meta.get("links", []) - if data.reference: - links.append(sanitize_link(data.reference)) - - # Entry metadata (excluding tags and links which go at transaction level) - entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} - entry_meta["source"] = "libra-api" - entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id - - # Format as Beancount entry - fava = get_fava_client() - - entry = format_transaction( - date_val=data.entry_date.date() if data.entry_date else datetime.now().date(), - flag=data.flag.value if data.flag else "*", - narration=data.description, - postings=postings, - tags=tags if tags else None, - links=links if links else None, - meta=entry_meta - ) - - # Submit to Fava - result = await fava.add_entry(entry) - logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") - - # Return simplified JournalEntry for API compatibility - # Note: Libra no longer stores entries in DB, Fava is the source of truth - timestamp = datetime.now().timestamp() - return JournalEntry( - id=f"fava-{timestamp}", - description=data.description, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id - created_at=datetime.now(), - reference=data.reference, - flag=data.flag if data.flag else JournalEntryFlag.CLEARED, - lines=[], # Empty - entry is stored in Fava, not Libra DB - meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} - ) - - -# ===== SIMPLIFIED ENTRY ENDPOINTS ===== - - -@router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED) -async def api_create_expense_entry( - data: ExpenseEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create an expense entry for a user. - If is_equity=True, records as equity contribution. - If is_equity=False, records as liability (libra owes user). - - If currency is provided, amount is converted from fiat to satoshis. - """ - # Check that libra wallet is configured - await check_libra_wallet_configured() - - # Check that user has configured their wallet - await check_user_wallet_configured(wallet.wallet.user) - # Handle currency conversion - amount_sats = int(data.amount) - metadata = {} - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Convert fiat to satoshis - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - # Store currency metadata (store fiat_amount as string to preserve Decimal precision) - metadata = { - "fiat_currency": data.currency.upper(), - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Get or create expense account - expense_account = await get_account_by_name(data.expense_account) - if not expense_account: - # Try to get it by ID - expense_account = await get_account(data.expense_account) - if not expense_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Expense account '{data.expense_account}' not found", - ) - - # Validate user has permission to submit expenses to this account - from ..crud import get_user_permissions_with_inheritance - - submit_perms = await get_user_permissions_with_inheritance( - wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE - ) - - if not submit_perms: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.", - ) - - # Get or create user-specific account - if data.is_equity: - # Validate equity eligibility - from ..crud import get_user_equity_status - - equity_status = await get_user_equity_status(wallet.wallet.user) - - if not equity_status or not equity_status.is_equity_eligible: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", - ) - - if not equity_status.equity_account_name: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="User equity account not configured. Contact administrator.", - ) - - # Equity contribution - use user's specific equity account - user_account = await get_account_by_name(equity_status.equity_account_name) - if not user_account: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", - ) - else: - # Liability (libra owes user) - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable" - ) - - # Create journal entry - # DR Expense, CR User Account (Liability or Equity) - description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" - - # Add meta information for audit trail - entry_meta = { - "source": "api", - "created_via": "expense_entry", - "user_id": wallet.wallet.user, - "is_equity": data.is_equity, - } - - # Format as Beancount entry and submit to Fava - from ..fava_client import get_fava_client - from ..beancount_format import format_expense_entry - - fava = get_fava_client() - - # Extract fiat info from metadata - fiat_currency = metadata.get("fiat_currency") if metadata else None - fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Format Beancount entry. Identity travels as entry-id metadata + - # exp-{entry_id} link; the user reference becomes its own link. - entry = format_expense_entry( - user_id=wallet.wallet.user, - expense_account=expense_account.name, - user_account=user_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), - is_equity=data.is_equity, - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - # Return a JournalEntry-like response for compatibility - from ..models import EntryLine - return JournalEntry( - id=entry_id, # Use the generated libra entry ID - description=data.description + description_suffix, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.PENDING, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=expense_account.id, - amount=amount_sats, - description=f"Expense paid by user {wallet.wallet.user[:8]}", - metadata=metadata or {} - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=user_account.id, - amount=-amount_sats, - description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", - metadata=metadata or {} - ), - ] - ) - - -@router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) -async def api_create_income_entry( - data: IncomeEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create a user-submitted income/revenue entry (pending approval). - - Mirrors the expense submission flow: entry is created with '!' (pending) - flag and goes through the same /api/v1/entries/{id}/approve and /reject - endpoints. Requires SUBMIT_INCOME permission on the revenue account. - - Postings: DR payment_method_account (asset), CR revenue_account. - """ - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Resolve revenue account by name or ID - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - if revenue_account.account_type != AccountType.REVENUE: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", - ) - - # Permission check on the revenue account - from ..crud import get_user_permissions_with_inheritance - - submit_perms = await get_user_permissions_with_inheritance( - wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME - ) - if not submit_perms: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", - ) - - # Income lands on the user as a receivable — they're holding cash on - # behalf of the entity until they hand it over via /settle-receivable. - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" - ) - - # Convert fiat to sats - fiat_currency = data.currency.upper() - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - metadata = { - "fiat_currency": fiat_currency, - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Submit to Fava - from ..fava_client import get_fava_client - from ..beancount_format import format_income_entry - - fava = get_fava_client() - - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Identity travels as entry-id metadata + inc-{entry_id} link; the - # user reference becomes its own link. - entry = format_income_entry( - user_id=wallet.wallet.user, - user_account=user_account.name, - revenue_account=revenue_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=data.amount, - reference=data.reference, - entry_id=entry_id, - ) - - result = await fava.add_entry(entry) - logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") - - description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" - entry_meta = { - "source": "api", - "created_via": "income_entry", - "user_id": wallet.wallet.user, - } - - from ..models import EntryLine - return JournalEntry( - id=entry_id, - description=data.description + description_suffix, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.PENDING, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=user_account.id, - amount=amount_sats, - description=f"User holds cash receivable to entity ({user_account.name})", - metadata=metadata, - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=revenue_account.id, - amount=-amount_sats, - description="Revenue earned (pending approval)", - metadata=metadata, - ), - ], - ) - - -@router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) -async def api_create_receivable_entry( - data: ReceivableEntry, - auth: AuthContext = Depends(require_super_user), -) -> JournalEntry: - """ - Create an accounts receivable entry (user owes libra). - Admin only to prevent abuse. - - If currency is provided, amount is converted from fiat to satoshis. - """ - # Handle currency conversion - amount_sats = int(data.amount) - metadata = {} - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Convert fiat to satoshis - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - # Store currency metadata (store fiat_amount as string to preserve Decimal precision) - metadata = { - "fiat_currency": data.currency.upper(), - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Get or create revenue account - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - - # Get or create user-specific receivable account - user_receivable = await get_or_create_user_account( - data.user_id, AccountType.ASSET, "Accounts Receivable" - ) - - # Create journal entry - # DR Accounts Receivable (User), CR Revenue - description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" - - # Add meta information for audit trail - entry_meta = { - "source": "api", - "created_via": "receivable_entry", - "debtor_user_id": data.user_id, - } - - # Format as Beancount entry and submit to Fava - from ..fava_client import get_fava_client - from ..beancount_format import format_receivable_entry - - fava = get_fava_client() - - # Extract fiat info from metadata - fiat_currency = metadata.get("fiat_currency") if metadata else None - fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Format Beancount entry. Identity travels as entry-id metadata + - # rcv-{entry_id} link; the user reference becomes its own link. - entry = format_receivable_entry( - user_id=data.user_id, - revenue_account=revenue_account.name, - receivable_account=user_receivable.name, - amount_sats=amount_sats, - description=data.description, - entry_date=datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - # Return a JournalEntry-like response for compatibility - from ..models import EntryLine - return JournalEntry( - id=entry_id, # Use the generated libra entry ID - description=data.description + description_suffix, - entry_date=datetime.now(), - created_by=auth.user_id, - created_at=datetime.now(), - reference=data.reference, - # Receivables are written cleared (format_receivable_entry uses - # flag="*") — reporting PENDING here misled the UI (libra-#35). - flag=JournalEntryFlag.CLEARED, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=user_receivable.id, - amount=amount_sats, - description=f"Amount owed by user {data.user_id[:8]}", - metadata=metadata or {} - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=revenue_account.id, - amount=-amount_sats, - description="Revenue earned", - metadata=metadata or {} - ), - ] - ) - - -@router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) -async def api_create_revenue_entry( - data: RevenueEntry, - auth: AuthContext = Depends(require_super_user), -) -> JournalEntry: - """ - Create a revenue entry (libra receives payment). - Admin only. - - Submits entry to Fava/Beancount. - """ - from ..fava_client import get_fava_client - from ..beancount_format import format_revenue_entry - - # Get revenue account - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - - # Get payment method account - payment_account = await get_account_by_name(data.payment_method_account) - if not payment_account: - payment_account = await get_account(data.payment_method_account) - if not payment_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Payment account '{data.payment_method_account}' not found", - ) - - # Handle currency conversion if provided - amount_sats = int(data.amount) - fiat_currency = None - fiat_amount = None - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}", - ) - - # Store fiat info for cost basis - fiat_currency = data.currency.upper() - fiat_amount = data.amount # Original fiat amount - # In this case, data.amount should be the satoshi amount - # This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat - # For now, assume if currency is provided, amount is fiat and needs conversion - # TODO: Consider updating the API model to be clearer about this - - # Format as Beancount entry and submit to Fava - fava = get_fava_client() - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Identity travels as entry-id metadata; the user reference becomes - # its own link. - entry = format_revenue_entry( - payment_account=payment_account.name, - revenue_account=revenue_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id, - ) - - # Submit to Fava - result = await fava.add_entry(entry) - logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") - - # Return simplified JournalEntry for API compatibility - # Note: Libra no longer stores entries in DB, Fava is the source of truth - return JournalEntry( - id=entry_id, - description=data.description, - entry_date=datetime.now(), - created_by=auth.user_id, - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.CLEARED, - lines=[], # Empty - entry is stored in Fava, not Libra DB - meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} - ) - - -# ===== USER BALANCE ENDPOINTS ===== - -@router.post("/api/v1/entries/{entry_id}/approve") -async def api_approve_expense_entry( - entry_id: str, - auth: AuthContext = Depends(require_super_user), -) -> dict: - """ - Approve a pending expense entry by changing flag from '!' to '*' (super user only). - - This updates the transaction in the Beancount file via Fava API. - """ - from ..fava_client import ChecksumConflictError, get_fava_client - - fava = get_fava_client() - - # 1. Get all journal entries from Fava - all_entries = await fava.get_journal_entries() - - # 2. Find the pending transaction with matching canonical entry id - target_entry = None - - for entry in all_entries: - # Only look at transactions with pending flag - if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry - break - - if not target_entry: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Pending entry {entry_id} not found in Beancount ledger" - ) - - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") - - if not filename or not lineno: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" - ) - - # 3. Flip the flag under FavaClient's write lock — the whole - # read-modify-write is atomic against every other ledger writer. - old_pattern = f"{date_str} !" - - def _approve(line: str) -> str: - if old_pattern not in line: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {line}" - ) - return line.replace(old_pattern, f"{date_str} *", 1) - - try: - await fava.transform_source_line(filename, lineno, _approve) - except ValueError as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) - except ChecksumConflictError: - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail="Ledger changed concurrently; retry the approval", - ) - - logger.info(f"Entry {entry_id} approved (flag changed to *)") - - return { - "message": f"Entry {entry_id} approved successfully", - "entry_id": entry_id, - "date": date_str, - "description": target_entry.get("narration", "") - } - - -@router.post("/api/v1/entries/{entry_id}/reject") -async def api_reject_expense_entry( - entry_id: str, - auth: AuthContext = Depends(require_super_user), -) -> dict: - """ - Reject a pending expense entry by marking it as voided (super user only). - - Adds #voided tag for audit trail while keeping the '!' flag. - Voided transactions are excluded from balances but preserved in the ledger. - """ - from ..fava_client import ChecksumConflictError, get_fava_client - - fava = get_fava_client() - - # 1. Get all journal entries from Fava - all_entries = await fava.get_journal_entries() - - # 2. Find the pending transaction with matching canonical entry id - target_entry = None - - for entry in all_entries: - # Only look at transactions with pending flag - if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry - break - - if not target_entry: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Pending entry {entry_id} not found in Beancount ledger" - ) - - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") - - if not filename or not lineno: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" - ) - - # 3. Add the #voided tag under FavaClient's write lock — the whole - # read-modify-write is atomic against every other ledger writer. - def _void(line: str) -> str: - if "#voided" in line: - return line # already voided — no-op - return line.rstrip() + ' #voided' - - try: - changed = await fava.transform_source_line(filename, lineno, _void) - except ValueError as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) - except ChecksumConflictError: - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail="Ledger changed concurrently; retry the rejection", - ) - if changed: - logger.info(f"Entry {entry_id} rejected (added #voided tag)") - - return { - "message": f"Entry {entry_id} rejected (marked as voided)", - "entry_id": entry_id, - "date": date_str, - "description": target_entry.get("narration", "") - } - - -# ===== BALANCE ASSERTION ENDPOINTS ===== diff --git a/views_api/payments.py b/views_api/payments.py deleted file mode 100644 index 4d48ea3..0000000 --- a/views_api/payments.py +++ /dev/null @@ -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 ===== diff --git a/views_api/permissions.py b/views_api/permissions.py deleted file mode 100644 index acec4cc..0000000 --- a/views_api/permissions.py +++ /dev/null @@ -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 ===== diff --git a/views_api/reconciliation.py b/views_api/reconciliation.py deleted file mode 100644 index fa7f300..0000000 --- a/views_api/reconciliation.py +++ /dev/null @@ -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 ===== diff --git a/views_api/settings_reports.py b/views_api/settings_reports.py deleted file mode 100644 index 0254e28..0000000 --- a/views_api/settings_reports.py +++ /dev/null @@ -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, - )