initial commit
This commit is contained in:
commit
95b8af2360
15 changed files with 1519 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
__pycache__
|
||||
node_modules
|
||||
.venv
|
||||
.mypy_cache
|
||||
93
README.md
Normal file
93
README.md
Normal 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
17
__init__.py
Normal 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
11
config.json
Normal 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
290
crud.py
Normal 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
65
description.md
Normal 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
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"repos": [
|
||||
{
|
||||
"id": "castle",
|
||||
"organisation": "lnbits",
|
||||
"repository": "castle"
|
||||
}
|
||||
]
|
||||
}
|
||||
117
migrations.py
Normal file
117
migrations.py
Normal 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
99
models.py
Normal 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
1
static/image/castle.png
Normal 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
12
static/image/castle.svg
Normal 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
162
static/js/index.js
Normal 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
209
templates/castle/index.html
Normal 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
19
views.py
Normal 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
411
views_api.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue