Compare commits
10 commits
c7bc0c7904
...
3248d3dad6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3248d3dad6 | |||
| 3add13075c | |||
| 8f35788e1a | |||
| cfa25cc61b | |||
| 4957826c49 | |||
| e2472d13a2 | |||
| 8b16ead5b1 | |||
| 5e67ce562b | |||
| 762f5cc411 | |||
| d1f22dfda8 |
16 changed files with 780 additions and 229 deletions
280
CLAUDE.md
Normal file
280
CLAUDE.md
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Design Principles
|
||||||
|
|
||||||
|
**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
|
||||||
|
|
||||||
|
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
|
||||||
|
- `core/balance.py` - Balance calculation from journal entries
|
||||||
|
- `core/inventory.py` - Multi-currency position tracking (similar to Beancount's Inventory)
|
||||||
|
- `core/validation.py` - Entry validation rules
|
||||||
|
|
||||||
|
**Account Hierarchy**: Beancount-style hierarchical naming with `:` separators:
|
||||||
|
- `Assets:Lightning:Balance`
|
||||||
|
- `Assets:Receivable:User-af983632`
|
||||||
|
- `Liabilities:Payable:User-af983632`
|
||||||
|
- `Expenses:Food:Supplies`
|
||||||
|
|
||||||
|
**Metadata System**: Each `entry_line` stores JSON metadata preserving original fiat amounts. Critical: fiat balances are calculated by summing `fiat_amount` from metadata, NOT by converting current satoshi balances. This prevents exchange rate fluctuations from affecting historical records.
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
- `models.py` - Pydantic models for API I/O and data structures
|
||||||
|
- `crud.py` - Database operations (create/read/update accounts, journal entries)
|
||||||
|
- `views_api.py` - FastAPI endpoints for all operations
|
||||||
|
- `views.py` - Web interface routing
|
||||||
|
- `services.py` - Settings management layer
|
||||||
|
- `migrations.py` - Database schema migrations
|
||||||
|
- `tasks.py` - Background tasks (daily reconciliation checks)
|
||||||
|
- `account_utils.py` - Hierarchical account naming utilities
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**accounts**: Chart of accounts with hierarchical names
|
||||||
|
- `user_id` field for per-user accounts (Receivable, Payable, Equity)
|
||||||
|
- Indexed on `user_id` and `account_type`
|
||||||
|
|
||||||
|
**journal_entries**: Transaction headers
|
||||||
|
- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void)
|
||||||
|
- `meta` field: JSON storing source, tags, audit info
|
||||||
|
- `reference` field: Links to payment_hash, invoice numbers, etc.
|
||||||
|
|
||||||
|
**entry_lines**: Individual debit/credit lines
|
||||||
|
- Always balanced (sum of debits = sum of credits per entry)
|
||||||
|
- `metadata` field stores fiat currency info as JSON
|
||||||
|
- Indexed on `journal_entry_id` and `account_id`
|
||||||
|
|
||||||
|
**balance_assertions**: Reconciliation checkpoints (Beancount-style)
|
||||||
|
- Assert expected balance at a date
|
||||||
|
- Status: pending, passed, failed
|
||||||
|
- Used for daily reconciliation checks
|
||||||
|
|
||||||
|
**extension_settings**: Castle wallet configuration (admin-only)
|
||||||
|
|
||||||
|
**user_wallet_settings**: Per-user wallet configuration
|
||||||
|
|
||||||
|
**manual_payment_requests**: User requests for cash/manual payments
|
||||||
|
|
||||||
|
## Transaction Flows
|
||||||
|
|
||||||
|
### User Adds Expense (Liability)
|
||||||
|
User pays cash for groceries, Castle owes them:
|
||||||
|
```
|
||||||
|
DR Expenses:Food 39,669 sats
|
||||||
|
CR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
|
```
|
||||||
|
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
|
||||||
|
|
||||||
|
### Castle Adds Receivable
|
||||||
|
User owes Castle for accommodation:
|
||||||
|
```
|
||||||
|
DR Assets:Receivable:User-af983632 268,548 sats
|
||||||
|
CR Income:Accommodation 268,548 sats
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Pays with Lightning
|
||||||
|
Invoice generated on **Castle's wallet** (not user's). After payment:
|
||||||
|
```
|
||||||
|
DR Assets:Lightning:Balance 268,548 sats
|
||||||
|
CR Assets:Receivable:User-af983632 268,548 sats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Payment Approval
|
||||||
|
User requests cash payment → Admin approves → Journal entry created:
|
||||||
|
```
|
||||||
|
DR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
|
CR Assets:Lightning:Balance 39,669 sats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Balance Calculation Logic
|
||||||
|
|
||||||
|
**User Balance**:
|
||||||
|
- Positive = Castle owes user (LIABILITY accounts have credit balance)
|
||||||
|
- Negative = User owes Castle (ASSET accounts have debit balance)
|
||||||
|
- Calculated from sum of all entry lines across user's accounts
|
||||||
|
- Fiat balances summed from metadata, NOT converted from sats
|
||||||
|
|
||||||
|
**Perspective-Based UI**:
|
||||||
|
- **User View**: Green = Castle owes them, Red = They owe Castle
|
||||||
|
- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
- `GET /api/v1/accounts` - List all accounts
|
||||||
|
- `POST /api/v1/accounts` - Create account (admin)
|
||||||
|
- `GET /api/v1/accounts/{id}/balance` - Get account balance
|
||||||
|
|
||||||
|
### Journal Entries
|
||||||
|
- `POST /api/v1/entries/expense` - User adds expense (creates liability or equity)
|
||||||
|
- `POST /api/v1/entries/receivable` - Admin records what user owes (admin only)
|
||||||
|
- `POST /api/v1/entries/revenue` - Admin records direct revenue (admin only)
|
||||||
|
- `GET /api/v1/entries/user` - Get user's journal entries
|
||||||
|
- `POST /api/v1/entries` - Create raw journal entry (admin only)
|
||||||
|
|
||||||
|
### Payments & Balances
|
||||||
|
- `GET /api/v1/balance` - Get user balance (or Castle total if super user)
|
||||||
|
- `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames)
|
||||||
|
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
||||||
|
- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle
|
||||||
|
- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
|
||||||
|
- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning)
|
||||||
|
|
||||||
|
### Manual Payment Requests
|
||||||
|
- `POST /api/v1/manual-payment-requests` - User requests payment
|
||||||
|
- `GET /api/v1/manual-payment-requests` - User's requests
|
||||||
|
- `GET /api/v1/manual-payment-requests/all` - All requests (admin)
|
||||||
|
- `POST /api/v1/manual-payment-requests/{id}/approve` - Approve (admin)
|
||||||
|
- `POST /api/v1/manual-payment-requests/{id}/reject` - Reject (admin)
|
||||||
|
|
||||||
|
### Reconciliation
|
||||||
|
- `POST /api/v1/assertions/balance` - Create balance assertion
|
||||||
|
- `GET /api/v1/assertions/balance` - List balance assertions
|
||||||
|
- `POST /api/v1/assertions/balance/{id}/check` - Check assertion
|
||||||
|
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- `GET /api/v1/settings` - Get Castle settings (super user)
|
||||||
|
- `PUT /api/v1/settings` - Update Castle settings (super user)
|
||||||
|
- `GET /api/v1/user/wallet` - Get user wallet settings
|
||||||
|
- `PUT /api/v1/user/wallet` - Update user wallet settings
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Testing Entry Creation
|
||||||
|
|
||||||
|
When creating journal entries programmatically, use the helper endpoints:
|
||||||
|
- `POST /api/v1/entries/expense` for user expenses (handles account creation automatically)
|
||||||
|
- `POST /api/v1/entries/receivable` for what users owe
|
||||||
|
- `POST /api/v1/entries/revenue` for direct revenue
|
||||||
|
|
||||||
|
For custom entries, use `POST /api/v1/entries` with properly balanced lines.
|
||||||
|
|
||||||
|
### User Account Management
|
||||||
|
|
||||||
|
User-specific accounts are created automatically with format:
|
||||||
|
- Assets: `Assets:Receivable:User-{user_id[:8]}`
|
||||||
|
- Liabilities: `Liabilities:Payable:User-{user_id[:8]}`
|
||||||
|
- Equity: `Equity:MemberEquity:User-{user_id[:8]}`
|
||||||
|
|
||||||
|
Use `get_or_create_user_account()` in crud.py to ensure consistency.
|
||||||
|
|
||||||
|
### Currency Handling
|
||||||
|
|
||||||
|
**CRITICAL**: Use `Decimal` for all fiat amounts, never `float`. Fiat amounts are stored in metadata as strings to preserve precision:
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"fiat_currency": "EUR",
|
||||||
|
"fiat_amount": str(Decimal("250.00")),
|
||||||
|
"fiat_rate": str(Decimal("1074.192")),
|
||||||
|
"btc_rate": str(Decimal("0.000931"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When reading: `fiat_amount = Decimal(metadata["fiat_amount"])`
|
||||||
|
|
||||||
|
### Balance Assertions for Reconciliation
|
||||||
|
|
||||||
|
Create balance assertions to verify accounting accuracy:
|
||||||
|
```python
|
||||||
|
await create_balance_assertion(
|
||||||
|
account_id="lightning_account_id",
|
||||||
|
expected_balance_sats=1000000,
|
||||||
|
expected_balance_fiat=Decimal("500.00"),
|
||||||
|
fiat_currency="EUR",
|
||||||
|
tolerance_sats=100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `POST /api/v1/tasks/daily-reconciliation` to check all assertions.
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
|
||||||
|
- **Super User**: Full access (check via `wallet.wallet.user == lnbits_settings.super_user`)
|
||||||
|
- **Admin Key**: Required for creating receivables, approving payments, viewing all balances
|
||||||
|
- **Invoice Key**: Read access to user's own data
|
||||||
|
- **Users**: Can only see/manage their own accounts and transactions
|
||||||
|
|
||||||
|
### Extension as LNbits Module
|
||||||
|
|
||||||
|
This extension follows LNbits extension structure:
|
||||||
|
- Registered via `castle_ext` router in `__init__.py`
|
||||||
|
- Static files served from `static/` directory
|
||||||
|
- Templates in `templates/castle/`
|
||||||
|
- Database accessed via `db = Database("ext_castle")`
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add New Expense Account
|
||||||
|
```python
|
||||||
|
await create_account(CreateAccount(
|
||||||
|
name="Expenses:Internet",
|
||||||
|
account_type=AccountType.EXPENSE,
|
||||||
|
description="Internet service costs"
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manually Record Cash Payment
|
||||||
|
```python
|
||||||
|
await create_journal_entry(CreateJournalEntry(
|
||||||
|
description="Cash payment for groceries",
|
||||||
|
lines=[
|
||||||
|
CreateEntryLine(account_id=expense_account_id, debit=50000),
|
||||||
|
CreateEntryLine(account_id=cash_account_id, credit=50000)
|
||||||
|
],
|
||||||
|
flag=JournalEntryFlag.CLEARED,
|
||||||
|
meta={"source": "manual", "payment_method": "cash"}
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check User Balance
|
||||||
|
```python
|
||||||
|
balance = await get_user_balance(user_id)
|
||||||
|
print(f"Sats: {balance.balance}") # Positive = Castle owes user
|
||||||
|
print(f"Fiat: {balance.fiat_balances}") # {"EUR": Decimal("36.93")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export to Beancount (Future)
|
||||||
|
Follow patterns in `docs/BEANCOUNT_PATTERNS.md` for implementing Beancount export. Use hierarchical account names and preserve metadata in Beancount comments.
|
||||||
|
|
||||||
|
## Data Integrity
|
||||||
|
|
||||||
|
**Critical Invariants**:
|
||||||
|
1. Every journal entry MUST have balanced debits and credits
|
||||||
|
2. Fiat balances calculated from metadata, not from converting sats
|
||||||
|
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
|
||||||
|
4. Balance assertions checked daily via background task
|
||||||
|
|
||||||
|
**Validation** is performed in `core/validation.py`:
|
||||||
|
- `validate_journal_entry()` - Checks balance, minimum lines
|
||||||
|
- `validate_balance()` - Verifies account balance calculation
|
||||||
|
- `validate_receivable_entry()` - Ensures receivable entries are valid
|
||||||
|
- `validate_expense_entry()` - Ensures expense entries are valid
|
||||||
|
|
||||||
|
## Known Issues & Future Work
|
||||||
|
|
||||||
|
See `docs/DOCUMENTATION.md` for comprehensive list. Key items:
|
||||||
|
- No journal entry editing/deletion (use reversing entries)
|
||||||
|
- No date range filtering on list endpoints (hardcoded limit of 100)
|
||||||
|
- No batch operations for bulk imports
|
||||||
|
- Plugin system architecture designed but not implemented
|
||||||
|
- Beancount export endpoint not yet implemented
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- `docs/README.md` - User-facing overview
|
||||||
|
- `docs/DOCUMENTATION.md` - Comprehensive technical documentation
|
||||||
|
- `docs/BEANCOUNT_PATTERNS.md` - Beancount-inspired design patterns
|
||||||
|
- `docs/PHASE1_COMPLETE.md`, `PHASE2_COMPLETE.md`, `PHASE3_COMPLETE.md` - Development milestones
|
||||||
|
- `docs/EXPENSE_APPROVAL.md` - Manual payment request workflow
|
||||||
|
- `docs/DAILY_RECONCILIATION.md` - Automated reconciliation system
|
||||||
26
__init__.py
26
__init__.py
|
|
@ -1,6 +1,10 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import castle_generic_router
|
from .views import castle_generic_router
|
||||||
from .views_api import castle_api_router
|
from .views_api import castle_api_router
|
||||||
|
|
||||||
|
|
@ -15,4 +19,24 @@ castle_static_files = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ["castle_ext", "castle_static_files", "db"]
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
|
def castle_stop():
|
||||||
|
"""Clean up background tasks on extension shutdown"""
|
||||||
|
for task in scheduled_tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
def castle_start():
|
||||||
|
"""Initialize Castle extension background tasks"""
|
||||||
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
|
||||||
|
task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
|
||||||
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]
|
||||||
|
|
|
||||||
14
crud.py
14
crud.py
|
|
@ -226,6 +226,20 @@ async def get_journal_entry(entry_id: str) -> Optional[JournalEntry]:
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def get_journal_entry_by_reference(reference: str) -> Optional[JournalEntry]:
|
||||||
|
"""Get a journal entry by its reference field (e.g., payment_hash)"""
|
||||||
|
entry = await db.fetchone(
|
||||||
|
"SELECT * FROM journal_entries WHERE reference = :reference",
|
||||||
|
{"reference": reference},
|
||||||
|
JournalEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry:
|
||||||
|
entry.lines = await get_entry_lines(entry.id)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]:
|
async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]:
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT * FROM entry_lines WHERE journal_entry_id = :id",
|
"SELECT * FROM entry_lines WHERE journal_entry_id = :id",
|
||||||
|
|
|
||||||
|
|
@ -332,3 +332,34 @@ async def m008_rename_lightning_account(db):
|
||||||
WHERE name = 'Assets:Lightning:Balance'
|
WHERE name = 'Assets:Lightning:Balance'
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m009_add_onchain_bitcoin_account(db):
|
||||||
|
"""
|
||||||
|
Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
|
||||||
|
This allows tracking on-chain Bitcoin separately from Lightning Network payments.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Check if the account already exists
|
||||||
|
existing = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT id FROM accounts
|
||||||
|
WHERE name = 'Assets:Bitcoin:OnChain'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Create the on-chain Bitcoin asset account
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO accounts (id, name, account_type, description, created_at)
|
||||||
|
VALUES (:id, :name, :type, :description, {db.timestamp_now})
|
||||||
|
""",
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "Assets:Bitcoin:OnChain",
|
||||||
|
"type": "asset",
|
||||||
|
"description": "On-chain Bitcoin wallet"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -188,11 +188,13 @@ class SettleReceivable(BaseModel):
|
||||||
|
|
||||||
user_id: str
|
user_id: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
payment_method: str # "cash", "bank_transfer", "lightning", "other"
|
payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
|
||||||
description: str # Description of the payment
|
description: str # Description of the payment
|
||||||
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
|
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
||||||
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
|
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
|
||||||
|
payment_hash: Optional[str] = None # For lightning payments
|
||||||
|
txid: Optional[str] = None # For on-chain Bitcoin transactions
|
||||||
|
|
||||||
|
|
||||||
class PayUser(BaseModel):
|
class PayUser(BaseModel):
|
||||||
|
|
@ -200,12 +202,13 @@ class PayUser(BaseModel):
|
||||||
|
|
||||||
user_id: str
|
user_id: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
payment_method: str # "cash", "bank_transfer", "lightning", "check", "other"
|
payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
|
||||||
description: Optional[str] = None # Description of the payment
|
description: Optional[str] = None # Description of the payment
|
||||||
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
|
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
||||||
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
|
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
|
||||||
payment_hash: Optional[str] = None # For lightning payments
|
payment_hash: Optional[str] = None # For lightning payments
|
||||||
|
txid: Optional[str] = None # For on-chain Bitcoin transactions
|
||||||
|
|
||||||
|
|
||||||
class AssertionStatus(str, Enum):
|
class AssertionStatus(str, Enum):
|
||||||
|
|
|
||||||
|
|
@ -857,6 +857,10 @@ window.app = Vue.createApp({
|
||||||
const account = this.accounts.find(a => a.id === accountId)
|
const account = this.accounts.find(a => a.id === accountId)
|
||||||
return account ? account.name : accountId
|
return account ? account.name : accountId
|
||||||
},
|
},
|
||||||
|
getUserName(userId) {
|
||||||
|
const user = this.users.find(u => u.user_id === userId)
|
||||||
|
return user ? user.username : userId.substring(0, 16) + '...'
|
||||||
|
},
|
||||||
async loadReconciliationSummary() {
|
async loadReconciliationSummary() {
|
||||||
if (!this.isSuperUser) return
|
if (!this.isSuperUser) return
|
||||||
|
|
||||||
|
|
|
||||||
122
tasks.py
122
tasks.py
|
|
@ -4,10 +4,13 @@ These tasks handle automated reconciliation checks and maintenance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from asyncio import Queue
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import check_balance_assertion, get_balance_assertions
|
from .crud import check_balance_assertion, get_balance_assertions
|
||||||
from .models import AssertionStatus
|
from .models import AssertionStatus
|
||||||
|
|
@ -106,3 +109,122 @@ def start_daily_reconciliation_task():
|
||||||
print("[CASTLE] Daily reconciliation task registered")
|
print("[CASTLE] Daily reconciliation task registered")
|
||||||
# In a production system, you would register this with LNbits task scheduler
|
# In a production system, you would register this with LNbits task scheduler
|
||||||
# For now, it can be triggered manually via API endpoint
|
# For now, it can be triggered manually via API endpoint
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
"""
|
||||||
|
Background task that listens for paid invoices and automatically
|
||||||
|
records them in the accounting system.
|
||||||
|
|
||||||
|
This ensures payments are recorded even if the user closes their browser
|
||||||
|
before the payment is detected by client-side polling.
|
||||||
|
"""
|
||||||
|
invoice_queue = Queue()
|
||||||
|
register_invoice_listener(invoice_queue, "ext_castle")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
"""
|
||||||
|
Handle a paid Castle invoice by automatically creating a journal entry.
|
||||||
|
|
||||||
|
This function is called automatically when any invoice on the Castle wallet
|
||||||
|
is paid. It checks if the invoice is a Castle payment and records it in
|
||||||
|
the accounting system.
|
||||||
|
"""
|
||||||
|
# Only process Castle-specific payments
|
||||||
|
if not payment.extra or payment.extra.get("tag") != "castle":
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = payment.extra.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if payment already recorded (idempotency)
|
||||||
|
from .crud import get_journal_entry_by_reference
|
||||||
|
existing = await get_journal_entry_by_reference(payment.payment_hash)
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Payment {payment.payment_hash} already recorded, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular dependencies
|
||||||
|
from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account
|
||||||
|
from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag
|
||||||
|
|
||||||
|
# Convert amount from millisatoshis to satoshis
|
||||||
|
amount_sats = payment.amount // 1000
|
||||||
|
|
||||||
|
# Extract fiat metadata from invoice (if present)
|
||||||
|
from decimal import Decimal
|
||||||
|
line_metadata = {}
|
||||||
|
if payment.extra:
|
||||||
|
fiat_currency = payment.extra.get("fiat_currency")
|
||||||
|
fiat_amount = payment.extra.get("fiat_amount")
|
||||||
|
fiat_rate = payment.extra.get("fiat_rate")
|
||||||
|
btc_rate = payment.extra.get("btc_rate")
|
||||||
|
|
||||||
|
if fiat_currency and fiat_amount:
|
||||||
|
line_metadata = {
|
||||||
|
"fiat_currency": fiat_currency,
|
||||||
|
"fiat_amount": str(fiat_amount),
|
||||||
|
"fiat_rate": fiat_rate,
|
||||||
|
"btc_rate": btc_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get user's receivable account (what user owes)
|
||||||
|
user_receivable = await get_or_create_user_account(
|
||||||
|
user_id, AccountType.ASSET, "Accounts Receivable"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get lightning account
|
||||||
|
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
||||||
|
if not lightning_account:
|
||||||
|
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create journal entry to record payment
|
||||||
|
# DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User)
|
||||||
|
# This reduces what the user owes
|
||||||
|
entry_meta = {
|
||||||
|
"source": "lightning_payment",
|
||||||
|
"created_via": "auto_invoice_listener",
|
||||||
|
"payment_hash": payment.payment_hash,
|
||||||
|
"payer_user_id": user_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
entry_data = CreateJournalEntry(
|
||||||
|
description=f"Lightning payment from user {user_id[:8]}",
|
||||||
|
reference=payment.payment_hash,
|
||||||
|
flag=JournalEntryFlag.CLEARED,
|
||||||
|
meta=entry_meta,
|
||||||
|
lines=[
|
||||||
|
CreateEntryLine(
|
||||||
|
account_id=lightning_account.id,
|
||||||
|
debit=amount_sats,
|
||||||
|
credit=0,
|
||||||
|
description="Lightning payment received",
|
||||||
|
metadata=line_metadata,
|
||||||
|
),
|
||||||
|
CreateEntryLine(
|
||||||
|
account_id=user_receivable.id,
|
||||||
|
debit=0,
|
||||||
|
credit=amount_sats,
|
||||||
|
description="Payment applied to balance",
|
||||||
|
metadata=line_metadata,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = await create_journal_entry(entry_data, user_id)
|
||||||
|
logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
|
||||||
|
raise
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,60 @@
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
|
<!-- Pending Expense Entries (Super User Only) -->
|
||||||
|
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
|
||||||
|
<q-list separator>
|
||||||
|
<q-item v-for="entry in pendingExpenses" :key="entry.id">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="pending" color="orange" size="sm">
|
||||||
|
<q-tooltip>Pending approval</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<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-label caption v-if="entry.meta && entry.meta.user_id">
|
||||||
|
User: {% raw %}{{ getUserName(entry.meta.user_id) }}{% endraw %}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption v-if="entry.reference" class="text-grey">
|
||||||
|
Ref: {% raw %}{{ entry.reference }}{% 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-label caption v-if="getEntryFiatAmount(entry)">
|
||||||
|
{% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="q-gutter-xs">
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="positive"
|
||||||
|
@click="approveExpense(entry.id)"
|
||||||
|
:loading="entry.approving"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="negative"
|
||||||
|
@click="rejectExpense(entry.id)"
|
||||||
|
:loading="entry.rejecting"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -101,58 +155,6 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</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="isSuperUser ? (balance.balance >= 0 ? 'text-negative' : 'text-positive') : (balance.balance >= 0 ? 'text-positive' : 'text-negative')">
|
|
||||||
{% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %}
|
|
||||||
</div>
|
|
||||||
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-h6 q-mt-sm">
|
|
||||||
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
|
|
||||||
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-subtitle2" v-if="isSuperUser">
|
|
||||||
{% raw %}{{ balance.balance > 0 ? 'Total you owe' : balance.balance < 0 ? 'Total owed to you' : 'No outstanding balances' }}{% endraw %}
|
|
||||||
</div>
|
|
||||||
<div class="text-subtitle2" v-else>
|
|
||||||
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %}
|
|
||||||
</div>
|
|
||||||
<div class="q-mt-md q-gutter-sm">
|
|
||||||
<q-btn
|
|
||||||
v-if="balance.balance < 0 && !isSuperUser"
|
|
||||||
color="primary"
|
|
||||||
@click="showPayBalanceDialog"
|
|
||||||
>
|
|
||||||
Pay Balance
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
v-if="balance.balance > 0 && !isSuperUser"
|
|
||||||
color="secondary"
|
|
||||||
@click="showManualPaymentDialog"
|
|
||||||
>
|
|
||||||
Request Manual Payment
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<q-spinner color="primary" size="md"></q-spinner>
|
|
||||||
Loading balance...
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<!-- User Balances Breakdown (Super User Only) -->
|
<!-- User Balances Breakdown (Super User Only) -->
|
||||||
<q-card v-if="isSuperUser && outstandingUserBalances.length > 0">
|
<q-card v-if="isSuperUser && outstandingUserBalances.length > 0">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -222,57 +224,55 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- Pending Expense Entries (Super User Only) -->
|
<!-- User Balance Card -->
|
||||||
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
<q-list separator>
|
<div class="col">
|
||||||
<q-item v-for="entry in pendingExpenses" :key="entry.id">
|
<h6 class="q-my-none">Your Balance</h6>
|
||||||
<q-item-section avatar>
|
</div>
|
||||||
<q-icon name="pending" color="orange" size="sm">
|
<div class="col-auto">
|
||||||
<q-tooltip>Pending approval</q-tooltip>
|
<q-btn flat round icon="refresh" @click="loadBalance">
|
||||||
</q-icon>
|
<q-tooltip>Refresh balance</q-tooltip>
|
||||||
</q-item-section>
|
|
||||||
<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-label caption v-if="entry.meta && entry.meta.user_id">
|
|
||||||
User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption v-if="entry.reference" class="text-grey">
|
|
||||||
Ref: {% raw %}{{ entry.reference }}{% 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-label caption v-if="getEntryFiatAmount(entry)">
|
|
||||||
{% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %}
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<div class="q-gutter-xs">
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="positive"
|
|
||||||
@click="approveExpense(entry.id)"
|
|
||||||
:loading="entry.approving"
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="negative"
|
|
||||||
@click="rejectExpense(entry.id)"
|
|
||||||
:loading="entry.rejecting"
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</div>
|
||||||
</q-item>
|
<div v-if="balance !== null">
|
||||||
</q-list>
|
<div class="text-h4" :class="isSuperUser ? (balance.balance >= 0 ? 'text-negative' : 'text-positive') : (balance.balance >= 0 ? 'text-positive' : 'text-negative')">
|
||||||
|
{% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %}
|
||||||
|
</div>
|
||||||
|
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-h6 q-mt-sm">
|
||||||
|
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
|
||||||
|
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-subtitle2" v-if="isSuperUser">
|
||||||
|
{% raw %}{{ balance.balance > 0 ? 'Total you owe' : balance.balance < 0 ? 'Total owed to you' : 'No outstanding balances' }}{% endraw %}
|
||||||
|
</div>
|
||||||
|
<div class="text-subtitle2" v-else>
|
||||||
|
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %}
|
||||||
|
</div>
|
||||||
|
<div class="q-mt-md q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
v-if="balance.balance < 0 && !isSuperUser"
|
||||||
|
color="primary"
|
||||||
|
@click="showPayBalanceDialog"
|
||||||
|
>
|
||||||
|
Pay Balance
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="balance.balance > 0 && !isSuperUser"
|
||||||
|
color="secondary"
|
||||||
|
@click="showManualPaymentDialog"
|
||||||
|
>
|
||||||
|
Request Manual Payment
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-spinner color="primary" size="md"></q-spinner>
|
||||||
|
Loading balance...
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{% raw %}{{ request.description }}{% endraw %}</q-item-label>
|
<q-item-label>{% raw %}{{ request.description }}{% endraw %}</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
User: {% raw %}{{ request.user_id.substring(0, 16) }}...{% endraw %}
|
User: {% raw %}{{ getUserName(request.user_id) }}{% endraw %}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %}
|
Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %}
|
||||||
|
|
@ -319,6 +319,80 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</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 avatar>
|
||||||
|
<!-- Transaction status flag -->
|
||||||
|
<q-icon v-if="entry.flag === '*'" name="check_circle" color="positive" size="sm">
|
||||||
|
<q-tooltip>Cleared</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon v-else-if="entry.flag === '!'" name="pending" color="orange" size="sm">
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon v-else-if="entry.flag === '#'" name="flag" color="red" size="sm">
|
||||||
|
<q-tooltip>Flagged - needs review</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon v-else-if="entry.flag === 'x'" name="cancel" color="grey" size="sm">
|
||||||
|
<q-tooltip>Voided</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
{% raw %}{{ entry.description }}{% endraw %}
|
||||||
|
<!-- Castle's perspective: Receivables are incoming (green), Payables are outgoing (red) -->
|
||||||
|
<q-badge v-if="isSuperUser && isReceivable(entry)" color="positive" class="q-ml-sm">
|
||||||
|
Receivable
|
||||||
|
</q-badge>
|
||||||
|
<q-badge v-else-if="isSuperUser && isPayable(entry)" color="negative" class="q-ml-sm">
|
||||||
|
Payable
|
||||||
|
</q-badge>
|
||||||
|
<!-- User's perspective: Receivables are outgoing (red), Payables are incoming (green) -->
|
||||||
|
<q-badge v-else-if="!isSuperUser && isReceivable(entry)" color="negative" class="q-ml-sm">
|
||||||
|
Payable
|
||||||
|
</q-badge>
|
||||||
|
<q-badge v-else-if="!isSuperUser && isPayable(entry)" color="positive" class="q-ml-sm">
|
||||||
|
Receivable
|
||||||
|
</q-badge>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption v-if="entry.reference" class="text-grey">
|
||||||
|
Ref: {% raw %}{{ entry.reference }}{% endraw %}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption v-if="entry.meta && Object.keys(entry.meta).length > 0" class="text-blue-grey-6">
|
||||||
|
<q-icon name="info" size="xs" class="q-mr-xs"></q-icon>
|
||||||
|
<span v-if="entry.meta.source">Source: {% raw %}{{ entry.meta.source }}{% endraw %}</span>
|
||||||
|
<span v-if="entry.meta.created_via" class="q-ml-sm">Via: {% raw %}{{ entry.meta.created_via }}{% endraw %}</span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
|
||||||
|
<q-item-label caption v-if="getEntryFiatAmount(entry)">
|
||||||
|
{% raw %}{{ getEntryFiatAmount(entry) }}{% 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>
|
||||||
|
|
||||||
<!-- Balance Assertions (Super User Only) -->
|
<!-- Balance Assertions (Super User Only) -->
|
||||||
<q-card v-if="isSuperUser">
|
<q-card v-if="isSuperUser">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -565,80 +639,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</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 avatar>
|
|
||||||
<!-- Transaction status flag -->
|
|
||||||
<q-icon v-if="entry.flag === '*'" name="check_circle" color="positive" size="sm">
|
|
||||||
<q-tooltip>Cleared</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon v-else-if="entry.flag === '!'" name="pending" color="orange" size="sm">
|
|
||||||
<q-tooltip>Pending</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon v-else-if="entry.flag === '#'" name="flag" color="red" size="sm">
|
|
||||||
<q-tooltip>Flagged - needs review</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon v-else-if="entry.flag === 'x'" name="cancel" color="grey" size="sm">
|
|
||||||
<q-tooltip>Voided</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>
|
|
||||||
{% raw %}{{ entry.description }}{% endraw %}
|
|
||||||
<!-- Castle's perspective: Receivables are incoming (green), Payables are outgoing (red) -->
|
|
||||||
<q-badge v-if="isSuperUser && isReceivable(entry)" color="positive" class="q-ml-sm">
|
|
||||||
Receivable
|
|
||||||
</q-badge>
|
|
||||||
<q-badge v-else-if="isSuperUser && isPayable(entry)" color="negative" class="q-ml-sm">
|
|
||||||
Payable
|
|
||||||
</q-badge>
|
|
||||||
<!-- User's perspective: Receivables are outgoing (red), Payables are incoming (green) -->
|
|
||||||
<q-badge v-else-if="!isSuperUser && isReceivable(entry)" color="negative" class="q-ml-sm">
|
|
||||||
Payable
|
|
||||||
</q-badge>
|
|
||||||
<q-badge v-else-if="!isSuperUser && isPayable(entry)" color="positive" class="q-ml-sm">
|
|
||||||
Receivable
|
|
||||||
</q-badge>
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption>
|
|
||||||
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption v-if="entry.reference" class="text-grey">
|
|
||||||
Ref: {% raw %}{{ entry.reference }}{% endraw %}
|
|
||||||
</q-item-label>
|
|
||||||
<q-item-label caption v-if="entry.meta && Object.keys(entry.meta).length > 0" class="text-blue-grey-6">
|
|
||||||
<q-icon name="info" size="xs" class="q-mr-xs"></q-icon>
|
|
||||||
<span v-if="entry.meta.source">Source: {% raw %}{{ entry.meta.source }}{% endraw %}</span>
|
|
||||||
<span v-if="entry.meta.created_via" class="q-ml-sm">Via: {% raw %}{{ entry.meta.created_via }}{% endraw %}</span>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
|
|
||||||
<q-item-label caption v-if="getEntryFiatAmount(entry)">
|
|
||||||
{% raw %}{{ getEntryFiatAmount(entry) }}{% 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>
|
||||||
|
|
||||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
|
|
||||||
143
views_api.py
143
views_api.py
|
|
@ -585,13 +585,52 @@ async def api_generate_payment_invoice(
|
||||||
# Get castle wallet ID
|
# Get castle wallet ID
|
||||||
castle_wallet_id = await check_castle_wallet_configured()
|
castle_wallet_id = await check_castle_wallet_configured()
|
||||||
|
|
||||||
|
# Get user's balance to calculate fiat metadata
|
||||||
|
user_balance = await get_user_balance(target_user_id)
|
||||||
|
|
||||||
|
# Calculate proportional fiat amount for this invoice
|
||||||
|
invoice_extra = {"tag": "castle", "user_id": target_user_id}
|
||||||
|
|
||||||
|
if user_balance.fiat_balances:
|
||||||
|
# Simple single-currency solution: use the first (and should be only) currency
|
||||||
|
currencies = list(user_balance.fiat_balances.keys())
|
||||||
|
|
||||||
|
if len(currencies) > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(currencies) == 1:
|
||||||
|
fiat_currency = currencies[0]
|
||||||
|
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
|
||||||
|
total_sat_balance = abs(user_balance.balance) # Use absolute value
|
||||||
|
|
||||||
|
if total_sat_balance > 0:
|
||||||
|
# Calculate proportional fiat amount for this invoice
|
||||||
|
# fiat_amount = (invoice_amount / total_sats) * total_fiat
|
||||||
|
from decimal import Decimal
|
||||||
|
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
|
||||||
|
invoice_fiat_amount = abs(total_fiat_balance) * proportion
|
||||||
|
|
||||||
|
# Calculate fiat rate (sats per fiat unit)
|
||||||
|
fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0
|
||||||
|
btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0
|
||||||
|
|
||||||
|
invoice_extra.update({
|
||||||
|
"fiat_currency": fiat_currency,
|
||||||
|
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
|
||||||
|
"fiat_rate": fiat_rate,
|
||||||
|
"btc_rate": btc_rate,
|
||||||
|
})
|
||||||
|
|
||||||
# Create invoice on castle wallet
|
# Create invoice on castle wallet
|
||||||
invoice_data = CreateInvoice(
|
invoice_data = CreateInvoice(
|
||||||
out=False,
|
out=False,
|
||||||
amount=data.amount,
|
amount=data.amount,
|
||||||
memo=f"Payment from user {target_user_id[:8]} to Castle",
|
memo=f"Payment from user {target_user_id[:8]} to Castle",
|
||||||
unit="sat",
|
unit="sat",
|
||||||
extra={"user_id": target_user_id, "type": "castle_payment"},
|
extra=invoice_extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
payment = await create_payment_request(castle_wallet_id, invoice_data)
|
payment = await create_payment_request(castle_wallet_id, invoice_data)
|
||||||
|
|
@ -648,9 +687,37 @@ async def api_record_payment(
|
||||||
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
|
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if payment already recorded (idempotency)
|
||||||
|
from .crud import get_journal_entry_by_reference
|
||||||
|
existing = await get_journal_entry_by_reference(data.payment_hash)
|
||||||
|
if existing:
|
||||||
|
# Payment already recorded, return existing entry
|
||||||
|
balance = await get_user_balance(target_user_id)
|
||||||
|
return {
|
||||||
|
"journal_entry_id": existing.id,
|
||||||
|
"new_balance": balance.balance,
|
||||||
|
"message": "Payment already recorded",
|
||||||
|
}
|
||||||
|
|
||||||
# Convert amount from millisatoshis to satoshis
|
# Convert amount from millisatoshis to satoshis
|
||||||
amount_sats = payment.amount // 1000
|
amount_sats = payment.amount // 1000
|
||||||
|
|
||||||
|
# Extract fiat metadata from invoice (if present)
|
||||||
|
line_metadata = {}
|
||||||
|
if payment.extra and isinstance(payment.extra, dict):
|
||||||
|
fiat_currency = payment.extra.get("fiat_currency")
|
||||||
|
fiat_amount = payment.extra.get("fiat_amount")
|
||||||
|
fiat_rate = payment.extra.get("fiat_rate")
|
||||||
|
btc_rate = payment.extra.get("btc_rate")
|
||||||
|
|
||||||
|
if fiat_currency and fiat_amount:
|
||||||
|
line_metadata = {
|
||||||
|
"fiat_currency": fiat_currency,
|
||||||
|
"fiat_amount": str(fiat_amount),
|
||||||
|
"fiat_rate": fiat_rate,
|
||||||
|
"btc_rate": btc_rate,
|
||||||
|
}
|
||||||
|
|
||||||
# Get user's receivable account (what user owes)
|
# Get user's receivable account (what user owes)
|
||||||
user_receivable = await get_or_create_user_account(
|
user_receivable = await get_or_create_user_account(
|
||||||
target_user_id, AccountType.ASSET, "Accounts Receivable"
|
target_user_id, AccountType.ASSET, "Accounts Receivable"
|
||||||
|
|
@ -686,12 +753,14 @@ async def api_record_payment(
|
||||||
debit=amount_sats,
|
debit=amount_sats,
|
||||||
credit=0,
|
credit=0,
|
||||||
description="Lightning payment received",
|
description="Lightning payment received",
|
||||||
|
metadata=line_metadata,
|
||||||
),
|
),
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=user_receivable.id,
|
account_id=user_receivable.id,
|
||||||
debit=0,
|
debit=0,
|
||||||
credit=amount_sats,
|
credit=amount_sats,
|
||||||
description="Payment applied to balance",
|
description="Payment applied to balance",
|
||||||
|
metadata=line_metadata,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -786,7 +855,7 @@ async def api_settle_receivable(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate payment method
|
# Validate payment method
|
||||||
valid_methods = ["cash", "bank_transfer", "check", "other"]
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
||||||
if data.payment_method.lower() not in valid_methods:
|
if data.payment_method.lower() not in valid_methods:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -800,13 +869,15 @@ async def api_settle_receivable(
|
||||||
|
|
||||||
# Get the appropriate asset account based on payment method
|
# Get the appropriate asset account based on payment method
|
||||||
payment_account_map = {
|
payment_account_map = {
|
||||||
"cash": "Cash",
|
"cash": "Assets:Cash",
|
||||||
"bank_transfer": "Bank Account",
|
"bank_transfer": "Assets:Bank",
|
||||||
"check": "Bank Account",
|
"check": "Assets:Bank",
|
||||||
"other": "Cash"
|
"lightning": "Assets:Bitcoin:Lightning",
|
||||||
|
"btc_onchain": "Assets:Bitcoin:OnChain",
|
||||||
|
"other": "Assets:Cash"
|
||||||
}
|
}
|
||||||
|
|
||||||
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
|
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
|
||||||
payment_account = await get_account_by_name(account_name)
|
payment_account = await get_account_by_name(account_name)
|
||||||
|
|
||||||
# If account doesn't exist, try to find or create a generic one
|
# If account doesn't exist, try to find or create a generic one
|
||||||
|
|
@ -842,15 +913,24 @@ async def api_settle_receivable(
|
||||||
)
|
)
|
||||||
amount_in_sats = data.amount_sats
|
amount_in_sats = data.amount_sats
|
||||||
line_metadata = {
|
line_metadata = {
|
||||||
"fiat_currency": data.currency,
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": str(data.amount),
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
||||||
"exchange_rate": data.amount_sats / float(data.amount)
|
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||||
|
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Satoshi payment
|
# Satoshi payment
|
||||||
amount_in_sats = int(data.amount)
|
amount_in_sats = int(data.amount)
|
||||||
line_metadata = {}
|
line_metadata = {}
|
||||||
|
|
||||||
|
# Add payment hash for lightning payments
|
||||||
|
if data.payment_hash:
|
||||||
|
line_metadata["payment_hash"] = data.payment_hash
|
||||||
|
|
||||||
|
# Add transaction ID for on-chain Bitcoin payments
|
||||||
|
if data.txid:
|
||||||
|
line_metadata["txid"] = data.txid
|
||||||
|
|
||||||
# Add meta information for audit trail
|
# Add meta information for audit trail
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"source": "manual_settlement",
|
"source": "manual_settlement",
|
||||||
|
|
@ -923,7 +1003,7 @@ async def api_pay_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate payment method
|
# Validate payment method
|
||||||
valid_methods = ["cash", "bank_transfer", "check", "lightning", "other"]
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
||||||
if data.payment_method.lower() not in valid_methods:
|
if data.payment_method.lower() not in valid_methods:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -936,32 +1016,20 @@ async def api_pay_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the appropriate asset account based on payment method
|
# Get the appropriate asset account based on payment method
|
||||||
if data.payment_method.lower() == "lightning":
|
|
||||||
# For lightning, use the Lightning Wallet account
|
|
||||||
payment_account = await get_account_by_name("Lightning Wallet")
|
|
||||||
if not payment_account:
|
|
||||||
# Create it if it doesn't exist
|
|
||||||
payment_account = await create_account(
|
|
||||||
CreateAccount(
|
|
||||||
name="Lightning Wallet",
|
|
||||||
account_type=AccountType.ASSET,
|
|
||||||
description="Lightning Network wallet for Castle",
|
|
||||||
),
|
|
||||||
wallet.wallet.id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# For cash/bank/check
|
|
||||||
payment_account_map = {
|
payment_account_map = {
|
||||||
"cash": "Cash",
|
"cash": "Assets:Cash",
|
||||||
"bank_transfer": "Bank Account",
|
"bank_transfer": "Assets:Bank",
|
||||||
"check": "Bank Account",
|
"check": "Assets:Bank",
|
||||||
"other": "Cash"
|
"lightning": "Assets:Bitcoin:Lightning",
|
||||||
|
"btc_onchain": "Assets:Bitcoin:OnChain",
|
||||||
|
"other": "Assets:Cash"
|
||||||
}
|
}
|
||||||
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
|
|
||||||
|
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
|
||||||
payment_account = await get_account_by_name(account_name)
|
payment_account = await get_account_by_name(account_name)
|
||||||
|
|
||||||
if not payment_account:
|
if not payment_account:
|
||||||
# Try to find any asset account
|
# Try to find any asset account that's not receivable
|
||||||
all_accounts = await get_all_accounts()
|
all_accounts = await get_all_accounts()
|
||||||
for acc in all_accounts:
|
for acc in all_accounts:
|
||||||
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
|
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
|
||||||
|
|
@ -988,9 +1056,10 @@ async def api_pay_user(
|
||||||
)
|
)
|
||||||
amount_in_sats = data.amount_sats
|
amount_in_sats = data.amount_sats
|
||||||
line_metadata = {
|
line_metadata = {
|
||||||
"fiat_currency": data.currency,
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": str(data.amount),
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
||||||
"exchange_rate": data.amount_sats / float(data.amount)
|
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||||
|
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Satoshi payment
|
# Satoshi payment
|
||||||
|
|
@ -1001,6 +1070,10 @@ async def api_pay_user(
|
||||||
if data.payment_hash:
|
if data.payment_hash:
|
||||||
line_metadata["payment_hash"] = data.payment_hash
|
line_metadata["payment_hash"] = data.payment_hash
|
||||||
|
|
||||||
|
# Add transaction ID for on-chain Bitcoin payments
|
||||||
|
if data.txid:
|
||||||
|
line_metadata["txid"] = data.txid
|
||||||
|
|
||||||
# Create journal entry
|
# Create journal entry
|
||||||
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
|
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
|
||||||
# This records that castle paid its debt
|
# This records that castle paid its debt
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue