Adds user equity eligibility management

Implements functionality to manage user equity eligibility, allowing admins to grant and revoke access.

Adds database migration, models, CRUD operations, and API endpoints for managing user equity status.
This feature enables finer-grained control over who can convert expenses to equity contributions.
Validates a user's eligibility before allowing them to submit expenses as equity.
This commit is contained in:
padreug 2025-11-07 16:51:55 +01:00
parent 3248d3dad6
commit 7f9cecefa1
4 changed files with 249 additions and 4 deletions

113
crud.py
View file

@ -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]

View file

@ -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;
"""
)

View file

@ -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

View file

@ -287,9 +287,29 @@ 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)
@ -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()