Hygiene sweep: dead code, role-assignment race, cached user lookup, stale files #60
19 changed files with 454 additions and 2942 deletions
chore: hygiene sweep — dead code, role race, user lookup, stale files
One pass over the LOW-tier review items plus two folded issues: - Delete validate_journal_entry (dead since the Fava migration; it validated the pre-string-amount model) with its exports, unused crud imports, and tests. Beancount validates entries now. - Migration m006: UNIQUE index on user_roles(user_id, role_id) after deduping; assign_user_role inserts with ON CONFLICT DO NOTHING and returns the existing assignment — closes the auto-assign check-then-act race on concurrent logins. - Extract _get_username_from_user_id (110 lines in views_api, fresh LNbits Database per call inside per-row hot paths) into user_lookup.py with one shared core-DB handle, a 60s TTL cache and a batch get_usernames API (review #18). - Receivable-entry responses report CLEARED, matching the flag the formatter actually writes; PENDING misled the UI (libra-#35). - Replace the remaining print() calls in tasks.py with logger. - get_all_accounts derives valid roots from account_utils.ACCOUNT_TYPE_ROOTS instead of a hardcoded tuple, and the no-op per-test rate-limit reset is gone (libra-#54). - Delete migrations_old.py.bak, MIGRATION_SQUASH_SUMMARY.md, docs/PHASE*_COMPLETE.md and the rendered .html; .gitignore data/ (it holds the runtime .lnbits_auth_key secret). - Track docs/CODE-REVIEW-2026-06.md with finding statuses updated for the PR #55-#59 + chore/hygiene series. - CLAUDE.md notes LNbits pins Pydantic v1: keep .dict(), don't "modernize" to .model_dump(). Note: format_payment_entry's is_payable docstring (flagged in review follow-up) turned out to be consistent with the body — no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit
ec6cac51f0
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ __pycache__
|
||||||
node_modules
|
node_modules
|
||||||
.venv
|
.venv
|
||||||
.mypy_cache
|
.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.
|
Use `get_or_create_user_account()` in crud.py to ensure consistency.
|
||||||
|
|
||||||
|
### Pydantic version
|
||||||
|
|
||||||
|
LNbits pins **Pydantic v1** (`pydantic~=1.10`) — keep `.dict()` /
|
||||||
|
`.parse_obj()` v1 APIs. Do NOT "modernize" to `.model_dump()` etc.;
|
||||||
|
it would crash at runtime until upstream migrates.
|
||||||
|
|
||||||
### Currency Handling
|
### Currency Handling
|
||||||
|
|
||||||
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`.
|
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
All accounting calculations are now performed via Fava's query API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .validation import ValidationError, validate_journal_entry, validate_balance
|
from .validation import ValidationError, validate_balance
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ValidationError",
|
"ValidationError",
|
||||||
"validate_journal_entry",
|
|
||||||
"validate_balance",
|
"validate_balance",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -18,81 +18,6 @@ class ValidationError(Exception):
|
||||||
self.details = details or {}
|
self.details = details or {}
|
||||||
|
|
||||||
|
|
||||||
def validate_journal_entry(
|
|
||||||
entry: Dict[str, Any],
|
|
||||||
entry_lines: List[Dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Validate a journal entry and its lines (Beancount-style with single amount field).
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Entry must have at least 2 lines (double-entry requirement)
|
|
||||||
2. Entry must be balanced (sum of amounts = 0)
|
|
||||||
3. All lines must have account_id
|
|
||||||
4. No line should have amount = 0 (would serve no purpose)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entry: Journal entry dict with keys:
|
|
||||||
- id: str
|
|
||||||
- description: str
|
|
||||||
- entry_date: datetime
|
|
||||||
entry_lines: List of entry line dicts with keys:
|
|
||||||
- account_id: str
|
|
||||||
- amount: int (positive = debit, negative = credit)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: If validation fails
|
|
||||||
"""
|
|
||||||
# Check minimum number of lines
|
|
||||||
if len(entry_lines) < 2:
|
|
||||||
raise ValidationError(
|
|
||||||
"Journal entry must have at least 2 lines",
|
|
||||||
{
|
|
||||||
"entry_id": entry.get("id"),
|
|
||||||
"line_count": len(entry_lines),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate each line
|
|
||||||
for i, line in enumerate(entry_lines):
|
|
||||||
# Check account_id exists
|
|
||||||
if not line.get("account_id"):
|
|
||||||
raise ValidationError(
|
|
||||||
f"Entry line {i + 1} missing account_id",
|
|
||||||
{
|
|
||||||
"entry_id": entry.get("id"),
|
|
||||||
"line_index": i,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get amount (Beancount-style: positive = debit, negative = credit)
|
|
||||||
amount = line.get("amount", 0)
|
|
||||||
|
|
||||||
# Check that amount is non-zero (zero amounts serve no purpose)
|
|
||||||
if amount == 0:
|
|
||||||
raise ValidationError(
|
|
||||||
f"Entry line {i + 1} has amount = 0 (serves no purpose)",
|
|
||||||
{
|
|
||||||
"entry_id": entry.get("id"),
|
|
||||||
"line_index": i,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check entry is balanced (sum of amounts must equal 0)
|
|
||||||
# Beancount-style: positive amounts cancel out negative amounts
|
|
||||||
total_amount = sum(line.get("amount", 0) for line in entry_lines)
|
|
||||||
|
|
||||||
if total_amount != 0:
|
|
||||||
raise ValidationError(
|
|
||||||
"Journal entry is not balanced (sum of amounts must equal 0)",
|
|
||||||
{
|
|
||||||
"entry_id": entry.get("id"),
|
|
||||||
"total_amount": total_amount,
|
|
||||||
"line_count": len(entry_lines),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_balance(
|
def validate_balance(
|
||||||
account_id: str,
|
account_id: str,
|
||||||
expected_balance_sats: int,
|
expected_balance_sats: int,
|
||||||
|
|
|
||||||
30
crud.py
30
crud.py
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -18,13 +17,9 @@ from .models import (
|
||||||
CreateAccount,
|
CreateAccount,
|
||||||
CreateAccountPermission,
|
CreateAccountPermission,
|
||||||
CreateBalanceAssertion,
|
CreateBalanceAssertion,
|
||||||
CreateEntryLine,
|
|
||||||
CreateJournalEntry,
|
|
||||||
CreateRole,
|
CreateRole,
|
||||||
CreateRolePermission,
|
CreateRolePermission,
|
||||||
CreateUserEquityStatus,
|
CreateUserEquityStatus,
|
||||||
EntryLine,
|
|
||||||
JournalEntry,
|
|
||||||
PermissionType,
|
PermissionType,
|
||||||
Role,
|
Role,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
|
|
@ -39,16 +34,6 @@ from .models import (
|
||||||
UserWithRoles,
|
UserWithRoles,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import core accounting logic
|
|
||||||
from .core.validation import (
|
|
||||||
ValidationError,
|
|
||||||
validate_journal_entry,
|
|
||||||
validate_balance,
|
|
||||||
validate_receivable_entry,
|
|
||||||
validate_expense_entry,
|
|
||||||
validate_payment_entry,
|
|
||||||
)
|
|
||||||
|
|
||||||
db = Database("ext_libra")
|
db = Database("ext_libra")
|
||||||
|
|
||||||
# ===== CACHING =====
|
# ===== CACHING =====
|
||||||
|
|
@ -1540,10 +1525,15 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.execute(
|
# The unique index on (user_id, role_id) makes this insert the
|
||||||
|
# arbiter against concurrent assignments (e.g. two simultaneous
|
||||||
|
# logins both auto-assigning the default role). rowcount 0 means
|
||||||
|
# the assignment already exists — return it (idempotent).
|
||||||
|
result = await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO user_roles (id, user_id, role_id, granted_by, granted_at, expires_at, notes)
|
INSERT INTO user_roles (id, user_id, role_id, granted_by, granted_at, expires_at, notes)
|
||||||
VALUES (:id, :user_id, :role_id, :granted_by, :granted_at, :expires_at, :notes)
|
VALUES (:id, :user_id, :role_id, :granted_by, :granted_at, :expires_at, :notes)
|
||||||
|
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"id": user_role.id,
|
"id": user_role.id,
|
||||||
|
|
@ -1555,6 +1545,14 @@ async def assign_user_role(data: AssignUserRole, granted_by: str) -> UserRole:
|
||||||
"notes": user_role.notes,
|
"notes": user_role.notes,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
existing = await db.fetchone(
|
||||||
|
"SELECT * FROM user_roles WHERE user_id = :user_id AND role_id = :role_id",
|
||||||
|
{"user_id": data.user_id, "role_id": data.role_id},
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
return user_role
|
return user_role
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
logger.warning(f"Failed to fetch {endpoint}: {e}")
|
||||||
|
|
||||||
# Filter out synthetic entries like "Net Profit"
|
# Filter out synthetic entries like "Net Profit"
|
||||||
|
from .account_utils import ACCOUNT_TYPE_ROOTS
|
||||||
|
|
||||||
|
valid_roots = set(ACCOUNT_TYPE_ROOTS.values())
|
||||||
account_names = {
|
account_names = {
|
||||||
name for name in account_names
|
name for name in account_names
|
||||||
if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses")
|
if ":" in name or name in valid_roots
|
||||||
}
|
}
|
||||||
|
|
||||||
if account_names:
|
if account_names:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
except Exception as e:
|
||||||
results["errors"] += 1
|
results["errors"] += 1
|
||||||
print(f"Error checking assertion {assertion.id}: {e}")
|
logger.error(f"Error checking assertion {assertion.id}: {e}")
|
||||||
|
|
||||||
# Log results
|
# Log results
|
||||||
if results["failed"] > 0:
|
if results["failed"] > 0:
|
||||||
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
logger.warning(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
||||||
for failed in results["failed_assertions"]:
|
for failed in results["failed_assertions"]:
|
||||||
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
|
logger.warning(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
|
||||||
else:
|
else:
|
||||||
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
logger.info(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
|
||||||
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
|
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
|
||||||
or by LNbits background task system.
|
or by LNbits background task system.
|
||||||
"""
|
"""
|
||||||
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
logger.info(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await check_all_balance_assertions()
|
results = await check_all_balance_assertions()
|
||||||
|
|
@ -86,12 +86,12 @@ async def scheduled_daily_reconciliation():
|
||||||
# TODO: Send notifications if there are failures
|
# TODO: Send notifications if there are failures
|
||||||
# This could send email, webhook, or in-app notification
|
# This could send email, webhook, or in-app notification
|
||||||
if results["failed"] > 0:
|
if results["failed"] > 0:
|
||||||
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
|
logger.warning(f"[LIBRA] {results['failed']} balance assertions failed!")
|
||||||
# Future: Send alert notification
|
# Future: Send alert notification
|
||||||
|
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
logger.error(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -166,7 +166,7 @@ def start_daily_reconciliation_task():
|
||||||
# Run daily at 2 AM
|
# Run daily at 2 AM
|
||||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||||
"""
|
"""
|
||||||
print("[LIBRA] Daily reconciliation task registered")
|
logger.info("[LIBRA] Daily reconciliation task registered")
|
||||||
# In a production system, you would register this with LNbits task scheduler
|
# In a production system, you would register this with LNbits task scheduler
|
||||||
# For now, it can be triggered manually via API endpoint
|
# For now, it can be triggered manually via API endpoint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,6 @@ def _settings_cleanup(settings: Settings) -> None:
|
||||||
settings.lnbits_user_activation_by_invitation_code = False
|
settings.lnbits_user_activation_by_invitation_code = False
|
||||||
settings.lnbits_register_reusable_activation_code = ""
|
settings.lnbits_register_reusable_activation_code = ""
|
||||||
settings.lnbits_register_one_time_activation_codes = []
|
settings.lnbits_register_one_time_activation_codes = []
|
||||||
# Keep the rate limiter disabled across per-test settings resets (the
|
|
||||||
# limiter itself is fixed at app-creation time, but keep the value coherent).
|
|
||||||
settings.lnbits_rate_limit_no = 1_000_000
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|
|
||||||
|
|
@ -394,66 +394,6 @@ def test_migrate_account_name_expense_with_ampersand():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# core.validation — validate_journal_entry
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_journal_entry_balanced_passes():
|
|
||||||
val.validate_journal_entry(
|
|
||||||
{"id": "x"},
|
|
||||||
[
|
|
||||||
{"account_id": "a", "amount": 100},
|
|
||||||
{"account_id": "b", "amount": -100},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_journal_entry_unbalanced_raises():
|
|
||||||
with pytest.raises(val.ValidationError) as exc:
|
|
||||||
val.validate_journal_entry(
|
|
||||||
{"id": "x"},
|
|
||||||
[
|
|
||||||
{"account_id": "a", "amount": 100},
|
|
||||||
{"account_id": "b", "amount": -50},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert "not balanced" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_journal_entry_single_line_raises():
|
|
||||||
with pytest.raises(val.ValidationError) as exc:
|
|
||||||
val.validate_journal_entry(
|
|
||||||
{"id": "x"},
|
|
||||||
[{"account_id": "a", "amount": 100}],
|
|
||||||
)
|
|
||||||
assert "at least 2 lines" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_journal_entry_zero_amount_raises():
|
|
||||||
with pytest.raises(val.ValidationError) as exc:
|
|
||||||
val.validate_journal_entry(
|
|
||||||
{"id": "x"},
|
|
||||||
[
|
|
||||||
{"account_id": "a", "amount": 0},
|
|
||||||
{"account_id": "b", "amount": 0},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert "amount = 0" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_journal_entry_missing_account_id_raises():
|
|
||||||
with pytest.raises(val.ValidationError) as exc:
|
|
||||||
val.validate_journal_entry(
|
|
||||||
{"id": "x"},
|
|
||||||
[
|
|
||||||
{"amount": 100},
|
|
||||||
{"account_id": "b", "amount": -100},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert "missing account_id" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# core.validation — validate_balance
|
# core.validation — validate_balance
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
126
user_lookup.py
Normal file
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 .account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
|
||||||
from .beancount_format import fiat_rate_metadata
|
from .beancount_format import fiat_rate_metadata
|
||||||
|
from .user_lookup import get_username
|
||||||
from .crud import (
|
from .crud import (
|
||||||
approve_manual_payment_request,
|
approve_manual_payment_request,
|
||||||
check_balance_assertion,
|
check_balance_assertion,
|
||||||
|
|
@ -643,7 +644,7 @@ async def api_get_user_entries(
|
||||||
break
|
break
|
||||||
|
|
||||||
# Look up actual username using helper function
|
# Look up actual username using helper function
|
||||||
username = await _get_username_from_user_id(user_id_match) if user_id_match else None
|
username = await get_username(user_id_match) if user_id_match else None
|
||||||
|
|
||||||
entry_data = {
|
entry_data = {
|
||||||
"id": entry_id or e.get("entry_hash", "unknown"),
|
"id": entry_id or e.get("entry_hash", "unknown"),
|
||||||
|
|
@ -684,119 +685,6 @@ async def api_get_user_entries(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _get_username_from_user_id(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Helper function to get username from user_id, handling various formats.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d"
|
|
||||||
- Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d"
|
|
||||||
- Partial ID (8 chars from account names): "375ec158"
|
|
||||||
|
|
||||||
Returns username or formatted fallback.
|
|
||||||
"""
|
|
||||||
from lnbits.core.crud.users import get_user
|
|
||||||
|
|
||||||
logger.debug(f"[USERNAME] Called with: '{user_id}' (len={len(user_id) if user_id else 0})")
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Case 1: Already in standard UUID format (36 chars with dashes)
|
|
||||||
if len(user_id) == 36 and user_id.count('-') == 4:
|
|
||||||
logger.debug(f"[USERNAME] Case 1: Full UUID format")
|
|
||||||
user = await get_user(user_id)
|
|
||||||
result = user.username if user and user.username else f"User-{user_id[:8]}"
|
|
||||||
logger.debug(f"[USERNAME] Case 1 result: '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Case 2: Dashless 32-char UUID - lookup via Libra user settings, fallback to LNbits
|
|
||||||
elif len(user_id) == 32 and '-' not in user_id:
|
|
||||||
logger.debug(f"[USERNAME] Case 2: Dashless UUID format - looking up in Libra user settings")
|
|
||||||
try:
|
|
||||||
# Convert dashless to dashed format
|
|
||||||
user_id_with_dashes = f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}-{user_id[16:20]}-{user_id[20:32]}"
|
|
||||||
logger.debug(f"[USERNAME] Converted to dashed format: {user_id_with_dashes}")
|
|
||||||
|
|
||||||
# Try Libra settings first
|
|
||||||
user_settings = await get_all_user_wallet_settings()
|
|
||||||
for setting in user_settings:
|
|
||||||
if setting.id == user_id_with_dashes:
|
|
||||||
logger.debug(f"[USERNAME] Found matching user in Libra settings")
|
|
||||||
user = await get_user(setting.id)
|
|
||||||
result = user.username if user and user.username else f"User-{user_id[:8]}"
|
|
||||||
logger.debug(f"[USERNAME] Case 2 result (from Libra): '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Not in Libra settings - try LNbits database directly
|
|
||||||
logger.debug(f"[USERNAME] Not in Libra settings, querying LNbits database directly")
|
|
||||||
from lnbits.db import Database
|
|
||||||
db = Database("database")
|
|
||||||
async with db.connect() as conn:
|
|
||||||
row = await conn.fetchone(
|
|
||||||
"SELECT id, username FROM accounts WHERE id = :user_id LIMIT 1",
|
|
||||||
{"user_id": user_id_with_dashes}
|
|
||||||
)
|
|
||||||
logger.debug(f"[USERNAME] Database query result: {row}")
|
|
||||||
if row and row["username"]:
|
|
||||||
result = row["username"]
|
|
||||||
logger.debug(f"[USERNAME] Case 2 result (from LNbits DB): '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# User doesn't exist anywhere
|
|
||||||
logger.debug(f"[USERNAME] User not found in LNbits database either")
|
|
||||||
result = f"User-{user_id[:8]}"
|
|
||||||
logger.debug(f"[USERNAME] Case 2 result (not found): '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error looking up user by dashless UUID {user_id}: {e}")
|
|
||||||
result = f"User-{user_id[:8]}"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Case 3: Partial ID (8 chars from account name) - lookup via Libra user settings
|
|
||||||
elif len(user_id) == 8:
|
|
||||||
logger.debug(f"[USERNAME] Case 3: Partial ID format - looking up in Libra user settings")
|
|
||||||
try:
|
|
||||||
# Get all Libra users (which have full user_ids)
|
|
||||||
user_settings = await get_all_user_wallet_settings()
|
|
||||||
|
|
||||||
# Find matching user by first 8 chars
|
|
||||||
for setting in user_settings:
|
|
||||||
if setting.id.startswith(user_id):
|
|
||||||
logger.debug(f"[USERNAME] Found full user_id: {setting.id}")
|
|
||||||
# Now get username from LNbits with full ID
|
|
||||||
user = await get_user(setting.id)
|
|
||||||
result = user.username if user and user.username else f"User-{user_id}"
|
|
||||||
logger.debug(f"[USERNAME] Case 3 result (found): '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# No matching user found in Libra settings
|
|
||||||
logger.debug(f"[USERNAME] No matching user found in Libra settings")
|
|
||||||
result = f"User-{user_id}"
|
|
||||||
logger.debug(f"[USERNAME] Case 3 result (not found): '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error looking up user by partial ID {user_id}: {e}")
|
|
||||||
result = f"User-{user_id}"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Case 4: Unknown format - try as-is and fall back
|
|
||||||
else:
|
|
||||||
logger.debug(f"[USERNAME] Case 4: Unknown format - trying as-is")
|
|
||||||
try:
|
|
||||||
user = await get_user(user_id)
|
|
||||||
result = user.username if user and user.username else f"User-{user_id[:8]}"
|
|
||||||
logger.debug(f"[USERNAME] Case 4 result: '{result}'")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[USERNAME] Case 4 exception: {e}")
|
|
||||||
result = f"User-{user_id[:8]}"
|
|
||||||
logger.debug(f"[USERNAME] Case 4 fallback result: '{result}'")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@libra_api_router.get("/api/v1/entries/pending")
|
@libra_api_router.get("/api/v1/entries/pending")
|
||||||
async def api_get_pending_entries(
|
async def api_get_pending_entries(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
auth: AuthContext = Depends(require_super_user),
|
||||||
|
|
@ -844,7 +732,7 @@ async def api_get_pending_entries(
|
||||||
break
|
break
|
||||||
|
|
||||||
# Look up username using helper function
|
# Look up username using helper function
|
||||||
username = await _get_username_from_user_id(user_id) if user_id else None
|
username = await get_username(user_id) if user_id else None
|
||||||
|
|
||||||
# Extract amount from postings (sum of absolute values / 2)
|
# Extract amount from postings (sum of absolute values / 2)
|
||||||
amount_sats = 0
|
amount_sats = 0
|
||||||
|
|
@ -1453,7 +1341,9 @@ async def api_create_receivable_entry(
|
||||||
created_by=auth.user_id,
|
created_by=auth.user_id,
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
reference=data.reference,
|
reference=data.reference,
|
||||||
flag=JournalEntryFlag.PENDING,
|
# Receivables are written cleared (format_receivable_entry uses
|
||||||
|
# flag="*") — reporting PENDING here misled the UI (libra-#35).
|
||||||
|
flag=JournalEntryFlag.CLEARED,
|
||||||
meta=entry_meta,
|
meta=entry_meta,
|
||||||
lines=[
|
lines=[
|
||||||
EntryLine(
|
EntryLine(
|
||||||
|
|
@ -1682,7 +1572,7 @@ async def api_get_all_balances(
|
||||||
# Enrich with username information using helper function
|
# Enrich with username information using helper function
|
||||||
result = []
|
result = []
|
||||||
for balance in balances:
|
for balance in balances:
|
||||||
username = await _get_username_from_user_id(balance["user_id"])
|
username = await get_username(balance["user_id"])
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"user_id": balance["user_id"],
|
"user_id": balance["user_id"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue