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:
parent
3248d3dad6
commit
7f9cecefa1
4 changed files with 249 additions and 4 deletions
113
crud.py
113
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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
26
models.py
26
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
|
||||
|
|
|
|||
86
views_api.py
86
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue