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
+ + + + + Pending approval + + + + {% raw %}{{ entry.description }}{% endraw %} + + {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} + + + User: {% raw %}{{ getUserName(entry.meta.user_id) }}{% 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... +
@@ -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 %} @@ -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 -
-
-
diff --git a/views_api.py b/views_api.py index cfc950a..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={"user_id": target_user_id, "type": "castle_payment"}, + extra=invoice_extra, ) payment = await create_payment_request(castle_wallet_id, invoice_data) @@ -648,9 +687,37 @@ 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 + # 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" @@ -686,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, ), ], ) @@ -786,7 +855,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, @@ -800,13 +869,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", - "other": "Cash" + "cash": "Assets:Cash", + "bank_transfer": "Assets:Bank", + "check": "Assets:Bank", + "lightning": "Assets:Bitcoin:Lightning", + "btc_onchain": "Assets:Bitcoin:OnChain", + "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 @@ -842,15 +913,24 @@ 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 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", @@ -923,7 +1003,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, @@ -936,43 +1016,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": "Assets:Cash", + "bank_transfer": "Assets:Bank", + "check": "Assets:Bank", + "lightning": "Assets:Bitcoin:Lightning", + "btc_onchain": "Assets:Bitcoin:OnChain", + "other": "Assets: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(), "Assets: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 @@ -988,9 +1056,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 @@ -1001,6 +1070,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