Adds settings to the Castle extension for integration with a Fava/Beancount accounting system. This enables all accounting operations to be managed through Fava. It includes settings for the Fava URL, ledger slug, and request timeout.
324 lines
11 KiB
Python
324 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
|
|
amount: int # in satoshis; positive = debit, negative = credit
|
|
description: Optional[str] = None
|
|
metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc.
|
|
|
|
|
|
class CreateEntryLine(BaseModel):
|
|
account_id: str
|
|
amount: int # in satoshis; positive = debit, negative = credit
|
|
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
|
|
|
|
# Fava/Beancount integration - ALL accounting is done via Fava
|
|
fava_url: str = "http://localhost:3333" # Base URL of Fava server
|
|
fava_ledger_slug: str = "castle-accounting" # Ledger identifier in Fava URL
|
|
fava_timeout: float = 10.0 # Request timeout in seconds
|
|
|
|
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 # Auto-generated as "Equity:User-{user_id}" if not provided
|
|
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
|