Hygiene sweep: dead code, role-assignment race, cached user lookup, stale files #60
19 changed files with 454 additions and 2942 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ __pycache__
|
|||
node_modules
|
||||
.venv
|
||||
.mypy_cache
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -169,6 +169,12 @@ User-specific accounts are created automatically with format:
|
|||
|
||||
Use `get_or_create_user_account()` in crud.py to ensure consistency.
|
||||
|
||||
### Pydantic version
|
||||
|
||||
LNbits pins **Pydantic v1** (`pydantic~=1.10`) — keep `.dict()` /
|
||||
`.parse_obj()` v1 APIs. Do NOT "modernize" to `.model_dump()` etc.;
|
||||
it would crash at runtime until upstream migrates.
|
||||
|
||||
### Currency Handling
|
||||
|
||||
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
from .validation import ValidationError, validate_journal_entry, validate_balance
|
||||
from .validation import ValidationError, validate_balance
|
||||
|
||||
__all__ = [
|
||||
"ValidationError",
|
||||
"validate_journal_entry",
|
||||
"validate_balance",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,81 +18,6 @@ class ValidationError(Exception):
|
|||
self.details = details or {}
|
||||
|
||||
|
||||
def validate_journal_entry(
|
||||
entry: Dict[str, Any],
|
||||
entry_lines: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""
|
||||
Validate a journal entry and its lines (Beancount-style with single amount field).
|
||||
|
||||
Checks:
|
||||
1. Entry must have at least 2 lines (double-entry requirement)
|
||||
2. Entry must be balanced (sum of amounts = 0)
|
||||
3. All lines must have account_id
|
||||
4. No line should have amount = 0 (would serve no purpose)
|
||||
|
||||
Args:
|
||||
entry: Journal entry dict with keys:
|
||||
- id: str
|
||||
- description: str
|
||||
- entry_date: datetime
|
||||
entry_lines: List of entry line dicts with keys:
|
||||
- account_id: str
|
||||
- amount: int (positive = debit, negative = credit)
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
"""
|
||||
# Check minimum number of lines
|
||||
if len(entry_lines) < 2:
|
||||
raise ValidationError(
|
||||
"Journal entry must have at least 2 lines",
|
||||
{
|
||||
"entry_id": entry.get("id"),
|
||||
"line_count": len(entry_lines),
|
||||
}
|
||||
)
|
||||
|
||||
# Validate each line
|
||||
for i, line in enumerate(entry_lines):
|
||||
# Check account_id exists
|
||||
if not line.get("account_id"):
|
||||
raise ValidationError(
|
||||
f"Entry line {i + 1} missing account_id",
|
||||
{
|
||||
"entry_id": entry.get("id"),
|
||||
"line_index": i,
|
||||
}
|
||||
)
|
||||
|
||||
# Get amount (Beancount-style: positive = debit, negative = credit)
|
||||
amount = line.get("amount", 0)
|
||||
|
||||
# Check that amount is non-zero (zero amounts serve no purpose)
|
||||
if amount == 0:
|
||||
raise ValidationError(
|
||||
f"Entry line {i + 1} has amount = 0 (serves no purpose)",
|
||||
{
|
||||
"entry_id": entry.get("id"),
|
||||
"line_index": i,
|
||||
}
|
||||
)
|
||||
|
||||
# Check entry is balanced (sum of amounts must equal 0)
|
||||
# Beancount-style: positive amounts cancel out negative amounts
|
||||
total_amount = sum(line.get("amount", 0) for line in entry_lines)
|
||||
|
||||
if total_amount != 0:
|
||||
raise ValidationError(
|
||||
"Journal entry is not balanced (sum of amounts must equal 0)",
|
||||
{
|
||||
"entry_id": entry.get("id"),
|
||||
"total_amount": total_amount,
|
||||
"line_count": len(entry_lines),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_balance(
|
||||
account_id: str,
|
||||
expected_balance_sats: int,
|
||||
|
|
|
|||
30
crud.py
30
crud.py
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -18,13 +17,9 @@ from .models import (
|
|||
CreateAccount,
|
||||
CreateAccountPermission,
|
||||
CreateBalanceAssertion,
|
||||
CreateEntryLine,
|
||||
CreateJournalEntry,
|
||||
CreateRole,
|
||||
CreateRolePermission,
|
||||
CreateUserEquityStatus,
|
||||
EntryLine,
|
||||
JournalEntry,
|
||||
PermissionType,
|
||||
Role,
|
||||
RolePermission,
|
||||
|
|
@ -39,16 +34,6 @@ from .models import (
|
|||
UserWithRoles,
|
||||
)
|
||||
|
||||
# Import core accounting logic
|
||||
from .core.validation import (
|
||||
ValidationError,
|
||||
validate_journal_entry,
|
||||
validate_balance,
|
||||
validate_receivable_entry,
|
||||
validate_expense_entry,
|
||||
validate_payment_entry,
|
||||
)
|
||||
|
||||
db = Database("ext_libra")
|
||||
|
||||
# ===== CACHING =====
|
||||
|
|
@ -1540,10 +1525,15 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
|
|||
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)
|
||||
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,
|
||||
|
|
@ -1555,6 +1545,14 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
|
|||
"notes": user_role.notes,
|
||||
},
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
existing = await db.fetchone(
|
||||
"SELECT * FROM user_roles WHERE user_id = :user_id AND role_id = :role_id",
|
||||
{"user_id": data.user_id, "role_id": data.role_id},
|
||||
UserRole,
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
return user_role
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Libra’s net settlement entry pattern used for recording Lightning
|
||||
Network payments that settle fiat-denominated receivables. The analysis
|
||||
identifies areas where the implementation deviates from traditional
|
||||
accounting best practices and provides specific recommendations for
|
||||
improvement.</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 * "room (200.00 EUR)" #receivable-entry
|
||||
user-id: "375ec158"
|
||||
source: "libra-api"
|
||||
sats-amount: "225033"
|
||||
Assets:Receivable:User-375ec158 200.00 EUR
|
||||
sats-equivalent: "225033"
|
||||
Income:Accommodation:Guests -200.00 EUR
|
||||
sats-equivalent: "225033"
|
||||
|
||||
; Step 2: Lightning Payment Received
|
||||
2025-11-12 * "Lightning payment settlement from user 375ec158"
|
||||
#lightning-payment #net-settlement
|
||||
user-id: "375ec158"
|
||||
source: "lightning_payment"
|
||||
payment-type: "net-settlement"
|
||||
payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
|
||||
Assets:Bitcoin:Lightning 225033 SATS @ 0.0008887585... EUR
|
||||
payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
sats-equivalent: "225033"
|
||||
Liabilities:Payable:User-375ec158 0.00 EUR</code></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">"account"</span>: payment_account,</span>
|
||||
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</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">"account"</span>: receivable_account,</span>
|
||||
<span id="cb2-10"><a href="#cb2-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</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">"</span>,</span>
|
||||
<span id="cb2-11"><a href="#cb2-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-equivalent"</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">"account"</span>: payable_account,</span>
|
||||
<span id="cb2-15"><a href="#cb2-15" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb2-16"><a href="#cb2-16" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</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>: > “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">"account"</span>: payment_account, <span class="st">"amount"</span>: ...},</span>
|
||||
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> {<span class="st">"account"</span>: receivable_account, <span class="st">"amount"</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'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">></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">"account"</span>: payable_account,</span>
|
||||
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</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: "225033"</code></pre></li>
|
||||
</ol>
|
||||
<p><strong>Why This Is Problematic</strong>: - The <code>@@</code>
|
||||
notation already records the exact satoshi amount - Beancount’s price
|
||||
database stores this relationship - Metadata becomes redundant for this
|
||||
specific posting - Increases storage and potential for inconsistency</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">'Assets:Bitcoin:Lightning'</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: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
; No sats-equivalent needed here</code></pre>
|
||||
<p><strong>Option B - Use EUR positions with metadata</strong> (Libra’s
|
||||
current approach):</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
sats-cleared: "225033"</code></pre>
|
||||
<p><strong>Don’t</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 * "Lightning payment with exchange loss"
|
||||
Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
|
||||
; Market rate at payment time = 199.50 EUR
|
||||
Expenses:Foreign-Exchange-Loss 0.50 EUR
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR</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>: > “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>: Beancount’s price
|
||||
database now contains “prices” that aren’t real market prices</li>
|
||||
<li><strong>Auditor Confusion</strong>: An auditor reviewing this would
|
||||
question why purchase prices don’t 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: "payment-received"
|
||||
Revenue:Exchange-Gain 0.50 EUR
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
|
||||
; Approach 2: Don't use @ notation at all
|
||||
Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
fmv-at-receipt: "199.50 EUR"
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR</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 it’s used for simple
|
||||
payments that aren’t true net settlements.</p>
|
||||
<p><strong>Example from User’s 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 * "Bitcoin payment from user 375ec158"
|
||||
Assets:Bitcoin:Lightning 199.50 EUR
|
||||
sats-received: "225033"
|
||||
fmv-per-sat: "0.000886 EUR"
|
||||
cost-basis: "199.50 EUR"
|
||||
payment-hash: "8d080ec4..."
|
||||
Revenue:Exchange-Gain 0.50 EUR
|
||||
source: "cryptocurrency-receipt"
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR</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 * "Bitcoin payment from user 375ec158"
|
||||
Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
sats-rate: "1125.165"
|
||||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR</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 * "Net settlement via Lightning"
|
||||
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
|
||||
Assets:Bitcoin:Lightning 517.00 EUR
|
||||
sats-received: "565251"
|
||||
Assets:Receivable:User-375ec158 -555.00 EUR
|
||||
Liabilities:Payable:User-375ec158 38.00 EUR</code></pre>
|
||||
<p><strong>When to Use</strong>: Only when <strong>both</strong>
|
||||
receivables and payables exist and you’re 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">"account"</span>: payable_account,</span>
|
||||
<span id="cb23-6"><a href="#cb23-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb23-7"><a href="#cb23-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</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">"account"</span>: payment_account,</span>
|
||||
<span id="cb24-4"><a href="#cb24-4" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb24-5"><a href="#cb24-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</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">"account"</span>: receivable_account,</span>
|
||||
<span id="cb24-9"><a href="#cb24-9" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</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">"</span>,</span>
|
||||
<span id="cb24-10"><a href="#cb24-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-equivalent"</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'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">></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">"account"</span>: payable_account,</span>
|
||||
<span id="cb24-18"><a href="#cb24-18" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb24-19"><a href="#cb24-19" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</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">"account"</span>: payment_account,</span>
|
||||
<span id="cb25-5"><a href="#cb25-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</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">"meta"</span>: {</span>
|
||||
<span id="cb25-7"><a href="#cb25-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"sats-received"</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">"payment-hash"</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">"account"</span>: receivable_account,</span>
|
||||
<span id="cb25-13"><a href="#cb25-13" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</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">"</span>,</span>
|
||||
<span id="cb25-14"><a href="#cb25-14" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-cleared"</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">"account"</span>: payment_account,</span>
|
||||
<span id="cb26-5"><a href="#cb26-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb26-6"><a href="#cb26-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</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">"account"</span>: receivable_account,</span>
|
||||
<span id="cb26-10"><a href="#cb26-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</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">"</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 Libra’s 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'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">"fiat_balances"</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">></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 (> 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">></span> Decimal(<span class="st">"0.01"</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">></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">"Revenue:Foreign-Exchange-Gain"</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">"Expenses:Foreign-Exchange-Loss"</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">"account"</span>: exchange_account,</span>
|
||||
<span id="cb27-27"><a href="#cb27-27" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</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">"</span>,</span>
|
||||
<span id="cb27-28"><a href="#cb27-28" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {</span>
|
||||
<span id="cb27-29"><a href="#cb27-29" aria-hidden="true" tabindex="-1"></a> <span class="st">"sats-amount"</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">"market-rate"</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">"receivable-amount"</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">"""</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'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"> """</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">"fiat_balances"</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">></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"><</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">></span> <span class="dv">0</span> <span class="kw">and</span> payable_amount <span class="op">></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">></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 * "Payment"
|
||||
Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
Assets:Receivable:User -200.00 EUR
|
||||
sats-cleared: "225033"</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 * "Payment"
|
||||
Assets:Bitcoin:Lightning 225033 SATS
|
||||
eur-value: "200.00"
|
||||
Assets:Receivable:User -225033 SATS
|
||||
eur-cleared: "200.00"</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 * "Payment received from user"
|
||||
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 * "Lightning payment received"
|
||||
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 * "Lightning payment from user"
|
||||
Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User -200.00 EUR
|
||||
sats-cleared: "225033"</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 * "Net settlement via Lightning"
|
||||
Assets:Bitcoin:Lightning 517.00 EUR
|
||||
sats-received: "565251"
|
||||
payment-hash: "abc123..."
|
||||
Assets:Receivable:User -555.00 EUR
|
||||
sats-portion: "565251"
|
||||
Liabilities:Payable:User 38.00 EUR</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 * "Lightning payment with exchange loss"
|
||||
Assets:Bitcoin:Lightning 199.50 EUR
|
||||
sats-received: "225033"
|
||||
market-rate: "0.000886"
|
||||
Expenses:Foreign-Exchange-Loss 0.50 EUR
|
||||
Assets:Receivable:User -200.00 EUR</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 Libra’s use case?</strong> <strong>Yes,
|
||||
with modifications</strong>, it’s 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
|
||||
wasn’t designed for this scenario. There is no established “standard”
|
||||
for recording cryptocurrency payments of fiat-denominated receivables.
|
||||
Libra’s approach is functional, but should be refined to align better
|
||||
with accounting principles where possible.</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
|
||||
Libra’s payment recording system.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
260
docs/CODE-REVIEW-2026-06.md
Normal file
260
docs/CODE-REVIEW-2026-06.md
Normal 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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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*
|
||||
|
|
@ -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*
|
||||
|
|
@ -1409,9 +1409,12 @@ class FavaClient:
|
|||
logger.warning(f"Failed to fetch {endpoint}: {e}")
|
||||
|
||||
# Filter out synthetic entries like "Net Profit"
|
||||
from .account_utils import ACCOUNT_TYPE_ROOTS
|
||||
|
||||
valid_roots = set(ACCOUNT_TYPE_ROOTS.values())
|
||||
account_names = {
|
||||
name for name in account_names
|
||||
if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses")
|
||||
if ":" in name or name in valid_roots
|
||||
}
|
||||
|
||||
if account_names:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
16
tasks.py
16
tasks.py
|
|
@ -58,15 +58,15 @@ async def check_all_balance_assertions() -> dict:
|
|||
})
|
||||
except Exception as e:
|
||||
results["errors"] += 1
|
||||
print(f"Error checking assertion {assertion.id}: {e}")
|
||||
logger.error(f"Error checking assertion {assertion.id}: {e}")
|
||||
|
||||
# Log results
|
||||
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"]:
|
||||
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:
|
||||
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
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
|
|||
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
|
||||
or by LNbits background task system.
|
||||
"""
|
||||
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||
logger.info(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||
|
||||
try:
|
||||
results = await check_all_balance_assertions()
|
||||
|
|
@ -86,12 +86,12 @@ async def scheduled_daily_reconciliation():
|
|||
# TODO: Send notifications if there are failures
|
||||
# This could send email, webhook, or in-app notification
|
||||
if results["failed"] > 0:
|
||||
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
|
||||
logger.warning(f"[LIBRA] {results['failed']} balance assertions failed!")
|
||||
# Future: Send alert notification
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
||||
logger.error(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ def start_daily_reconciliation_task():
|
|||
# Run daily at 2 AM
|
||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
"""
|
||||
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
|
||||
# For now, it can be triggered manually via API endpoint
|
||||
|
||||
|
|
|
|||
|
|
@ -108,9 +108,6 @@ def _settings_cleanup(settings: Settings) -> None:
|
|||
settings.lnbits_user_activation_by_invitation_code = False
|
||||
settings.lnbits_register_reusable_activation_code = ""
|
||||
settings.lnbits_register_one_time_activation_codes = []
|
||||
# Keep the rate limiter disabled across per-test settings resets (the
|
||||
# limiter itself is fixed at app-creation time, but keep the value coherent).
|
||||
settings.lnbits_rate_limit_no = 1_000_000
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
126
user_lookup.py
Normal file
126
user_lookup.py
Normal 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
|
||||
124
views_api.py
124
views_api.py
|
|
@ -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 .beancount_format import fiat_rate_metadata
|
||||
from .user_lookup import get_username
|
||||
from .crud import (
|
||||
approve_manual_payment_request,
|
||||
check_balance_assertion,
|
||||
|
|
@ -643,7 +644,7 @@ async def api_get_user_entries(
|
|||
break
|
||||
|
||||
# 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 = {
|
||||
"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")
|
||||
async def api_get_pending_entries(
|
||||
auth: AuthContext = Depends(require_super_user),
|
||||
|
|
@ -844,7 +732,7 @@ async def api_get_pending_entries(
|
|||
break
|
||||
|
||||
# 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)
|
||||
amount_sats = 0
|
||||
|
|
@ -1453,7 +1341,9 @@ async def api_create_receivable_entry(
|
|||
created_by=auth.user_id,
|
||||
created_at=datetime.now(),
|
||||
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,
|
||||
lines=[
|
||||
EntryLine(
|
||||
|
|
@ -1682,7 +1572,7 @@ async def api_get_all_balances(
|
|||
# Enrich with username information using helper function
|
||||
result = []
|
||||
for balance in balances:
|
||||
username = await _get_username_from_user_id(balance["user_id"])
|
||||
username = await get_username(balance["user_id"])
|
||||
|
||||
result.append({
|
||||
"user_id": balance["user_id"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue