Hygiene sweep: dead code, role-assignment race, cached user lookup, stale files #60

Open
padreug wants to merge 1 commit from chore/hygiene into fix/auth-and-input-validation
19 changed files with 454 additions and 2942 deletions
Showing only changes of commit ec6cac51f0 - Show all commits

chore: hygiene sweep — dead code, role race, user lookup, stale files

One pass over the LOW-tier review items plus two folded issues:

- Delete validate_journal_entry (dead since the Fava migration; it
  validated the pre-string-amount model) with its exports, unused
  crud imports, and tests. Beancount validates entries now.
- Migration m006: UNIQUE index on user_roles(user_id, role_id) after
  deduping; assign_user_role inserts with ON CONFLICT DO NOTHING and
  returns the existing assignment — closes the auto-assign
  check-then-act race on concurrent logins.
- Extract _get_username_from_user_id (110 lines in views_api, fresh
  LNbits Database per call inside per-row hot paths) into
  user_lookup.py with one shared core-DB handle, a 60s TTL cache and
  a batch get_usernames API (review #18).
- Receivable-entry responses report CLEARED, matching the flag the
  formatter actually writes; PENDING misled the UI (libra-#35).
- Replace the remaining print() calls in tasks.py with logger.
- get_all_accounts derives valid roots from
  account_utils.ACCOUNT_TYPE_ROOTS instead of a hardcoded tuple, and
  the no-op per-test rate-limit reset is gone (libra-#54).
- Delete migrations_old.py.bak, MIGRATION_SQUASH_SUMMARY.md,
  docs/PHASE*_COMPLETE.md and the rendered .html; .gitignore data/
  (it holds the runtime .lnbits_auth_key secret).
- Track docs/CODE-REVIEW-2026-06.md with finding statuses updated for
  the PR #55-#59 + chore/hygiene series.
- CLAUDE.md notes LNbits pins Pydantic v1: keep .dict(), don't
  "modernize" to .model_dump().

Note: format_payment_entry's is_payable docstring (flagged in review
follow-up) turned out to be consistent with the body — no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Padreug 2026-07-12 16:01:04 +02:00

1
.gitignore vendored
View file

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

View file

@ -169,6 +169,12 @@ User-specific accounts are created automatically with format:
Use `get_or_create_user_account()` in crud.py to ensure consistency. 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 ### Currency Handling
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`. **CRITICAL**: Use `Decimal` for all fiat amounts, never `float`.

View file

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

View file

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

View file

@ -18,81 +18,6 @@ class ValidationError(Exception):
self.details = details or {} 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( def validate_balance(
account_id: str, account_id: str,
expected_balance_sats: int, expected_balance_sats: int,

30
crud.py
View file

@ -1,4 +1,3 @@
import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -18,13 +17,9 @@ from .models import (
CreateAccount, CreateAccount,
CreateAccountPermission, CreateAccountPermission,
CreateBalanceAssertion, CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
CreateRole, CreateRole,
CreateRolePermission, CreateRolePermission,
CreateUserEquityStatus, CreateUserEquityStatus,
EntryLine,
JournalEntry,
PermissionType, PermissionType,
Role, Role,
RolePermission, RolePermission,
@ -39,16 +34,6 @@ from .models import (
UserWithRoles, 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") db = Database("ext_libra")
# ===== CACHING ===== # ===== CACHING =====
@ -1540,10 +1525,15 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
notes=data.notes, notes=data.notes,
) )
await db.execute( # 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(
""" """
INSERT INTO user_roles (id, user_id, role_id, granted_by, granted_at, expires_at, notes) 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) 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, "id": user_role.id,
@ -1555,6 +1545,14 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
"notes": user_role.notes, "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 return user_role

View file

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

260
docs/CODE-REVIEW-2026-06.md Normal file
View file

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

View file

@ -1,200 +0,0 @@
# 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.

View file

@ -1,273 +0,0 @@
# 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*

View file

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

View file

@ -1409,9 +1409,12 @@ class FavaClient:
logger.warning(f"Failed to fetch {endpoint}: {e}") logger.warning(f"Failed to fetch {endpoint}: {e}")
# Filter out synthetic entries like "Net Profit" # Filter out synthetic entries like "Net Profit"
from .account_utils import ACCOUNT_TYPE_ROOTS
valid_roots = set(ACCOUNT_TYPE_ROOTS.values())
account_names = { account_names = {
name for name in account_names name for name in account_names
if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses") if ":" in name or name in valid_roots
} }
if account_names: if account_names:

View file

@ -651,3 +651,30 @@ async def m005_add_processed_payments(db):
); );
""" """
) )
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)
"""
)

View file

@ -1,651 +0,0 @@
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")

View file

@ -58,15 +58,15 @@ async def check_all_balance_assertions() -> dict:
}) })
except Exception as e: except Exception as e:
results["errors"] += 1 results["errors"] += 1
print(f"Error checking assertion {assertion.id}: {e}") logger.error(f"Error checking assertion {assertion.id}: {e}")
# Log results # Log results
if results["failed"] > 0: if results["failed"] > 0:
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!") logger.warning(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
for failed in results["failed_assertions"]: for failed in results["failed_assertions"]:
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}") logger.warning(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
else: else:
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓") logger.info(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
return results 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.) This function is meant to be called by a scheduler (cron, systemd timer, etc.)
or by LNbits background task system. or by LNbits background task system.
""" """
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}") logger.info(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
try: try:
results = await check_all_balance_assertions() results = await check_all_balance_assertions()
@ -86,12 +86,12 @@ async def scheduled_daily_reconciliation():
# TODO: Send notifications if there are failures # TODO: Send notifications if there are failures
# This could send email, webhook, or in-app notification # This could send email, webhook, or in-app notification
if results["failed"] > 0: if results["failed"] > 0:
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!") logger.warning(f"[LIBRA] {results['failed']} balance assertions failed!")
# Future: Send alert notification # Future: Send alert notification
return results return results
except Exception as e: except Exception as e:
print(f"[LIBRA] Error in scheduled reconciliation: {e}") logger.error(f"[LIBRA] Error in scheduled reconciliation: {e}")
raise raise
@ -166,7 +166,7 @@ def start_daily_reconciliation_task():
# Run daily at 2 AM # 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" 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
""" """
print("[LIBRA] Daily reconciliation task registered") logger.info("[LIBRA] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler # In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint # For now, it can be triggered manually via API endpoint

View file

@ -108,9 +108,6 @@ def _settings_cleanup(settings: Settings) -> None:
settings.lnbits_user_activation_by_invitation_code = False settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = "" settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = [] 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") @pytest.fixture(scope="session")

View file

@ -394,66 +394,6 @@ 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 # core.validation — validate_balance
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

126
user_lookup.py Normal file
View file

@ -0,0 +1,126 @@
"""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

View file

@ -15,6 +15,7 @@ from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satos
from .account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name from .account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
from .beancount_format import fiat_rate_metadata from .beancount_format import fiat_rate_metadata
from .user_lookup import get_username
from .crud import ( from .crud import (
approve_manual_payment_request, approve_manual_payment_request,
check_balance_assertion, check_balance_assertion,
@ -643,7 +644,7 @@ async def api_get_user_entries(
break break
# Look up actual username using helper function # Look up actual username using helper function
username = await _get_username_from_user_id(user_id_match) if user_id_match else None username = await get_username(user_id_match) if user_id_match else None
entry_data = { entry_data = {
"id": entry_id or e.get("entry_hash", "unknown"), "id": entry_id or e.get("entry_hash", "unknown"),
@ -684,119 +685,6 @@ 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") @libra_api_router.get("/api/v1/entries/pending")
async def api_get_pending_entries( async def api_get_pending_entries(
auth: AuthContext = Depends(require_super_user), auth: AuthContext = Depends(require_super_user),
@ -844,7 +732,7 @@ async def api_get_pending_entries(
break break
# Look up username using helper function # Look up username using helper function
username = await _get_username_from_user_id(user_id) if user_id else None username = await get_username(user_id) if user_id else None
# Extract amount from postings (sum of absolute values / 2) # Extract amount from postings (sum of absolute values / 2)
amount_sats = 0 amount_sats = 0
@ -1453,7 +1341,9 @@ async def api_create_receivable_entry(
created_by=auth.user_id, created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=data.reference,
flag=JournalEntryFlag.PENDING, # Receivables are written cleared (format_receivable_entry uses
# flag="*") — reporting PENDING here misled the UI (libra-#35).
flag=JournalEntryFlag.CLEARED,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
EntryLine( EntryLine(
@ -1682,7 +1572,7 @@ async def api_get_all_balances(
# Enrich with username information using helper function # Enrich with username information using helper function
result = [] result = []
for balance in balances: for balance in balances:
username = await _get_username_from_user_id(balance["user_id"]) username = await get_username(balance["user_id"])
result.append({ result.append({
"user_id": balance["user_id"], "user_id": balance["user_id"],