Compare commits

...

10 commits

Author SHA1 Message Date
3248d3dad6 add dev docs 2025-11-04 01:19:30 +01:00
3add13075c Adds fiat currency metadata to payments
Adds fiat currency information to payment invoices and ledger entries.

This allows for tracking the fiat value of transactions and provides a more complete financial picture. Calculates the fiat amount proportionally based on the user's balance and includes the fiat currency, amount, and exchange rates in the invoice's extra data. This data is then extracted and added to the ledger entry's metadata when recording the payment.
2025-11-02 02:52:41 +01:00
8f35788e1a Adds task cleanup on extension shutdown
Implements a mechanism to cancel pending background tasks
when the extension is stopped. This ensures proper cleanup and
prevents potential issues with lingering tasks.
2025-11-02 01:41:34 +01:00
cfa25cc61b Adds background task for invoice processing
Implements a background task that listens for paid invoices
and automatically records them in the accounting system. This
ensures payments are captured even if the user closes their
browser before the client-side polling detects the payment.

Introduces a new `get_journal_entry_by_reference` function to
improve idempotency when recording payments.
2025-11-02 01:40:40 +01:00
4957826c49 Updates asset account names for consistency
Ensures that default and specified account names for
cash, bank transfers, and checks align with the correct
asset account structure.
2025-11-01 23:47:08 +01:00
e2472d13a2 Adds on-chain Bitcoin payment support
Adds support for on-chain Bitcoin payments by:
- Introducing a new `Assets:Bitcoin:OnChain` account.
- Updating the `SettleReceivable` and `PayUser` models to include `txid` for storing transaction IDs.
- Modifying the API endpoints to handle `btc_onchain` as a valid payment method and associate it with the new account.

This allows tracking on-chain Bitcoin transactions separately from Lightning Network payments.
2025-11-01 23:45:28 +01:00
8b16ead5b1 Formats fiat amounts and rates in API calls
Ensures consistent formatting of fiat currency, amount,
and exchange rates in the `api_settle_receivable` and
`api_pay_user` API endpoints.
Specifically, it:
- Converts fiat currency to uppercase.
- Formats fiat amount to three decimal places.
- Calculates and includes fiat and BTC rates.
2025-11-01 23:24:38 +01:00
5e67ce562b Adds CLAUDE.md to guide Claude Code
Creates a markdown file to provide guidance to Claude Code (claude.ai/code) when interacting with the Castle Accounting codebase.

This document outlines project overview, architecture, key files, database schema, transaction flows, API endpoints, development notes, and other crucial information. It aims to improve Claude's ability to understand and assist with code-related tasks.
2025-11-01 23:22:25 +01:00
762f5cc411 Improves user display in audit log and requests
Uses usernames instead of user IDs in the audit log and
approval request listings to improve readability. If a user
is not found, it displays a truncated version of the user ID.
2025-11-01 09:25:22 +01:00
d1f22dfda8 Reorders cards on the dashboard
Moves the pending expense approvals card to the top of the dashboard
and the user balance card to a later position for better UX.
2025-11-01 09:21:49 +01:00
16 changed files with 780 additions and 229 deletions

280
CLAUDE.md Normal file
View 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

View file

@ -1,6 +1,10 @@
import asyncio
from fastapi import APIRouter
from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import castle_generic_router
from .views_api import castle_api_router
@ -15,4 +19,24 @@ castle_static_files = [
}
]
__all__ = ["castle_ext", "castle_static_files", "db"]
scheduled_tasks: list[asyncio.Task] = []
def castle_stop():
"""Clean up background tasks on extension shutdown"""
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
def castle_start():
"""Initialize Castle extension background tasks"""
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
scheduled_tasks.append(task)
__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]

14
crud.py
View file

@ -226,6 +226,20 @@ async def get_journal_entry(entry_id: str) -> Optional[JournalEntry]:
return entry
async def get_journal_entry_by_reference(reference: str) -> Optional[JournalEntry]:
"""Get a journal entry by its reference field (e.g., payment_hash)"""
entry = await db.fetchone(
"SELECT * FROM journal_entries WHERE reference = :reference",
{"reference": reference},
JournalEntry,
)
if entry:
entry.lines = await get_entry_lines(entry.id)
return entry
async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]:
rows = await db.fetchall(
"SELECT * FROM entry_lines WHERE journal_entry_id = :id",

View file

@ -332,3 +332,34 @@ async def m008_rename_lightning_account(db):
WHERE name = 'Assets:Lightning:Balance'
"""
)
async def m009_add_onchain_bitcoin_account(db):
"""
Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
This allows tracking on-chain Bitcoin separately from Lightning Network payments.
"""
import uuid
# Check if the account already exists
existing = await db.fetchone(
"""
SELECT id FROM accounts
WHERE name = 'Assets:Bitcoin:OnChain'
"""
)
if not existing:
# Create the on-chain Bitcoin asset account
await db.execute(
f"""
INSERT INTO accounts (id, name, account_type, description, created_at)
VALUES (:id, :name, :type, :description, {db.timestamp_now})
""",
{
"id": str(uuid.uuid4()),
"name": "Assets:Bitcoin:OnChain",
"type": "asset",
"description": "On-chain Bitcoin wallet"
}
)

View file

@ -188,11 +188,13 @@ class SettleReceivable(BaseModel):
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
payment_method: str # "cash", "bank_transfer", "lightning", "other"
payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
description: str # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments
txid: Optional[str] = None # For on-chain Bitcoin transactions
class PayUser(BaseModel):
@ -200,12 +202,13 @@ class PayUser(BaseModel):
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
payment_method: str # "cash", "bank_transfer", "lightning", "check", "other"
payment_method: str # "cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"
description: Optional[str] = None # Description of the payment
reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.)
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking)
payment_hash: Optional[str] = None # For lightning payments
txid: Optional[str] = None # For on-chain Bitcoin transactions
class AssertionStatus(str, Enum):

View file

@ -857,6 +857,10 @@ window.app = Vue.createApp({
const account = this.accounts.find(a => a.id === accountId)
return account ? account.name : accountId
},
getUserName(userId) {
const user = this.users.find(u => u.user_id === userId)
return user ? user.username : userId.substring(0, 16) + '...'
},
async loadReconciliationSummary() {
if (!this.isSuperUser) return

122
tasks.py
View file

@ -4,10 +4,13 @@ These tasks handle automated reconciliation checks and maintenance.
"""
import asyncio
from asyncio import Queue
from datetime import datetime
from typing import Optional
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import check_balance_assertion, get_balance_assertions
from .models import AssertionStatus
@ -106,3 +109,122 @@ def start_daily_reconciliation_task():
print("[CASTLE] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint
async def wait_for_paid_invoices():
"""
Background task that listens for paid invoices and automatically
records them in the accounting system.
This ensures payments are recorded even if the user closes their browser
before the payment is detected by client-side polling.
"""
invoice_queue = Queue()
register_invoice_listener(invoice_queue, "ext_castle")
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
"""
Handle a paid Castle invoice by automatically creating a journal entry.
This function is called automatically when any invoice on the Castle wallet
is paid. It checks if the invoice is a Castle payment and records it in
the accounting system.
"""
# Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle":
return
user_id = payment.extra.get("user_id")
if not user_id:
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
return
# Check if payment already recorded (idempotency)
from .crud import get_journal_entry_by_reference
existing = await get_journal_entry_by_reference(payment.payment_hash)
if existing:
logger.info(f"Payment {payment.payment_hash} already recorded, skipping")
return
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}")
try:
# Import here to avoid circular dependencies
from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account
from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
from decimal import Decimal
line_metadata = {}
if payment.extra:
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount = payment.extra.get("fiat_amount")
fiat_rate = payment.extra.get("fiat_rate")
btc_rate = payment.extra.get("btc_rate")
if fiat_currency and fiat_amount:
line_metadata = {
"fiat_currency": fiat_currency,
"fiat_amount": str(fiat_amount),
"fiat_rate": fiat_rate,
"btc_rate": btc_rate,
}
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
# Create journal entry to record payment
# DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User)
# This reduces what the user owes
entry_meta = {
"source": "lightning_payment",
"created_via": "auto_invoice_listener",
"payment_hash": payment.payment_hash,
"payer_user_id": user_id,
}
entry_data = CreateJournalEntry(
description=f"Lightning payment from user {user_id[:8]}",
reference=payment.payment_hash,
flag=JournalEntryFlag.CLEARED,
meta=entry_meta,
lines=[
CreateEntryLine(
account_id=lightning_account.id,
debit=amount_sats,
credit=0,
description="Lightning payment received",
metadata=line_metadata,
),
CreateEntryLine(
account_id=user_receivable.id,
debit=0,
credit=amount_sats,
description="Payment applied to balance",
metadata=line_metadata,
),
],
)
entry = await create_journal_entry(entry_data, user_id)
logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}")
except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
raise

View file

@ -62,6 +62,60 @@
</template>
</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 -->
<q-card>
<q-card-section>
@ -101,58 +155,6 @@
</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="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) -->
<q-card v-if="isSuperUser && outstandingUserBalances.length > 0">
<q-card-section>
@ -222,57 +224,55 @@
</q-card-section>
</q-card>
<!-- Pending Expense Entries (Super User Only) -->
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
<!-- User Balance Card -->
<q-card>
<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 %}{{ 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>
</div>
</q-item-section>
</q-item>
</q-list>
<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>
@ -285,7 +285,7 @@
<q-item-section>
<q-item-label>{% raw %}{{ request.description }}{% endraw %}</q-item-label>
<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 caption>
Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %}
@ -319,6 +319,80 @@
</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 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) -->
<q-card v-if="isSuperUser">
<q-card-section>
@ -565,80 +639,6 @@
</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 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 class="col-12 col-md-4 col-lg-5 q-gutter-y-md">

View file

@ -585,13 +585,52 @@ async def api_generate_payment_invoice(
# Get castle wallet ID
castle_wallet_id = await check_castle_wallet_configured()
# Get user's balance to calculate fiat metadata
user_balance = await get_user_balance(target_user_id)
# Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "castle", "user_id": target_user_id}
if user_balance.fiat_balances:
# Simple single-currency solution: use the first (and should be only) currency
currencies = list(user_balance.fiat_balances.keys())
if len(currencies) > 1:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
)
if len(currencies) == 1:
fiat_currency = currencies[0]
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
total_sat_balance = abs(user_balance.balance) # Use absolute value
if total_sat_balance > 0:
# Calculate proportional fiat amount for this invoice
# fiat_amount = (invoice_amount / total_sats) * total_fiat
from decimal import Decimal
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
invoice_fiat_amount = abs(total_fiat_balance) * proportion
# Calculate fiat rate (sats per fiat unit)
fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0
btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0
invoice_extra.update({
"fiat_currency": fiat_currency,
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
"fiat_rate": fiat_rate,
"btc_rate": btc_rate,
})
# Create invoice on castle wallet
invoice_data = CreateInvoice(
out=False,
amount=data.amount,
memo=f"Payment from user {target_user_id[:8]} to Castle",
unit="sat",
extra={"user_id": target_user_id, "type": "castle_payment"},
extra=invoice_extra,
)
payment = await create_payment_request(castle_wallet_id, invoice_data)
@ -648,9 +687,37 @@ async def api_record_payment(
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
)
# Check if payment already recorded (idempotency)
from .crud import get_journal_entry_by_reference
existing = await get_journal_entry_by_reference(data.payment_hash)
if existing:
# Payment already recorded, return existing entry
balance = await get_user_balance(target_user_id)
return {
"journal_entry_id": existing.id,
"new_balance": balance.balance,
"message": "Payment already recorded",
}
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
line_metadata = {}
if payment.extra and isinstance(payment.extra, dict):
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount = payment.extra.get("fiat_amount")
fiat_rate = payment.extra.get("fiat_rate")
btc_rate = payment.extra.get("btc_rate")
if fiat_currency and fiat_amount:
line_metadata = {
"fiat_currency": fiat_currency,
"fiat_amount": str(fiat_amount),
"fiat_rate": fiat_rate,
"btc_rate": btc_rate,
}
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
target_user_id, AccountType.ASSET, "Accounts Receivable"
@ -686,12 +753,14 @@ async def api_record_payment(
debit=amount_sats,
credit=0,
description="Lightning payment received",
metadata=line_metadata,
),
CreateEntryLine(
account_id=user_receivable.id,
debit=0,
credit=amount_sats,
description="Payment applied to balance",
metadata=line_metadata,
),
],
)
@ -786,7 +855,7 @@ async def api_settle_receivable(
)
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "other"]
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@ -800,13 +869,15 @@ async def api_settle_receivable(
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Cash",
"bank_transfer": "Bank Account",
"check": "Bank Account",
"other": "Cash"
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
# If account doesn't exist, try to find or create a generic one
@ -842,15 +913,24 @@ async def api_settle_receivable(
)
amount_in_sats = data.amount_sats
line_metadata = {
"fiat_currency": data.currency,
"fiat_amount": str(data.amount),
"exchange_rate": data.amount_sats / float(data.amount)
"fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
}
else:
# Satoshi payment
amount_in_sats = int(data.amount)
line_metadata = {}
# Add payment hash for lightning payments
if data.payment_hash:
line_metadata["payment_hash"] = data.payment_hash
# Add transaction ID for on-chain Bitcoin payments
if data.txid:
line_metadata["txid"] = data.txid
# Add meta information for audit trail
entry_meta = {
"source": "manual_settlement",
@ -923,7 +1003,7 @@ async def api_pay_user(
)
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "other"]
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@ -936,43 +1016,31 @@ async def api_pay_user(
)
# Get the appropriate asset account based on payment method
if data.payment_method.lower() == "lightning":
# For lightning, use the Lightning Wallet account
payment_account = await get_account_by_name("Lightning Wallet")
if not payment_account:
# Create it if it doesn't exist
payment_account = await create_account(
CreateAccount(
name="Lightning Wallet",
account_type=AccountType.ASSET,
description="Lightning Network wallet for Castle",
),
wallet.wallet.id,
)
else:
# For cash/bank/check
payment_account_map = {
"cash": "Cash",
"bank_transfer": "Bank Account",
"check": "Bank Account",
"other": "Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Cash")
payment_account = await get_account_by_name(account_name)
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
if not payment_account:
# Try to find any asset account
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Determine the amount to record in the journal
# IMPORTANT: Always record in satoshis to match the payable account balance
@ -988,9 +1056,10 @@ async def api_pay_user(
)
amount_in_sats = data.amount_sats
line_metadata = {
"fiat_currency": data.currency,
"fiat_amount": str(data.amount),
"exchange_rate": data.amount_sats / float(data.amount)
"fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
"fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0,
"btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0,
}
else:
# Satoshi payment
@ -1001,6 +1070,10 @@ async def api_pay_user(
if data.payment_hash:
line_metadata["payment_hash"] = data.payment_hash
# Add transaction ID for on-chain Bitcoin payments
if data.txid:
line_metadata["txid"] = data.txid
# Create journal entry
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that castle paid its debt