diff --git a/crud.py b/crud.py index 81e601a..db7f134 100644 --- a/crud.py +++ b/crud.py @@ -949,3 +949,116 @@ async def delete_balance_assertion(assertion_id: str) -> None: "DELETE FROM balance_assertions WHERE id = :id", {"id": assertion_id}, ) + + +# User Equity Status CRUD operations + + +async def get_user_equity_status(user_id: str) -> Optional["UserEquityStatus"]: + """Get user's equity eligibility status""" + from .models import UserEquityStatus + + row = await db.fetchone( + """ + SELECT * FROM user_equity_status + WHERE user_id = :user_id + """, + {"user_id": user_id}, + ) + + return UserEquityStatus(**row) if row else None + + +async def create_or_update_user_equity_status( + data: "CreateUserEquityStatus", granted_by: str +) -> "UserEquityStatus": + """Create or update user equity eligibility status""" + from datetime import datetime + from .models import UserEquityStatus + + # Check if user already has equity status + existing = await get_user_equity_status(data.user_id) + + if existing: + # Update existing record + await db.execute( + """ + UPDATE user_equity_status + SET is_equity_eligible = :is_equity_eligible, + equity_account_name = :equity_account_name, + notes = :notes, + granted_by = :granted_by, + granted_at = :granted_at, + revoked_at = :revoked_at + WHERE user_id = :user_id + """, + { + "user_id": data.user_id, + "is_equity_eligible": data.is_equity_eligible, + "equity_account_name": data.equity_account_name, + "notes": data.notes, + "granted_by": granted_by, + "granted_at": datetime.now(), + "revoked_at": None if data.is_equity_eligible else datetime.now(), + }, + ) + else: + # Create new record + await db.execute( + """ + INSERT INTO user_equity_status ( + user_id, is_equity_eligible, equity_account_name, + notes, granted_by, granted_at + ) + VALUES ( + :user_id, :is_equity_eligible, :equity_account_name, + :notes, :granted_by, :granted_at + ) + """, + { + "user_id": data.user_id, + "is_equity_eligible": data.is_equity_eligible, + "equity_account_name": data.equity_account_name, + "notes": data.notes, + "granted_by": granted_by, + "granted_at": datetime.now(), + }, + ) + + # Return the created/updated record + result = await get_user_equity_status(data.user_id) + if not result: + raise ValueError(f"Failed to create/update equity status for user {data.user_id}") + return result + + +async def revoke_user_equity_eligibility(user_id: str) -> Optional["UserEquityStatus"]: + """Revoke user's equity contribution eligibility""" + from datetime import datetime + + await db.execute( + """ + UPDATE user_equity_status + SET is_equity_eligible = FALSE, + revoked_at = :revoked_at + WHERE user_id = :user_id + """, + {"user_id": user_id, "revoked_at": datetime.now()}, + ) + + return await get_user_equity_status(user_id) + + +async def get_all_equity_eligible_users() -> list["UserEquityStatus"]: + """Get all equity-eligible users""" + from .models import UserEquityStatus + + rows = await db.fetchall( + """ + SELECT * FROM user_equity_status + WHERE is_equity_eligible = TRUE + ORDER BY granted_at DESC + """ + ) + + return [UserEquityStatus(**row) for row in rows] diff --git a/migrations.py b/migrations.py index 5efb00d..345e09a 100644 --- a/migrations.py +++ b/migrations.py @@ -363,3 +363,31 @@ async def m009_add_onchain_bitcoin_account(db): "description": "On-chain Bitcoin wallet" } ) + + +async def m010_user_equity_status(db): + """ + Create user_equity_status table for managing equity contribution eligibility. + Only equity-eligible users can convert their expenses to equity contributions. + """ + await db.execute( + f""" + CREATE TABLE user_equity_status ( + user_id TEXT PRIMARY KEY, + is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE, + equity_account_name TEXT, + notes TEXT, + granted_by TEXT NOT NULL, + granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + revoked_at TIMESTAMP + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_user_equity_status_eligible + ON user_equity_status (is_equity_eligible) + WHERE is_equity_eligible = TRUE; + """ + ) diff --git a/models.py b/models.py index ffde1c6..5881a8a 100644 --- a/models.py +++ b/models.py @@ -247,3 +247,29 @@ class CreateBalanceAssertion(BaseModel): 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 diff --git a/views_api.py b/views_api.py index f8bc5eb..755cfba 100644 --- a/views_api.py +++ b/views_api.py @@ -287,10 +287,30 @@ async def api_create_expense_entry( # Get or create user-specific account if data.is_equity: - # Equity contribution - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.EQUITY, "Member Equity" - ) + # Validate equity eligibility + from .crud import get_user_equity_status + + equity_status = await get_user_equity_status(wallet.wallet.user) + + if not equity_status or not equity_status.is_equity_eligible: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", + ) + + if not equity_status.equity_account_name: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="User equity account not configured. Contact administrator.", + ) + + # Equity contribution - use user's specific equity account + user_account = await get_account_by_name(equity_status.equity_account_name) + if not user_account: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", + ) else: # Liability (castle owes user) user_account = await get_or_create_user_account( @@ -1820,3 +1840,61 @@ async def api_run_daily_reconciliation( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error running daily reconciliation: {str(e)}", ) + + +# ===== USER EQUITY ELIGIBILITY ENDPOINTS ===== + + +@castle_api_router.get("/api/v1/user/info") +async def api_get_user_info( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> UserInfo: + """Get current user's information including equity eligibility""" + from .crud import get_user_equity_status + from .models import UserInfo + + equity_status = await get_user_equity_status(wallet.wallet.user) + + return UserInfo( + user_id=wallet.wallet.user, + is_equity_eligible=equity_status.is_equity_eligible if equity_status else False, + equity_account_name=equity_status.equity_account_name if equity_status else None, + ) + + +@castle_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED) +async def api_grant_equity_eligibility( + data: CreateUserEquityStatus, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> UserEquityStatus: + """Grant equity contribution eligibility to a user (admin only)""" + from .crud import create_or_update_user_equity_status + + return await create_or_update_user_equity_status(data, wallet.wallet.user) + + +@castle_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}") +async def api_revoke_equity_eligibility( + user_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> UserEquityStatus: + """Revoke equity contribution eligibility from a user (admin only)""" + from .crud import revoke_user_equity_eligibility + + result = await revoke_user_equity_eligibility(user_id) + if not result: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"User {user_id} not found in equity status records", + ) + return result + + +@castle_api_router.get("/api/v1/admin/equity-eligibility") +async def api_list_equity_eligible_users( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> list[UserEquityStatus]: + """List all equity-eligible users (admin only)""" + from .crud import get_all_equity_eligible_users + + return await get_all_equity_eligible_users()