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 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
14
crud.py
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
122
tasks.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
171
views_api.py
171
views_api.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue