castle/models.py
padreug 92c1649f3b Adds account permissioning system
Adds an account permissioning system to allow granular control over account access.

Introduces the ability to grant users specific permissions (read, submit_expense, manage) on individual accounts.  This includes support for hierarchical permission inheritance, where permissions on parent accounts cascade to child accounts.

Adds new API endpoints for managing account permissions, including granting, listing, and revoking permissions.

Integrates permission checks into existing endpoints, such as creating expense entries, to ensure that users only have access to the accounts they are authorized to use.

Fixes #33 - Implements role based access control
2025-11-07 17:55:59 +01:00

320 lines
11 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.)
entry_date: Optional[datetime] = None # Date of the expense transaction
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", "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):
"""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", "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):
"""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")
class UserEquityStatus(BaseModel):
"""Tracks user's equity eligibility and status"""
user_id: str # User's wallet ID
is_equity_eligible: bool # Can user convert expenses to equity?
equity_account_name: Optional[str] = None # e.g., "Equity:Alice"
notes: Optional[str] = None # Admin notes
granted_by: str # Admin who granted eligibility
granted_at: datetime
revoked_at: Optional[datetime] = None # If eligibility was revoked
class CreateUserEquityStatus(BaseModel):
"""Create or update user equity eligibility"""
user_id: str
is_equity_eligible: bool
equity_account_name: Optional[str] = None
notes: Optional[str] = None
class UserInfo(BaseModel):
"""User information including equity eligibility"""
user_id: str
is_equity_eligible: bool
equity_account_name: Optional[str] = None
class PermissionType(str, Enum):
"""Types of permissions for account access"""
READ = "read" # Can view account and its balance
SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account
MANAGE = "manage" # Can modify account (admin level)
class AccountPermission(BaseModel):
"""Defines which accounts a user can access"""
id: str # Unique permission ID
user_id: str # User's wallet ID (from invoice key)
account_id: str # Account ID from accounts table
permission_type: PermissionType
granted_by: str # Admin user ID who granted permission
granted_at: datetime
expires_at: Optional[datetime] = None # Optional expiration
notes: Optional[str] = None # Admin notes about this permission
class CreateAccountPermission(BaseModel):
"""Create account permission"""
user_id: str
account_id: str
permission_type: PermissionType
expires_at: Optional[datetime] = None
notes: Optional[str] = None
class AccountWithPermissions(BaseModel):
"""Account with user-specific permission metadata"""
id: str
name: str
account_type: AccountType
description: Optional[str] = None
user_id: Optional[str] = None
created_at: datetime
# Only included when filter_by_user=true
user_permissions: Optional[list[PermissionType]] = None
inherited_from: Optional[str] = None # Parent account ID if inherited
# Hierarchical structure
parent_account: Optional[str] = None # Parent account name
level: Optional[int] = None # Depth in hierarchy (0 = top level)
has_children: Optional[bool] = None # Whether this account has sub-accounts