castle/models.py
padreug 60aba90e00 Adds functionality to pay users (Castle pays)
Implements the ability for the super user (Castle) to pay other users for expenses or liabilities.

Introduces a new `PayUser` model to represent these payments, along with API endpoints to process and record them.

Integrates a "Pay User" button into the user list, allowing the super user to initiate payments through either lightning or manual methods (cash, bank transfer, check).

Adds UI elements and logic for handling both lightning payments (generating invoices and paying them) and manual payment recording.

This functionality allows Castle to manage and settle debts with its users directly through the application.
2025-10-23 10:01:33 +02:00

245 lines
8.1 KiB
Python

from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class AccountType(str, Enum):
ASSET = "asset"
LIABILITY = "liability"
EQUITY = "equity"
REVENUE = "revenue"
EXPENSE = "expense"
class JournalEntryFlag(str, Enum):
"""Transaction status flags (Beancount-style)"""
CLEARED = "*" # Fully reconciled/confirmed
PENDING = "!" # Not yet confirmed/awaiting approval
FLAGGED = "#" # Needs review/attention
VOID = "x" # Voided/cancelled entry
class Account(BaseModel):
id: str
name: str
account_type: AccountType
description: Optional[str] = None
user_id: Optional[str] = None # For user-specific accounts
created_at: datetime
class CreateAccount(BaseModel):
name: str
account_type: AccountType
description: Optional[str] = None
user_id: Optional[str] = None
class EntryLine(BaseModel):
id: str
journal_entry_id: str
account_id: str
debit: int = 0 # in satoshis
credit: int = 0 # in satoshis
description: Optional[str] = None
metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc.
class CreateEntryLine(BaseModel):
account_id: str
debit: int = 0
credit: int = 0
description: Optional[str] = None
metadata: dict = {} # Stores currency info
class JournalEntry(BaseModel):
id: str
description: str
entry_date: datetime
created_by: str # wallet ID of user who created it
created_at: datetime
reference: Optional[str] = None # Invoice ID or reference number
lines: list[EntryLine] = []
flag: JournalEntryFlag = JournalEntryFlag.CLEARED # Transaction status
meta: dict = {} # Metadata: source, tags, links, notes, etc.
class CreateJournalEntry(BaseModel):
description: str
entry_date: Optional[datetime] = None
reference: Optional[str] = None
lines: list[CreateEntryLine]
flag: JournalEntryFlag = JournalEntryFlag.CLEARED
meta: dict = {}
class UserBalance(BaseModel):
user_id: str
balance: int # positive = castle owes user, negative = user owes castle
accounts: list[Account] = []
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
class ExpenseEntry(BaseModel):
"""Helper model for creating expense entries"""
description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
expense_account: str # account name or ID
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
user_wallet: str
reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
class ReceivableEntry(BaseModel):
"""Helper model for creating accounts receivable entries"""
description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
revenue_account: str # account name or ID
user_id: str # The user_id (not wallet_id) of the user who owes the castle
reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
class RevenueEntry(BaseModel):
"""Helper model for creating revenue entries"""
description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
revenue_account: str
payment_method_account: str # e.g., "Cash", "Bank", "Lightning"
reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
class CastleSettings(BaseModel):
"""Settings for the Castle extension"""
castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle
updated_at: datetime = Field(default_factory=lambda: datetime.now())
@classmethod
def is_admin_only(cls) -> bool:
return True
class UserCastleSettings(CastleSettings):
"""User-specific settings (stored with user_id)"""
id: str
class UserWalletSettings(BaseModel):
"""Per-user wallet settings"""
user_wallet_id: Optional[str] = None # The wallet ID for this specific user
updated_at: datetime = Field(default_factory=lambda: datetime.now())
class StoredUserWalletSettings(UserWalletSettings):
"""Stored user wallet settings with user ID"""
id: str # user_id
class ManualPaymentRequest(BaseModel):
"""Manual payment request from user to castle"""
id: str
user_id: str
amount: int # in satoshis
description: str
status: str = "pending" # pending, approved, rejected
created_at: datetime
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[str] = None # user_id of castle admin who reviewed
journal_entry_id: Optional[str] = None # set when approved
class CreateManualPaymentRequest(BaseModel):
"""Create a manual payment request"""
amount: int
description: str
class GeneratePaymentInvoice(BaseModel):
"""Generate payment invoice request"""
amount: int
user_id: Optional[str] = None # For admin-generated settlement invoices
class RecordPayment(BaseModel):
"""Record a payment"""
payment_hash: str
class SettleReceivable(BaseModel):
"""Manually settle a receivable (user pays castle in person)"""
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
payment_method: str # "cash", "bank_transfer", "lightning", "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)
class PayUser(BaseModel):
"""Pay a user (castle pays user for expense/liability)"""
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"
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
class AssertionStatus(str, Enum):
"""Status of a balance assertion"""
PENDING = "pending" # Not yet checked
PASSED = "passed" # Assertion passed (balance matches)
FAILED = "failed" # Assertion failed (balance mismatch)
class BalanceAssertion(BaseModel):
"""Assert expected balance at a specific date for reconciliation"""
id: str
date: datetime
account_id: str
expected_balance_sats: int # Expected balance in satoshis
expected_balance_fiat: Optional[Decimal] = None # Optional fiat balance
fiat_currency: Optional[str] = None # Currency for fiat balance (EUR, USD, etc.)
tolerance_sats: int = 0 # Allow +/- this much difference in sats
tolerance_fiat: Decimal = Decimal("0") # Allow +/- this much difference in fiat
checked_balance_sats: Optional[int] = None # Actual balance found
checked_balance_fiat: Optional[Decimal] = None # Actual fiat balance found
difference_sats: Optional[int] = None # Difference in sats
difference_fiat: Optional[Decimal] = None # Difference in fiat
status: AssertionStatus = AssertionStatus.PENDING
created_by: str
created_at: datetime
checked_at: Optional[datetime] = None
class CreateBalanceAssertion(BaseModel):
"""Create a balance assertion"""
account_id: str
date: Optional[datetime] = None # If None, use current time
expected_balance_sats: int
expected_balance_fiat: Optional[Decimal] = None
fiat_currency: Optional[str] = None
tolerance_sats: int = 0
tolerance_fiat: Decimal = Decimal("0")