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 fbdfba9..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,25 +304,6 @@ def format_posting_simple( } -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, @@ -743,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 @@ -762,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. @@ -825,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_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 index 65185e8..1b3149a 100644 --- a/views_api.py +++ b/views_api.py @@ -13,9 +13,6 @@ from lnbits.decorators import ( ) 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 fiat_rate_metadata -from .user_lookup import get_username from .crud import ( approve_manual_payment_request, check_balance_assertion, @@ -297,19 +294,7 @@ async def api_create_account( 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", - ) + return await create_account(data) @libra_api_router.get("/api/v1/accounts/{account_id}") @@ -644,7 +629,7 @@ async def api_get_user_entries( break # Look up actual username using helper function - username = await get_username(user_id_match) if user_id_match else None + 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"), @@ -685,6 +670,119 @@ async def api_get_user_entries( } +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), @@ -732,7 +830,7 @@ async def api_get_pending_entries( break # Look up username using helper function - username = await get_username(user_id) if user_id else None + 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 @@ -976,7 +1074,8 @@ async def api_create_expense_entry( 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), + "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 @@ -1173,7 +1272,8 @@ async def api_create_income_entry( metadata = { "fiat_currency": fiat_currency, "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), - **fiat_rate_metadata(amount_sats, data.amount), + "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 @@ -1271,7 +1371,8 @@ async def api_create_receivable_entry( 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), + "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 @@ -1341,9 +1442,7 @@ async def api_create_receivable_entry( 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, + flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( @@ -1572,7 +1671,7 @@ async def api_get_all_balances( # Enrich with username information using helper function result = [] for balance in balances: - username = await get_username(balance["user_id"]) + username = await _get_username_from_user_id(balance["user_id"]) result.append({ "user_id": balance["user_id"], @@ -1661,10 +1760,15 @@ async def api_generate_payment_invoice( 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_metadata(data.amount, invoice_fiat_amount), + "fiat_rate": fiat_rate, + "btc_rate": btc_rate, }) logger.info(f"Invoice extra metadata: {invoice_extra}") @@ -1746,130 +1850,90 @@ async def api_record_payment( 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. + # Get recent entries from Fava's journal endpoint response = await client.get( - f"{fava.base_url}/journal", + f"{fava.base_url}/api/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. + 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}") - 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", - ) + # Continue anyway - Fava/Beancount will catch duplicate if it exists # Convert amount from millisatoshis to satoshis - try: - amount_sats = payment.amount // 1000 + 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)) + # 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}") + 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 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 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")] - # 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 + ) - # 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}") - 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 + # 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": entry_id, + "journal_entry_id": f"fava-{datetime.now().timestamp()}", "new_balance": balance_data["balance"], "message": "Payment recorded successfully", } @@ -1960,19 +2024,6 @@ async def api_settle_receivable( 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), @@ -2016,10 +2067,10 @@ async def api_settle_receivable( "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), + "cash_paid": float(cash_paid), + "net_obligation": float(net_obligation), + "receivable_total": float(receivable_total), + "payable_total": float(payable_total), "currency": data.currency.upper(), }, ) @@ -2759,30 +2810,15 @@ async def api_approve_manual_payment_request( 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}" + # 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 ) - 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 @libra_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject") @@ -2805,13 +2841,7 @@ async def api_reject_manual_payment_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 + return await reject_manual_payment_request(request_id, auth.user_id) # ===== EXPENSE APPROVAL ENDPOINTS ===== @@ -2827,7 +2857,8 @@ async def api_approve_expense_entry( This updates the transaction in the Beancount file via Fava API. """ - from .fava_client import ChecksumConflictError, get_fava_client + import httpx + from .fava_client import get_fava_client fava = get_fava_client() @@ -2862,29 +2893,57 @@ async def api_approve_expense_entry( 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} !" + # 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"] - def _approve(line: str) -> str: - if old_pattern not in line: + 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} does not contain expected pattern '{old_pattern}'. Found: {line}" + detail=f"Line {lineno} not found in source file" ) - 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", + 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 *)") @@ -2907,7 +2966,8 @@ async def api_reject_expense_entry( 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 + import httpx + from .fava_client import get_fava_client fava = get_fava_client() @@ -2942,26 +3002,50 @@ async def api_reject_expense_entry( 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' + # 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"] - 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)") + 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)", @@ -3574,21 +3658,52 @@ async def api_get_account_hierarchy( # ===== ACCOUNT SYNC ENDPOINTS ===== -_VALID_ACCOUNT_PREFIXES = VALID_ACCOUNT_PREFIXES +_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. - Thin HTTP wrapper around account_utils.validate_account_name — the - single source of truth for account-name syntax (libra-#51). + 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. """ - try: - validate_account_name(name) - except ValueError as e: + 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=str(e), + 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)." + ), ) @@ -3659,14 +3774,8 @@ async def api_admin_add_chart_account( "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, - ) + # Mirror into libra DB so permissions / metadata layer sees it. + synced = await sync_single_account_from_beancount(payload.name) return { "success": True,