From 95b8af236056b71780aa82c49d0f08a5a65f540e Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 22 Oct 2025 12:33:45 +0200 Subject: [PATCH] initial commit --- .gitignore | 4 + README.md | 93 ++++++++ __init__.py | 17 ++ config.json | 11 + crud.py | 290 +++++++++++++++++++++++++ description.md | 65 ++++++ manifest.json | 9 + migrations.py | 117 ++++++++++ models.py | 99 +++++++++ static/image/castle.png | 1 + static/image/castle.svg | 12 ++ static/js/index.js | 162 ++++++++++++++ templates/castle/index.html | 209 ++++++++++++++++++ views.py | 19 ++ views_api.py | 411 ++++++++++++++++++++++++++++++++++++ 15 files changed, 1519 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 description.md create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 static/image/castle.png create mode 100644 static/image/castle.svg create mode 100644 static/js/index.js create mode 100644 templates/castle/index.html create mode 100644 views.py create mode 100644 views_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e68ab2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +node_modules +.venv +.mypy_cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..6174bfb --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Castle Accounting Extension for LNbits + +A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments. + +## Overview + +Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to: +- Track expenses and revenue with proper accounting +- Manage individual member balances +- Record contributions as equity or reimbursable expenses +- Track accounts receivable (what members owe) +- Generate Lightning invoices for settlements + +## Installation + +This extension is designed to be installed in the `lnbits/extensions/` directory. + +```bash +cd lnbits/extensions/ +# Copy or clone the castle directory here +``` + +Enable the extension through the LNbits admin interface or by adding it to your configuration. + +## Usage + +### For Members + +1. **Add an Expense**: Record money you spent on behalf of the collective + - Choose "Liability" if you want reimbursement + - Choose "Equity" if it's a contribution + +2. **View Your Balance**: See if the Castle owes you money or vice versa + +3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe + +### For Admins + +1. **Create Accounts Receivable**: Record when someone owes the collective money + +2. **Record Revenue**: Track income received by the collective + +3. **View All Transactions**: See complete accounting history + +4. **Make Payments**: Record payments to members + +## Architecture + +### Data Models + +- **Account**: Individual accounts in the chart of accounts +- **JournalEntry**: Transaction header with description and date +- **EntryLine**: Individual debit/credit lines (always balanced) + +### Account Types + +- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable) +- **Liabilities**: What the Castle owes (Accounts Payable to members) +- **Equity**: Member contributions and retained earnings +- **Revenue**: Income streams +- **Expenses**: Operating costs + +### Database Schema + +The extension creates three tables: +- `castle.accounts` - Chart of accounts +- `castle.journal_entries` - Transaction headers +- `castle.entry_lines` - Debit/credit lines + +## API Reference + +See [description.md](description.md) for full API documentation. + +## Development + +To modify this extension: + +1. Edit models in `models.py` +2. Add database migrations in `migrations.py` +3. Implement business logic in `crud.py` +4. Create API endpoints in `views_api.py` +5. Update UI in `templates/castle/index.html` + +## Contributing + +Contributions welcome! Please ensure: +- Journal entries always balance +- User permissions are properly checked +- Database transactions are atomic + +## License + +MIT License - feel free to use and modify for your collective! diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b17296e --- /dev/null +++ b/__init__.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from .views import castle_generic_router +from .views_api import castle_api_router + +castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["castle"]) +castle_ext.include_router(castle_generic_router) +castle_ext.include_router(castle_api_router) + +castle_static_files = [ + { + "path": "/castle/static", + "name": "castle_static", + } +] + +__all__ = ["castle_ext", "castle_static_files"] diff --git a/config.json b/config.json new file mode 100644 index 0000000..b83f290 --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "name": "Castle Accounting", + "short_description": "Double-entry accounting system for collective projects", + "tile": "/castle/static/image/castle.png", + "contributors": [ + "Your Name" + ], + "hidden": false, + "migration_module": "lnbits.extensions.castle.migrations", + "db_name": "ext_castle" +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..d52b007 --- /dev/null +++ b/crud.py @@ -0,0 +1,290 @@ +from datetime import datetime +from typing import Optional + +from lnbits.db import Database +from lnbits.helpers import urlsafe_short_hash + +from .models import ( + Account, + AccountType, + CreateAccount, + CreateEntryLine, + CreateJournalEntry, + EntryLine, + JournalEntry, + UserBalance, +) + +db = Database("ext_castle") + + +# ===== ACCOUNT OPERATIONS ===== + + +async def create_account(data: CreateAccount) -> Account: + account_id = urlsafe_short_hash() + account = Account( + id=account_id, + name=data.name, + account_type=data.account_type, + description=data.description, + user_id=data.user_id, + created_at=datetime.now(), + ) + await db.insert("castle.accounts", account) + return account + + +async def get_account(account_id: str) -> Optional[Account]: + return await db.fetchone( + "SELECT * FROM castle.accounts WHERE id = :id", + {"id": account_id}, + Account, + ) + + +async def get_account_by_name(name: str) -> Optional[Account]: + return await db.fetchone( + "SELECT * FROM castle.accounts WHERE name = :name", + {"name": name}, + Account, + ) + + +async def get_all_accounts() -> list[Account]: + return await db.fetchall( + "SELECT * FROM castle.accounts ORDER BY account_type, name", + model=Account, + ) + + +async def get_accounts_by_type(account_type: AccountType) -> list[Account]: + return await db.fetchall( + "SELECT * FROM castle.accounts WHERE account_type = :type ORDER BY name", + {"type": account_type.value}, + Account, + ) + + +async def get_or_create_user_account( + user_id: str, account_type: AccountType, base_name: str +) -> Account: + """Get or create a user-specific account (e.g., 'Accounts Payable - User123')""" + account_name = f"{base_name} - {user_id[:8]}" + + account = await db.fetchone( + """ + SELECT * FROM castle.accounts + WHERE user_id = :user_id AND account_type = :type AND name = :name + """, + {"user_id": user_id, "type": account_type.value, "name": account_name}, + Account, + ) + + if not account: + account = await create_account( + CreateAccount( + name=account_name, + account_type=account_type, + description=f"User-specific {account_type.value} account", + user_id=user_id, + ) + ) + + return account + + +# ===== JOURNAL ENTRY OPERATIONS ===== + + +async def create_journal_entry( + data: CreateJournalEntry, created_by: str +) -> JournalEntry: + entry_id = urlsafe_short_hash() + + # Validate that debits equal credits + total_debits = sum(line.debit for line in data.lines) + total_credits = sum(line.credit for line in data.lines) + + if total_debits != total_credits: + raise ValueError( + f"Journal entry must balance: debits={total_debits}, credits={total_credits}" + ) + + entry_date = data.entry_date or datetime.now() + + journal_entry = JournalEntry( + id=entry_id, + description=data.description, + entry_date=entry_date, + created_by=created_by, + created_at=datetime.now(), + reference=data.reference, + lines=[], + ) + + await db.insert("castle.journal_entries", journal_entry) + + # Create entry lines + lines = [] + for line_data in data.lines: + line_id = urlsafe_short_hash() + line = EntryLine( + id=line_id, + journal_entry_id=entry_id, + account_id=line_data.account_id, + debit=line_data.debit, + credit=line_data.credit, + description=line_data.description, + ) + await db.insert("castle.entry_lines", line) + lines.append(line) + + journal_entry.lines = lines + return journal_entry + + +async def get_journal_entry(entry_id: str) -> Optional[JournalEntry]: + entry = await db.fetchone( + "SELECT * FROM castle.journal_entries WHERE id = :id", + {"id": entry_id}, + JournalEntry, + ) + + if entry: + entry.lines = await get_entry_lines(entry_id) + + return entry + + +async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]: + return await db.fetchall( + "SELECT * FROM castle.entry_lines WHERE journal_entry_id = :id", + {"id": journal_entry_id}, + EntryLine, + ) + + +async def get_all_journal_entries(limit: int = 100) -> list[JournalEntry]: + entries = await db.fetchall( + """ + SELECT * FROM castle.journal_entries + ORDER BY entry_date DESC, created_at DESC + LIMIT :limit + """, + {"limit": limit}, + JournalEntry, + ) + + for entry in entries: + entry.lines = await get_entry_lines(entry.id) + + return entries + + +async def get_journal_entries_by_user( + user_id: str, limit: int = 100 +) -> list[JournalEntry]: + entries = await db.fetchall( + """ + SELECT * FROM castle.journal_entries + WHERE created_by = :user_id + ORDER BY entry_date DESC, created_at DESC + LIMIT :limit + """, + {"user_id": user_id, "limit": limit}, + JournalEntry, + ) + + for entry in entries: + entry.lines = await get_entry_lines(entry.id) + + return entries + + +# ===== BALANCE AND REPORTING ===== + + +async def get_account_balance(account_id: str) -> int: + """Calculate account balance (debits - credits for assets/expenses, credits - debits for liabilities/equity/revenue)""" + result = await db.fetchone( + """ + SELECT + COALESCE(SUM(debit), 0) as total_debit, + COALESCE(SUM(credit), 0) as total_credit + FROM castle.entry_lines + WHERE account_id = :id + """, + {"id": account_id}, + ) + + if not result: + return 0 + + account = await get_account(account_id) + if not account: + return 0 + + total_debit = result["total_debit"] + total_credit = result["total_credit"] + + # Normal balance for each account type: + # Assets and Expenses: Debit balance (debit - credit) + # Liabilities, Equity, and Revenue: Credit balance (credit - debit) + if account.account_type in [AccountType.ASSET, AccountType.EXPENSE]: + return total_debit - total_credit + else: + return total_credit - total_debit + + +async def get_user_balance(user_id: str) -> UserBalance: + """Get user's balance with the Castle (positive = castle owes user, negative = user owes castle)""" + # Get all user-specific accounts + user_accounts = await db.fetchall( + "SELECT * FROM castle.accounts WHERE user_id = :user_id", + {"user_id": user_id}, + Account, + ) + + total_balance = 0 + + for account in user_accounts: + balance = await get_account_balance(account.id) + + # If it's a liability account (castle owes user), it's positive + # If it's an asset account (user owes castle), it's negative + if account.account_type == AccountType.LIABILITY: + total_balance += balance + elif account.account_type == AccountType.ASSET: + total_balance -= balance + # Equity contributions are tracked but don't affect what castle owes + + return UserBalance( + user_id=user_id, + balance=total_balance, + accounts=user_accounts, + ) + + +async def get_account_transactions( + account_id: str, limit: int = 100 +) -> list[tuple[JournalEntry, EntryLine]]: + """Get all transactions affecting a specific account""" + lines = await db.fetchall( + """ + SELECT * FROM castle.entry_lines + WHERE account_id = :id + ORDER BY id DESC + LIMIT :limit + """, + {"id": account_id, "limit": limit}, + EntryLine, + ) + + transactions = [] + for line in lines: + entry = await get_journal_entry(line.journal_entry_id) + if entry: + transactions.append((entry, line)) + + return transactions diff --git a/description.md b/description.md new file mode 100644 index 0000000..b3628a2 --- /dev/null +++ b/description.md @@ -0,0 +1,65 @@ +# Castle Accounting + +A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits. + +## Features + +- **Double-Entry Bookkeeping**: Full accounting system with debits and credits +- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses +- **User Expense Tracking**: Members can record out-of-pocket expenses as either: + - **Liabilities**: Castle owes them money (reimbursable) + - **Equity**: Their contribution to the collective +- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees) +- **Revenue Tracking**: Record revenue received by the collective +- **User Balance Dashboard**: Each user sees their balance with the Castle +- **Lightning Integration**: Generate invoices for outstanding balances +- **Transaction History**: View all accounting entries and transactions + +## Use Cases + +### 1. User Pays Expense Out of Pocket +When a member buys supplies for the Castle: +- They can choose to be reimbursed (Liability) +- Or contribute it as equity (Equity) + +### 2. Accounts Receivable +When someone stays at the Castle and owes money: +- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€") +- User sees they owe 50€ in their dashboard +- They can generate an invoice to pay it off + +### 3. Revenue Recording +When the Castle receives revenue: +- Record revenue with the payment method (Cash, Lightning, Bank) +- Properly categorized in the accounting system + +## Technical Details + +- Built on PostgreSQL/SQLite with full transaction support +- RESTful API for all operations +- Validates that journal entries balance before saving +- Automatic user-specific account creation +- Indexed for performance on large datasets + +## API Endpoints + +### User Endpoints (Invoice Key) +- `GET /api/v1/balance` - Get your balance +- `POST /api/v1/entries/expense` - Add an expense +- `GET /api/v1/entries/user` - Get your transactions +- `POST /api/v1/pay-balance` - Record a payment + +### Admin Endpoints (Admin Key) +- `POST /api/v1/entries/receivable` - Create accounts receivable +- `POST /api/v1/entries/revenue` - Record revenue +- `POST /api/v1/pay-user` - Pay a user +- `POST /api/v1/accounts` - Create new accounts +- `GET /api/v1/entries` - View all entries + +## Getting Started + +1. Enable the Castle extension in LNbits +2. Visit the Castle page to see your dashboard +3. Start tracking expenses and balances! + +The extension automatically creates a default chart of accounts on first run. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1a8c320 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "castle", + "organisation": "lnbits", + "repository": "castle" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..053321a --- /dev/null +++ b/migrations.py @@ -0,0 +1,117 @@ +async def m001_initial(db): + """ + Initial migration for Castle accounting extension. + Creates tables for double-entry bookkeeping system. + """ + await db.execute( + f""" + CREATE TABLE castle.accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + account_type TEXT NOT NULL, + description TEXT, + user_id TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_accounts_user_id ON castle.accounts (user_id); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_accounts_type ON castle.accounts (account_type); + """ + ) + + await db.execute( + f""" + CREATE TABLE castle.journal_entries ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + entry_date TIMESTAMP NOT NULL, + created_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + reference TEXT + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_journal_entries_created_by ON castle.journal_entries (created_by); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_journal_entries_date ON castle.journal_entries (entry_date); + """ + ) + + await db.execute( + f""" + CREATE TABLE castle.entry_lines ( + id TEXT PRIMARY KEY, + journal_entry_id TEXT NOT NULL, + account_id TEXT NOT NULL, + debit INTEGER NOT NULL DEFAULT 0, + credit INTEGER NOT NULL DEFAULT 0, + description TEXT, + FOREIGN KEY (journal_entry_id) REFERENCES castle.journal_entries (id), + FOREIGN KEY (account_id) REFERENCES castle.accounts (id) + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_entry_lines_journal_entry ON castle.entry_lines (journal_entry_id); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_entry_lines_account ON castle.entry_lines (account_id); + """ + ) + + # Insert default chart of accounts + default_accounts = [ + # Assets + ("cash", "Cash", "asset", "Cash on hand"), + ("bank", "Bank Account", "asset", "Bank account"), + ("lightning", "Lightning Balance", "asset", "Lightning Network balance"), + ("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"), + + # Liabilities + ("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"), + + # Equity + ("member_equity", "Member Equity", "equity", "Member contributions"), + ("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"), + + # Revenue + ("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"), + ("service_revenue", "Service Revenue", "revenue", "Revenue from services"), + ("other_revenue", "Other Revenue", "revenue", "Other revenue"), + + # Expenses + ("utilities", "Utilities", "expense", "Electricity, water, internet"), + ("food", "Food & Supplies", "expense", "Food and supplies"), + ("maintenance", "Maintenance", "expense", "Repairs and maintenance"), + ("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"), + ] + + for acc_id, name, acc_type, desc in default_accounts: + await db.execute( + """ + INSERT INTO castle.accounts (id, name, account_type, description) + VALUES (:id, :name, :type, :description) + """, + {"id": acc_id, "name": name, "type": acc_type, "description": desc} + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..bc19eb8 --- /dev/null +++ b/models.py @@ -0,0 +1,99 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class AccountType(str, Enum): + ASSET = "asset" + LIABILITY = "liability" + EQUITY = "equity" + REVENUE = "revenue" + EXPENSE = "expense" + + +class Account(BaseModel): + id: str + name: str + account_type: AccountType + description: Optional[str] = None + user_id: Optional[str] = None # For user-specific accounts + created_at: datetime + + +class CreateAccount(BaseModel): + name: str + account_type: AccountType + description: Optional[str] = None + user_id: Optional[str] = None + + +class EntryLine(BaseModel): + id: str + journal_entry_id: str + account_id: str + debit: int = 0 # in satoshis + credit: int = 0 # in satoshis + description: Optional[str] = None + + +class CreateEntryLine(BaseModel): + account_id: str + debit: int = 0 + credit: int = 0 + description: Optional[str] = None + + +class JournalEntry(BaseModel): + id: str + description: str + entry_date: datetime + created_by: str # wallet ID of user who created it + created_at: datetime + reference: Optional[str] = None # Invoice ID or reference number + lines: list[EntryLine] = [] + + +class CreateJournalEntry(BaseModel): + description: str + entry_date: Optional[datetime] = None + reference: Optional[str] = None + lines: list[CreateEntryLine] + + +class UserBalance(BaseModel): + user_id: str + balance: int # positive = castle owes user, negative = user owes castle + accounts: list[Account] = [] + + +class ExpenseEntry(BaseModel): + """Helper model for creating expense entries""" + + description: str + amount: int # in satoshis + expense_account: str # account name or ID + is_equity: bool = False # True = equity contribution, False = liability (castle owes user) + user_wallet: str + reference: Optional[str] = None + + +class ReceivableEntry(BaseModel): + """Helper model for creating accounts receivable entries""" + + description: str + amount: int # in satoshis + revenue_account: str # account name or ID + user_wallet: str + reference: Optional[str] = None + + +class RevenueEntry(BaseModel): + """Helper model for creating revenue entries""" + + description: str + amount: int # in satoshis + revenue_account: str + payment_method_account: str # e.g., "Cash", "Bank", "Lightning" + reference: Optional[str] = None diff --git a/static/image/castle.png b/static/image/castle.png new file mode 100644 index 0000000..5098b34 --- /dev/null +++ b/static/image/castle.png @@ -0,0 +1 @@ +PNG placeholder - In production, convert the SVG to PNG or use an actual image file \ No newline at end of file diff --git a/static/image/castle.svg b/static/image/castle.svg new file mode 100644 index 0000000..65a92f3 --- /dev/null +++ b/static/image/castle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..db1d768 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,162 @@ +const mapJournalEntry = obj => { + return obj +} + +window.app = Vue.createApp({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + balance: null, + transactions: [], + accounts: [], + expenseDialog: { + show: false, + description: '', + amount: null, + expenseAccount: '', + isEquity: false, + reference: '', + loading: false + }, + payDialog: { + show: false, + amount: null, + loading: false + } + } + }, + computed: { + expenseAccounts() { + return this.accounts.filter(a => a.account_type === 'expense') + } + }, + methods: { + async loadBalance() { + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/balance', + this.g.user.wallets[0].inkey + ) + this.balance = response.data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async loadTransactions() { + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/entries/user', + this.g.user.wallets[0].inkey + ) + this.transactions = response.data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async loadAccounts() { + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/accounts', + this.g.user.wallets[0].inkey + ) + this.accounts = response.data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async submitExpense() { + this.expenseDialog.loading = true + try { + await LNbits.api.request( + 'POST', + '/castle/api/v1/entries/expense', + this.g.user.wallets[0].inkey, + { + description: this.expenseDialog.description, + amount: this.expenseDialog.amount, + expense_account: this.expenseDialog.expenseAccount, + is_equity: this.expenseDialog.isEquity, + user_wallet: this.g.user.wallets[0].id, + reference: this.expenseDialog.reference || null + } + ) + this.$q.notify({ + type: 'positive', + message: 'Expense added successfully' + }) + this.expenseDialog.show = false + this.resetExpenseDialog() + await this.loadBalance() + await this.loadTransactions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.expenseDialog.loading = false + } + }, + async submitPayment() { + this.payDialog.loading = true + try { + // First, generate an invoice for the payment + const invoiceResponse = await LNbits.api.request( + 'POST', + '/api/v1/payments', + this.g.user.wallets[0].inkey, + { + out: false, + amount: this.payDialog.amount, + memo: `Payment to Castle - ${this.payDialog.amount} sats`, + unit: 'sat' + } + ) + + // Show the invoice to the user + this.$q.notify({ + type: 'positive', + message: 'Invoice generated! Pay it to settle your balance.', + timeout: 5000 + }) + + // TODO: After payment, call /castle/api/v1/pay-balance to record it + // This would typically be done via a webhook or payment verification + + this.payDialog.show = false + this.payDialog.amount = null + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.payDialog.loading = false + } + }, + showPayBalanceDialog() { + this.payDialog.amount = Math.abs(this.balance.balance) + this.payDialog.show = true + }, + resetExpenseDialog() { + this.expenseDialog.description = '' + this.expenseDialog.amount = null + this.expenseDialog.expenseAccount = '' + this.expenseDialog.isEquity = false + this.expenseDialog.reference = '' + }, + formatSats(amount) { + return new Intl.NumberFormat().format(amount) + }, + formatDate(dateString) { + return new Date(dateString).toLocaleDateString() + }, + getTotalAmount(entry) { + if (!entry.lines || entry.lines.length === 0) return 0 + return entry.lines.reduce((sum, line) => sum + line.debit + line.credit, 0) / 2 + } + }, + async created() { + await this.loadBalance() + await this.loadTransactions() + await this.loadAccounts() + } +}) diff --git a/templates/castle/index.html b/templates/castle/index.html new file mode 100644 index 0000000..4b7e57e --- /dev/null +++ b/templates/castle/index.html @@ -0,0 +1,209 @@ +{% extends "base.html" %} +{% from "macros.jinja" import window_vars with context %} + +{% block scripts %} +{{ window_vars(user) }} + +{% endblock %} + +{% block page %} +
+
+ + +
🏰 Castle Accounting
+

Track expenses, receivables, and balances for the collective

+
+
+ + + + +
+
+
Your Balance
+
+
+ + Refresh balance + +
+
+
+
+ {% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %} +
+
+ {% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %} +
+ + Pay Balance + +
+
+ + Loading balance... +
+
+
+ + + + +
Quick Actions
+
+ + Add Expense + + + View Transactions + +
+
+
+ + + + +
+
+
Recent Transactions
+
+
+ + Refresh transactions + +
+
+ + + + {% raw %}{{ entry.description }}{% endraw %} + + {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} + + + + {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} + + + +
+ No transactions yet +
+
+
+
+ +
+ + + +
Chart of Accounts
+ + + + {% raw %}{{ account.name }}{% endraw %} + {% raw %}{{ account.account_type }}{% endraw %} + + + +
+ + Loading accounts... +
+
+
+
+
+ + + + + +
Add Expense
+ + + + + + +
+ + Submit Expense + + Cancel +
+
+
+
+
+ + + + + +
Pay Balance
+

Amount owed: {% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats

+ + +
+ + Generate Invoice + + Cancel +
+
+
+
+
+ +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..2de9357 --- /dev/null +++ b/views.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.helpers import template_renderer + +castle_generic_router = APIRouter(tags=["castle"]) + + +@castle_generic_router.get( + "/", description="Castle accounting home page", response_class=HTMLResponse +) +async def index( + request: Request, + user: User = Depends(check_user_exists), +): + return template_renderer(["castle/templates"]).TemplateResponse( + request, "castle/index.html", {"user": user.json()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..439c6ee --- /dev/null +++ b/views_api.py @@ -0,0 +1,411 @@ +from http import HTTPStatus + +from fastapi import APIRouter, HTTPException +from lnbits.core.crud import get_user +from lnbits.decorators import require_admin_key, require_invoice_key + +from .crud import ( + create_account, + create_journal_entry, + get_account, + get_account_balance, + get_account_by_name, + get_account_transactions, + get_all_accounts, + get_all_journal_entries, + get_journal_entries_by_user, + get_journal_entry, + get_or_create_user_account, + get_user_balance, +) +from .models import ( + Account, + AccountType, + CreateAccount, + CreateEntryLine, + CreateJournalEntry, + ExpenseEntry, + JournalEntry, + ReceivableEntry, + RevenueEntry, + UserBalance, +) + +castle_api_router = APIRouter(prefix="/api/v1", tags=["castle"]) + + +# ===== ACCOUNT ENDPOINTS ===== + + +@castle_api_router.get("/accounts") +async def api_get_accounts() -> list[Account]: + """Get all accounts in the chart of accounts""" + return await get_all_accounts() + + +@castle_api_router.post("/accounts", status_code=HTTPStatus.CREATED) +async def api_create_account( + data: CreateAccount, + wallet_id: str = require_admin_key, # type: ignore +) -> Account: + """Create a new account (admin only)""" + return await create_account(data) + + +@castle_api_router.get("/accounts/{account_id}") +async def api_get_account(account_id: str) -> Account: + """Get a specific account""" + account = await get_account(account_id) + if not account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Account not found" + ) + return account + + +@castle_api_router.get("/accounts/{account_id}/balance") +async def api_get_account_balance(account_id: str) -> dict: + """Get account balance""" + balance = await get_account_balance(account_id) + return {"account_id": account_id, "balance": balance} + + +@castle_api_router.get("/accounts/{account_id}/transactions") +async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]: + """Get all transactions for an account""" + transactions = await get_account_transactions(account_id, limit) + return [ + { + "journal_entry": entry.dict(), + "entry_line": line.dict(), + } + for entry, line in transactions + ] + + +# ===== JOURNAL ENTRY ENDPOINTS ===== + + +@castle_api_router.get("/entries") +async def api_get_journal_entries(limit: int = 100) -> list[JournalEntry]: + """Get all journal entries""" + return await get_all_journal_entries(limit) + + +@castle_api_router.get("/entries/user") +async def api_get_user_entries( + wallet_id: str = require_invoice_key, limit: int = 100 # type: ignore +) -> list[JournalEntry]: + """Get journal entries created by the current user""" + return await get_journal_entries_by_user(wallet_id, limit) + + +@castle_api_router.get("/entries/{entry_id}") +async def api_get_journal_entry(entry_id: str) -> JournalEntry: + """Get a specific journal entry""" + entry = await get_journal_entry(entry_id) + if not entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Journal entry not found" + ) + return entry + + +@castle_api_router.post("/entries", status_code=HTTPStatus.CREATED) +async def api_create_journal_entry( + data: CreateJournalEntry, + wallet_id: str = require_invoice_key, # type: ignore +) -> JournalEntry: + """Create a new journal entry""" + try: + return await create_journal_entry(data, wallet_id) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + + +# ===== SIMPLIFIED ENTRY ENDPOINTS ===== + + +@castle_api_router.post("/entries/expense", status_code=HTTPStatus.CREATED) +async def api_create_expense_entry( + data: ExpenseEntry, + wallet_id: str = require_invoice_key, # type: ignore +) -> JournalEntry: + """ + Create an expense entry for a user. + If is_equity=True, records as equity contribution. + If is_equity=False, records as liability (castle owes user). + """ + # Get or create expense account + expense_account = await get_account_by_name(data.expense_account) + if not expense_account: + # Try to get it by ID + expense_account = await get_account(data.expense_account) + if not expense_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Expense account '{data.expense_account}' not found", + ) + + # Get or create user-specific account + if data.is_equity: + # Equity contribution + user_account = await get_or_create_user_account( + data.user_wallet, AccountType.EQUITY, "Member Equity" + ) + else: + # Liability (castle owes user) + user_account = await get_or_create_user_account( + data.user_wallet, AccountType.LIABILITY, "Accounts Payable" + ) + + # Create journal entry + # DR Expense, CR User Account (Liability or Equity) + entry_data = CreateJournalEntry( + description=data.description, + reference=data.reference, + lines=[ + CreateEntryLine( + account_id=expense_account.id, + debit=data.amount, + credit=0, + description=f"Expense paid by user {data.user_wallet[:8]}", + ), + CreateEntryLine( + account_id=user_account.id, + debit=0, + credit=data.amount, + description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", + ), + ], + ) + + return await create_journal_entry(entry_data, wallet_id) + + +@castle_api_router.post("/entries/receivable", status_code=HTTPStatus.CREATED) +async def api_create_receivable_entry( + data: ReceivableEntry, + wallet_id: str = require_admin_key, # type: ignore +) -> JournalEntry: + """ + Create an accounts receivable entry (user owes castle). + Admin only to prevent abuse. + """ + # Get or create revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get or create user-specific receivable account + user_receivable = await get_or_create_user_account( + data.user_wallet, AccountType.ASSET, "Accounts Receivable" + ) + + # Create journal entry + # DR Accounts Receivable (User), CR Revenue + entry_data = CreateJournalEntry( + description=data.description, + reference=data.reference, + lines=[ + CreateEntryLine( + account_id=user_receivable.id, + debit=data.amount, + credit=0, + description=f"Amount owed by user {data.user_wallet[:8]}", + ), + CreateEntryLine( + account_id=revenue_account.id, + debit=0, + credit=data.amount, + description="Revenue earned", + ), + ], + ) + + return await create_journal_entry(entry_data, wallet_id) + + +@castle_api_router.post("/entries/revenue", status_code=HTTPStatus.CREATED) +async def api_create_revenue_entry( + data: RevenueEntry, + wallet_id: str = require_admin_key, # type: ignore +) -> JournalEntry: + """ + Create a revenue entry (castle receives payment). + Admin only. + """ + # Get revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get payment method account + payment_account = await get_account_by_name(data.payment_method_account) + if not payment_account: + payment_account = await get_account(data.payment_method_account) + if not payment_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Payment account '{data.payment_method_account}' not found", + ) + + # Create journal entry + # DR Cash/Lightning/Bank, CR Revenue + entry_data = CreateJournalEntry( + description=data.description, + reference=data.reference, + lines=[ + CreateEntryLine( + account_id=payment_account.id, + debit=data.amount, + credit=0, + description="Payment received", + ), + CreateEntryLine( + account_id=revenue_account.id, + debit=0, + credit=data.amount, + description="Revenue earned", + ), + ], + ) + + return await create_journal_entry(entry_data, wallet_id) + + +# ===== USER BALANCE ENDPOINTS ===== + + +@castle_api_router.get("/balance") +async def api_get_my_balance( + wallet_id: str = require_invoice_key, # type: ignore +) -> UserBalance: + """Get current user's balance with the Castle""" + return await get_user_balance(wallet_id) + + +@castle_api_router.get("/balance/{user_id}") +async def api_get_user_balance(user_id: str) -> UserBalance: + """Get a specific user's balance with the Castle""" + return await get_user_balance(user_id) + + +# ===== PAYMENT ENDPOINTS ===== + + +@castle_api_router.post("/pay-balance") +async def api_pay_balance( + amount: int, + wallet_id: str = require_invoice_key, # type: ignore +) -> dict: + """ + Record a payment from user to castle (reduces what user owes or what castle owes user). + This should be called after an invoice is paid. + """ + # Get user's receivable account (what user owes) + user_receivable = await get_or_create_user_account( + wallet_id, AccountType.ASSET, "Accounts Receivable" + ) + + # Get lightning account + lightning_account = await get_account_by_name("Lightning Balance") + if not lightning_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" + ) + + # Create journal entry + # DR Lightning Balance, CR Accounts Receivable (User) + entry_data = CreateJournalEntry( + description=f"Payment received from user {wallet_id[:8]}", + lines=[ + CreateEntryLine( + account_id=lightning_account.id, + debit=amount, + credit=0, + description="Lightning payment received", + ), + CreateEntryLine( + account_id=user_receivable.id, + debit=0, + credit=amount, + description="Payment applied to balance", + ), + ], + ) + + entry = await create_journal_entry(entry_data, wallet_id) + + # Get updated balance + balance = await get_user_balance(wallet_id) + + return { + "journal_entry": entry.dict(), + "new_balance": balance.balance, + "message": "Payment recorded successfully", + } + + +@castle_api_router.post("/pay-user") +async def api_pay_user( + user_id: str, + amount: int, + wallet_id: str = require_admin_key, # type: ignore +) -> dict: + """ + Record a payment from castle to user (reduces what castle owes user). + Admin only. + """ + # Get user's payable account (what castle owes) + user_payable = await get_or_create_user_account( + user_id, AccountType.LIABILITY, "Accounts Payable" + ) + + # Get lightning account + lightning_account = await get_account_by_name("Lightning Balance") + if not lightning_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" + ) + + # Create journal entry + # DR Accounts Payable (User), CR Lightning Balance + entry_data = CreateJournalEntry( + description=f"Payment to user {user_id[:8]}", + lines=[ + CreateEntryLine( + account_id=user_payable.id, + debit=amount, + credit=0, + description="Payment made to user", + ), + CreateEntryLine( + account_id=lightning_account.id, + debit=0, + credit=amount, + description="Lightning payment sent", + ), + ], + ) + + entry = await create_journal_entry(entry_data, wallet_id) + + # Get updated balance + balance = await get_user_balance(user_id) + + return { + "journal_entry": entry.dict(), + "new_balance": balance.balance, + "message": "Payment recorded successfully", + }