initial commit

This commit is contained in:
padreug 2025-10-22 12:33:45 +02:00
commit 95b8af2360
15 changed files with 1519 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__
node_modules
.venv
.mypy_cache

93
README.md Normal file
View file

@ -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!

17
__init__.py Normal file
View file

@ -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"]

11
config.json Normal file
View file

@ -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"
}

290
crud.py Normal file
View file

@ -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

65
description.md Normal file
View file

@ -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.

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"repos": [
{
"id": "castle",
"organisation": "lnbits",
"repository": "castle"
}
]
}

117
migrations.py Normal file
View file

@ -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}
)

99
models.py Normal file
View file

@ -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

1
static/image/castle.png Normal file
View file

@ -0,0 +1 @@
PNG placeholder - In production, convert the SVG to PNG or use an actual image file

12
static/image/castle.svg Normal file
View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect x="20" y="35" width="60" height="50" fill="#8B4513" stroke="#000" stroke-width="2"/>
<rect x="35" y="55" width="15" height="30" fill="#654321"/>
<rect x="30" y="30" width="10" height="15" fill="#8B4513" stroke="#000" stroke-width="1"/>
<rect x="60" y="30" width="10" height="15" fill="#8B4513" stroke="#000" stroke-width="1"/>
<rect x="30" y="25" width="10" height="5" fill="#654321"/>
<rect x="60" y="25" width="10" height="5" fill="#654321"/>
<rect x="42" y="20" width="16" height="20" fill="#A0522D" stroke="#000" stroke-width="1"/>
<polygon points="50,15 46,20 54,20" fill="#654321"/>
<circle cx="42" cy="65" r="1.5" fill="#FFD700"/>
<text x="50" y="92" font-family="Arial" font-size="8" text-anchor="middle" fill="#FFD700"></text>
</svg>

After

Width:  |  Height:  |  Size: 860 B

162
static/js/index.js Normal file
View file

@ -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()
}
})

209
templates/castle/index.html Normal file
View file

@ -0,0 +1,209 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('castle/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h5 class="q-my-none">🏰 Castle Accounting</h5>
<p>Track expenses, receivables, and balances for the collective</p>
</q-card-section>
</q-card>
<!-- User Balance Card -->
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h6 class="q-my-none">Your Balance</h6>
</div>
<div class="col-auto">
<q-btn flat round icon="refresh" @click="loadBalance">
<q-tooltip>Refresh balance</q-tooltip>
</q-btn>
</div>
</div>
<div v-if="balance !== null">
<div class="text-h4" :class="balance.balance >= 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %}
</div>
<div class="text-subtitle2">
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %}
</div>
<q-btn
v-if="balance.balance < 0"
color="primary"
class="q-mt-md"
@click="showPayBalanceDialog"
>
Pay Balance
</q-btn>
</div>
<div v-else>
<q-spinner color="primary" size="md" />
Loading balance...
</div>
</q-card-section>
</q-card>
<!-- Quick Actions -->
<q-card>
<q-card-section>
<h6 class="q-my-none q-mb-md">Quick Actions</h6>
<div class="row q-gutter-sm">
<q-btn color="primary" @click="expenseDialog.show = true">
Add Expense
</q-btn>
<q-btn color="secondary" @click="loadTransactions">
View Transactions
</q-btn>
</div>
</q-card-section>
</q-card>
<!-- Recent Transactions -->
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h6 class="q-my-none">Recent Transactions</h6>
</div>
<div class="col-auto">
<q-btn flat round icon="refresh" @click="loadTransactions">
<q-tooltip>Refresh transactions</q-tooltip>
</q-btn>
</div>
</div>
<q-list v-if="transactions.length > 0" separator>
<q-item v-for="entry in transactions" :key="entry.id">
<q-item-section>
<q-item-label>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
<q-item-label caption>
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-center q-pa-md text-grey">
No transactions yet
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<!-- Chart of Accounts -->
<q-card>
<q-card-section>
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
<q-list dense v-if="accounts.length > 0">
<q-item v-for="account in accounts" :key="account.id">
<q-item-section>
<q-item-label>{% raw %}{{ account.name }}{% endraw %}</q-item-label>
<q-item-label caption>{% raw %}{{ account.account_type }}{% endraw %}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-else>
<q-spinner color="primary" size="sm" />
Loading accounts...
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Add Expense Dialog -->
<q-dialog v-model="expenseDialog.show" position="top">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<h6 class="q-my-none q-mb-md">Add Expense</h6>
<q-form @submit="submitExpense" class="q-gutter-md">
<q-input
v-model="expenseDialog.description"
label="Description"
placeholder="e.g., Groceries for the house"
required
/>
<q-input
v-model.number="expenseDialog.amount"
type="number"
label="Amount (sats)"
required
min="1"
/>
<q-select
v-model="expenseDialog.expenseAccount"
:options="expenseAccounts"
option-label="name"
option-value="id"
emit-value
map-options
label="Expense Category"
required
/>
<q-select
v-model="expenseDialog.isEquity"
:options="[
{label: 'Liability (Castle owes me)', value: false},
{label: 'Equity (My contribution)', value: true}
]"
option-label="label"
option-value="value"
emit-value
map-options
label="Type"
required
/>
<q-input
v-model="expenseDialog.reference"
label="Reference (optional)"
placeholder="e.g., Receipt #123"
/>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="expenseDialog.loading">
Submit Expense
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
<!-- Pay Balance Dialog -->
<q-dialog v-model="payDialog.show" position="top">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<h6 class="q-my-none q-mb-md">Pay Balance</h6>
<p v-if="balance">Amount owed: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong></p>
<q-form @submit="submitPayment" class="q-gutter-md">
<q-input
v-model.number="payDialog.amount"
type="number"
label="Amount to pay (sats)"
required
min="1"
:max="balance ? Math.abs(balance.balance) : 0"
/>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="payDialog.loading">
Generate Invoice
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
{% endblock %}

19
views.py Normal file
View file

@ -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()}
)

411
views_api.py Normal file
View file

@ -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",
}