From d1f22dfda866f53fad45ea46bf11b3969556f8ff Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 09:21:49 +0100 Subject: [PATCH 01/10] Reorders cards on the dashboard Moves the pending expense approvals card to the top of the dashboard and the user balance card to a later position for better UX. --- templates/castle/index.html | 352 ++++++++++++++++++------------------ 1 file changed, 176 insertions(+), 176 deletions(-) diff --git a/templates/castle/index.html b/templates/castle/index.html index 9687d2b..5462904 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -62,6 +62,60 @@ + + + +
Pending Expense Approvals
+ + + + + Pending approval + + + + {% raw %}{{ entry.description }}{% endraw %} + + {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} + + + User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %} + + + Ref: {% raw %}{{ entry.reference }}{% endraw %} + + + + {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} + + {% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %} + + + +
+ + Approve + + + Reject + +
+
+
+
+
+
+ @@ -101,58 +155,6 @@ - - - -
-
-
Your Balance
-
-
- - Refresh balance - -
-
-
-
- {% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %} -
-
- - {% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %} - -
-
- {% raw %}{{ balance.balance > 0 ? 'Total you owe' : balance.balance < 0 ? 'Total owed to you' : 'No outstanding balances' }}{% endraw %} -
-
- {% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %} -
-
- - Pay Balance - - - Request Manual Payment - -
-
-
- - Loading balance... -
-
-
- @@ -222,57 +224,55 @@ - - + + -
Pending Expense Approvals
- - - - - Pending approval - - - - {% raw %}{{ entry.description }}{% endraw %} - - {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} - - - User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %} - - - Ref: {% raw %}{{ entry.reference }}{% endraw %} - - - - {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} - - {% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %} - - - -
- - Approve - - - Reject - -
-
-
-
+
+
+
Your Balance
+
+
+ + Refresh balance + +
+
+
+
+ {% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %} +
+
+ + {% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %} + +
+
+ {% raw %}{{ balance.balance > 0 ? 'Total you owe' : balance.balance < 0 ? 'Total owed to you' : 'No outstanding balances' }}{% endraw %} +
+
+ {% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %} +
+
+ + Pay Balance + + + Request Manual Payment + +
+
+
+ + Loading balance... +
@@ -319,6 +319,80 @@
+ + + +
+
+
Recent Transactions
+
+
+ + Refresh transactions + +
+
+ + + + + + Cleared + + + Pending + + + Flagged - needs review + + + Voided + + + + + {% raw %}{{ entry.description }}{% endraw %} + + + Receivable + + + Payable + + + + Payable + + + Receivable + + + + {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} + + + Ref: {% raw %}{{ entry.reference }}{% endraw %} + + + + Source: {% raw %}{{ entry.meta.source }}{% endraw %} + Via: {% raw %}{{ entry.meta.created_via }}{% endraw %} + + + + {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} + + {% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %} + + + + +
+ No transactions yet +
+
+
+ @@ -565,80 +639,6 @@ - - - - -
-
-
Recent Transactions
-
-
- - Refresh transactions - -
-
- - - - - - Cleared - - - Pending - - - Flagged - needs review - - - Voided - - - - - {% raw %}{{ entry.description }}{% endraw %} - - - Receivable - - - Payable - - - - Payable - - - Receivable - - - - {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} - - - Ref: {% raw %}{{ entry.reference }}{% endraw %} - - - - Source: {% raw %}{{ entry.meta.source }}{% endraw %} - Via: {% raw %}{{ entry.meta.created_via }}{% endraw %} - - - - {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} - - {% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %} - - - - -
- No transactions yet -
-
-
From 762f5cc411a3bec5644dd462b00eef168408ca41 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 09:25:22 +0100 Subject: [PATCH 02/10] Improves user display in audit log and requests Uses usernames instead of user IDs in the audit log and approval request listings to improve readability. If a user is not found, it displays a truncated version of the user ID. --- static/js/index.js | 4 ++++ templates/castle/index.html | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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/templates/castle/index.html b/templates/castle/index.html index 5462904..b5d6a01 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -79,7 +79,7 @@ {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} - User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %} + User: {% raw %}{{ getUserName(entry.meta.user_id) }}{% endraw %} Ref: {% raw %}{{ entry.reference }}{% endraw %} @@ -285,7 +285,7 @@ {% raw %}{{ request.description }}{% endraw %} - User: {% raw %}{{ request.user_id.substring(0, 16) }}...{% endraw %} + User: {% raw %}{{ getUserName(request.user_id) }}{% endraw %} Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %} From 5e67ce562b675e4c53c4ecaa57833d29439d8475 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 23:22:25 +0100 Subject: [PATCH 03/10] Adds CLAUDE.md to guide Claude Code Creates a markdown file to provide guidance to Claude Code (claude.ai/code) when interacting with the Castle Accounting codebase. This document outlines project overview, architecture, key files, database schema, transaction flows, API endpoints, development notes, and other crucial information. It aims to improve Claude's ability to understand and assist with code-related tasks. --- CLAUDE.md | 280 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 CLAUDE.md 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 From 8b16ead5b172d8f6e5bfb93a0e292de52f537939 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 23:24:38 +0100 Subject: [PATCH 04/10] Formats fiat amounts and rates in API calls Ensures consistent formatting of fiat currency, amount, and exchange rates in the `api_settle_receivable` and `api_pay_user` API endpoints. Specifically, it: - Converts fiat currency to uppercase. - Formats fiat amount to three decimal places. - Calculates and includes fiat and BTC rates. --- views_api.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/views_api.py b/views_api.py index cfc950a..2be05b1 100644 --- a/views_api.py +++ b/views_api.py @@ -842,9 +842,10 @@ async def api_settle_receivable( ) amount_in_sats = data.amount_sats line_metadata = { - "fiat_currency": data.currency, - "fiat_amount": str(data.amount), - "exchange_rate": data.amount_sats / float(data.amount) + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), + "fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0, } else: # Satoshi payment @@ -988,9 +989,10 @@ async def api_pay_user( ) amount_in_sats = data.amount_sats line_metadata = { - "fiat_currency": data.currency, - "fiat_amount": str(data.amount), - "exchange_rate": data.amount_sats / float(data.amount) + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), + "fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0, } else: # Satoshi payment From e2472d13a21f121f2317fa94bfff18cc97c5f6ec Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 23:45:28 +0100 Subject: [PATCH 05/10] Adds on-chain Bitcoin payment support Adds support for on-chain Bitcoin payments by: - Introducing a new `Assets:Bitcoin:OnChain` account. - Updating the `SettleReceivable` and `PayUser` models to include `txid` for storing transaction IDs. - Modifying the API endpoints to handle `btc_onchain` as a valid payment method and associate it with the new account. This allows tracking on-chain Bitcoin transactions separately from Lightning Network payments. --- migrations.py | 31 +++++++++++++++++++++ models.py | 7 +++-- views_api.py | 76 ++++++++++++++++++++++++++------------------------- 3 files changed, 75 insertions(+), 39 deletions(-) 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/views_api.py b/views_api.py index 2be05b1..a504e92 100644 --- a/views_api.py +++ b/views_api.py @@ -786,7 +786,7 @@ async def api_settle_receivable( ) # Validate payment method - valid_methods = ["cash", "bank_transfer", "check", "other"] + valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] if data.payment_method.lower() not in valid_methods: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -803,6 +803,8 @@ async def api_settle_receivable( "cash": "Cash", "bank_transfer": "Bank Account", "check": "Bank Account", + "lightning": "Assets:Bitcoin:Lightning", + "btc_onchain": "Assets:Bitcoin:OnChain", "other": "Cash" } @@ -852,6 +854,14 @@ async def api_settle_receivable( amount_in_sats = int(data.amount) line_metadata = {} + # Add payment hash for lightning payments + if data.payment_hash: + line_metadata["payment_hash"] = data.payment_hash + + # Add transaction ID for on-chain Bitcoin payments + if data.txid: + line_metadata["txid"] = data.txid + # Add meta information for audit trail entry_meta = { "source": "manual_settlement", @@ -924,7 +934,7 @@ async def api_pay_user( ) # Validate payment method - valid_methods = ["cash", "bank_transfer", "check", "lightning", "other"] + valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] if data.payment_method.lower() not in valid_methods: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -937,43 +947,31 @@ async def api_pay_user( ) # Get the appropriate asset account based on payment method - if data.payment_method.lower() == "lightning": - # For lightning, use the Lightning Wallet account - payment_account = await get_account_by_name("Lightning Wallet") - if not payment_account: - # Create it if it doesn't exist - payment_account = await create_account( - CreateAccount( - name="Lightning Wallet", - account_type=AccountType.ASSET, - description="Lightning Network wallet for Castle", - ), - wallet.wallet.id, - ) - else: - # For cash/bank/check - payment_account_map = { - "cash": "Cash", - "bank_transfer": "Bank Account", - "check": "Bank Account", - "other": "Cash" - } - account_name = payment_account_map.get(data.payment_method.lower(), "Cash") - payment_account = await get_account_by_name(account_name) + payment_account_map = { + "cash": "Cash", + "bank_transfer": "Bank Account", + "check": "Bank Account", + "lightning": "Assets:Bitcoin:Lightning", + "btc_onchain": "Assets:Bitcoin:OnChain", + "other": "Cash" + } - if not payment_account: - # Try to find any asset account - all_accounts = await get_all_accounts() - for acc in all_accounts: - if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower(): - payment_account = acc - break + account_name = payment_account_map.get(data.payment_method.lower(), "Cash") + payment_account = await get_account_by_name(account_name) - if not payment_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Payment account '{account_name}' not found. Please create it first.", - ) + if not payment_account: + # Try to find any asset account that's not receivable + all_accounts = await get_all_accounts() + for acc in all_accounts: + if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower(): + payment_account = acc + break + + if not payment_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Payment account '{account_name}' not found. Please create it first.", + ) # Determine the amount to record in the journal # IMPORTANT: Always record in satoshis to match the payable account balance @@ -1003,6 +1001,10 @@ async def api_pay_user( if data.payment_hash: line_metadata["payment_hash"] = data.payment_hash + # Add transaction ID for on-chain Bitcoin payments + if data.txid: + line_metadata["txid"] = data.txid + # Create journal entry # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased) # This records that castle paid its debt From 4957826c49121526712545214180c20af27a3971 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 1 Nov 2025 23:47:08 +0100 Subject: [PATCH 06/10] Updates asset account names for consistency Ensures that default and specified account names for cash, bank transfers, and checks align with the correct asset account structure. --- views_api.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/views_api.py b/views_api.py index a504e92..5f6ce0d 100644 --- a/views_api.py +++ b/views_api.py @@ -800,15 +800,15 @@ async def api_settle_receivable( # Get the appropriate asset account based on payment method payment_account_map = { - "cash": "Cash", - "bank_transfer": "Bank Account", - "check": "Bank Account", + "cash": "Assets:Cash", + "bank_transfer": "Assets:Bank", + "check": "Assets:Bank", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", - "other": "Cash" + "other": "Assets:Cash" } - account_name = payment_account_map.get(data.payment_method.lower(), "Cash") + account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash") payment_account = await get_account_by_name(account_name) # If account doesn't exist, try to find or create a generic one @@ -948,15 +948,15 @@ async def api_pay_user( # Get the appropriate asset account based on payment method payment_account_map = { - "cash": "Cash", - "bank_transfer": "Bank Account", - "check": "Bank Account", + "cash": "Assets:Cash", + "bank_transfer": "Assets:Bank", + "check": "Assets:Bank", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", - "other": "Cash" + "other": "Assets:Cash" } - account_name = payment_account_map.get(data.payment_method.lower(), "Cash") + account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash") payment_account = await get_account_by_name(account_name) if not payment_account: From cfa25cc61b7ce7d906638453eba1a990c4aa3540 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 2 Nov 2025 01:40:40 +0100 Subject: [PATCH 07/10] Adds background task for invoice processing Implements a background task that listens for paid invoices and automatically records them in the accounting system. This ensures payments are captured even if the user closes their browser before the client-side polling detects the payment. Introduces a new `get_journal_entry_by_reference` function to improve idempotency when recording payments. --- __init__.py | 13 ++++++- crud.py | 14 +++++++ tasks.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 14 ++++++- 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 56cd641..dc64349 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,11 @@ castle_static_files = [ } ] -__all__ = ["castle_ext", "castle_static_files", "db"] + +def castle_start(): + """Initialize Castle extension background tasks""" + logger.info("Starting Castle accounting extension background tasks") + asyncio.create_task(wait_for_paid_invoices()) + + +__all__ = ["castle_ext", "castle_static_files", "db", "castle_start"] 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/tasks.py b/tasks.py index 991eaaf..d0a31a9 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,103 @@ 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 + + # 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", + ), + CreateEntryLine( + account_id=user_receivable.id, + debit=0, + credit=amount_sats, + description="Payment applied to balance", + ), + ], + ) + + 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/views_api.py b/views_api.py index 5f6ce0d..3753f85 100644 --- a/views_api.py +++ b/views_api.py @@ -591,7 +591,7 @@ async def api_generate_payment_invoice( amount=data.amount, memo=f"Payment from user {target_user_id[:8]} to Castle", unit="sat", - extra={"user_id": target_user_id, "type": "castle_payment"}, + extra={"tag": "castle", "user_id": target_user_id}, ) payment = await create_payment_request(castle_wallet_id, invoice_data) @@ -648,6 +648,18 @@ async def api_record_payment( detail="Payment metadata missing user_id. Cannot determine which user to credit.", ) + # Check if payment already recorded (idempotency) + from .crud import get_journal_entry_by_reference + existing = await get_journal_entry_by_reference(data.payment_hash) + if existing: + # Payment already recorded, return existing entry + balance = await get_user_balance(target_user_id) + return { + "journal_entry_id": existing.id, + "new_balance": balance.balance, + "message": "Payment already recorded", + } + # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 From 8f35788e1a84da6a58d8ed01ad20f5d15b8b6bb1 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 2 Nov 2025 01:41:34 +0100 Subject: [PATCH 08/10] Adds task cleanup on extension shutdown Implements a mechanism to cancel pending background tasks when the extension is stopped. This ensures proper cleanup and prevents potential issues with lingering tasks. --- __init__.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index dc64349..014ffec 100644 --- a/__init__.py +++ b/__init__.py @@ -19,11 +19,24 @@ castle_static_files = [ } ] +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""" - logger.info("Starting Castle accounting extension background tasks") - asyncio.create_task(wait_for_paid_invoices()) + 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"] +__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"] From 3add13075c95a0855d42d6c66c1002ae972f8f8c Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 2 Nov 2025 02:52:41 +0100 Subject: [PATCH 09/10] Adds fiat currency metadata to payments Adds fiat currency information to payment invoices and ledger entries. This allows for tracking the fiat value of transactions and provides a more complete financial picture. Calculates the fiat amount proportionally based on the user's balance and includes the fiat currency, amount, and exchange rates in the invoice's extra data. This data is then extracted and added to the ledger entry's metadata when recording the payment. --- tasks.py | 19 +++++++++++++++++ views_api.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index d0a31a9..32333e1 100644 --- a/tasks.py +++ b/tasks.py @@ -161,6 +161,23 @@ async def on_invoice_paid(payment: Payment) -> None: # 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" @@ -193,12 +210,14 @@ async def on_invoice_paid(payment: Payment) -> None: 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, ), ], ) diff --git a/views_api.py b/views_api.py index 3753f85..f8bc5eb 100644 --- a/views_api.py +++ b/views_api.py @@ -585,13 +585,52 @@ async def api_generate_payment_invoice( # Get castle wallet ID castle_wallet_id = await check_castle_wallet_configured() + # Get user's balance to calculate fiat metadata + user_balance = await get_user_balance(target_user_id) + + # Calculate proportional fiat amount for this invoice + invoice_extra = {"tag": "castle", "user_id": target_user_id} + + if user_balance.fiat_balances: + # Simple single-currency solution: use the first (and should be only) currency + currencies = list(user_balance.fiat_balances.keys()) + + if len(currencies) > 1: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.", + ) + + if len(currencies) == 1: + fiat_currency = currencies[0] + total_fiat_balance = user_balance.fiat_balances[fiat_currency] + total_sat_balance = abs(user_balance.balance) # Use absolute value + + if total_sat_balance > 0: + # Calculate proportional fiat amount for this invoice + # fiat_amount = (invoice_amount / total_sats) * total_fiat + from decimal import Decimal + proportion = Decimal(data.amount) / Decimal(total_sat_balance) + invoice_fiat_amount = abs(total_fiat_balance) * proportion + + # Calculate fiat rate (sats per fiat unit) + fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0 + btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0 + + invoice_extra.update({ + "fiat_currency": fiat_currency, + "fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))), + "fiat_rate": fiat_rate, + "btc_rate": btc_rate, + }) + # Create invoice on castle wallet invoice_data = CreateInvoice( out=False, amount=data.amount, memo=f"Payment from user {target_user_id[:8]} to Castle", unit="sat", - extra={"tag": "castle", "user_id": target_user_id}, + extra=invoice_extra, ) payment = await create_payment_request(castle_wallet_id, invoice_data) @@ -663,6 +702,22 @@ async def api_record_payment( # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 + # Extract fiat metadata from invoice (if present) + line_metadata = {} + if payment.extra and isinstance(payment.extra, dict): + 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( target_user_id, AccountType.ASSET, "Accounts Receivable" @@ -698,12 +753,14 @@ async def api_record_payment( 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, ), ], ) From 3248d3dad634a09bd5627be6f11d71a02dc27baa Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 01:19:30 +0100 Subject: [PATCH 10/10] add dev docs --- BEANCOUNT_PATTERNS.md => docs/BEANCOUNT_PATTERNS.md | 0 DAILY_RECONCILIATION.md => docs/DAILY_RECONCILIATION.md | 0 DOCUMENTATION.md => docs/DOCUMENTATION.md | 0 EXPENSE_APPROVAL.md => docs/EXPENSE_APPROVAL.md | 0 PHASE1_COMPLETE.md => docs/PHASE1_COMPLETE.md | 0 PHASE2_COMPLETE.md => docs/PHASE2_COMPLETE.md | 0 PHASE3_COMPLETE.md => docs/PHASE3_COMPLETE.md | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename BEANCOUNT_PATTERNS.md => docs/BEANCOUNT_PATTERNS.md (100%) rename DAILY_RECONCILIATION.md => docs/DAILY_RECONCILIATION.md (100%) rename DOCUMENTATION.md => docs/DOCUMENTATION.md (100%) rename EXPENSE_APPROVAL.md => docs/EXPENSE_APPROVAL.md (100%) rename PHASE1_COMPLETE.md => docs/PHASE1_COMPLETE.md (100%) rename PHASE2_COMPLETE.md => docs/PHASE2_COMPLETE.md (100%) rename PHASE3_COMPLETE.md => docs/PHASE3_COMPLETE.md (100%) 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