forked from aiolabs/libra
Rename Castle Accounting extension to Libra
Full identifier rename: module path lnbits.extensions.castle →
lnbits.extensions.libra, DB ext_castle → ext_libra, URL prefix
/castle/ → /libra/, manifest id castle → libra, fava ledger slug
default castle-ledger → libra-ledger, Beancount source metadata
castle-api → libra-api and link prefixes castle-{entry,tx}- →
libra-{entry,tx}-, column castle_wallet_id → libra_wallet_id, all
Python/JS/HTML identifiers (castle_ext, CastleSettings,
castle_reference, castleWalletConfigured, etc.).
Display name "Castle Accounting" → "Libra" (the scales/balance
metaphor — fits double-entry bookkeeping).
No backward compat: production hosts will be force-updated. Old
castle-prefixed Beancount metadata in existing Fava ledgers is
historical; new entries use libra-* prefixes going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c577c740c
commit
c174cda48d
44 changed files with 953 additions and 953 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# Beancount Patterns Analysis for Castle Extension
|
||||
# Beancount Patterns Analysis for Libra Extension
|
||||
|
||||
## Overview
|
||||
|
||||
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension.
|
||||
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Libra extension.
|
||||
|
||||
## Key Patterns to Adopt
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ class Posting(NamedTuple):
|
|||
- More memory efficient than regular classes
|
||||
- Thread-safe by design
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# In models.py
|
||||
from typing import NamedTuple, Optional
|
||||
|
|
@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config):
|
|||
return entries, errors
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Create plugins/ directory
|
||||
# lnbits/extensions/castle/plugins/__init__.py
|
||||
# lnbits/extensions/libra/plugins/__init__.py
|
||||
|
||||
from typing import Protocol, Tuple, List, Any
|
||||
|
||||
class CastlePlugin(Protocol):
|
||||
"""Protocol for Castle plugins"""
|
||||
class LibraPlugin(Protocol):
|
||||
"""Protocol for Libra plugins"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
|
|
@ -130,7 +130,7 @@ class CastlePlugin(Protocol):
|
|||
|
||||
Args:
|
||||
entries: Journal entries to process
|
||||
settings: Castle settings
|
||||
settings: Libra settings
|
||||
config: Plugin-specific configuration
|
||||
|
||||
Returns:
|
||||
|
|
@ -212,7 +212,7 @@ class PluginManager:
|
|||
if plugin_file.name.startswith('_'):
|
||||
continue
|
||||
|
||||
module_name = f"castle.plugins.{plugin_file.stem}"
|
||||
module_name = f"libra.plugins.{plugin_file.stem}"
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
if hasattr(module, '__plugins__'):
|
||||
|
|
@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]):
|
|||
)
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# core/inventory.py
|
||||
from decimal import Decimal
|
||||
|
|
@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple
|
|||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CastlePosition:
|
||||
"""A position in the Castle inventory"""
|
||||
class LibraPosition:
|
||||
"""A position in the Libra inventory"""
|
||||
currency: str # "SATS", "EUR", "USD"
|
||||
amount: Decimal
|
||||
cost_currency: Optional[str] = None # Original currency if converted
|
||||
|
|
@ -293,22 +293,22 @@ class CastlePosition:
|
|||
date: Optional[datetime] = None
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
class CastleInventory:
|
||||
class LibraInventory:
|
||||
"""
|
||||
Track user balances across multiple currencies with conversion tracking.
|
||||
Similar to Beancount's Inventory but optimized for Castle's use case.
|
||||
Similar to Beancount's Inventory but optimized for Libra's use case.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {}
|
||||
self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {}
|
||||
|
||||
def add_position(self, position: CastlePosition):
|
||||
def add_position(self, position: LibraPosition):
|
||||
"""Add or merge a position"""
|
||||
key = (position.currency, position.cost_currency)
|
||||
|
||||
if key in self.positions:
|
||||
existing = self.positions[key]
|
||||
self.positions[key] = CastlePosition(
|
||||
self.positions[key] = LibraPosition(
|
||||
currency=position.currency,
|
||||
amount=existing.amount + position.amount,
|
||||
cost_currency=position.cost_currency,
|
||||
|
|
@ -353,9 +353,9 @@ class CastleInventory:
|
|||
}
|
||||
|
||||
# Usage in balance calculation:
|
||||
async def get_user_inventory(user_id: str) -> CastleInventory:
|
||||
async def get_user_inventory(user_id: str) -> LibraInventory:
|
||||
"""Calculate user's inventory from journal entries"""
|
||||
inventory = CastleInventory()
|
||||
inventory = LibraInventory()
|
||||
|
||||
user_accounts = await get_user_accounts(user_id)
|
||||
for account in user_accounts:
|
||||
|
|
@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> CastleInventory:
|
|||
# Beancount-style: positive = debit, negative = credit
|
||||
# Adjust sign for cost amount based on amount direction
|
||||
cost_sign = 1 if line.amount > 0 else -1
|
||||
inventory.add_position(CastlePosition(
|
||||
inventory.add_position(LibraPosition(
|
||||
currency="SATS",
|
||||
amount=Decimal(line.amount),
|
||||
cost_currency=metadata.get("fiat_currency"),
|
||||
|
|
@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores:
|
|||
- `lineno`: Line number
|
||||
- Custom metadata like tags, links, notes
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
class JournalEntryMeta(BaseModel):
|
||||
"""Metadata for journal entries"""
|
||||
|
|
@ -447,7 +447,7 @@ entry = await create_journal_entry(
|
|||
|
||||
This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error.
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# models.py
|
||||
class BalanceAssertion(BaseModel):
|
||||
|
|
@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel):
|
|||
created_at: datetime
|
||||
|
||||
# API endpoint
|
||||
@castle_api_router.post("/api/v1/assertions/balance")
|
||||
@libra_api_router.post("/api/v1/assertions/balance")
|
||||
async def create_balance_assertion(
|
||||
data: CreateBalanceAssertion,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
|
|
@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex
|
|||
|
||||
Accounts are organized hierarchically with `:` separator.
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Currently: "Accounts Receivable - af983632"
|
||||
# Better: "Assets:Receivable:User-af983632"
|
||||
|
|
@ -617,7 +617,7 @@ def format_account_name(
|
|||
|
||||
Flags: `*` = cleared, `!` = pending, `#` = flagged for review
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Add flag field to journal_entries
|
||||
class JournalEntryFlag(str, Enum):
|
||||
|
|
@ -661,7 +661,7 @@ from decimal import Decimal
|
|||
amount = Decimal("19.99")
|
||||
```
|
||||
|
||||
**Castle Current Issue:**
|
||||
**Libra Current Issue:**
|
||||
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
|
||||
|
||||
**Fix:**
|
||||
|
|
@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking'
|
|||
AND date >= 2025-01-01;
|
||||
```
|
||||
|
||||
**Castle Application (Future):**
|
||||
**Libra Application (Future):**
|
||||
```python
|
||||
# Add query endpoint
|
||||
@castle_api_router.post("/api/v1/query")
|
||||
@libra_api_router.post("/api/v1/query")
|
||||
async def execute_query(
|
||||
query: str,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
|
|
@ -756,12 +756,12 @@ beancount/
|
|||
tools/ # Reporting and analysis
|
||||
```
|
||||
|
||||
**Castle Should Adopt:**
|
||||
**Libra Should Adopt:**
|
||||
```
|
||||
castle/
|
||||
libra/
|
||||
core/ # NEW: Pure accounting logic
|
||||
__init__.py
|
||||
inventory.py # CastleInventory for position tracking
|
||||
inventory.py # LibraInventory for position tracking
|
||||
balance.py # Balance calculation logic
|
||||
validation.py # Entry validation (debits=credits, etc)
|
||||
account.py # Account hierarchy and naming
|
||||
|
|
@ -805,11 +805,11 @@ def validate_entries(entries):
|
|||
return errors
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
class CastleError(NamedTuple):
|
||||
class LibraError(NamedTuple):
|
||||
"""Base error type"""
|
||||
source: dict # {'endpoint': '...', 'user_id': '...'}
|
||||
message: str
|
||||
|
|
@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple):
|
|||
difference: int
|
||||
|
||||
# Return errors from validation
|
||||
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]:
|
||||
async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
||||
errors = []
|
||||
|
||||
# Beancount-style: sum of amounts must equal 0
|
||||
|
|
@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
|
||||
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
|
||||
9. ✅ Create `core/` module with pure accounting logic
|
||||
10. ✅ Implement `CastleInventory` for position tracking
|
||||
10. ✅ Implement `LibraInventory` for position tracking
|
||||
11. ✅ Move balance calculation to `core/balance.py`
|
||||
12. ✅ Add comprehensive validation in `core/validation.py`
|
||||
|
||||
|
|
@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
7. ✅ Separation of core logic from I/O
|
||||
8. ✅ Comprehensive validation
|
||||
|
||||
**What Castle Should Adopt First:**
|
||||
**What Libra Should Adopt First:**
|
||||
1. **Decimal for fiat amounts** (prevent rounding errors)
|
||||
2. **Meta field** (audit trail, source tracking)
|
||||
3. **Flag field** (transaction status)
|
||||
|
|
@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
|
||||
## Conclusion
|
||||
|
||||
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can:
|
||||
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can:
|
||||
- Prevent financial calculation errors (Decimal)
|
||||
- Support complex workflows (plugins)
|
||||
- Build user trust (balance assertions, audit trail)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue