diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6376629
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,280 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
+
+## Architecture
+
+### Core Design Principles
+
+**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
+
+**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
+- `core/balance.py` - Balance calculation from journal entries
+- `core/inventory.py` - Multi-currency position tracking (similar to Beancount's Inventory)
+- `core/validation.py` - Entry validation rules
+
+**Account Hierarchy**: Beancount-style hierarchical naming with `:` separators:
+- `Assets:Lightning:Balance`
+- `Assets:Receivable:User-af983632`
+- `Liabilities:Payable:User-af983632`
+- `Expenses:Food:Supplies`
+
+**Metadata System**: Each `entry_line` stores JSON metadata preserving original fiat amounts. Critical: fiat balances are calculated by summing `fiat_amount` from metadata, NOT by converting current satoshi balances. This prevents exchange rate fluctuations from affecting historical records.
+
+### Key Files
+
+- `models.py` - Pydantic models for API I/O and data structures
+- `crud.py` - Database operations (create/read/update accounts, journal entries)
+- `views_api.py` - FastAPI endpoints for all operations
+- `views.py` - Web interface routing
+- `services.py` - Settings management layer
+- `migrations.py` - Database schema migrations
+- `tasks.py` - Background tasks (daily reconciliation checks)
+- `account_utils.py` - Hierarchical account naming utilities
+
+### Database Schema
+
+**accounts**: Chart of accounts with hierarchical names
+- `user_id` field for per-user accounts (Receivable, Payable, Equity)
+- Indexed on `user_id` and `account_type`
+
+**journal_entries**: Transaction headers
+- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void)
+- `meta` field: JSON storing source, tags, audit info
+- `reference` field: Links to payment_hash, invoice numbers, etc.
+
+**entry_lines**: Individual debit/credit lines
+- Always balanced (sum of debits = sum of credits per entry)
+- `metadata` field stores fiat currency info as JSON
+- Indexed on `journal_entry_id` and `account_id`
+
+**balance_assertions**: Reconciliation checkpoints (Beancount-style)
+- Assert expected balance at a date
+- Status: pending, passed, failed
+- Used for daily reconciliation checks
+
+**extension_settings**: Castle wallet configuration (admin-only)
+
+**user_wallet_settings**: Per-user wallet configuration
+
+**manual_payment_requests**: User requests for cash/manual payments
+
+## Transaction Flows
+
+### User Adds Expense (Liability)
+User pays cash for groceries, Castle owes them:
+```
+DR Expenses:Food 39,669 sats
+ CR Liabilities:Payable:User-af983632 39,669 sats
+```
+Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
+
+### Castle Adds Receivable
+User owes Castle for accommodation:
+```
+DR Assets:Receivable:User-af983632 268,548 sats
+ CR Income:Accommodation 268,548 sats
+```
+
+### User Pays with Lightning
+Invoice generated on **Castle's wallet** (not user's). After payment:
+```
+DR Assets:Lightning:Balance 268,548 sats
+ CR Assets:Receivable:User-af983632 268,548 sats
+```
+
+### Manual Payment Approval
+User requests cash payment → Admin approves → Journal entry created:
+```
+DR Liabilities:Payable:User-af983632 39,669 sats
+ CR Assets:Lightning:Balance 39,669 sats
+```
+
+## Balance Calculation Logic
+
+**User Balance**:
+- Positive = Castle owes user (LIABILITY accounts have credit balance)
+- Negative = User owes Castle (ASSET accounts have debit balance)
+- Calculated from sum of all entry lines across user's accounts
+- Fiat balances summed from metadata, NOT converted from sats
+
+**Perspective-Based UI**:
+- **User View**: Green = Castle owes them, Red = They owe Castle
+- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user
+
+## API Endpoints
+
+### Accounts
+- `GET /api/v1/accounts` - List all accounts
+- `POST /api/v1/accounts` - Create account (admin)
+- `GET /api/v1/accounts/{id}/balance` - Get account balance
+
+### Journal Entries
+- `POST /api/v1/entries/expense` - User adds expense (creates liability or equity)
+- `POST /api/v1/entries/receivable` - Admin records what user owes (admin only)
+- `POST /api/v1/entries/revenue` - Admin records direct revenue (admin only)
+- `GET /api/v1/entries/user` - Get user's journal entries
+- `POST /api/v1/entries` - Create raw journal entry (admin only)
+
+### Payments & Balances
+- `GET /api/v1/balance` - Get user balance (or Castle total if super user)
+- `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames)
+- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
+- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle
+- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
+- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning)
+
+### Manual Payment Requests
+- `POST /api/v1/manual-payment-requests` - User requests payment
+- `GET /api/v1/manual-payment-requests` - User's requests
+- `GET /api/v1/manual-payment-requests/all` - All requests (admin)
+- `POST /api/v1/manual-payment-requests/{id}/approve` - Approve (admin)
+- `POST /api/v1/manual-payment-requests/{id}/reject` - Reject (admin)
+
+### Reconciliation
+- `POST /api/v1/assertions/balance` - Create balance assertion
+- `GET /api/v1/assertions/balance` - List balance assertions
+- `POST /api/v1/assertions/balance/{id}/check` - Check assertion
+- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
+
+### Settings
+- `GET /api/v1/settings` - Get Castle settings (super user)
+- `PUT /api/v1/settings` - Update Castle settings (super user)
+- `GET /api/v1/user/wallet` - Get user wallet settings
+- `PUT /api/v1/user/wallet` - Update user wallet settings
+
+## Development Notes
+
+### Testing Entry Creation
+
+When creating journal entries programmatically, use the helper endpoints:
+- `POST /api/v1/entries/expense` for user expenses (handles account creation automatically)
+- `POST /api/v1/entries/receivable` for what users owe
+- `POST /api/v1/entries/revenue` for direct revenue
+
+For custom entries, use `POST /api/v1/entries` with properly balanced lines.
+
+### User Account Management
+
+User-specific accounts are created automatically with format:
+- Assets: `Assets:Receivable:User-{user_id[:8]}`
+- Liabilities: `Liabilities:Payable:User-{user_id[:8]}`
+- Equity: `Equity:MemberEquity:User-{user_id[:8]}`
+
+Use `get_or_create_user_account()` in crud.py to ensure consistency.
+
+### Currency Handling
+
+**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`. Fiat amounts are stored in metadata as strings to preserve precision:
+```python
+from decimal import Decimal
+
+metadata = {
+ "fiat_currency": "EUR",
+ "fiat_amount": str(Decimal("250.00")),
+ "fiat_rate": str(Decimal("1074.192")),
+ "btc_rate": str(Decimal("0.000931"))
+}
+```
+
+When reading: `fiat_amount = Decimal(metadata["fiat_amount"])`
+
+### Balance Assertions for Reconciliation
+
+Create balance assertions to verify accounting accuracy:
+```python
+await create_balance_assertion(
+ account_id="lightning_account_id",
+ expected_balance_sats=1000000,
+ expected_balance_fiat=Decimal("500.00"),
+ fiat_currency="EUR",
+ tolerance_sats=100
+)
+```
+
+Run `POST /api/v1/tasks/daily-reconciliation` to check all assertions.
+
+### Permission Model
+
+- **Super User**: Full access (check via `wallet.wallet.user == lnbits_settings.super_user`)
+- **Admin Key**: Required for creating receivables, approving payments, viewing all balances
+- **Invoice Key**: Read access to user's own data
+- **Users**: Can only see/manage their own accounts and transactions
+
+### Extension as LNbits Module
+
+This extension follows LNbits extension structure:
+- Registered via `castle_ext` router in `__init__.py`
+- Static files served from `static/` directory
+- Templates in `templates/castle/`
+- Database accessed via `db = Database("ext_castle")`
+
+## Common Tasks
+
+### Add New Expense Account
+```python
+await create_account(CreateAccount(
+ name="Expenses:Internet",
+ account_type=AccountType.EXPENSE,
+ description="Internet service costs"
+))
+```
+
+### Manually Record Cash Payment
+```python
+await create_journal_entry(CreateJournalEntry(
+ description="Cash payment for groceries",
+ lines=[
+ CreateEntryLine(account_id=expense_account_id, debit=50000),
+ CreateEntryLine(account_id=cash_account_id, credit=50000)
+ ],
+ flag=JournalEntryFlag.CLEARED,
+ meta={"source": "manual", "payment_method": "cash"}
+))
+```
+
+### Check User Balance
+```python
+balance = await get_user_balance(user_id)
+print(f"Sats: {balance.balance}") # Positive = Castle owes user
+print(f"Fiat: {balance.fiat_balances}") # {"EUR": Decimal("36.93")}
+```
+
+### Export to Beancount (Future)
+Follow patterns in `docs/BEANCOUNT_PATTERNS.md` for implementing Beancount export. Use hierarchical account names and preserve metadata in Beancount comments.
+
+## Data Integrity
+
+**Critical Invariants**:
+1. Every journal entry MUST have balanced debits and credits
+2. Fiat balances calculated from metadata, not from converting sats
+3. User accounts use `user_id` (NOT `wallet_id`) for consistency
+4. Balance assertions checked daily via background task
+
+**Validation** is performed in `core/validation.py`:
+- `validate_journal_entry()` - Checks balance, minimum lines
+- `validate_balance()` - Verifies account balance calculation
+- `validate_receivable_entry()` - Ensures receivable entries are valid
+- `validate_expense_entry()` - Ensures expense entries are valid
+
+## Known Issues & Future Work
+
+See `docs/DOCUMENTATION.md` for comprehensive list. Key items:
+- No journal entry editing/deletion (use reversing entries)
+- No date range filtering on list endpoints (hardcoded limit of 100)
+- No batch operations for bulk imports
+- Plugin system architecture designed but not implemented
+- Beancount export endpoint not yet implemented
+
+## Related Documentation
+
+- `docs/README.md` - User-facing overview
+- `docs/DOCUMENTATION.md` - Comprehensive technical documentation
+- `docs/BEANCOUNT_PATTERNS.md` - Beancount-inspired design patterns
+- `docs/PHASE1_COMPLETE.md`, `PHASE2_COMPLETE.md`, `PHASE3_COMPLETE.md` - Development milestones
+- `docs/EXPENSE_APPROVAL.md` - Manual payment request workflow
+- `docs/DAILY_RECONCILIATION.md` - Automated reconciliation system
diff --git a/__init__.py b/__init__.py
index 56cd641..014ffec 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,6 +1,10 @@
+import asyncio
+
from fastapi import APIRouter
+from loguru import logger
from .crud import db
+from .tasks import wait_for_paid_invoices
from .views import castle_generic_router
from .views_api import castle_api_router
@@ -15,4 +19,24 @@ castle_static_files = [
}
]
-__all__ = ["castle_ext", "castle_static_files", "db"]
+scheduled_tasks: list[asyncio.Task] = []
+
+
+def castle_stop():
+ """Clean up background tasks on extension shutdown"""
+ for task in scheduled_tasks:
+ try:
+ task.cancel()
+ except Exception as ex:
+ logger.warning(ex)
+
+
+def castle_start():
+ """Initialize Castle extension background tasks"""
+ from lnbits.tasks import create_permanent_unique_task
+
+ task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
+ scheduled_tasks.append(task)
+
+
+__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]
diff --git a/crud.py b/crud.py
index 0ac20fb..81e601a 100644
--- a/crud.py
+++ b/crud.py
@@ -226,6 +226,20 @@ async def get_journal_entry(entry_id: str) -> Optional[JournalEntry]:
return entry
+async def get_journal_entry_by_reference(reference: str) -> Optional[JournalEntry]:
+ """Get a journal entry by its reference field (e.g., payment_hash)"""
+ entry = await db.fetchone(
+ "SELECT * FROM journal_entries WHERE reference = :reference",
+ {"reference": reference},
+ JournalEntry,
+ )
+
+ if entry:
+ entry.lines = await get_entry_lines(entry.id)
+
+ return entry
+
+
async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]:
rows = await db.fetchall(
"SELECT * FROM entry_lines WHERE journal_entry_id = :id",
diff --git a/BEANCOUNT_PATTERNS.md b/docs/BEANCOUNT_PATTERNS.md
similarity index 100%
rename from BEANCOUNT_PATTERNS.md
rename to docs/BEANCOUNT_PATTERNS.md
diff --git a/DAILY_RECONCILIATION.md b/docs/DAILY_RECONCILIATION.md
similarity index 100%
rename from DAILY_RECONCILIATION.md
rename to docs/DAILY_RECONCILIATION.md
diff --git a/DOCUMENTATION.md b/docs/DOCUMENTATION.md
similarity index 100%
rename from DOCUMENTATION.md
rename to docs/DOCUMENTATION.md
diff --git a/EXPENSE_APPROVAL.md b/docs/EXPENSE_APPROVAL.md
similarity index 100%
rename from EXPENSE_APPROVAL.md
rename to docs/EXPENSE_APPROVAL.md
diff --git a/PHASE1_COMPLETE.md b/docs/PHASE1_COMPLETE.md
similarity index 100%
rename from PHASE1_COMPLETE.md
rename to docs/PHASE1_COMPLETE.md
diff --git a/PHASE2_COMPLETE.md b/docs/PHASE2_COMPLETE.md
similarity index 100%
rename from PHASE2_COMPLETE.md
rename to docs/PHASE2_COMPLETE.md
diff --git a/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md
similarity index 100%
rename from PHASE3_COMPLETE.md
rename to docs/PHASE3_COMPLETE.md
diff --git a/migrations.py b/migrations.py
index 468057e..5efb00d 100644
--- a/migrations.py
+++ b/migrations.py
@@ -332,3 +332,34 @@ async def m008_rename_lightning_account(db):
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"
+ }
+ )
diff --git a/models.py b/models.py
index bdf79a0..ffde1c6 100644
--- a/models.py
+++ b/models.py
@@ -188,11 +188,13 @@ class SettleReceivable(BaseModel):
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
- payment_method: str # "cash", "bank_transfer", "lightning", "other"
+ payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
description: str # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
+ payment_hash: Optional[str] = None # For lightning payments
+ txid: Optional[str] = None # For on-chain Bitcoin transactions
class PayUser(BaseModel):
@@ -200,12 +202,13 @@ class PayUser(BaseModel):
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
- payment_method: str # "cash", "bank_transfer", "lightning", "check", "other"
+ payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
description: Optional[str] = None # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments
+ txid: Optional[str] = None # For on-chain Bitcoin transactions
class AssertionStatus(str, Enum):
diff --git a/static/js/index.js b/static/js/index.js
index 746faf6..2517657 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -857,6 +857,10 @@ window.app = Vue.createApp({
const account = this.accounts.find(a => a.id === accountId)
return account ? account.name : accountId
},
+ getUserName(userId) {
+ const user = this.users.find(u => u.user_id === userId)
+ return user ? user.username : userId.substring(0, 16) + '...'
+ },
async loadReconciliationSummary() {
if (!this.isSuperUser) return
diff --git a/tasks.py b/tasks.py
index 991eaaf..32333e1 100644
--- a/tasks.py
+++ b/tasks.py
@@ -4,10 +4,13 @@ These tasks handle automated reconciliation checks and maintenance.
"""
import asyncio
+from asyncio import Queue
from datetime import datetime
from typing import Optional
+from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
+from loguru import logger
from .crud import check_balance_assertion, get_balance_assertions
from .models import AssertionStatus
@@ -106,3 +109,122 @@ def start_daily_reconciliation_task():
print("[CASTLE] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint
+
+
+async def wait_for_paid_invoices():
+ """
+ Background task that listens for paid invoices and automatically
+ records them in the accounting system.
+
+ This ensures payments are recorded even if the user closes their browser
+ before the payment is detected by client-side polling.
+ """
+ invoice_queue = Queue()
+ register_invoice_listener(invoice_queue, "ext_castle")
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ """
+ Handle a paid Castle invoice by automatically creating a journal entry.
+
+ This function is called automatically when any invoice on the Castle wallet
+ is paid. It checks if the invoice is a Castle payment and records it in
+ the accounting system.
+ """
+ # Only process Castle-specific payments
+ if not payment.extra or payment.extra.get("tag") != "castle":
+ return
+
+ user_id = payment.extra.get("user_id")
+ if not user_id:
+ logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
+ return
+
+ # Check if payment already recorded (idempotency)
+ from .crud import get_journal_entry_by_reference
+ existing = await get_journal_entry_by_reference(payment.payment_hash)
+ if existing:
+ logger.info(f"Payment {payment.payment_hash} already recorded, skipping")
+ return
+
+ logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}")
+
+ try:
+ # Import here to avoid circular dependencies
+ from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account
+ from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag
+
+ # Convert amount from millisatoshis to satoshis
+ amount_sats = payment.amount // 1000
+
+ # Extract fiat metadata from invoice (if present)
+ from decimal import Decimal
+ line_metadata = {}
+ if payment.extra:
+ fiat_currency = payment.extra.get("fiat_currency")
+ fiat_amount = payment.extra.get("fiat_amount")
+ fiat_rate = payment.extra.get("fiat_rate")
+ btc_rate = payment.extra.get("btc_rate")
+
+ if fiat_currency and fiat_amount:
+ line_metadata = {
+ "fiat_currency": fiat_currency,
+ "fiat_amount": str(fiat_amount),
+ "fiat_rate": fiat_rate,
+ "btc_rate": btc_rate,
+ }
+
+ # Get user's receivable account (what user owes)
+ user_receivable = await get_or_create_user_account(
+ user_id, AccountType.ASSET, "Accounts Receivable"
+ )
+
+ # Get lightning account
+ lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
+ if not lightning_account:
+ logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
+ return
+
+ # Create journal entry to record payment
+ # DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User)
+ # This reduces what the user owes
+ entry_meta = {
+ "source": "lightning_payment",
+ "created_via": "auto_invoice_listener",
+ "payment_hash": payment.payment_hash,
+ "payer_user_id": user_id,
+ }
+
+ entry_data = CreateJournalEntry(
+ description=f"Lightning payment from user {user_id[:8]}",
+ reference=payment.payment_hash,
+ flag=JournalEntryFlag.CLEARED,
+ meta=entry_meta,
+ lines=[
+ CreateEntryLine(
+ account_id=lightning_account.id,
+ debit=amount_sats,
+ credit=0,
+ description="Lightning payment received",
+ metadata=line_metadata,
+ ),
+ CreateEntryLine(
+ account_id=user_receivable.id,
+ debit=0,
+ credit=amount_sats,
+ description="Payment applied to balance",
+ metadata=line_metadata,
+ ),
+ ],
+ )
+
+ entry = await create_journal_entry(entry_data, user_id)
+ logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}")
+
+ except Exception as e:
+ logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
+ raise
diff --git a/templates/castle/index.html b/templates/castle/index.html
index 9687d2b..b5d6a01 100644
--- a/templates/castle/index.html
+++ b/templates/castle/index.html
@@ -62,6 +62,60 @@
+
+ Pending Expense Approvals
+ Your Balance
- Pending Expense Approvals
- Your Balance
+ Recent Transactions
+ Recent Transactions
-