From 45d2b67a9d3b49f5018a5c82016b602e5d0ed708 Mon Sep 17 00:00:00 2001 From: Joseph Pollack Date: Thu, 29 Jan 2026 21:21:57 +0100 Subject: [PATCH 1/7] initial commit alpaca brokerage --- .env.example | 17 ++ README.md | 2 +- ...f9a0b1c2d3_add_alpaca_customer_accounts.py | 53 ++++ app/api/__init__.py | 2 + app/api/brokerage_routes.py | 219 +++++++++++++++ app/api/trading_routes.py | 52 +++- app/core/config.py | 35 +++ app/db/models.py | 47 +++- app/policies/compliance/kyc_compliance.yaml | 18 ++ app/services/alpaca_account_service.py | 255 ++++++++++++++++++ app/services/alpaca_broker_service.py | 213 +++++++++++++++ app/services/background_tasks.py | 42 +++ app/services/kyc_service.py | 7 + app/services/order_service.py | 32 ++- app/services/plaid_service.py | 44 +++ app/services/portfolio_aggregation_service.py | 5 +- app/services/trading_api_service.py | 180 +++++++++++++ client/src/components/BrokerageOnboarding.tsx | 219 +++++++++++++++ client/src/components/LinkAccounts.tsx | 51 +++- client/src/components/PortfolioDashboard.tsx | 27 +- client/src/components/trading/OrderForm.tsx | 6 +- .../components/trading/TradingDashboard.tsx | 26 ++ client/src/pages/UserSettings.tsx | 11 +- docs/guides/alpaca-trading-setup.md | 29 ++ docs/guides/alpaca-trading-setup.mdx | 17 ++ server.py | 2 + tests/integration/test_brokerage_flow.py | 49 ++++ tests/unit/test_alpaca_account_service.py | 105 ++++++++ tests/unit/test_alpaca_broker_service.py | 111 ++++++++ 29 files changed, 1854 insertions(+), 22 deletions(-) create mode 100644 alembic/versions/e8f9a0b1c2d3_add_alpaca_customer_accounts.py create mode 100644 app/api/brokerage_routes.py create mode 100644 app/services/alpaca_account_service.py create mode 100644 app/services/alpaca_broker_service.py create mode 100644 client/src/components/BrokerageOnboarding.tsx create mode 100644 tests/integration/test_brokerage_flow.py create mode 100644 tests/unit/test_alpaca_account_service.py create mode 100644 tests/unit/test_alpaca_broker_service.py diff --git a/.env.example b/.env.example index e3fa660..b3a2a7d 100644 --- a/.env.example +++ b/.env.example @@ -262,11 +262,28 @@ COMPANIES_HOUSE_API_KEY= # See docs/guides/alpaca-trading-setup.md # Trading: place/cancel orders, portfolio, market data in Trading Dashboard. # Historical bars: when ALPACA_DATA_ENABLED=true, used for stock prediction and backtest; else yahooquery. +# Note: For multiuser brokerage use ALPACA_BROKER_* below; Trading API vars are for data/backtest only. ALPACA_BASE_URL=https://paper-api.alpaca.markets ALPACA_API_KEY= ALPACA_API_SECRET= ALPACA_DATA_ENABLED=false +# ============================================================================= +# Alpaca Broker API (multiuser brokerage) +# ============================================================================= +# Each user gets an Alpaca customer account; orders are placed per account. +# Sandbox: https://broker-api.sandbox.alpaca.markets | Live: https://broker-api.alpaca.markets +ALPACA_BROKER_BASE_URL=https://broker-api.sandbox.alpaca.markets +ALPACA_BROKER_API_KEY= +ALPACA_BROKER_API_SECRET= +ALPACA_BROKER_PAPER=true + +# Brokerage onboarding product and optional fee (Plaid link-for-brokerage + payment) +BROKERAGE_ONBOARDING_PRODUCT_ID=brokerage_onboarding +BROKERAGE_ONBOARDING_FEE_ENABLED=false +BROKERAGE_ONBOARDING_FEE_AMOUNT=0.00 +BROKERAGE_ONBOARDING_FEE_CURRENCY=USD + # ============================================================================= # Stock Prediction & Modal (Chronos) # ============================================================================= diff --git a/README.md b/README.md index 1cda4f3..4ad5326 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ [![Green Finance](https://img.shields.io/badge/Green-Finance-green?style=flat-square)](https://tonic-ai.mintlify.app/features/green-finance) [![Join us on Discord](https://img.shields.io/discord/1109943800132010065?label=Discord&logo=discord&style=flat-square)](https://discord.gg/qdfnvSPcqP) -CreditNexus is a next-generation financial operating system that bridges the gap between **Sustainabiity-Linked Loans (Legal Contracts)** and **Physical Reality (Satellite Data)**. It uses AI agents to extract covenants from PDF agreements and orchestrates "Ground Truth" verification using geospatial deep learning. +CreditNexus is a next-generation financial operating system that bridges the gap between **Sustainabiity-Linked Loans (Legal Contracts)** and **Physical Reality (Satellite Data)**. It uses AI agents to extract covenants from PDF agreements and orchestrates "Ground Truth" verification using geospatial deep learning. **Multiuser trading brokerage** is supported via the Alpaca Broker API (one Alpaca customer account per user), with optional Plaid bank linking and onboarding fees. > 📚 **[Full Documentation](https://tonic-ai.mintlify.app)** | 🏢 **[Company Site](https://josephrp.github.io/creditnexus)** | 🎥 **[Demo Video](https://www.youtube.com/watch?v=jg25So46Wks)** diff --git a/alembic/versions/e8f9a0b1c2d3_add_alpaca_customer_accounts.py b/alembic/versions/e8f9a0b1c2d3_add_alpaca_customer_accounts.py new file mode 100644 index 0000000..dee206b --- /dev/null +++ b/alembic/versions/e8f9a0b1c2d3_add_alpaca_customer_accounts.py @@ -0,0 +1,53 @@ +"""add alpaca customer accounts and order alpaca_account_id + +Revision ID: e8f9a0b1c2d3 +Revises: c4d5e6f7a8b9 +Create Date: 2026-01-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "e8f9a0b1c2d3" +down_revision: Union[str, Sequence[str], None] = "c4d5e6f7a8b9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "alpaca_customer_accounts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("alpaca_account_id", sa.String(length=64), nullable=False), + sa.Column("account_number", sa.String(length=64), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False, server_default="SUBMITTED"), + sa.Column("currency", sa.String(length=3), nullable=False, server_default="USD"), + sa.Column("action_required_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_alpaca_customer_accounts_user_id"), "alpaca_customer_accounts", ["user_id"], unique=True) + op.create_index(op.f("ix_alpaca_customer_accounts_alpaca_account_id"), "alpaca_customer_accounts", ["alpaca_account_id"], unique=True) + op.create_index(op.f("ix_alpaca_customer_accounts_account_number"), "alpaca_customer_accounts", ["account_number"], unique=False) + op.create_index(op.f("ix_alpaca_customer_accounts_status"), "alpaca_customer_accounts", ["status"], unique=False) + + op.add_column("orders", sa.Column("alpaca_account_id", sa.String(length=64), nullable=True)) + op.create_index(op.f("ix_orders_alpaca_account_id"), "orders", ["alpaca_account_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_orders_alpaca_account_id"), table_name="orders") + op.drop_column("orders", "alpaca_account_id") + + op.drop_index(op.f("ix_alpaca_customer_accounts_status"), table_name="alpaca_customer_accounts") + op.drop_index(op.f("ix_alpaca_customer_accounts_account_number"), table_name="alpaca_customer_accounts") + op.drop_index(op.f("ix_alpaca_customer_accounts_alpaca_account_id"), table_name="alpaca_customer_accounts") + op.drop_index(op.f("ix_alpaca_customer_accounts_user_id"), table_name="alpaca_customer_accounts") + op.drop_table("alpaca_customer_accounts") diff --git a/app/api/__init__.py b/app/api/__init__.py index 6973ce0..a845c3d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -7,6 +7,7 @@ from app.api.signature_routes import signature_router from app.api.kyc_routes import kyc_router from app.api.structured_products_routes import router as structured_products_router +from app.api.brokerage_routes import router as brokerage_router api_router = APIRouter(prefix="/api") @@ -17,3 +18,4 @@ api_router.include_router(signature_router) api_router.include_router(kyc_router) api_router.include_router(structured_products_router) +api_router.include_router(brokerage_router) \ No newline at end of file diff --git a/app/api/brokerage_routes.py b/app/api/brokerage_routes.py new file mode 100644 index 0000000..9303ec8 --- /dev/null +++ b/app/api/brokerage_routes.py @@ -0,0 +1,219 @@ +"""Brokerage API: Alpaca account opening (apply, status, documents).""" + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import require_auth +from app.core.config import settings +from app.db import get_db +from app.db.models import User, AlpacaCustomerAccount +from app.db.models import AuditAction +from app.services.alpaca_account_service import open_alpaca_account, AlpacaAccountServiceError +from app.services.alpaca_broker_service import get_broker_client, AlpacaBrokerAPIError +from app.services.plaid_service import ( + create_link_token_for_brokerage, + get_identity, + get_plaid_connection, +) +from app.utils.audit import log_audit_action + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/brokerage", tags=["brokerage"]) + + +class AccountStatusResponse(BaseModel): + """Brokerage account status for the current user.""" + has_account: bool + status: Optional[str] = None + alpaca_account_id: Optional[str] = None + account_number: Optional[str] = None + action_required_reason: Optional[str] = None + currency: str = "USD" + + +@router.get("/link-token", response_model=Dict[str, Any]) +async def brokerage_link_token( + current_user: User = Depends(require_auth), +): + """Get Plaid Link token for brokerage onboarding (link-for-brokerage). Optional fee info when fee enabled.""" + result = create_link_token_for_brokerage(current_user.id) + if "error" in result: + raise HTTPException(status_code=503, detail=result["error"]) + out = {"link_token": result["link_token"]} + if getattr(settings, "BROKERAGE_ONBOARDING_FEE_ENABLED", False): + out["fee_enabled"] = True + out["fee_amount"] = str(getattr(settings, "BROKERAGE_ONBOARDING_FEE_AMOUNT", 0)) + out["fee_currency"] = getattr( + getattr(settings, "BROKERAGE_ONBOARDING_FEE_CURRENCY", None), "value", "USD" + ) + out["product_id"] = getattr(settings, "BROKERAGE_ONBOARDING_PRODUCT_ID", "brokerage_onboarding") + else: + out["fee_enabled"] = False + return out + + +@router.get("/prefill", response_model=Dict[str, Any]) +async def brokerage_prefill( + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """Get identity/account prefill from linked Plaid connection for brokerage application form.""" + conn = get_plaid_connection(db, current_user.id) + if not conn or not getattr(conn, "connection_data", None) or not isinstance(conn.connection_data, dict): + return {"prefill": {}, "message": "No linked bank account. Link an account to prefill the form."} + access_token = conn.connection_data.get("access_token") + if not access_token: + return {"prefill": {}, "message": "Plaid connection missing access token."} + identity_resp = get_identity(access_token) + if "error" in identity_resp: + return {"prefill": {}, "message": identity_resp.get("error", "Could not fetch identity.")} + accounts = identity_resp.get("accounts") or [] + prefill_data: Dict[str, Any] = {} + for acc in accounts: + owners = acc.get("owners") or [] + for owner in owners: + if not isinstance(owner, dict): + continue + names = owner.get("names") or [] + if names and isinstance(names, list): + full = (names[0] or "").strip() + parts = full.split(None, 1) + prefill_data["given_name"] = parts[0] if parts else "" + prefill_data["family_name"] = parts[1] if len(parts) > 1 else (names[1] if len(names) > 1 else "") + addrs = owner.get("addresses") or [] + for a in addrs: + if isinstance(a, dict) and a.get("data"): + d = a["data"] + prefill_data["street_address"] = d.get("street") or "" + prefill_data["city"] = d.get("city") or "" + prefill_data["state"] = d.get("region") or "" + prefill_data["postal_code"] = d.get("postal_code") or "" + prefill_data["country"] = d.get("country") or "US" + break + if prefill_data: + break + if prefill_data: + break + return {"prefill": prefill_data} + + +@router.post("/account/apply", response_model=Dict[str, Any]) +async def brokerage_account_apply( + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """Submit Alpaca Broker account application. Requires KYC to be sufficient.""" + try: + rec = open_alpaca_account(current_user.id, db) + return { + "status": "submitted", + "alpaca_account_id": rec.alpaca_account_id, + "account_status": rec.status, + "message": "Application submitted. You will receive status updates; check GET /api/brokerage/account/status.", + } + except AlpacaAccountServiceError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/account/status", response_model=AccountStatusResponse) +async def brokerage_account_status( + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """Get current user's Alpaca Broker account status.""" + acc = ( + db.query(AlpacaCustomerAccount) + .filter(AlpacaCustomerAccount.user_id == current_user.id) + .first() + ) + if not acc: + return AccountStatusResponse(has_account=False, currency="USD") + return AccountStatusResponse( + has_account=True, + status=acc.status, + alpaca_account_id=acc.alpaca_account_id, + account_number=acc.account_number, + action_required_reason=acc.action_required_reason, + currency=acc.currency or "USD", + ) + + +@router.post("/account/documents", response_model=Dict[str, Any]) +async def brokerage_account_documents( + document_type: str = Form(..., description="e.g. identity_document, address_verification"), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(require_auth), +): + """Upload a document for Alpaca account (when status is ACTION_REQUIRED).""" + acc = ( + db.query(AlpacaCustomerAccount) + .filter( + AlpacaCustomerAccount.user_id == current_user.id, + AlpacaCustomerAccount.status == "ACTION_REQUIRED", + ) + .first() + ) + if not acc: + raise HTTPException( + status_code=400, + detail="No brokerage account in ACTION_REQUIRED status. Apply first or check status.", + ) + client = get_broker_client() + if not client: + raise HTTPException(status_code=503, detail="Broker API not configured") + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Empty file") + try: + import io + client.upload_document( + acc.alpaca_account_id, + document_type=document_type, + file_content=io.BytesIO(content), + filename=file.filename or "document.pdf", + content_type=file.content_type or "application/pdf", + ) + except AlpacaBrokerAPIError as e: + logger.warning("Broker document upload failed: %s", e) + raise HTTPException(status_code=502, detail=f"Broker API error: {e}") + log_audit_action( + db=db, + action=AuditAction.UPDATE, + target_type="alpaca_customer_account", + target_id=acc.id, + user_id=current_user.id, + metadata={ + "alpaca_account_id": acc.alpaca_account_id, + "brokerage_event": "document_upload", + "document_type": document_type, + }, + ) + return {"status": "uploaded", "message": "Document submitted for review."} + + +@router.post("/fund", response_model=Dict[str, Any]) +async def brokerage_fund_placeholder( + current_user: User = Depends(require_auth), +): + """Placeholder: fund account from bank (Plaid/ACH). Not implemented.""" + raise HTTPException( + status_code=501, + detail="Fund account not yet implemented. See docs for future Plaid/ACH integration.", + ) + + +@router.post("/withdraw", response_model=Dict[str, Any]) +async def brokerage_withdraw_placeholder( + current_user: User = Depends(require_auth), +): + """Placeholder: withdraw from brokerage account. Not implemented.""" + raise HTTPException( + status_code=501, + detail="Withdraw not yet implemented. See docs for future integration.", + ) diff --git a/app/api/trading_routes.py b/app/api/trading_routes.py index 15ad18a..63687fd 100644 --- a/app/api/trading_routes.py +++ b/app/api/trading_routes.py @@ -13,7 +13,14 @@ from app.auth.jwt_auth import get_current_user, require_auth from app.core.permissions import has_permission, PERMISSION_TRADE_VIEW, PERMISSION_TRADE_EXECUTE from app.services.order_service import OrderService, OrderValidationError -from app.services.trading_api_service import TradingAPIService, TradingAPIError, MockTradingAPIService, AlpacaTradingAPIService +from app.services.trading_api_service import ( + TradingAPIService, + TradingAPIError, + MockTradingAPIService, + AlpacaTradingAPIService, + AlpacaBrokerTradingAPIService, +) +from app.db.models import AlpacaCustomerAccount from app.services.commission_service import CommissionService from app.services.market_data_service import get_historical_data, is_valid_symbol from app.core.config import settings @@ -134,13 +141,41 @@ class ManualHoldingResponse(BaseModel): # Service Dependencies # ============================================================================ -def get_trading_api_service() -> TradingAPIService: - """Get trading API service instance.""" - # Check if Alpaca credentials are configured +def get_trading_api_service( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> TradingAPIService: + """Get trading API service: Broker (per-account) when configured and user has ACTIVE account, else legacy or mock.""" + # 1. Broker API: if configured and user has ACTIVE Alpaca account, use per-account broker service + broker_key = getattr(settings, "ALPACA_BROKER_API_KEY", None) + if broker_key and current_user: + acc = db.query(AlpacaCustomerAccount).filter( + AlpacaCustomerAccount.user_id == current_user.id, + AlpacaCustomerAccount.status == "ACTIVE", + ).first() + if acc: + try: + return AlpacaBrokerTradingAPIService(alpaca_account_id=acc.alpaca_account_id) + except TradingAPIError as e: + logger.warning("Alpaca Broker service init failed: %s. Falling back.", e) + else: + # Broker API is configured but user has no ACTIVE account: require brokerage onboarding + has_any = db.query(AlpacaCustomerAccount).filter( + AlpacaCustomerAccount.user_id == current_user.id, + ).first() + if has_any: + raise HTTPException( + status_code=403, + detail="Complete brokerage onboarding. Your trading account is not yet active; check status or upload documents in Settings.", + ) + raise HTTPException( + status_code=403, + detail="Complete brokerage onboarding to trade. Open Settings → Trading account to apply.", + ) + # 2. Legacy Alpaca Trading API (single account) alpaca_key = getattr(settings, "ALPACA_API_KEY", None) alpaca_secret = getattr(settings, "ALPACA_API_SECRET", None) alpaca_base_url = getattr(settings, "ALPACA_BASE_URL", None) - if alpaca_key and alpaca_secret: try: k = alpaca_key.get_secret_value() if hasattr(alpaca_key, "get_secret_value") else str(alpaca_key) @@ -151,11 +186,10 @@ def get_trading_api_service() -> TradingAPIService: base_url=alpaca_base_url ) except Exception as e: - logger.warning(f"Failed to initialize Alpaca API service: {e}. Using mock service.") + logger.warning("Failed to initialize Alpaca API service: %s. Using mock service.", e) return MockTradingAPIService() - else: - logger.info("Alpaca credentials not configured. Using mock trading API service.") - return MockTradingAPIService() + logger.info("Alpaca credentials not configured. Using mock trading API service.") + return MockTradingAPIService() def get_order_service( diff --git a/app/core/config.py b/app/core/config.py index a1fd370..a0a0f33 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -283,6 +283,41 @@ class Settings(BaseSettings): description="Use Alpaca for historical bars in stock prediction and backtesting when ALPACA_API_KEY/SECRET set", ) + # Alpaca Broker API (multiuser brokerage; each user gets an Alpaca customer account) + ALPACA_BROKER_API_KEY: Optional[SecretStr] = Field( + default=None, + description="Alpaca Broker API key (for account opening and per-account trading)", + ) + ALPACA_BROKER_API_SECRET: Optional[SecretStr] = Field( + default=None, + description="Alpaca Broker API secret", + ) + ALPACA_BROKER_BASE_URL: Optional[str] = Field( + default="https://broker-api.sandbox.alpaca.markets", + description="Alpaca Broker API base URL (sandbox: broker-api.sandbox.alpaca.markets; live: broker-api.alpaca.markets)", + ) + # Brokerage onboarding product and optional fee (Plaid link-for-brokerage + payment) + BROKERAGE_ONBOARDING_PRODUCT_ID: str = Field( + default="brokerage_onboarding", + description="Product ID for brokerage onboarding (used with Plaid link and billing)", + ) + BROKERAGE_ONBOARDING_FEE_ENABLED: bool = Field( + default=False, + description="When True, require payment (fee) before or after brokerage account application", + ) + BROKERAGE_ONBOARDING_FEE_AMOUNT: Decimal = Field( + default=Decimal("0.00"), + description="Optional onboarding fee amount (e.g. 9.99)", + ) + BROKERAGE_ONBOARDING_FEE_CURRENCY: Currency = Field( + default=Currency.USD, + description="Currency for brokerage onboarding fee", + ) + ALPACA_BROKER_PAPER: bool = Field( + default=True, + description="Use Alpaca Broker sandbox/paper when True", + ) + # Stock Prediction STOCK_PREDICTION_ENABLED: bool = Field( default=False, diff --git a/app/db/models.py b/app/db/models.py index 00a6052..36bd8b8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -310,6 +310,13 @@ class User(Base): "Meeting", back_populates="organizer", foreign_keys="Meeting.organizer_id" ) implementation_connections = relationship("UserImplementationConnection", back_populates="user") + alpaca_customer_account = relationship( + "AlpacaCustomerAccount", + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + foreign_keys="AlpacaCustomerAccount.user_id", + ) organization_identifier = Column(EncryptedString(255), nullable=True, index=True) # Organization alias, blockchain address, or key organization_id = Column(Integer, ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True, index=True) organization = relationship("Organization", back_populates="users", foreign_keys=[organization_id]) @@ -3853,7 +3860,7 @@ class VerifiedImplementation(Base): created_at = Column(DateTime, default=datetime.utcnow, nullable=False) user_connections = relationship("UserImplementationConnection", back_populates="implementation") - + def to_dict(self): """Convert model to dictionary.""" return { @@ -3868,6 +3875,40 @@ def to_dict(self): } +class AlpacaCustomerAccount(Base): + """Alpaca Broker API customer account link (one per user).""" + + __tablename__ = "alpaca_customer_accounts" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + alpaca_account_id = Column(String(64), unique=True, nullable=False, index=True) # Alpaca account UUID + account_number = Column(String(64), nullable=True, index=True) # Human-readable account number from Alpaca + status = Column( + String(32), nullable=False, index=True, default="SUBMITTED" + ) # SUBMITTED, APPROVED, ACTIVE, ACTION_REQUIRED, REJECTED, APPROVAL_PENDING + currency = Column(String(3), default="USD", nullable=False) + action_required_reason = Column(Text, nullable=True) # Reason when status is ACTION_REQUIRED + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", back_populates="alpaca_customer_account", foreign_keys=[user_id]) + + def to_dict(self): + """Convert model to dictionary.""" + return { + "id": self.id, + "user_id": self.user_id, + "alpaca_account_id": self.alpaca_account_id, + "account_number": self.account_number, + "status": self.status, + "currency": self.currency, + "action_required_reason": self.action_required_reason, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + class UserImplementationConnection(Base): """User's connection to a verified implementation.""" @@ -4213,7 +4254,8 @@ class Order(Base): commission_currency = Column(String(3), default="USD", nullable=False) # Trading API integration - trading_api = Column(String(50), nullable=True, index=True) # "alpaca", "polygon", etc. + trading_api = Column(String(50), nullable=True, index=True) # "alpaca", "alpaca_broker", "polygon", etc. + alpaca_account_id = Column(String(64), nullable=True, index=True) # Alpaca Broker customer account ID (when trading_api=alpaca_broker) trading_api_order_id = Column(String(255), nullable=True, index=True) # Order ID from trading API trading_api_response = Column(JSONB, nullable=True) # Full response from trading API @@ -4252,6 +4294,7 @@ def to_dict(self): "commission": float(self.commission) if self.commission is not None else None, "commission_currency": self.commission_currency or "USD", "trading_api": self.trading_api, + "alpaca_account_id": self.alpaca_account_id, "trading_api_order_id": self.trading_api_order_id, "time_in_force": self.time_in_force or "day", "expires_at": self.expires_at.isoformat() if self.expires_at else None, diff --git a/app/policies/compliance/kyc_compliance.yaml b/app/policies/compliance/kyc_compliance.yaml index 952bb72..b125eb5 100644 --- a/app/policies/compliance/kyc_compliance.yaml +++ b/app/policies/compliance/kyc_compliance.yaml @@ -15,6 +15,24 @@ # INDIVIDUAL PROFILE KYC RULES # ============================================================================ +# Allow brokerage account opening when identity (and optionally docs) verified +- name: allow_brokerage_identity_verified + when: + all: + - field: profile_type + op: eq + value: "individual" + - field: deal_type + op: eq + value: "brokerage" + - field: identity_verified + op: eq + value: true + action: allow + priority: 98 + description: "Allow brokerage (Alpaca account) when identity is verified" + category: "kyc_brokerage" + # Block individuals with insufficient identity verification - name: block_individual_insufficient_identity when: diff --git a/app/services/alpaca_account_service.py b/app/services/alpaca_account_service.py new file mode 100644 index 0000000..62ea4e1 --- /dev/null +++ b/app/services/alpaca_account_service.py @@ -0,0 +1,255 @@ +""" +Alpaca Broker account opening orchestration. + +- open_alpaca_account(user_id, db): KYC gate, build payload from User + KYCVerification, + call Broker API create_account, persist AlpacaCustomerAccount. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from sqlalchemy.orm import Session + +from app.db.models import User, KYCVerification, AlpacaCustomerAccount +from app.services.alpaca_broker_service import get_broker_client, AlpacaBrokerAPIError +from app.services.kyc_service import KYCService +from app.utils.audit import log_audit_action +from app.db.models import AuditAction + +logger = logging.getLogger(__name__) + + +class AlpacaAccountServiceError(Exception): + """Raised when account opening or status update fails.""" + pass + + +def _build_account_payload(user: User, verification: Optional[KYCVerification]) -> Dict[str, Any]: + """Build Alpaca Broker API account creation payload from User and KYCVerification.""" + email = getattr(user, "email", None) or "" + if hasattr(email, "get_secret_value"): + email = email.get_secret_value() or "" + email = str(email) + + display_name = getattr(user, "display_name", None) or email.split("@")[0] or "User" + if hasattr(display_name, "get_secret_value"): + display_name = display_name.get_secret_value() or email.split("@")[0] + display_name = str(display_name).strip() + parts = display_name.split(None, 1) + given_name = parts[0] if parts else "Given" + family_name = parts[1] if len(parts) > 1 else "User" + + profile_data = getattr(user, "profile_data", None) or {} + if isinstance(profile_data, dict): + phone = profile_data.get("phone") or profile_data.get("phone_number") or "" + street = profile_data.get("street_address") or profile_data.get("address") or "" + city = profile_data.get("city") or "" + state = profile_data.get("state") or "" + postal_code = profile_data.get("postal_code") or profile_data.get("zip") or "" + country = profile_data.get("country") or "USA" + else: + phone = street = city = state = postal_code = "" + country = "USA" + + # Alpaca account opening payload (contact, identity, address) + # https://docs.alpaca.markets/reference/createaccount + contact = { + "email_address": email, + "phone_number": str(phone)[:20] if phone else "", + } + identity = { + "given_name": given_name[:50], + "family_name": family_name[:50], + "date_of_birth": "1990-01-01", # Placeholder if not in profile; Alpaca may require or return ACTION_REQUIRED + } + if isinstance(verification, KYCVerification) and getattr(verification, "verification_metadata", None): + meta = verification.verification_metadata or {} + if isinstance(meta, dict) and meta.get("date_of_birth"): + identity["date_of_birth"] = str(meta["date_of_birth"])[:10] + + address = { + "street_address": [str(street)[:64]] if street else ["N/A"], + "city": city[:32] if city else "N/A", + "state": state[:32] if state else "NY", + "postal_code": str(postal_code)[:10] if postal_code else "10001", + "country": country[:2] if len(country) == 2 else "US", + } + + return { + "contact": contact, + "identity": identity, + "disclosures": { + "is_control_person": False, + "is_affiliated_exchange_or_finra": False, + "is_politically_exposed": False, + "immediate_family_exposed": False, + }, + "agreements": [ + {"agreement": "customer_agreement", "signed_at": None, "ip_address": None}, + {"agreement": "margin_agreement", "signed_at": None, "ip_address": None}, + ], + "documents": [], + "trusted_contact": { + "given_name": given_name[:50], + "family_name": family_name[:50], + "email_address": email, + }, + "address": address, + } + + +def open_alpaca_account(user_id: int, db: Session) -> AlpacaCustomerAccount: + """ + Open an Alpaca Broker account for the user. + - Ensures KYC is sufficient (evaluate_kyc_for_brokerage). + - Builds account payload from User + KYCVerification. + - Calls Broker API create_account. + - Persists AlpacaCustomerAccount (SUBMITTED). + """ + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise AlpacaAccountServiceError(f"User {user_id} not found") + + existing = db.query(AlpacaCustomerAccount).filter(AlpacaCustomerAccount.user_id == user_id).first() + if existing: + if existing.status == "ACTIVE": + return existing + if existing.status in ("SUBMITTED", "APPROVAL_PENDING", "APPROVED"): + raise AlpacaAccountServiceError( + f"Account application already in progress (status: {existing.status})" + ) + if existing.status == "ACTION_REQUIRED": + raise AlpacaAccountServiceError( + "Account application requires action; upload documents via brokerage/account/documents" + ) + if existing.status == "REJECTED": + raise AlpacaAccountServiceError("Account was rejected; contact support to reapply") + + kyc = KYCService(db) + if not kyc.evaluate_kyc_for_brokerage(user_id): + raise AlpacaAccountServiceError( + "KYC not sufficient for brokerage. Complete identity verification and required documents first." + ) + + client = get_broker_client() + if not client: + raise AlpacaAccountServiceError("Broker API not configured (ALPACA_BROKER_API_KEY/SECRET)") + + verification = getattr(user, "kyc_verification", None) + payload = _build_account_payload(user, verification) + + try: + result = client.create_account(payload) + except AlpacaBrokerAPIError as e: + logger.warning("Alpaca create_account failed for user %s: %s", user_id, e) + raise AlpacaAccountServiceError(f"Broker API error: {e}") from e + + account_id = result.get("id") + if not account_id: + raise AlpacaAccountServiceError("Broker API did not return account id") + + status = (result.get("status") or "SUBMITTED").upper() + account_number = result.get("account_number") + currency = result.get("currency") or "USD" + + rec = AlpacaCustomerAccount( + user_id=user_id, + alpaca_account_id=str(account_id), + account_number=account_number, + status=status, + currency=currency, + ) + db.add(rec) + db.commit() + db.refresh(rec) + + log_audit_action( + db=db, + action=AuditAction.CREATE, + target_type="alpaca_customer_account", + target_id=rec.id, + user_id=user_id, + metadata={ + "alpaca_account_id": rec.alpaca_account_id, + "status": rec.status, + }, + ) + logger.info("Alpaca account application submitted for user %s: %s", user_id, rec.alpaca_account_id) + return rec + + +# Statuses that are "final" — no need to poll for updates +_FINAL_STATUSES = frozenset({"ACTIVE", "REJECTED"}) + + +def sync_alpaca_account_status(rec: AlpacaCustomerAccount, db: Session) -> bool: + """ + Poll Alpaca Broker API for account status and update local record. + Returns True if status or account_number/action_required_reason changed. + """ + client = get_broker_client() + if not client: + return False + try: + data = client.get_account(rec.alpaca_account_id) + except AlpacaBrokerAPIError as e: + logger.warning("Alpaca get_account failed for %s: %s", rec.alpaca_account_id, e) + return False + + status = (data.get("status") or rec.status).upper() + account_number = data.get("account_number") or rec.account_number + # Alpaca may return action_required_reason or similar when status is ACTION_REQUIRED + action_reason = ( + data.get("action_required_reason") + or data.get("reason") + or rec.action_required_reason + ) + changed = ( + rec.status != status + or rec.account_number != account_number + or rec.action_required_reason != action_reason + ) + if changed: + previous_status = rec.status + rec.status = status + rec.account_number = account_number + rec.action_required_reason = action_reason + db.commit() + log_audit_action( + db=db, + action=AuditAction.UPDATE, + target_type="alpaca_customer_account", + target_id=rec.id, + user_id=rec.user_id, + metadata={ + "alpaca_account_id": rec.alpaca_account_id, + "status": status, + "previous_status": previous_status, + }, + ) + return changed + + +def sync_all_pending_alpaca_accounts(db: Session) -> Dict[str, Any]: + """ + Sync status from Alpaca for all customer accounts not yet ACTIVE or REJECTED. + Used by background worker (poll Event API / account GET). + """ + pending = ( + db.query(AlpacaCustomerAccount) + .filter(AlpacaCustomerAccount.status.notin_(list(_FINAL_STATUSES))) + .limit(200) + .all() + ) + synced = 0 + errors = 0 + for rec in pending: + try: + if sync_alpaca_account_status(rec, db): + synced += 1 + except Exception as e: + logger.warning("Sync failed for Alpaca account %s: %s", rec.alpaca_account_id, e) + errors += 1 + return {"pending_count": len(pending), "synced": synced, "errors": errors} diff --git a/app/services/alpaca_broker_service.py b/app/services/alpaca_broker_service.py new file mode 100644 index 0000000..49fd695 --- /dev/null +++ b/app/services/alpaca_broker_service.py @@ -0,0 +1,213 @@ +""" +Alpaca Broker API client for multiuser brokerage. + +- Account CRUD: create_account, get_account, update_account +- Trading per account: create_order, get_order, cancel_order, list_orders, get_positions +- Documents: upload_document (for ACTION_REQUIRED) +- Events: account status updates (poll or SSE) + +Broker API uses HTTP Basic auth: base64(API_KEY:API_SECRET). +See: https://docs.alpaca.markets/docs/authentication +""" + +from __future__ import annotations + +import base64 +import logging +from typing import Any, BinaryIO, Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + + +class AlpacaBrokerAPIError(Exception): + """Raised when Alpaca Broker API returns an error.""" + + def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.status_code = status_code + self.response = response or {} + + +class AlpacaBrokerClient: + """HTTP client for Alpaca Broker API (accounts, orders, positions, documents).""" + + def __init__( + self, + api_key: str, + api_secret: str, + base_url: Optional[str] = None, + ): + self.base_url = (base_url or "https://broker-api.sandbox.alpaca.markets").rstrip("/") + credentials = f"{api_key}:{api_secret}" + self._auth_header = "Basic " + base64.b64encode(credentials.encode()).decode() + self._session = requests.Session() + self._session.headers["Authorization"] = self._auth_header + self._session.headers["Content-Type"] = "application/json" + + def _request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + json: Optional[Dict[str, Any]] = None, + data: Optional[Any] = None, + files: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + url = f"{self.base_url}{path}" + try: + resp = self._session.request( + method, + url, + params=params, + json=json, + data=data, + files=files, + timeout=30, + ) + if resp.status_code >= 400: + try: + err_body = resp.json() + except Exception: + err_body = {"message": resp.text or str(resp.status_code)} + raise AlpacaBrokerAPIError( + err_body.get("message") or err_body.get("error") or resp.text or f"HTTP {resp.status_code}", + status_code=resp.status_code, + response=err_body, + ) + if resp.status_code == 204 or not resp.content: + return {} + return resp.json() + except AlpacaBrokerAPIError: + raise + except requests.RequestException as e: + logger.warning("Alpaca Broker API request failed: %s", e) + raise AlpacaBrokerAPIError(str(e)) + + # ------------------------------------------------------------------------- + # Account API + # ------------------------------------------------------------------------- + + def create_account(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """ + POST /v1/accounts — Create a new customer account (KYC submitted to Alpaca). + Returns account id and status (e.g. SUBMITTED). + """ + return self._request("POST", "/v1/accounts", json=payload) + + def get_account(self, account_id: str) -> Dict[str, Any]: + """GET /v1/accounts/{account_id} — Get account details.""" + return self._request("GET", f"/v1/accounts/{account_id}") + + def update_account(self, account_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """PATCH /v1/accounts/{account_id} — Update account (e.g. contact, identity).""" + return self._request("PATCH", f"/v1/accounts/{account_id}", json=payload) + + # ------------------------------------------------------------------------- + # Trading API (per account) + # ------------------------------------------------------------------------- + + def create_order(self, account_id: str, order_request: Dict[str, Any]) -> Dict[str, Any]: + """ + POST /v1/trading/accounts/{account_id}/orders — Submit order for account. + order_request: symbol, qty or notional, side, type, time_in_force, limit_price, stop_price, etc. + """ + return self._request("POST", f"/v1/trading/accounts/{account_id}/orders", json=order_request) + + def get_order(self, account_id: str, order_id: str) -> Dict[str, Any]: + """GET /v1/trading/accounts/{account_id}/orders/{order_id}.""" + return self._request("GET", f"/v1/trading/accounts/{account_id}/orders/{order_id}") + + def cancel_order(self, account_id: str, order_id: str) -> Dict[str, Any]: + """DELETE /v1/trading/accounts/{account_id}/orders/{order_id}.""" + return self._request("DELETE", f"/v1/trading/accounts/{account_id}/orders/{order_id}") + + def list_orders( + self, + account_id: str, + status: Optional[str] = None, + limit: Optional[int] = None, + after: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """GET /v1/trading/accounts/{account_id}/orders.""" + params: Dict[str, Any] = {} + if status: + params["status"] = status + if limit is not None: + params["limit"] = limit + if after: + params["after"] = after + data = self._request("GET", f"/v1/trading/accounts/{account_id}/orders", params=params or None) + return data.get("orders") if isinstance(data.get("orders"), list) else [] + + def get_positions(self, account_id: str) -> List[Dict[str, Any]]: + """GET /v1/trading/accounts/{account_id}/positions.""" + data = self._request("GET", f"/v1/trading/accounts/{account_id}/positions") + return data.get("positions") if isinstance(data.get("positions"), list) else [] + + def get_account_portfolio(self, account_id: str) -> Dict[str, Any]: + """GET /v1/trading/accounts/{account_id}/account — Equity, cash, buying power.""" + return self._request("GET", f"/v1/trading/accounts/{account_id}/account") + + # ------------------------------------------------------------------------- + # Documents (for ACTION_REQUIRED) + # ------------------------------------------------------------------------- + + def upload_document( + self, + account_id: str, + document_type: str, + file_content: BinaryIO, + filename: str, + content_type: str = "application/pdf", + ) -> Dict[str, Any]: + """ + Upload a document for an account (e.g. utility bill for address verification). + Alpaca Document API: POST /v1/accounts/{account_id}/documents/upload + """ + files = {"document": (filename, file_content, content_type)} + data = {"document_type": document_type} + # Many APIs expect multipart/form-data with file + fields + url = f"{self.base_url}/v1/accounts/{account_id}/documents/upload" + headers = {"Authorization": self._auth_header} + # Do not set Content-Type; requests sets it with boundary for multipart + r = self._session.post(url, files=files, data=data, timeout=60) + if r.status_code >= 400: + try: + err_body = r.json() + except Exception: + err_body = {"message": r.text or str(r.status_code)} + raise AlpacaBrokerAPIError( + err_body.get("message") or err_body.get("error") or r.text or f"HTTP {r.status_code}", + status_code=r.status_code, + response=err_body, + ) + if r.status_code == 204 or not r.content: + return {} + return r.json() + + # ------------------------------------------------------------------------- + # CIP (fully-disclosed broker-dealer only) + # ------------------------------------------------------------------------- + + def submit_cip(self, account_id: str, cip_payload: Dict[str, Any]) -> Dict[str, Any]: + """ + POST /v1/accounts/{account_id}/cip — Submit CIP after your KYC (fully-disclosed BD). + Only used when Alpaca relies on your KYC; otherwise Account API submission is enough. + """ + return self._request("POST", f"/v1/accounts/{account_id}/cip", json=cip_payload) + + +def get_broker_client() -> Optional[AlpacaBrokerClient]: + """Build AlpacaBrokerClient from settings if Broker API is configured.""" + from app.core.config import settings + + key = getattr(settings, "ALPACA_BROKER_API_KEY", None) + secret = getattr(settings, "ALPACA_BROKER_API_SECRET", None) + base_url = getattr(settings, "ALPACA_BROKER_BASE_URL", None) + if not key or not secret: + return None + k = key.get_secret_value() if hasattr(key, "get_secret_value") else str(key) + s = secret.get_secret_value() if hasattr(secret, "get_secret_value") else str(secret) + return AlpacaBrokerClient(api_key=k, api_secret=s, base_url=base_url) diff --git a/app/services/background_tasks.py b/app/services/background_tasks.py index cd197c6..88aa5b6 100644 --- a/app/services/background_tasks.py +++ b/app/services/background_tasks.py @@ -20,6 +20,7 @@ from app.agents.filing_verifier import FilingVerifier from app.services.loan_recovery_service import LoanRecoveryService from app.services.asset_amortization_service import AssetAmortizationService +from app.services.alpaca_account_service import sync_all_pending_alpaca_accounts logger = logging.getLogger(__name__) @@ -447,6 +448,42 @@ async def check_price_alerts() -> Dict[str, Any]: pass +async def sync_alpaca_account_statuses_task() -> Dict[str, Any]: + """ + Background task to poll Alpaca Broker API for account status updates. + Runs hourly; syncs AlpacaCustomerAccount records that are not yet ACTIVE or REJECTED. + """ + logger.info("Starting Alpaca account status sync task") + db = None + try: + db = next(get_db()) + result = sync_all_pending_alpaca_accounts(db) + logger.info( + "Alpaca account status sync completed: %s pending, %s synced, %s errors", + result.get("pending_count", 0), + result.get("synced", 0), + result.get("errors", 0), + ) + return { + "status": "success", + "timestamp": datetime.utcnow().isoformat(), + **result, + } + except Exception as e: + logger.error("Error in Alpaca account status sync task: %s", e, exc_info=True) + return { + "status": "error", + "timestamp": datetime.utcnow().isoformat(), + "error": str(e), + } + finally: + try: + if db is not None: + db.close() + except Exception: + pass + + # Task schedule configuration TASK_SCHEDULE = { "deadline_monitoring": { @@ -487,5 +524,10 @@ async def check_price_alerts() -> Dict[str, Any]: "task": check_price_alerts, "schedule": "hourly", "enabled": True + }, + "alpaca_account_status_sync": { + "task": sync_alpaca_account_statuses_task, + "schedule": "hourly", + "enabled": True } } diff --git a/app/services/kyc_service.py b/app/services/kyc_service.py index 47308b7..3d23900 100644 --- a/app/services/kyc_service.py +++ b/app/services/kyc_service.py @@ -203,6 +203,13 @@ def evaluate_kyc_compliance(self, user_id: int, deal_type: Optional[str] = None) "deal_type": deal_type, } + def evaluate_kyc_for_brokerage(self, user_id: int) -> bool: + """Evaluate whether user meets KYC requirements for brokerage (Alpaca account opening). + Uses policy with deal_type='brokerage'; requires identity_verified (and optionally docs). + """ + result = self.evaluate_kyc_compliance(user_id, deal_type="brokerage") + return result.get("compliant", False) is True + def get_kyc_requirements(self, deal_type: str) -> List[Dict[str, Any]]: """Get KYC requirements for a specific deal type.""" # This would typically come from a policy or config diff --git a/app/services/order_service.py b/app/services/order_service.py index c01ecbb..3f65162 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -7,7 +7,7 @@ from datetime import datetime from sqlalchemy.orm import Session -from app.db.models import Order, OrderStatus, OrderSide, OrderType, User +from app.db.models import Order, OrderStatus, OrderSide, OrderType, User, AlpacaCustomerAccount from app.services.trading_api_service import TradingAPIService, TradingAPIError from app.services.commission_service import CommissionService from app.utils.audit import log_audit_action @@ -163,8 +163,20 @@ def create_order( # Generate unique order ID order_id = f"ORD-{uuid.uuid4().hex[:12].upper()}" - # Determine trading API name - trading_api = "alpaca" # Default, can be made configurable + # Determine trading API and Alpaca account (Broker vs legacy) + trading_api = "alpaca" + alpaca_account_id = None + acc = ( + self.db.query(AlpacaCustomerAccount) + .filter( + AlpacaCustomerAccount.user_id == user_id, + AlpacaCustomerAccount.status == "ACTIVE", + ) + .first() + ) + if acc: + trading_api = "alpaca_broker" + alpaca_account_id = acc.alpaca_account_id # Create order order = Order( @@ -180,6 +192,7 @@ def create_order( time_in_force=time_in_force.lower(), expires_at=expires_at, trading_api=trading_api, + alpaca_account_id=alpaca_account_id, order_metadata=metadata or {} ) @@ -187,14 +200,23 @@ def create_order( self.db.commit() self.db.refresh(order) - # Log audit action + # Log audit action (include brokerage context when applicable) + audit_meta = { + "order_id": order_id, + "symbol": symbol, + "side": side, + "order_type": order_type, + } + if trading_api == "alpaca_broker" and alpaca_account_id: + audit_meta["trading_api"] = "alpaca_broker" + audit_meta["alpaca_account_id"] = alpaca_account_id log_audit_action( db=self.db, action=AuditAction.CREATE, target_type="order", target_id=order.id, user_id=user_id, - metadata={"order_id": order_id, "symbol": symbol, "side": side, "order_type": order_type} + metadata=audit_meta, ) logger.info(f"Created order {order_id} for user {user_id}: {side} {quantity} {symbol}") diff --git a/app/services/plaid_service.py b/app/services/plaid_service.py index f23e461..b27ada0 100644 --- a/app/services/plaid_service.py +++ b/app/services/plaid_service.py @@ -132,6 +132,43 @@ def create_link_token(user_id: int) -> Dict[str, Any]: return {"error": str(e)} +def create_link_token_for_brokerage(user_id: int) -> Dict[str, Any]: + """ + Create a Plaid Link token for brokerage onboarding (link-for-brokerage). + Uses auth + identity products for account verification and form prefill. + Returns {"link_token": str} or {"error": str}. + """ + api, err = _get_plaid_client() + if err: + return {"error": err} + + try: + from plaid.model.link_token_create_request import LinkTokenCreateRequest + from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser + from plaid.model.country_code import CountryCode + from plaid.model.products import Products + except ImportError as e: + return {"error": f"Plaid models: {e}"} + + user = LinkTokenCreateRequestUser(client_user_id=str(user_id)) + # Auth (routing/account verification) + Identity (name, address) for brokerage prefill + products = [Products("auth"), Products("identity")] + country_codes = [CountryCode("US")] + req = LinkTokenCreateRequest( + user=user, + client_name="CreditNexus Brokerage", + products=products, + country_codes=country_codes, + language="en", + ) + try: + resp = api.link_token_create(req) + return {"link_token": resp.link_token} + except Exception as e: + logger.warning("Plaid link_token_create (brokerage) failed: %s", e) + return {"error": str(e)} + + def exchange_public_token(public_token: str) -> Dict[str, Any]: """ Exchange public_token for access_token and item_id. @@ -463,6 +500,11 @@ def monitor_aml_screening(*_: Any, **__: Any) -> Dict[str, Any]: return {"error": "monitor_not_implemented"} +# Transfer billing: Plaid charges per transfer (see https://plaid.com/pricing). +# Callers (e.g. brokerage fund/withdraw) should record transfer usage for billing/credits +# via BillingService or RollingCreditsService when BROKERAGE_ONBOARDING_FEE or transfer fees apply. + + def create_transfer( *, access_token: str, @@ -479,6 +521,8 @@ def create_transfer( 1) POST /transfer/authorization/create 2) POST /transfer/create (using authorization) + Billing: Plaid charges per transfer; record usage for billing/credits when applicable. + Returns: - {"authorization": {...}, "transfer": {...}} on success - {"error": "..."} on failure diff --git a/app/services/portfolio_aggregation_service.py b/app/services/portfolio_aggregation_service.py index 3eb0baa..f4c49ec 100644 --- a/app/services/portfolio_aggregation_service.py +++ b/app/services/portfolio_aggregation_service.py @@ -41,9 +41,12 @@ class AggregatedLiabilities: def _get_user_access_token(db: Session, user_id: int) -> Optional[str]: """ Helper to fetch the user's Plaid access_token, if any. + Token is stored in connection_data (dict) on UserImplementationConnection. """ conn = plaid_service.get_plaid_connection(db, user_id) - return getattr(conn, "access_token", None) if conn else None + if not conn or not conn.connection_data or not isinstance(conn.connection_data, dict): + return None + return conn.connection_data.get("access_token") def aggregate_transactions( diff --git a/app/services/trading_api_service.py b/app/services/trading_api_service.py index e28022b..81556cd 100644 --- a/app/services/trading_api_service.py +++ b/app/services/trading_api_service.py @@ -503,6 +503,186 @@ def get_market_data(self, symbol: str, db: Optional[Any] = None) -> Dict[str, An raise TradingAPIError(f"Failed to get market data: {str(e)}") +class AlpacaBrokerTradingAPIService(TradingAPIService): + """Trading API service backed by Alpaca Broker API (per-account).""" + + def __init__(self, alpaca_account_id: str): + from app.services.alpaca_broker_service import get_broker_client + + self.alpaca_account_id = alpaca_account_id + self._client = get_broker_client() + if not self._client: + raise TradingAPIError("Alpaca Broker API not configured (ALPACA_BROKER_API_KEY/SECRET)") + + def _order_request( + self, + symbol: str, + side: str, + order_type: str, + quantity: Decimal, + price: Optional[Decimal] = None, + stop_price: Optional[Decimal] = None, + time_in_force: str = "day", + ) -> Dict[str, Any]: + """Build Broker API order request.""" + req: Dict[str, Any] = { + "symbol": symbol, + "qty": str(int(quantity) if quantity == int(quantity) else float(quantity)), + "side": side.lower(), + "type": order_type.lower(), + "time_in_force": time_in_force.lower(), + } + if order_type.lower() in ("limit", "stop_limit") and price is not None: + req["limit_price"] = str(float(price)) + if order_type.lower() in ("stop", "stop_limit") and stop_price is not None: + req["stop_price"] = str(float(stop_price)) + return req + + def submit_order( + self, + symbol: str, + side: str, + order_type: str, + quantity: Decimal, + price: Optional[Decimal] = None, + stop_price: Optional[Decimal] = None, + time_in_force: str = "day", + ) -> Dict[str, Any]: + try: + from app.services.alpaca_broker_service import AlpacaBrokerAPIError + except ImportError: + AlpacaBrokerAPIError = Exception + req = self._order_request(symbol, side, order_type, quantity, price, stop_price, time_in_force) + try: + order = self._client.create_order(self.alpaca_account_id, req) + except AlpacaBrokerAPIError as e: + logger.error("Alpaca Broker order submission failed: %s", e) + raise TradingAPIError(str(e)) + return self._normalize_order_response(order) + + def _normalize_order_response(self, order: Dict[str, Any]) -> Dict[str, Any]: + """Map Broker API order to existing response shape.""" + return { + "order_id": str(order.get("id", "")), + "status": (order.get("status") or "new").lower(), + "symbol": order.get("symbol", ""), + "side": (order.get("side") or "").lower(), + "order_type": (order.get("type") or "market").lower(), + "quantity": float(order.get("qty") or order.get("filled_qty") or 0), + "filled_quantity": float(order.get("filled_qty") or 0), + "average_fill_price": float(order["filled_avg_price"]) if order.get("filled_avg_price") is not None else None, + "submitted_at": order.get("submitted_at"), + "raw_response": serialize_cdm_data(order), + } + + def get_order_status(self, order_id: str) -> Dict[str, Any]: + try: + from app.services.alpaca_broker_service import AlpacaBrokerAPIError + except ImportError: + AlpacaBrokerAPIError = Exception + try: + order = self._client.get_order(self.alpaca_account_id, order_id) + except AlpacaBrokerAPIError as e: + logger.error("Alpaca Broker order status failed: %s", e) + raise TradingAPIError(str(e)) + return { + "order_id": str(order.get("id", "")), + "status": (order.get("status") or "").lower(), + "symbol": order.get("symbol", ""), + "side": (order.get("side") or "").lower(), + "order_type": (order.get("type") or "market").lower(), + "quantity": float(order.get("qty") or 0), + "filled_quantity": float(order.get("filled_qty") or 0), + "average_fill_price": float(order["filled_avg_price"]) if order.get("filled_avg_price") is not None else None, + "price": float(order["limit_price"]) if order.get("limit_price") is not None else None, + "stop_price": float(order["stop_price"]) if order.get("stop_price") is not None else None, + "submitted_at": order.get("submitted_at"), + "filled_at": order.get("filled_at"), + "cancelled_at": order.get("canceled_at") or order.get("cancelled_at"), + "raw_response": order, + } + + def cancel_order(self, order_id: str) -> Dict[str, Any]: + try: + from app.services.alpaca_broker_service import AlpacaBrokerAPIError + except ImportError: + AlpacaBrokerAPIError = Exception + try: + self._client.cancel_order(self.alpaca_account_id, order_id) + except AlpacaBrokerAPIError as e: + logger.error("Alpaca Broker cancel failed: %s", e) + raise TradingAPIError(str(e)) + return {"order_id": order_id, "status": "cancelled"} + + def get_account_info(self) -> Dict[str, Any]: + try: + from app.services.alpaca_broker_service import AlpacaBrokerAPIError + except ImportError: + AlpacaBrokerAPIError = Exception + try: + acc = self._client.get_account_portfolio(self.alpaca_account_id) + except AlpacaBrokerAPIError as e: + logger.error("Alpaca Broker account info failed: %s", e) + raise TradingAPIError(str(e)) + return { + "account_number": acc.get("account_number"), + "buying_power": float(acc.get("buying_power") or 0), + "cash": float(acc.get("cash") or 0), + "equity": float(acc.get("equity") or 0), + "portfolio_value": float(acc.get("portfolio_value") or acc.get("equity") or 0), + "currency": acc.get("currency") or "USD", + "raw_response": acc, + } + + def get_positions(self) -> List[Dict[str, Any]]: + try: + from app.services.alpaca_broker_service import AlpacaBrokerAPIError + except ImportError: + AlpacaBrokerAPIError = Exception + try: + positions = self._client.get_positions(self.alpaca_account_id) + except AlpacaBrokerAPIError as e: + logger.error("Alpaca Broker positions failed: %s", e) + raise TradingAPIError(str(e)) + return [ + { + "symbol": p.get("symbol", ""), + "quantity": float(p.get("qty") or 0), + "average_price": float(p["avg_entry_price"]) if p.get("avg_entry_price") is not None else None, + "current_price": float(p["current_price"]) if p.get("current_price") is not None else None, + "market_value": float(p["market_value"]) if p.get("market_value") is not None else None, + "unrealized_pl": float(p["unrealized_pl"]) if p.get("unrealized_pl") is not None else None, + "raw_response": p, + } + for p in (positions or []) + ] + + def get_market_data(self, symbol: str, db: Optional[Any] = None) -> Dict[str, Any]: + """Reuse Alpaca data client (no account needed).""" + from app.core.config import settings + + key = getattr(settings, "ALPACA_API_KEY", None) + secret = getattr(settings, "ALPACA_API_SECRET", None) + if key and secret: + k = key.get_secret_value() if hasattr(key, "get_secret_value") else str(key) + s = secret.get_secret_value() if hasattr(secret, "get_secret_value") else str(secret) + base = getattr(settings, "ALPACA_BASE_URL", None) or "https://paper-api.alpaca.markets" + try: + svc = AlpacaTradingAPIService(api_key=k, api_secret=s, base_url=base) + return svc.get_market_data(symbol, db=db) + except Exception as e: + logger.debug("Alpaca market data fallback failed: %s", e) + return { + "symbol": symbol, + "bid_price": None, + "ask_price": None, + "bid_size": None, + "ask_size": None, + "timestamp": datetime.utcnow().isoformat(), + "raw_response": {"source": "unavailable"}, + } + + class MockTradingAPIService(TradingAPIService): """Mock trading API service for testing/development.""" diff --git a/client/src/components/BrokerageOnboarding.tsx b/client/src/components/BrokerageOnboarding.tsx new file mode 100644 index 0000000..05ecba2 --- /dev/null +++ b/client/src/components/BrokerageOnboarding.tsx @@ -0,0 +1,219 @@ +/** + * Brokerage onboarding: apply for Alpaca account, check status, upload documents when ACTION_REQUIRED. + * Uses /api/brokerage/account/status, /api/brokerage/account/apply, /api/brokerage/account/documents. + */ + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { fetchWithAuth } from '@/context/AuthContext'; +import { resolveApiUrl } from '@/utils/apiBase'; +import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send } from 'lucide-react'; + +interface BrokerageStatus { + has_account: boolean; + status?: string; + alpaca_account_id?: string; + account_number?: string; + action_required_reason?: string; + currency: string; +} + +export function BrokerageOnboarding() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [applyLoading, setApplyLoading] = useState(false); + const [docLoading, setDocLoading] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [documentType, setDocumentType] = useState('identity_document'); + const [selectedFile, setSelectedFile] = useState(null); + + const fetchStatus = async () => { + setError(null); + try { + const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/account/status')); + if (r.ok) { + const d = await r.json(); + setStatus(d); + } else { + setStatus(null); + setError('Failed to load brokerage status.'); + } + } catch (e) { + setStatus(null); + setError('Failed to load brokerage status.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + }, []); + + const handleApply = async () => { + setApplyLoading(true); + setError(null); + setMessage(null); + try { + const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/account/apply'), { + method: 'POST', + }); + const d = await r.json().catch(() => ({})); + if (r.ok) { + setMessage(d.message || 'Application submitted. Check status for updates.'); + await fetchStatus(); + } else { + setError(d.detail || d.message || 'Failed to submit application.'); + } + } catch (e) { + setError('Failed to submit application.'); + } finally { + setApplyLoading(false); + } + }; + + const handleDocumentUpload = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedFile || !status?.has_account) return; + setDocLoading(true); + setError(null); + setMessage(null); + try { + const form = new FormData(); + form.append('document_type', documentType); + form.append('file', selectedFile); + const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/account/documents'), { + method: 'POST', + body: form, + }); + const d = await r.json().catch(() => ({})); + if (r.ok) { + setMessage(d.message || 'Document submitted for review.'); + setSelectedFile(null); + await fetchStatus(); + } else { + setError(d.detail || d.message || 'Failed to upload document.'); + } + } catch (e) { + setError('Failed to upload document.'); + } finally { + setDocLoading(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + const isActive = status?.status === 'ACTIVE'; + const isActionRequired = status?.status === 'ACTION_REQUIRED'; + const isPending = status?.has_account && !isActive && !isActionRequired; + + return ( +
+
+

Trading account (Alpaca)

+

+ Open a brokerage account to place trades. Complete the application and upload any requested documents. +

+
+ + {error && ( + + + {error} + + )} + {message && ( + + + {message} + + )} + + + + + {isActive ? ( + <> + + Trading account active + + ) : isActionRequired ? ( + <> + + Action required + + ) : isPending ? ( + <>Application in progress + ) : ( + <>Brokerage account + )} + + + {isActive && status?.account_number && `Account #${status.account_number}`} + {isActionRequired && status?.action_required_reason} + {isPending && 'Your application is under review. Status updates automatically.'} + {!status?.has_account && 'Apply to open a brokerage account and start trading.'} + + + + {!status?.has_account && ( + + )} + + {isActionRequired && ( +
+
+ + setDocumentType(e.target.value)} + placeholder="e.g. identity_document, address_verification" + className="mt-1" + /> +
+
+ + setSelectedFile(e.target.files?.[0] ?? null)} + className="mt-1" + /> +
+ +
+ )} + + {!loading && status?.has_account && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/LinkAccounts.tsx b/client/src/components/LinkAccounts.tsx index 1cc1773..3bc495c 100644 --- a/client/src/components/LinkAccounts.tsx +++ b/client/src/components/LinkAccounts.tsx @@ -14,13 +14,22 @@ import { fetchWithAuth } from '@/context/AuthContext'; import { usePayment } from '@/context/PaymentContext'; import { PermissionGate } from '@/components/PermissionGate'; import { PERMISSION_TRADE_VIEW } from '@/utils/permissions'; -import { Landmark, Link2, Loader2, Unplug, CheckCircle2 } from 'lucide-react'; +import { Landmark, Link2, Loader2, Unplug, CheckCircle2, Briefcase } from 'lucide-react'; interface BankingStatus { plaid_enabled: boolean; connected: boolean; } +interface BrokerageStatus { + has_account: boolean; + status?: string; + alpaca_account_id?: string; + account_number?: string; + action_required_reason?: string; + currency: string; +} + export function LinkAccounts() { const { fetchWithPaymentHandling } = usePayment(); const [status, setStatus] = useState(null); @@ -30,6 +39,7 @@ export function LinkAccounts() { const [connectLoading, setConnectLoading] = useState(false); const [connectError, setConnectError] = useState(null); const [disconnectLoading, setDisconnectLoading] = useState(false); + const [brokerageStatus, setBrokerageStatus] = useState(null); const openedForRef = useRef(null); const fetchStatus = async () => { @@ -47,9 +57,18 @@ export function LinkAccounts() { setError('Failed to load banking status.'); } } + // Brokerage (Alpaca) account status + const br = await fetchWithAuth('/api/brokerage/account/status'); + if (br.ok) { + const bd = await br.json(); + setBrokerageStatus(bd); + } else { + setBrokerageStatus(null); + } } catch (e) { setError('Failed to load banking status.'); setStatus(null); + setBrokerageStatus(null); } finally { setLoading(false); } @@ -200,6 +219,36 @@ export function LinkAccounts() { )} + + {brokerageStatus !== null && ( + + + + + Trading account (Alpaca) + + + + {brokerageStatus.has_account ? ( +
+ + {brokerageStatus.status === 'ACTIVE' ? : } + + + {brokerageStatus.status === 'ACTIVE' + ? `Active${brokerageStatus.account_number ? ` · #${brokerageStatus.account_number}` : ''}` + : brokerageStatus.status ?? 'Pending'} + + {brokerageStatus.action_required_reason && ( +

{brokerageStatus.action_required_reason}

+ )} +
+ ) : ( +

No brokerage account. Open one in Settings → Trading account.

+ )} +
+
+ )} ); diff --git a/client/src/components/PortfolioDashboard.tsx b/client/src/components/PortfolioDashboard.tsx index efb32dd..6554f74 100644 --- a/client/src/components/PortfolioDashboard.tsx +++ b/client/src/components/PortfolioDashboard.tsx @@ -68,6 +68,7 @@ export function PortfolioDashboard() { const [error, setError] = useState(null); const [refetch, setRefetch] = useState(0); const [activeTab, setActiveTab] = useState('overview'); + const [brokerageStatus, setBrokerageStatus] = useState<{ has_account: boolean; status?: string; account_number?: string } | null>(null); // Listen for FDC3 portfolio context updates useEffect(() => { @@ -130,6 +131,20 @@ export function PortfolioDashboard() { } }, []); + const loadBrokerageStatus = useCallback(async () => { + try { + const res = await fetchWithAuth('/api/brokerage/account/status', { method: 'GET' }); + if (res.ok) { + const d = await res.json(); + setBrokerageStatus(d); + } else { + setBrokerageStatus(null); + } + } catch { + setBrokerageStatus(null); + } + }, []); + const loadAll = useCallback(async () => { setLoading(true); setError(null); @@ -138,9 +153,10 @@ export function PortfolioDashboard() { loadTransactions(), loadInvestments(), loadLiabilities(), + loadBrokerageStatus(), ]); setLoading(false); - }, [loadOverview, loadTransactions, loadInvestments, loadLiabilities]); + }, [loadOverview, loadTransactions, loadInvestments, loadLiabilities, loadBrokerageStatus]); useTradingWebSocket(user?.id ?? null, () => setRefetch((r) => r + 1)); useEffect(() => { loadAll(); }, [loadAll, refetch]); @@ -170,6 +186,15 @@ export function PortfolioDashboard() {

Portfolio

Aggregated trading, bank, and manual assets

+ {brokerageStatus !== null && ( +

+ Trading account: {brokerageStatus.has_account + ? brokerageStatus.status === 'ACTIVE' + ? `Active${brokerageStatus.account_number ? ` · #${brokerageStatus.account_number}` : ''}` + : (brokerageStatus.status ?? 'Pending') + : 'Not opened'} +

+ )}
{overview && ( diff --git a/client/src/components/trading/OrderForm.tsx b/client/src/components/trading/OrderForm.tsx index a5ca493..d82e81d 100644 --- a/client/src/components/trading/OrderForm.tsx +++ b/client/src/components/trading/OrderForm.tsx @@ -118,7 +118,11 @@ export function OrderForm() { if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: 'Failed to place order' })); - throw new Error(errorData.detail || errorData.message || `HTTP ${response.status}: Failed to place order`); + const detail = errorData.detail || errorData.message; + if (response.status === 403 && (typeof detail === 'string' && (detail.toLowerCase().includes('brokerage') || detail.toLowerCase().includes('onboarding') || detail.toLowerCase().includes('account')))) { + throw new Error('Complete brokerage onboarding to trade. Open Settings → Trading account to apply.'); + } + throw new Error(detail || `HTTP ${response.status}: Failed to place order`); } const result: OrderResponse = await response.json(); diff --git a/client/src/components/trading/TradingDashboard.tsx b/client/src/components/trading/TradingDashboard.tsx index 9b32afb..1fa5228 100644 --- a/client/src/components/trading/TradingDashboard.tsx +++ b/client/src/components/trading/TradingDashboard.tsx @@ -8,7 +8,12 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { OrderForm } from './OrderForm'; +import { fetchWithAuth } from '@/context/AuthContext'; +import { resolveApiUrl } from '@/utils/apiBase'; +import { Link } from 'react-router-dom'; +import { AlertTriangle } from 'lucide-react'; import { PortfolioView } from './PortfolioView'; import { MarketData } from './MarketData'; import { OrderHistory } from './OrderHistory'; @@ -38,6 +43,18 @@ export function TradingDashboard() { const [activeTab, setActiveTab] = useState(getInitialTab); const isInitialMount = useRef(true); + const [brokerageStatus, setBrokerageStatus] = useState<{ has_account: boolean; status?: string } | null>(null); + + useEffect(() => { + let cancelled = false; + fetchWithAuth(resolveApiUrl('/api/brokerage/account/status')) + .then((r) => r.ok ? r.json() : null) + .then((d) => { if (!cancelled && d) setBrokerageStatus(d); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const brokerageGate = brokerageStatus?.has_account && brokerageStatus?.status !== 'ACTIVE'; // Save tab to sessionStorage whenever it changes useEffect(() => { @@ -79,6 +96,15 @@ export function TradingDashboard() { } >
+ {brokerageGate && ( + + + + Your trading account is not yet active ({brokerageStatus?.status ?? 'pending'}). Complete onboarding in{' '} + Settings → Trading account to place orders. + + + )}

Trading Dashboard

diff --git a/client/src/pages/UserSettings.tsx b/client/src/pages/UserSettings.tsx index a15bda9..8ee46e0 100644 --- a/client/src/pages/UserSettings.tsx +++ b/client/src/pages/UserSettings.tsx @@ -6,8 +6,9 @@ import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { useAuth } from '@/context/AuthContext'; -import { User, Key, Bell, Shield, Mic, TrendingUp, Building2, DollarSign, Link2 } from 'lucide-react'; +import { User, Key, Bell, Shield, Mic, TrendingUp, Building2, DollarSign, Link2, Briefcase } from 'lucide-react'; import { LinkAccounts } from '@/components/LinkAccounts'; +import { BrokerageOnboarding } from '@/components/BrokerageOnboarding'; interface UserPreferences { audio_input_mode: boolean; @@ -212,6 +213,10 @@ export function UserSettings() { Link Accounts + + + Trading account + Notifications API Keys @@ -351,6 +356,10 @@ export function UserSettings() { + + + + diff --git a/docs/guides/alpaca-trading-setup.md b/docs/guides/alpaca-trading-setup.md index 682c9c4..586183b 100644 --- a/docs/guides/alpaca-trading-setup.md +++ b/docs/guides/alpaca-trading-setup.md @@ -15,6 +15,26 @@ If Alpaca keys are not set, **trading** uses `MockTradingAPIService`. If `ALPACA --- +## 1.1 Broker API (Multiuser Brokerage) + +For **multiuser brokerage** (one Alpaca customer account per user), use the **Alpaca Broker API** instead of the Trading API: + +| Use | Service / component | When | +|-----|---------------------|------| +| **Account opening** | `POST /api/brokerage/account/apply` | KYC sufficient; creates Alpaca customer account (status SUBMITTED → ACTIVE via Event API). | +| **Account status** | `GET /api/brokerage/account/status` | Returns user's Alpaca account status (SUBMITTED, ACTIVE, ACTION_REQUIRED, REJECTED). | +| **Documents** | `POST /api/brokerage/account/documents` | When status is ACTION_REQUIRED; upload identity/address docs. | +| **Trading** | `AlpacaBrokerTradingAPIService` | When user has an ACTIVE Alpaca account; orders are placed per account. | + +**When to use which:** + +- **Broker API**: You are building a brokerage app (RIA, broker-dealer, trading app). Each user gets an Alpaca customer account; you open accounts via `POST /v1/accounts` and trade per account. Set `ALPACA_BROKER_*` env vars. +- **Trading API**: Single-account use (your own account only) or market data only. Set `ALPACA_API_KEY` / `ALPACA_API_SECRET` for data/backtest; for multiuser trading prefer Broker API. + +**KYC requirement:** Account opening is gated by platform KYC (`evaluate_kyc_for_brokerage`). Complete identity verification and required documents before applying. See [Account Opening](https://docs.alpaca.markets/docs/account-opening) and [Alpaca Broker API](https://docs.alpaca.markets/docs/about-broker-api). + +--- + ## 2. Configuration | Variable | Description | Default | @@ -24,6 +44,15 @@ If Alpaca keys are not set, **trading** uses `MockTradingAPIService`. If `ALPACA | `ALPACA_BASE_URL` | **Trading** API base. Paper: `https://paper-api.alpaca.markets`; live: `https://api.alpaca.markets` | `https://paper-api.alpaca.markets` | | `ALPACA_DATA_ENABLED` | Use Alpaca for **historical bars** in stock prediction and backtesting. When `false`, `MarketDataService` uses yahooquery only. | `false` | +**Broker API (multiuser):** + +| Variable | Description | Default | +|----------|-------------|---------| +| `ALPACA_BROKER_API_KEY` | Broker API key (from Alpaca Broker Dashboard) | — | +| `ALPACA_BROKER_API_SECRET` | Broker API secret | — | +| `ALPACA_BROKER_BASE_URL` | Broker API base. Sandbox: `https://broker-api.sandbox.alpaca.markets`; live: `https://broker-api.alpaca.markets` | `https://broker-api.sandbox.alpaca.markets` | +| `ALPACA_BROKER_PAPER` | Use sandbox/paper when `true` | `true` | + --- ## 3. Getting Alpaca Credentials diff --git a/docs/guides/alpaca-trading-setup.mdx b/docs/guides/alpaca-trading-setup.mdx index b363fd4..3ce43f9 100644 --- a/docs/guides/alpaca-trading-setup.mdx +++ b/docs/guides/alpaca-trading-setup.mdx @@ -18,6 +18,21 @@ If Alpaca keys are not set, **trading** uses `MockTradingAPIService`. If `ALPACA --- +## 1.1 Broker API (Multiuser Brokerage) + +For **multiuser brokerage** (one Alpaca customer account per user), use the **Alpaca Broker API**: + +| Use | Service / component | When | +|-----|---------------------|------| +| **Account opening** | `POST /api/brokerage/account/apply` | KYC sufficient; creates Alpaca customer account (SUBMITTED → ACTIVE). | +| **Account status** | `GET /api/brokerage/account/status` | Returns user's Alpaca account status. | +| **Documents** | `POST /api/brokerage/account/documents` | When status is ACTION_REQUIRED. | +| **Trading** | `AlpacaBrokerTradingAPIService` | When user has ACTIVE Alpaca account; orders per account. | + +Set `ALPACA_BROKER_*` env vars. See [Account Opening](https://docs.alpaca.markets/docs/account-opening) and [Broker API](https://docs.alpaca.markets/docs/about-broker-api). + +--- + ## 2. Configuration | Variable | Description | Default | @@ -27,6 +42,8 @@ If Alpaca keys are not set, **trading** uses `MockTradingAPIService`. If `ALPACA | `ALPACA_BASE_URL` | **Trading** API base. Paper: `https://paper-api.alpaca.markets`; live: `https://api.alpaca.markets` | `https://paper-api.alpaca.markets` | | `ALPACA_DATA_ENABLED` | Use Alpaca for **historical bars** in stock prediction and backtesting. When `false`, `MarketDataService` uses yahooquery only. | `false` | +**Broker API (multiuser):** `ALPACA_BROKER_API_KEY`, `ALPACA_BROKER_API_SECRET`, `ALPACA_BROKER_BASE_URL` (sandbox: `https://broker-api.sandbox.alpaca.markets`), `ALPACA_BROKER_PAPER`. + --- ## 3. Getting Alpaca Credentials diff --git a/server.py b/server.py index 49ecef9..a937752 100644 --- a/server.py +++ b/server.py @@ -50,6 +50,7 @@ from app.api.banking_routes import router as banking_router from app.api.asset_routes import router as asset_router from app.api.portfolio_routes import router as portfolio_router +from app.api.brokerage_routes import router as brokerage_router from app.api.polymarket_routes import router as polymarket_router from app.api.cross_chain_routes import router as cross_chain_router from app.api.challenge_coin_routes import router as challenge_coin_router @@ -719,6 +720,7 @@ async def add_security_headers(request: Request, call_next): app.include_router(banking_router) app.include_router(asset_router) app.include_router(portfolio_router) +app.include_router(brokerage_router, prefix="/api") app.include_router(polymarket_router) app.include_router(cross_chain_router) app.include_router(challenge_coin_router) diff --git a/tests/integration/test_brokerage_flow.py b/tests/integration/test_brokerage_flow.py new file mode 100644 index 0000000..6e21a56 --- /dev/null +++ b/tests/integration/test_brokerage_flow.py @@ -0,0 +1,49 @@ +""" +Integration tests for brokerage flow (apply, status, documents). +Tests response models and route logic; full app integration requires server env (eth_account, etc.). +""" + +import pytest + +_import_error = None +try: + from app.api.brokerage_routes import AccountStatusResponse +except Exception as e: + AccountStatusResponse = None + _import_error = e + + +@pytest.fixture(autouse=True) +def skip_if_no_app(): + if AccountStatusResponse is None: + pytest.skip(f"Brokerage routes not importable (e.g. missing deps): {_import_error}") + + +def test_brokerage_status_response_shape(): + """AccountStatusResponse has expected fields.""" + r = AccountStatusResponse(has_account=False, currency="USD") + assert r.has_account is False + assert r.currency == "USD" + + r2 = AccountStatusResponse( + has_account=True, + status="ACTIVE", + alpaca_account_id="acc-123", + account_number="12345678", + currency="USD", + ) + assert r2.status == "ACTIVE" + assert r2.alpaca_account_id == "acc-123" + + +def test_brokerage_status_response_action_required(): + """AccountStatusResponse supports ACTION_REQUIRED with reason.""" + r = AccountStatusResponse( + has_account=True, + status="ACTION_REQUIRED", + alpaca_account_id="acc-456", + action_required_reason="Upload identity document", + currency="USD", + ) + assert r.status == "ACTION_REQUIRED" + assert r.action_required_reason == "Upload identity document" diff --git a/tests/unit/test_alpaca_account_service.py b/tests/unit/test_alpaca_account_service.py new file mode 100644 index 0000000..46bbdef --- /dev/null +++ b/tests/unit/test_alpaca_account_service.py @@ -0,0 +1,105 @@ +""" +Unit tests for Alpaca account opening service (KYC gate, payload build, create_account). +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from sqlalchemy.orm import Session + +from app.db.models import User, KYCVerification, AlpacaCustomerAccount +from app.services.alpaca_account_service import ( + open_alpaca_account, + AlpacaAccountServiceError, + _build_account_payload, +) + + +@pytest.fixture +def mock_user(): + """User with email and display_name.""" + u = Mock(spec=User) + u.id = 1 + u.email = "user@example.com" + u.display_name = "Jane Doe" + u.profile_data = {} + u.kyc_verification = None + return u + + +@pytest.fixture +def mock_verification(): + """KYCVerification with identity_verified.""" + v = Mock(spec=KYCVerification) + v.verification_metadata = None + return v + + +def test_build_account_payload_minimal(mock_user, mock_verification): + """_build_account_payload produces contact, identity, address from User.""" + mock_user.profile_data = {} + payload = _build_account_payload(mock_user, mock_verification) + assert "contact" in payload + assert payload["contact"]["email_address"] == "user@example.com" + assert "identity" in payload + assert payload["identity"]["given_name"] == "Jane" + assert payload["identity"]["family_name"] == "Doe" + assert "address" in payload + assert payload["address"]["country"] in ("US", "USA") + + +def test_open_alpaca_account_raises_when_user_not_found(): + """open_alpaca_account raises when user_id not found.""" + db = MagicMock(spec=Session) + db.query.return_value.filter.return_value.first.return_value = None + with pytest.raises(AlpacaAccountServiceError) as exc_info: + open_alpaca_account(999, db) + assert "not found" in str(exc_info.value).lower() + + +def test_open_alpaca_account_raises_when_kyc_not_sufficient(): + """open_alpaca_account raises when evaluate_kyc_for_brokerage returns False.""" + user = Mock(spec=User) + user.id = 1 + user.email = "u@example.com" + user.display_name = "User" + user.profile_data = {} + user.kyc_verification = None + + db = MagicMock(spec=Session) + db.query.return_value.filter.return_value.first.side_effect = [ + user, # User + None, # AlpacaCustomerAccount + ] + + with patch("app.services.alpaca_account_service.KYCService") as mock_kyc_class: + mock_kyc = Mock() + mock_kyc.evaluate_kyc_for_brokerage.return_value = False + mock_kyc_class.return_value = mock_kyc + + with pytest.raises(AlpacaAccountServiceError) as exc_info: + open_alpaca_account(1, db) + assert "kyc" in str(exc_info.value).lower() or "verification" in str(exc_info.value).lower() + + +def test_open_alpaca_account_raises_when_already_submitted(): + """open_alpaca_account raises when user already has SUBMITTED account.""" + existing = Mock(spec=AlpacaCustomerAccount) + existing.status = "SUBMITTED" + existing.user_id = 1 + + user = Mock(spec=User) + user.id = 1 + user.email = "u@example.com" + user.display_name = "User" + user.profile_data = {} + user.kyc_verification = None + + db = MagicMock(spec=Session) + # First query: User; second: AlpacaCustomerAccount (existing SUBMITTED) + chain = MagicMock() + chain.filter.return_value.first.side_effect = [user, existing] + db.query.return_value = chain + + with pytest.raises(AlpacaAccountServiceError) as exc_info: + open_alpaca_account(1, db) + assert "in progress" in str(exc_info.value).lower() or "submitted" in str(exc_info.value).lower() diff --git a/tests/unit/test_alpaca_broker_service.py b/tests/unit/test_alpaca_broker_service.py new file mode 100644 index 0000000..6fc13f5 --- /dev/null +++ b/tests/unit/test_alpaca_broker_service.py @@ -0,0 +1,111 @@ +""" +Unit tests for Alpaca Broker API client with mocked HTTP. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + +from app.services.alpaca_broker_service import ( + AlpacaBrokerClient, + AlpacaBrokerAPIError, + get_broker_client, +) + + +@pytest.fixture +def broker_client(): + """AlpacaBrokerClient with mocked session.""" + with patch("app.services.alpaca_broker_service.requests.Session") as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + client = AlpacaBrokerClient( + api_key="test_key", + api_secret="test_secret", + base_url="https://broker-api.sandbox.alpaca.markets", + ) + client._session = mock_session + return client + + +def test_create_account_returns_account_id_and_status(broker_client): + """create_account returns account id and status from API response.""" + broker_client._session.request.return_value = Mock( + status_code=200, + json=lambda: {"id": "acc-123", "status": "SUBMITTED", "account_number": "ABC123", "currency": "USD"}, + content=b"{}", + ) + result = broker_client.create_account({"contact": {"email_address": "u@example.com"}}) + assert result["id"] == "acc-123" + assert result["status"] == "SUBMITTED" + broker_client._session.request.assert_called_once() + call_args = broker_client._session.request.call_args + assert call_args[0][0] == "POST" + assert call_args[1].get("json") is not None + + +def test_create_account_raises_on_4xx(broker_client): + """create_account raises AlpacaBrokerAPIError on 4xx.""" + broker_client._session.request.return_value = Mock( + status_code=400, + json=lambda: {"message": "Invalid payload"}, + text="Bad Request", + ) + with pytest.raises(AlpacaBrokerAPIError) as exc_info: + broker_client.create_account({}) + assert exc_info.value.status_code == 400 + assert "Invalid payload" in str(exc_info.value) + + +def test_get_order_returns_order(broker_client): + """get_order returns order dict from API.""" + broker_client._session.request.return_value = Mock( + status_code=200, + json=lambda: { + "id": "ord-456", + "status": "filled", + "symbol": "AAPL", + "side": "buy", + "type": "market", + "qty": "10", + "filled_qty": "10", + "filled_avg_price": "150.0", + }, + content=b"{}", + ) + result = broker_client.get_order("acc-123", "ord-456") + assert result["id"] == "ord-456" + assert result["status"] == "filled" + assert result["symbol"] == "AAPL" + + +def test_get_positions_returns_list(broker_client): + """get_positions returns list of positions.""" + broker_client._session.request.return_value = Mock( + status_code=200, + json=lambda: {"positions": [{"symbol": "AAPL", "qty": "10", "market_value": "1500"}]}, + content=b"{}", + ) + result = broker_client.get_positions("acc-123") + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["symbol"] == "AAPL" + + +def test_get_broker_client_returns_none_when_not_configured(): + """get_broker_client returns None when ALPACA_BROKER_* not set.""" + with patch("app.core.config.settings") as mock_cfg: + mock_cfg.ALPACA_BROKER_API_KEY = None + mock_cfg.ALPACA_BROKER_API_SECRET = None + assert get_broker_client() is None + + +def test_get_broker_client_returns_client_when_configured(): + """get_broker_client returns AlpacaBrokerClient when keys set.""" + with patch("app.core.config.settings") as mock_cfg: + mock_cfg.ALPACA_BROKER_API_KEY = Mock(get_secret_value=lambda: "k") + mock_cfg.ALPACA_BROKER_API_SECRET = Mock(get_secret_value=lambda: "s") + mock_cfg.ALPACA_BROKER_BASE_URL = "https://broker-api.sandbox.alpaca.markets" + mock_cfg.ALPACA_BROKER_PAPER = True + client = get_broker_client() + assert client is not None + assert isinstance(client, AlpacaBrokerClient) From 2a7392be9820116ffce2e058d3339017ca4fbfc7 Mon Sep 17 00:00:00 2001 From: Joseph Pollack Date: Thu, 29 Jan 2026 23:03:37 +0100 Subject: [PATCH 2/7] adds bugfixes and improvements --- app/api/brokerage_routes.py | 47 ++- app/auth/jwt_auth.py | 76 +++-- app/services/alpaca_account_service.py | 144 ++++++--- client/src/components/BrokerageOnboarding.tsx | 279 ++++++++++++++++-- 4 files changed, 467 insertions(+), 79 deletions(-) diff --git a/app/api/brokerage_routes.py b/app/api/brokerage_routes.py index 9303ec8..37fd386 100644 --- a/app/api/brokerage_routes.py +++ b/app/api/brokerage_routes.py @@ -36,6 +36,29 @@ class AccountStatusResponse(BaseModel): currency: str = "USD" +class AgreementItem(BaseModel): + """Single agreement acceptance (Alpaca customer_agreement / margin_agreement).""" + agreement: str = Field(..., description="e.g. customer_agreement, margin_agreement") + signed_at: str = Field(..., description="ISO 8601 timestamp when user accepted") + ip_address: Optional[str] = Field("0.0.0.0", description="Client IP at acceptance (optional)") + + +class ApplyRequest(BaseModel): + """Brokerage apply request: optional agreements (from UI) and Plaid KYC flag.""" + agreements: Optional[List[AgreementItem]] = Field( + None, + description="Client-provided agreement acceptances (signed_at from UI). Required for Plaid KYC flow.", + ) + use_plaid_kyc: bool = Field( + False, + description="When True, KYC is satisfied by linked Plaid identity (user must have linked via brokerage Link).", + ) + prefill: Optional[Dict[str, Any]] = Field( + None, + description="Optional prefill from Plaid identity (given_name, family_name, address, etc.).", + ) + + @router.get("/link-token", response_model=Dict[str, Any]) async def brokerage_link_token( current_user: User = Depends(require_auth), @@ -104,12 +127,32 @@ async def brokerage_prefill( @router.post("/account/apply", response_model=Dict[str, Any]) async def brokerage_account_apply( + body: Optional[ApplyRequest] = None, db: Session = Depends(get_db), current_user: User = Depends(require_auth), ): - """Submit Alpaca Broker account application. Requires KYC to be sufficient.""" + """Submit Alpaca Broker account application. + Use Plaid KYC flow: link via Plaid (brokerage link-token), pass agreements (signed_at from UI), use_plaid_kyc=True. + """ + agreements_override = None + prefill_override = None + use_plaid_kyc = False + if body: + use_plaid_kyc = body.use_plaid_kyc + prefill_override = body.prefill + if body.agreements and len(body.agreements) >= 2: + agreements_override = [ + {"agreement": a.agreement, "signed_at": a.signed_at, "ip_address": a.ip_address or "0.0.0.0"} + for a in body.agreements + ] try: - rec = open_alpaca_account(current_user.id, db) + rec = open_alpaca_account( + current_user.id, + db, + agreements_override=agreements_override, + prefill_override=prefill_override, + use_plaid_kyc=use_plaid_kyc, + ) return { "status": "submitted", "alpaca_account_id": rec.alpaca_account_id, diff --git a/app/auth/jwt_auth.py b/app/auth/jwt_auth.py index d3bc2ac..41dcd58 100644 --- a/app/auth/jwt_auth.py +++ b/app/auth/jwt_auth.py @@ -1062,42 +1062,66 @@ async def change_password( return {"message": "Password changed successfully"} +def _safe_user_dict(user: User) -> Dict[str, Any]: + """Build user dict without raising (handles EncryptedString, etc.).""" + try: + return user.to_dict() + except Exception as e: + logger.warning("user.to_dict() failed for user %s: %s", getattr(user, "id", None), e) + email = getattr(user, "email", None) + if hasattr(email, "get_secret_value"): + try: + email = email.get_secret_value() + except Exception: + email = "" + email = str(email or "") + return { + "id": getattr(user, "id", None), + "email": email, + "display_name": str(getattr(user, "display_name", None) or ""), + "profile_image": getattr(user, "profile_image", None), + "role": getattr(user, "role", None) or "viewer", + "is_active": getattr(user, "is_active", True), + "last_login": None, + "wallet_address": getattr(user, "wallet_address", None), + "signup_status": getattr(user, "signup_status", None), + "profile_data": getattr(user, "profile_data", None), + "created_at": None, + } + + @jwt_router.get("/me") async def get_current_user_info( user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db), ): - """Get the current authenticated user's information with organization and implementations.""" + """Get the current authenticated user's information with organization and implementations. + Never returns 500: on any error returns minimal payload so client stays usable. + """ if not user: return {"authenticated": False, "user": None, "organization": None, "implementations": []} try: - user_dict = user.to_dict() + user_dict = _safe_user_dict(user) + try: + ctx = _hydrate_user_context(user, db) + org, impls = ctx.get("organization"), ctx.get("implementations") or [] + except Exception as e: + logger.warning("_hydrate_user_context failed for user %s: %s", user.id, e) + org, impls = None, [] + return { + "authenticated": True, + "user": user_dict, + "organization": org, + "implementations": impls, + } except Exception as e: - logger.error(f"Error serializing user {user.id}: {e}", exc_info=True) - user_dict = { - "id": user.id, - "email": user.email or "", - "display_name": user.display_name or "", - "profile_image": user.profile_image, - "role": user.role or "viewer", - "is_active": user.is_active if user.is_active is not None else True, - "last_login": None, - "wallet_address": user.wallet_address, - "signup_status": user.signup_status, - "signup_submitted_at": None, - "signup_reviewed_at": None, - "signup_reviewed_by": user.signup_reviewed_by, - "signup_rejection_reason": user.signup_rejection_reason, - "profile_data": user.profile_data, - "created_at": None, + logger.error("get_current_user_info failed: %s", e, exc_info=True) + return { + "authenticated": True, + "user": _safe_user_dict(user), + "organization": None, + "implementations": [], } - ctx = _hydrate_user_context(user, db) - return { - "authenticated": True, - "user": user_dict, - "organization": ctx["organization"], - "implementations": ctx["implementations"], - } @jwt_router.get("/verify") async def verify_token(user: User = Depends(require_auth)): diff --git a/app/services/alpaca_account_service.py b/app/services/alpaca_account_service.py index 62ea4e1..e2726b6 100644 --- a/app/services/alpaca_account_service.py +++ b/app/services/alpaca_account_service.py @@ -8,11 +8,12 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session -from app.db.models import User, KYCVerification, AlpacaCustomerAccount +from app.db.models import User, UserRole, KYCVerification, AlpacaCustomerAccount from app.services.alpaca_broker_service import get_broker_client, AlpacaBrokerAPIError from app.services.kyc_service import KYCService from app.utils.audit import log_audit_action @@ -26,8 +27,27 @@ class AlpacaAccountServiceError(Exception): pass -def _build_account_payload(user: User, verification: Optional[KYCVerification]) -> Dict[str, Any]: - """Build Alpaca Broker API account creation payload from User and KYCVerification.""" +def is_instance_owner(user_id: int, db: Session) -> bool: + """True if user is instance owner (admin role or first user). Instance owner always has access to brokerage apply.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return False + role = getattr(user, "role", None) + if role == UserRole.ADMIN.value: + return True + # First user in DB (lowest id) is treated as instance owner + first = db.query(User).order_by(User.id.asc()).limit(1).first() + return first is not None and first.id == user_id + + +def _build_account_payload( + user: User, + verification: Optional[KYCVerification], + *, + prefill_override: Optional[Dict[str, Any]] = None, + agreements_override: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Build Alpaca Broker API account creation payload from User, KYCVerification, and optional Plaid prefill/agreements.""" email = getattr(user, "email", None) or "" if hasattr(email, "get_secret_value"): email = email.get_secret_value() or "" @@ -38,20 +58,24 @@ def _build_account_payload(user: User, verification: Optional[KYCVerification]) display_name = display_name.get_secret_value() or email.split("@")[0] display_name = str(display_name).strip() parts = display_name.split(None, 1) - given_name = parts[0] if parts else "Given" - family_name = parts[1] if len(parts) > 1 else "User" + given_name = (prefill_override or {}).get("given_name") or (parts[0] if parts else "Given") + family_name = (prefill_override or {}).get("family_name") or (parts[1] if len(parts) > 1 else "User") profile_data = getattr(user, "profile_data", None) or {} if isinstance(profile_data, dict): phone = profile_data.get("phone") or profile_data.get("phone_number") or "" - street = profile_data.get("street_address") or profile_data.get("address") or "" - city = profile_data.get("city") or "" - state = profile_data.get("state") or "" - postal_code = profile_data.get("postal_code") or profile_data.get("zip") or "" - country = profile_data.get("country") or "USA" + street = (prefill_override or {}).get("street_address") or profile_data.get("street_address") or profile_data.get("address") or "" + city = (prefill_override or {}).get("city") or profile_data.get("city") or "" + state = (prefill_override or {}).get("state") or profile_data.get("state") or "" + postal_code = (prefill_override or {}).get("postal_code") or profile_data.get("postal_code") or profile_data.get("zip") or "" + country = (prefill_override or {}).get("country") or profile_data.get("country") or "USA" else: - phone = street = city = state = postal_code = "" - country = "USA" + phone = "" + street = (prefill_override or {}).get("street_address") or "" + city = (prefill_override or {}).get("city") or "" + state = (prefill_override or {}).get("state") or "" + postal_code = (prefill_override or {}).get("postal_code") or "" + country = (prefill_override or {}).get("country") or "USA" # Alpaca account opening payload (contact, identity, address) # https://docs.alpaca.markets/reference/createaccount @@ -60,8 +84,8 @@ def _build_account_payload(user: User, verification: Optional[KYCVerification]) "phone_number": str(phone)[:20] if phone else "", } identity = { - "given_name": given_name[:50], - "family_name": family_name[:50], + "given_name": str(given_name)[:50], + "family_name": str(family_name)[:50], "date_of_birth": "1990-01-01", # Placeholder if not in profile; Alpaca may require or return ACTION_REQUIRED } if isinstance(verification, KYCVerification) and getattr(verification, "verification_metadata", None): @@ -71,12 +95,29 @@ def _build_account_payload(user: User, verification: Optional[KYCVerification]) address = { "street_address": [str(street)[:64]] if street else ["N/A"], - "city": city[:32] if city else "N/A", - "state": state[:32] if state else "NY", + "city": (str(city)[:32]) if city else "N/A", + "state": (str(state)[:32]) if state else "NY", "postal_code": str(postal_code)[:10] if postal_code else "10001", - "country": country[:2] if len(country) == 2 else "US", + "country": (str(country)[:2]) if country and len(str(country)) == 2 else "US", } + # Agreements: use client-provided (Plaid KYC flow) or server-generated + agreements: List[Dict[str, Any]] = [] + if agreements_override and len(agreements_override) >= 2: + for a in agreements_override: + if isinstance(a, dict) and a.get("agreement") and a.get("signed_at"): + agreements.append({ + "agreement": str(a["agreement"])[:64], + "signed_at": str(a["signed_at"]), + "ip_address": str(a.get("ip_address") or "0.0.0.0")[:45], + }) + if len(agreements) < 2: + signed_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + agreements = [ + {"agreement": "customer_agreement", "signed_at": signed_at, "ip_address": "0.0.0.0"}, + {"agreement": "margin_agreement", "signed_at": signed_at, "ip_address": "0.0.0.0"}, + ] + return { "contact": contact, "identity": identity, @@ -86,25 +127,53 @@ def _build_account_payload(user: User, verification: Optional[KYCVerification]) "is_politically_exposed": False, "immediate_family_exposed": False, }, - "agreements": [ - {"agreement": "customer_agreement", "signed_at": None, "ip_address": None}, - {"agreement": "margin_agreement", "signed_at": None, "ip_address": None}, - ], + "agreements": agreements, "documents": [], "trusted_contact": { - "given_name": given_name[:50], - "family_name": family_name[:50], + "given_name": str(given_name)[:50], + "family_name": str(family_name)[:50], "email_address": email, }, "address": address, } -def open_alpaca_account(user_id: int, db: Session) -> AlpacaCustomerAccount: +def _has_plaid_identity(user_id: int, db: Session) -> bool: + """True if user has linked Plaid and identity data (owners) is available. Used for Plaid KYC flow.""" + try: + from app.services.plaid_service import get_plaid_connection, get_identity + conn = get_plaid_connection(db, user_id) + if not conn or not getattr(conn, "connection_data", None) or not isinstance(conn.connection_data, dict): + return False + access_token = conn.connection_data.get("access_token") + if not access_token: + return False + identity_resp = get_identity(access_token) + if "error" in identity_resp: + return False + accounts = identity_resp.get("accounts") or [] + for acc in accounts: + owners = acc.get("owners") or [] + if owners: + return True + return False + except Exception as e: + logger.warning("_has_plaid_identity check failed for user %s: %s", user_id, e) + return False + + +def open_alpaca_account( + user_id: int, + db: Session, + *, + agreements_override: Optional[List[Dict[str, Any]]] = None, + prefill_override: Optional[Dict[str, Any]] = None, + use_plaid_kyc: bool = False, +) -> AlpacaCustomerAccount: """ Open an Alpaca Broker account for the user. - - Ensures KYC is sufficient (evaluate_kyc_for_brokerage). - - Builds account payload from User + KYCVerification. + - KYC: instance owner bypass, or use_plaid_kyc + Plaid identity, or evaluate_kyc_for_brokerage. + - Builds account payload from User + KYCVerification + optional Plaid prefill and client agreements. - Calls Broker API create_account. - Persists AlpacaCustomerAccount (SUBMITTED). """ @@ -127,18 +196,27 @@ def open_alpaca_account(user_id: int, db: Session) -> AlpacaCustomerAccount: if existing.status == "REJECTED": raise AlpacaAccountServiceError("Account was rejected; contact support to reapply") - kyc = KYCService(db) - if not kyc.evaluate_kyc_for_brokerage(user_id): - raise AlpacaAccountServiceError( - "KYC not sufficient for brokerage. Complete identity verification and required documents first." - ) + # KYC: instance owner bypass, or Plaid KYC (linked Plaid + identity), or policy KYC + kyc_satisfied = is_instance_owner(user_id, db) + if not kyc_satisfied and use_plaid_kyc and _has_plaid_identity(user_id, db): + kyc_satisfied = True + if not kyc_satisfied: + kyc = KYCService(db) + if not kyc.evaluate_kyc_for_brokerage(user_id): + raise AlpacaAccountServiceError( + "KYC not sufficient for brokerage. Verify identity with Plaid (link bank) or complete identity verification first." + ) client = get_broker_client() if not client: raise AlpacaAccountServiceError("Broker API not configured (ALPACA_BROKER_API_KEY/SECRET)") verification = getattr(user, "kyc_verification", None) - payload = _build_account_payload(user, verification) + payload = _build_account_payload( + user, verification, + prefill_override=prefill_override, + agreements_override=agreements_override, + ) try: result = client.create_account(payload) diff --git a/client/src/components/BrokerageOnboarding.tsx b/client/src/components/BrokerageOnboarding.tsx index 05ecba2..caf30c0 100644 --- a/client/src/components/BrokerageOnboarding.tsx +++ b/client/src/components/BrokerageOnboarding.tsx @@ -1,17 +1,22 @@ /** - * Brokerage onboarding: apply for Alpaca account, check status, upload documents when ACTION_REQUIRED. - * Uses /api/brokerage/account/status, /api/brokerage/account/apply, /api/brokerage/account/documents. + * Brokerage onboarding: Plaid KYC (identity) + Alpaca agreements + apply. + * Flow: 1) Link Plaid (brokerage link-token) for identity verification + * 2) Review prefill from Plaid + * 3) Accept Customer Agreement and Margin Agreement (required by Alpaca) + * 4) Submit application with agreements and use_plaid_kyc */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { usePlaidLink } from 'react-plaid-link'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { fetchWithAuth } from '@/context/AuthContext'; import { resolveApiUrl } from '@/utils/apiBase'; -import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send } from 'lucide-react'; +import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send, Link2, FileText } from 'lucide-react'; interface BrokerageStatus { has_account: boolean; @@ -22,6 +27,19 @@ interface BrokerageStatus { currency: string; } +interface Prefill { + given_name?: string; + family_name?: string; + street_address?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; +} + +const ALPACA_CUSTOMER_AGREEMENT_URL = 'https://alpaca.markets/disclosures'; +const ALPACA_MARGIN_AGREEMENT_URL = 'https://alpaca.markets/disclosures'; + export function BrokerageOnboarding() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -32,6 +50,15 @@ export function BrokerageOnboarding() { const [documentType, setDocumentType] = useState('identity_document'); const [selectedFile, setSelectedFile] = useState(null); + // Plaid KYC flow + const [linkToken, setLinkToken] = useState(null); + const [prefill, setPrefill] = useState(null); + const [prefillLoading, setPrefillLoading] = useState(false); + const [agreedCustomer, setAgreedCustomer] = useState(false); + const [agreedMargin, setAgreedMargin] = useState(false); + const [agreedAt, setAgreedAt] = useState(null); + const openedForRef = useRef(null); + const fetchStatus = async () => { setError(null); try { @@ -51,17 +78,116 @@ export function BrokerageOnboarding() { } }; + const fetchPrefill = async () => { + setPrefillLoading(true); + try { + const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/prefill')); + if (r.ok) { + const d = await r.json(); + setPrefill(d.prefill && Object.keys(d.prefill).length > 0 ? d.prefill : null); + } else { + setPrefill(null); + } + } catch { + setPrefill(null); + } finally { + setPrefillLoading(false); + } + }; + useEffect(() => { fetchStatus(); }, []); - const handleApply = async () => { + // When status loaded and no account yet, fetch prefill once (user may have linked Plaid earlier) + const prefillFetchedRef = useRef(false); + useEffect(() => { + if (!loading && status && !status.has_account && !prefillFetchedRef.current) { + prefillFetchedRef.current = true; + fetchPrefill(); + } + }, [loading, status?.has_account]); + + // Plaid Link for brokerage (identity verification) + const { open, ready } = usePlaidLink({ + token: linkToken, + onSuccess: async (public_token: string) => { + setError(null); + setApplyLoading(true); + try { + const r = await fetchWithAuth(resolveApiUrl('/api/banking/connect'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ public_token }), + }); + if (r.ok) { + setLinkToken(null); + openedForRef.current = null; + await fetchPrefill(); + } else { + const d = await r.json().catch(() => ({})); + setError(d.detail || 'Failed to connect bank for identity verification.'); + } + } catch (e) { + setError('Failed to connect bank.'); + } finally { + setApplyLoading(false); + } + }, + onExit: () => { + setLinkToken(null); + openedForRef.current = null; + }, + }); + + useEffect(() => { + if (linkToken && ready && open && openedForRef.current !== linkToken) { + open(); + openedForRef.current = linkToken; + } + }, [linkToken, ready, open]); + + const handleGetPlaidLinkToken = async () => { + setError(null); + try { + const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/link-token')); + const d = await r.json().catch(() => ({})); + if (d.link_token) { + setLinkToken(d.link_token); + } else { + setError(d.detail || d.error || 'Could not start identity verification.'); + } + } catch (e) { + setError('Could not start identity verification.'); + } + }; + + const handleAgreementChange = (customer: boolean, margin: boolean) => { + setAgreedCustomer(customer); + setAgreedMargin(margin); + if (customer && margin && !agreedAt) { + setAgreedAt(new Date().toISOString()); + } + }; + + const handleApply = async (usePlaidKyc: boolean) => { setApplyLoading(true); setError(null); setMessage(null); try { + const signedAt = agreedAt || new Date().toISOString(); + const body = { + use_plaid_kyc: usePlaidKyc, + agreements: [ + { agreement: 'customer_agreement', signed_at: signedAt, ip_address: '0.0.0.0' }, + { agreement: 'margin_agreement', signed_at: signedAt, ip_address: '0.0.0.0' }, + ], + prefill: prefill || undefined, + }; const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/account/apply'), { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }); const d = await r.json().catch(() => ({})); if (r.ok) { @@ -119,13 +245,15 @@ export function BrokerageOnboarding() { const isActive = status?.status === 'ACTIVE'; const isActionRequired = status?.status === 'ACTION_REQUIRED'; const isPending = status?.has_account && !isActive && !isActionRequired; + const canApplyPlaid = !status?.has_account && prefill !== null && agreedCustomer && agreedMargin; + const canApplyWithoutPlaid = !status?.has_account && agreedCustomer && agreedMargin; return (

Trading account (Alpaca)

- Open a brokerage account to place trades. Complete the application and upload any requested documents. + Verify your identity with Plaid, accept the required agreements, and submit to open a brokerage account.

@@ -158,26 +286,141 @@ export function BrokerageOnboarding() { ) : isPending ? ( <>Application in progress ) : ( - <>Brokerage account + <>Open brokerage account (Plaid KYC + agreements) )} {isActive && status?.account_number && `Account #${status.account_number}`} {isActionRequired && status?.action_required_reason} {isPending && 'Your application is under review. Status updates automatically.'} - {!status?.has_account && 'Apply to open a brokerage account and start trading.'} + {!status?.has_account && 'Complete the steps below to apply.'} - + {!status?.has_account && ( - + <> + {/* Step 1: Plaid identity verification */} +
+ +

+ Link a bank account to verify your identity. We use Plaid; no account access is required for verification. +

+ {prefill ? ( +
+ + Identity verified +
+ ) : ( + + )} +
+ + {/* Step 2: Prefill from Plaid (optional; show agreements even without prefill) */} + {(prefill !== null || prefillLoading || (prefill === null && !prefillLoading)) && ( +
+ + {prefillLoading ? ( + + ) : prefill && Object.keys(prefill).length > 0 ? ( +
+ {(prefill.given_name || prefill.family_name) && ( +

Name: {[prefill.given_name, prefill.family_name].filter(Boolean).join(' ')}

+ )} + {(prefill.street_address || prefill.city) && ( +

+ Address: {[prefill.street_address, prefill.city, prefill.state, prefill.postal_code, prefill.country] + .filter(Boolean) + .join(', ')} +

+ )} +
+ ) : !prefillLoading ? ( +

No Plaid identity linked. You can still apply without Plaid (admin/legacy flow) below.

+ ) : null} +
+ )} + + {/* Step 3: Alpaca agreements (required for all) */} + {( +
+ +

+ Alpaca requires acceptance of the Customer Agreement and Margin Agreement before opening an account. +

+
+
+ handleAgreementChange(!!checked, agreedMargin)} + /> + +
+
+ handleAgreementChange(agreedCustomer, !!checked)} + /> + +
+
+
+ )} + + {/* Step 4: Submit */} +
+ {prefill !== null && ( + + )} + + {(!agreedCustomer || !agreedMargin) && ( +

Accept both agreements above to submit.

+ )} +
+ )} {isActionRequired && ( @@ -207,7 +450,7 @@ export function BrokerageOnboarding() { )} - {!loading && status?.has_account && ( + {status?.has_account && !loading && ( From fa433e29c3821710d6e422f6036aae5b76b8499b Mon Sep 17 00:00:00 2001 From: Joseph Pollack Date: Fri, 30 Jan 2026 01:22:23 +0100 Subject: [PATCH 3/7] adds kyc , user settings --- ...dd_kyc_document_reviewed_by_reviewed_at.py | 37 ++ app/api/brokerage_routes.py | 126 +++-- app/api/kyc_routes.py | 117 +++- app/api/routes.py | 18 +- app/api/user_settings_routes.py | 66 +++ app/db/models.py | 16 +- app/services/alpaca_account_service.py | 70 ++- app/services/kyc_brokerage_notification.py | 101 ++++ app/services/kyc_service.py | 55 ++ .../src/components/AdminSignupDashboard.tsx | 222 +++++++- client/src/components/BrokerageOnboarding.tsx | 73 ++- client/src/pages/AdminSettings.tsx | 57 +- client/src/pages/UserSettings.tsx | 511 ++++++++++++++++-- 13 files changed, 1327 insertions(+), 142 deletions(-) create mode 100644 alembic/versions/f1a2b3c4d5e6_add_kyc_document_reviewed_by_reviewed_at.py create mode 100644 app/services/kyc_brokerage_notification.py diff --git a/alembic/versions/f1a2b3c4d5e6_add_kyc_document_reviewed_by_reviewed_at.py b/alembic/versions/f1a2b3c4d5e6_add_kyc_document_reviewed_by_reviewed_at.py new file mode 100644 index 0000000..abf0691 --- /dev/null +++ b/alembic/versions/f1a2b3c4d5e6_add_kyc_document_reviewed_by_reviewed_at.py @@ -0,0 +1,37 @@ +"""add kyc_document reviewed_by and reviewed_at + +Revision ID: f1a2b3c4d5e6 +Revises: e8f9a0b1c2d3 +Create Date: 2026-01-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f1a2b3c4d5e6" +down_revision: Union[str, Sequence[str], None] = "e8f9a0b1c2d3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("kyc_documents", sa.Column("reviewed_by", sa.Integer(), nullable=True)) + op.add_column("kyc_documents", sa.Column("reviewed_at", sa.DateTime(), nullable=True)) + op.create_index(op.f("ix_kyc_documents_reviewed_by"), "kyc_documents", ["reviewed_by"], unique=False) + op.create_foreign_key( + "fk_kyc_documents_reviewed_by_users", + "kyc_documents", + "users", + ["reviewed_by"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint("fk_kyc_documents_reviewed_by_users", "kyc_documents", type_="foreignkey") + op.drop_index(op.f("ix_kyc_documents_reviewed_by"), table_name="kyc_documents") + op.drop_column("kyc_documents", "reviewed_at") + op.drop_column("kyc_documents", "reviewed_by") diff --git a/app/api/brokerage_routes.py b/app/api/brokerage_routes.py index 37fd386..e40e896 100644 --- a/app/api/brokerage_routes.py +++ b/app/api/brokerage_routes.py @@ -80,49 +80,101 @@ async def brokerage_link_token( return out +def _kyc_to_prefill(profile_data: Any) -> Dict[str, Any]: + """Build brokerage prefill dict from user-settings KYC info (profile_data.kyc).""" + if not profile_data or not isinstance(profile_data, dict): + return {} + kyc = profile_data.get("kyc") or {} + if not kyc: + return {} + prefill: Dict[str, Any] = {} + legal = (kyc.get("legal_name") or "").strip() + if legal: + parts = legal.split(None, 1) + prefill["given_name"] = parts[0] if parts else "" + prefill["family_name"] = parts[1] if len(parts) > 1 else "" + if kyc.get("date_of_birth"): + prefill["date_of_birth"] = str(kyc["date_of_birth"])[:10] + if kyc.get("address_line1"): + prefill["street_address"] = str(kyc["address_line1"]).strip() + if kyc.get("address_city"): + prefill["city"] = str(kyc["address_city"]).strip() + if kyc.get("address_state"): + prefill["state"] = str(kyc["address_state"]).strip() + if kyc.get("address_postal_code"): + prefill["postal_code"] = str(kyc["address_postal_code"]).strip() + if kyc.get("address_country"): + prefill["country"] = str(kyc["address_country"]).strip() + return prefill + + @router.get("/prefill", response_model=Dict[str, Any]) async def brokerage_prefill( db: Session = Depends(get_db), current_user: User = Depends(require_auth), ): - """Get identity/account prefill from linked Plaid connection for brokerage application form.""" - conn = get_plaid_connection(db, current_user.id) - if not conn or not getattr(conn, "connection_data", None) or not isinstance(conn.connection_data, dict): - return {"prefill": {}, "message": "No linked bank account. Link an account to prefill the form."} - access_token = conn.connection_data.get("access_token") - if not access_token: - return {"prefill": {}, "message": "Plaid connection missing access token."} - identity_resp = get_identity(access_token) - if "error" in identity_resp: - return {"prefill": {}, "message": identity_resp.get("error", "Could not fetch identity.")} - accounts = identity_resp.get("accounts") or [] + """Get identity/account prefill from Plaid and/or User Settings KYC for brokerage application form.""" prefill_data: Dict[str, Any] = {} - for acc in accounts: - owners = acc.get("owners") or [] - for owner in owners: - if not isinstance(owner, dict): - continue - names = owner.get("names") or [] - if names and isinstance(names, list): - full = (names[0] or "").strip() - parts = full.split(None, 1) - prefill_data["given_name"] = parts[0] if parts else "" - prefill_data["family_name"] = parts[1] if len(parts) > 1 else (names[1] if len(names) > 1 else "") - addrs = owner.get("addresses") or [] - for a in addrs: - if isinstance(a, dict) and a.get("data"): - d = a["data"] - prefill_data["street_address"] = d.get("street") or "" - prefill_data["city"] = d.get("city") or "" - prefill_data["state"] = d.get("region") or "" - prefill_data["postal_code"] = d.get("postal_code") or "" - prefill_data["country"] = d.get("country") or "US" - break - if prefill_data: - break - if prefill_data: - break - return {"prefill": prefill_data} + source = "none" + message = "" + + # 1) Plaid identity (if linked) + conn = get_plaid_connection(db, current_user.id) + if conn and getattr(conn, "connection_data", None) and isinstance(conn.connection_data, dict): + access_token = conn.connection_data.get("access_token") + if access_token: + identity_resp = get_identity(access_token) + if "error" not in identity_resp: + accounts = identity_resp.get("accounts") or [] + for acc in accounts: + owners = acc.get("owners") or [] + for owner in owners: + if not isinstance(owner, dict): + continue + names = owner.get("names") or [] + if names and isinstance(names, list): + full = (names[0] or "").strip() + parts = full.split(None, 1) + prefill_data["given_name"] = parts[0] if parts else "" + prefill_data["family_name"] = parts[1] if len(parts) > 1 else (names[1] if len(names) > 1 else "") + addrs = owner.get("addresses") or [] + for a in addrs: + if isinstance(a, dict) and a.get("data"): + d = a["data"] + prefill_data["street_address"] = d.get("street") or "" + prefill_data["city"] = d.get("city") or "" + prefill_data["state"] = d.get("region") or "" + prefill_data["postal_code"] = d.get("postal_code") or "" + prefill_data["country"] = d.get("country") or "US" + break + if prefill_data: + break + if prefill_data: + break + if prefill_data: + source = "plaid" + else: + message = identity_resp.get("error", "Could not fetch identity.") + else: + message = "Plaid connection missing access token." + else: + message = "No linked bank account. Link an account or fill User Settings → KYC & Identity to prefill." + + # 2) Merge or fallback to User Settings KYC info + kyc_prefill = _kyc_to_prefill(getattr(current_user, "profile_data", None)) + if kyc_prefill: + if source == "plaid": + for key, value in kyc_prefill.items(): + if value and not prefill_data.get(key): + prefill_data[key] = value + source = "both" + else: + prefill_data = kyc_prefill + source = "user_settings" + if not message and source == "user_settings": + message = "Prefill from User Settings → KYC & Identity. Edit there to change." + + return {"prefill": prefill_data, "source": source, "message": message or None} @router.post("/account/apply", response_model=Dict[str, Any]) diff --git a/app/api/kyc_routes.py b/app/api/kyc_routes.py index 4068b8f..580b322 100644 --- a/app/api/kyc_routes.py +++ b/app/api/kyc_routes.py @@ -5,12 +5,13 @@ import logging from typing import List, Dict, Any, Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from app.auth.dependencies import get_current_user, get_db -from app.db.models import User +from app.db import get_db +from app.auth.jwt_auth import require_auth +from app.db.models import User, KYCDocument from app.services.kyc_service import KYCService logger = logging.getLogger(__name__) @@ -39,7 +40,7 @@ class LicenseUploadRequest(BaseModel): @kyc_router.post("/initiate") async def initiate_kyc( payload: InitiateKYCRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_auth), db: Session = Depends(get_db), ): """Initiate KYC verification process.""" @@ -51,7 +52,7 @@ async def initiate_kyc( @kyc_router.post("/documents/upload") async def upload_kyc_document( payload: KYCDocumentUploadRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_auth), db: Session = Depends(get_db), ): """Link an uploaded document to KYC verification.""" @@ -65,7 +66,7 @@ async def upload_kyc_document( @kyc_router.post("/licenses/upload") async def upload_license( payload: LicenseUploadRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_auth), db: Session = Depends(get_db), ): """Add a professional license.""" @@ -83,7 +84,7 @@ async def upload_license( @kyc_router.get("/status") async def get_kyc_status( - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_auth), db: Session = Depends(get_db), ): """Get current user's KYC status with latest policy evaluation, if available.""" @@ -115,10 +116,108 @@ async def get_kyc_requirements( @kyc_router.post("/evaluate") async def evaluate_kyc( deal_type: Optional[str] = None, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_auth), db: Session = Depends(get_db), ): - """Run KYC compliance evaluation.""" + """User KYC evaluation (current user). For profile/compliance KYC use POST /api/compliance/kyc/evaluate.""" service = KYCService(db) result = service.evaluate_kyc_compliance(current_user.id, deal_type=deal_type) return {"status": "success", "evaluation": result} + + +# --- Admin KYC (instance administrator) --- + +def _require_admin_or_reviewer(user: User) -> None: + if getattr(user, "role", None) not in ("admin", "reviewer"): + raise HTTPException( + status_code=403, + detail={"status": "error", "message": "Admin or reviewer access required"}, + ) + + +class VerifyDocumentRequest(BaseModel): + """Request body for verifying a KYC document.""" + verification_status: str = Field(..., description="verified or rejected") + + +class KYCReviewRequest(BaseModel): + """Request body for completing KYC review.""" + kyc_status: str = Field(..., description="completed or rejected") + rejection_reason: Optional[str] = Field(None, description="Reason when kyc_status is rejected") + + +@kyc_router.get("/admin/pending-documents") +async def list_pending_kyc_documents( + user_id: Optional[int] = Query(None, description="Filter by user ID"), + current_user: User = Depends(require_auth), + db: Session = Depends(get_db), +): + """List KYC documents with verification_status pending (admin/reviewer).""" + _require_admin_or_reviewer(current_user) + query = db.query(KYCDocument).filter(KYCDocument.verification_status == "pending") + if user_id is not None: + query = query.filter(KYCDocument.user_id == user_id) + docs = query.order_by(KYCDocument.created_at.desc()).all() + return { + "status": "success", + "documents": [ + { + "id": d.id, + "user_id": d.user_id, + "document_type": d.document_type, + "document_category": d.document_category, + "document_id": d.document_id, + "verification_status": d.verification_status, + "created_at": d.created_at.isoformat() if d.created_at else None, + } + for d in docs + ], + } + + +@kyc_router.post("/admin/documents/{kyc_document_id}/verify") +async def verify_kyc_document( + kyc_document_id: int, + body: VerifyDocumentRequest, + current_user: User = Depends(require_auth), + db: Session = Depends(get_db), +): + """Set verification status of a KYC document (admin/reviewer).""" + _require_admin_or_reviewer(current_user) + if body.verification_status not in ("verified", "rejected"): + raise HTTPException( + status_code=400, + detail={"status": "error", "message": "verification_status must be verified or rejected"}, + ) + service = KYCService(db) + try: + doc = service.verify_kyc_document( + kyc_document_id, body.verification_status, current_user.id + ) + return {"status": "success", "document": doc.to_dict()} + except ValueError as e: + raise HTTPException(status_code=404, detail={"status": "error", "message": str(e)}) + + +@kyc_router.post("/admin/users/{user_id}/kyc-review") +async def complete_kyc_review( + user_id: int, + body: KYCReviewRequest, + current_user: User = Depends(require_auth), + db: Session = Depends(get_db), +): + """Complete or reject a user's KYC verification (admin/reviewer).""" + _require_admin_or_reviewer(current_user) + if body.kyc_status not in ("completed", "rejected"): + raise HTTPException( + status_code=400, + detail={"status": "error", "message": "kyc_status must be completed or rejected"}, + ) + service = KYCService(db) + try: + verification = service.complete_kyc_review( + user_id, body.kyc_status, current_user.id, body.rejection_reason + ) + return {"status": "success", "verification": verification.to_dict()} + except ValueError as e: + raise HTTPException(status_code=404, detail={"status": "error", "message": str(e)}) diff --git a/app/api/routes.py b/app/api/routes.py index 9447fb0..65b8c6f 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1137,7 +1137,7 @@ class KYCComplianceRequest(BaseModel): deal_id: Optional[int] = Field(None, description="Optional deal ID for context") -@router.post("/kyc/evaluate") +@router.post("/compliance/kyc/evaluate") # Rate limiting: Uses slowapi default_limits (60/minute) from server.py async def evaluate_kyc_compliance( request: KYCComplianceRequest, @@ -11453,10 +11453,22 @@ async def get_signup_details( status_code=404, detail={"status": "error", "message": f"User {user_id} not found"} ) - + data = user.to_dict() + data["kyc_verification"] = user.kyc_verification.to_dict() if getattr(user, "kyc_verification", None) else None + kyc_docs = getattr(user, "kyc_documents", None) or [] + data["kyc_documents"] = [ + { + "id": d.id, + "document_type": d.document_type, + "document_category": d.document_category, + "verification_status": d.verification_status, + "document_id": d.document_id, + } + for d in kyc_docs + ] return { "status": "success", - "data": user.to_dict() + "data": data, } except HTTPException: raise diff --git a/app/api/user_settings_routes.py b/app/api/user_settings_routes.py index d09e038..57c7785 100644 --- a/app/api/user_settings_routes.py +++ b/app/api/user_settings_routes.py @@ -25,6 +25,8 @@ class UserPreferencesUpdate(BaseModel): trading_mode: bool = False email_notifications: bool = True push_notifications: bool = False + kyc_brokerage_notifications: bool = True + brokerage_plaid_kyc_preferred: bool = False class APIKeyCreate(BaseModel): @@ -67,6 +69,8 @@ async def get_user_preferences( "trading_mode": preferences.get("trading_mode", False), "email_notifications": preferences.get("email_notifications", True), "push_notifications": preferences.get("push_notifications", False), + "kyc_brokerage_notifications": preferences.get("kyc_brokerage_notifications", True), + "brokerage_plaid_kyc_preferred": preferences.get("brokerage_plaid_kyc_preferred", False), } @@ -225,6 +229,68 @@ class UserProfileUpdate(BaseModel): profile_image: Optional[str] = None +class UserKYCInfoUpdate(BaseModel): + """KYC information used for identity verification (stored in profile_data.kyc).""" + legal_name: Optional[str] = None + date_of_birth: Optional[str] = None # ISO date string YYYY-MM-DD + address_line1: Optional[str] = None + address_line2: Optional[str] = None + address_city: Optional[str] = None + address_state: Optional[str] = None + address_postal_code: Optional[str] = None + address_country: Optional[str] = None + phone: Optional[str] = None + + +@router.get("/kyc-info") +async def get_user_kyc_info( + current_user: User = Depends(require_auth), + db: Session = Depends(get_db), +): + """Get KYC-related information (legal name, DOB, address, phone) used for identity verification.""" + kyc = {} + if getattr(current_user, "profile_data", None) and isinstance(current_user.profile_data, dict): + kyc = current_user.profile_data.get("kyc") or {} + return { + "legal_name": kyc.get("legal_name") or "", + "date_of_birth": kyc.get("date_of_birth") or "", + "address_line1": kyc.get("address_line1") or "", + "address_line2": kyc.get("address_line2") or "", + "address_city": kyc.get("address_city") or "", + "address_state": kyc.get("address_state") or "", + "address_postal_code": kyc.get("address_postal_code") or "", + "address_country": kyc.get("address_country") or "", + "phone": kyc.get("phone") or "", + } + + +@router.put("/kyc-info") +async def update_user_kyc_info( + payload: UserKYCInfoUpdate, + current_user: User = Depends(require_auth), + db: Session = Depends(get_db), +): + """Update KYC-related information (stored in profile_data.kyc).""" + if not current_user.profile_data: + current_user.profile_data = {} + if "kyc" not in current_user.profile_data: + current_user.profile_data["kyc"] = {} + kyc = current_user.profile_data["kyc"] + data = payload.model_dump(exclude_none=False) + for key, value in data.items(): + kyc[key] = value or "" + # Sync to top-level profile_data so Alpaca/brokerage prefill can use them + current_user.profile_data["phone"] = kyc.get("phone") or "" + current_user.profile_data["street_address"] = kyc.get("address_line1") or "" + current_user.profile_data["city"] = kyc.get("address_city") or "" + current_user.profile_data["state"] = kyc.get("address_state") or "" + current_user.profile_data["postal_code"] = kyc.get("address_postal_code") or "" + current_user.profile_data["country"] = kyc.get("address_country") or "" + db.commit() + db.refresh(current_user) + return {"status": "success"} + + @router.get("/profile") async def get_user_profile( current_user: User = Depends(require_auth), diff --git a/app/db/models.py b/app/db/models.py index 36bd8b8..97d1493 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -335,7 +335,13 @@ class User(Base): foreign_keys="KYCVerification.user_id", ) licenses = relationship("UserLicense", back_populates="user", cascade="all, delete-orphan") - kyc_documents = relationship("KYCDocument", back_populates="user", cascade="all, delete-orphan") + # Explicit foreign_keys: KYCDocument has user_id and reviewed_by (both FK to users) + kyc_documents = relationship( + "KYCDocument", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="KYCDocument.user_id", + ) # Admin fields is_instance_admin = Column(Boolean, default=False, nullable=False, index=True) @@ -1502,10 +1508,12 @@ class KYCDocument(Base): extracted_data = Column(JSONB, nullable=True) # OCR-extracted data ocr_confidence = Column(Float, nullable=True) + reviewed_by = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + reviewed_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - # Relationships - user = relationship("User", back_populates="kyc_documents") + # Relationships (foreign_keys: link to document owner, not reviewer) + user = relationship("User", back_populates="kyc_documents", foreign_keys=[user_id]) kyc_verification = relationship("KYCVerification", back_populates="documents") document = relationship("Document") @@ -1520,6 +1528,8 @@ def to_dict(self): "verification_status": self.verification_status, "extracted_data": self.extracted_data, "ocr_confidence": self.ocr_confidence, + "reviewed_by": self.reviewed_by, + "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/app/services/alpaca_account_service.py b/app/services/alpaca_account_service.py index e2726b6..681a638 100644 --- a/app/services/alpaca_account_service.py +++ b/app/services/alpaca_account_service.py @@ -62,13 +62,46 @@ def _build_account_payload( family_name = (prefill_override or {}).get("family_name") or (parts[1] if len(parts) > 1 else "User") profile_data = getattr(user, "profile_data", None) or {} + kyc = isinstance(profile_data, dict) and profile_data.get("kyc") or {} if isinstance(profile_data, dict): - phone = profile_data.get("phone") or profile_data.get("phone_number") or "" - street = (prefill_override or {}).get("street_address") or profile_data.get("street_address") or profile_data.get("address") or "" - city = (prefill_override or {}).get("city") or profile_data.get("city") or "" - state = (prefill_override or {}).get("state") or profile_data.get("state") or "" - postal_code = (prefill_override or {}).get("postal_code") or profile_data.get("postal_code") or profile_data.get("zip") or "" - country = (prefill_override or {}).get("country") or profile_data.get("country") or "USA" + # Prefer user-configured KYC info from User Settings when present + phone = (kyc.get("phone") or profile_data.get("phone") or profile_data.get("phone_number") or "").strip() + street = ( + (prefill_override or {}).get("street_address") + or kyc.get("address_line1") + or profile_data.get("street_address") + or profile_data.get("address") + or "" + ).strip() + city = ( + (prefill_override or {}).get("city") + or kyc.get("address_city") + or profile_data.get("city") + or "" + ).strip() + state = ( + (prefill_override or {}).get("state") + or kyc.get("address_state") + or profile_data.get("state") + or "" + ).strip() + postal_code = ( + (prefill_override or {}).get("postal_code") + or kyc.get("address_postal_code") + or profile_data.get("postal_code") + or profile_data.get("zip") + or "" + ).strip() + country = ( + (prefill_override or {}).get("country") + or kyc.get("address_country") + or profile_data.get("country") + or "USA" + ).strip() + if kyc.get("legal_name"): + kyc_parts = str(kyc["legal_name"]).strip().split(None, 1) + given_name = (prefill_override or {}).get("given_name") or (kyc_parts[0] if kyc_parts else given_name) + family_name = (prefill_override or {}).get("family_name") or (kyc_parts[1] if len(kyc_parts) > 1 else family_name) else: phone = "" street = (prefill_override or {}).get("street_address") or "" @@ -83,15 +116,18 @@ def _build_account_payload( "email_address": email, "phone_number": str(phone)[:20] if phone else "", } + dob = "1990-01-01" # Placeholder if not in profile; Alpaca may require or return ACTION_REQUIRED + if kyc.get("date_of_birth"): + dob = str(kyc["date_of_birth"])[:10] + if isinstance(verification, KYCVerification) and getattr(verification, "verification_metadata", None): + meta = verification.verification_metadata or {} + if isinstance(meta, dict) and meta.get("date_of_birth"): + dob = str(meta["date_of_birth"])[:10] identity = { "given_name": str(given_name)[:50], "family_name": str(family_name)[:50], - "date_of_birth": "1990-01-01", # Placeholder if not in profile; Alpaca may require or return ACTION_REQUIRED + "date_of_birth": dob, } - if isinstance(verification, KYCVerification) and getattr(verification, "verification_metadata", None): - meta = verification.verification_metadata or {} - if isinstance(meta, dict) and meta.get("date_of_birth"): - identity["date_of_birth"] = str(meta["date_of_birth"])[:10] address = { "street_address": [str(street)[:64]] if street else ["N/A"], @@ -307,6 +343,18 @@ def sync_alpaca_account_status(rec: AlpacaCustomerAccount, db: Session) -> bool: "previous_status": previous_status, }, ) + if status in ("ACTIVE", "ACTION_REQUIRED"): + try: + from app.services.kyc_brokerage_notification import notify_kyc_brokerage_status + + subject = "Brokerage account status update" + if status == "ACTIVE": + msg = "Your brokerage account is now active. You can place trades." + else: + msg = "Action required on your brokerage account. Please check the app for details." + notify_kyc_brokerage_status(db, rec.user_id, subject, msg) + except Exception as exc: + logger.warning("KYC/brokerage notification failed after Alpaca status sync: %s", exc) return changed diff --git a/app/services/kyc_brokerage_notification.py b/app/services/kyc_brokerage_notification.py new file mode 100644 index 0000000..01dc954 --- /dev/null +++ b/app/services/kyc_brokerage_notification.py @@ -0,0 +1,101 @@ +"""KYC and brokerage status change notifications. + +When user preference kyc_brokerage_notifications is True, trigger notification +(log and optionally email) on brokerage account status change or KYC verification +completed/rejected by admin. +""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + +from sqlalchemy.orm import Session + +from app.db.models import User + +logger = logging.getLogger(__name__) + +_executor: Optional[ThreadPoolExecutor] = None + + +def _get_executor() -> ThreadPoolExecutor: + global _executor + if _executor is None: + _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="kyc_brokerage_notify") + return _executor + + +def _get_user_kyc_brokerage_notifications_preference(db: Session, user_id: int) -> bool: + """Return True if user has kyc_brokerage_notifications enabled.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return False + preferences = {} + if hasattr(user, "preferences") and user.preferences: + preferences = user.preferences + elif getattr(user, "profile_data", None) and isinstance(user.profile_data, dict): + preferences = user.profile_data.get("preferences") or {} + return preferences.get("kyc_brokerage_notifications", True) + + +def _get_user_email(user: User) -> Optional[str]: + """Return user email for notification (handles EncryptedString).""" + email = getattr(user, "email", None) + if email is None: + return None + if hasattr(email, "decrypt"): + try: + return email.decrypt() + except Exception: + return str(email) + return str(email) + + +async def _send_notification_email(user_id: int, recipient: str, subject: str, message: str) -> bool: + """Send notification email via messenger if configured.""" + try: + from app.services.messenger.factory import create_messenger + + messenger = create_messenger() + if not messenger: + return False + return await messenger.send_message(recipient, subject, message, None) + except Exception as exc: + logger.warning("Failed to send KYC/brokerage notification email to user %s: %s", user_id, exc) + return False + + +def _run_send(user_id: int, recipient: str, subject: str, message: str) -> None: + """Run async send in a dedicated thread (avoids nested event loop).""" + try: + asyncio.run(_send_notification_email(user_id, recipient, subject, message)) + except Exception as exc: + logger.warning("KYC/brokerage notification send failed for user %s: %s", user_id, exc) + + +def notify_kyc_brokerage_status( + db: Session, + user_id: int, + subject: str, + message: str, +) -> None: + """ + If user has kyc_brokerage_notifications enabled, log and optionally send email. + Called when brokerage account status changes or KYC verification is completed/rejected. + """ + if not _get_user_kyc_brokerage_notifications_preference(db, user_id): + return + logger.info( + "KYC/brokerage notification: user_id=%s subject=%s", + user_id, + subject, + extra={"user_id": user_id, "subject": subject}, + ) + user = db.query(User).filter(User.id == user_id).first() + recipient = _get_user_email(user) if user else None + if not recipient: + return + _get_executor().submit(_run_send, user_id, recipient, subject, message) diff --git a/app/services/kyc_service.py b/app/services/kyc_service.py index 3d23900..1a89795 100644 --- a/app/services/kyc_service.py +++ b/app/services/kyc_service.py @@ -222,3 +222,58 @@ def get_kyc_requirements(self, deal_type: str) -> List[Dict[str, Any]]: requirements.append({"type": "professional_license", "required": True, "description": "Relevant professional certification"}) return requirements + + def verify_kyc_document( + self, kyc_document_id: int, verification_status: str, reviewer_id: int + ) -> KYCDocument: + """Set verification status of a KYC document (admin/reviewer).""" + if verification_status not in ("verified", "rejected", "expired"): + raise ValueError(f"Invalid verification_status: {verification_status}") + kyc_doc = self.db.query(KYCDocument).filter(KYCDocument.id == kyc_document_id).first() + if not kyc_doc: + raise ValueError(f"KYCDocument {kyc_document_id} not found") + kyc_doc.verification_status = verification_status + kyc_doc.reviewed_by = reviewer_id + kyc_doc.reviewed_at = datetime.utcnow() + self.db.commit() + self.db.refresh(kyc_doc) + return kyc_doc + + def complete_kyc_review( + self, + user_id: int, + kyc_status: str, + reviewer_id: int, + rejection_reason: Optional[str] = None, + ) -> KYCVerification: + """Complete or reject a user's KYC verification (admin/reviewer).""" + if kyc_status not in ("completed", "rejected"): + raise ValueError(f"Invalid kyc_status: {kyc_status}") + verification = self.db.query(KYCVerification).filter(KYCVerification.user_id == user_id).first() + if not verification: + raise ValueError(f"KYCVerification for user {user_id} not found") + verification.kyc_status = kyc_status + verification.reviewed_at = datetime.utcnow() + verification.reviewed_by = reviewer_id + if kyc_status == "rejected" and rejection_reason: + meta = verification.verification_metadata or {} + meta["rejection_reason"] = rejection_reason + verification.verification_metadata = meta + if kyc_status == "completed": + verification.completed_at = datetime.utcnow() + self.db.commit() + self.db.refresh(verification) + try: + from app.services.kyc_brokerage_notification import notify_kyc_brokerage_status + + subject = "KYC verification update" + if kyc_status == "completed": + msg = "Your KYC verification has been completed." + else: + msg = "Your KYC verification has been reviewed. Please check the app for details." + if rejection_reason: + msg += f" Reason: {rejection_reason}" + notify_kyc_brokerage_status(self.db, user_id, subject, msg) + except Exception as exc: + logger.warning("KYC/brokerage notification failed after complete_kyc_review: %s", exc) + return verification diff --git a/client/src/components/AdminSignupDashboard.tsx b/client/src/components/AdminSignupDashboard.tsx index 261b234..69e9531 100644 --- a/client/src/components/AdminSignupDashboard.tsx +++ b/client/src/components/AdminSignupDashboard.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button'; import { User, Search, - // Filter removed - unused CheckCircle, XCircle, Clock, @@ -14,7 +13,8 @@ import { FileText, Building2, Mail, - Calendar + Calendar, + Shield, } from 'lucide-react'; import { fetchWithAuth, useAuth } from '@/context/AuthContext'; import { usePermissions } from '@/hooks/usePermissions'; @@ -55,6 +55,19 @@ interface SignupListResponse { }; } +interface KYCDocumentItem { + id: number; + document_type: string; + document_category: string; + verification_status: string; + document_id: number; +} + +interface SignupDetailUser extends SignupUser { + kyc_verification?: Record | null; + kyc_documents?: KYCDocumentItem[]; +} + export function AdminSignupDashboard() { const { hasPermission } = usePermissions(); const { user } = useAuth(); @@ -75,6 +88,9 @@ export function AdminSignupDashboard() { const [showApprovalModal, setShowApprovalModal] = useState(false); const [approvalUser, setApprovalUser] = useState(null); const [selectedUsers, setSelectedUsers] = useState([]); + const [signupDetails, setSignupDetails] = useState(null); + const [signupDetailsLoading, setSignupDetailsLoading] = useState(false); + const [kycActionLoading, setKycActionLoading] = useState(null); useEffect(() => { fetchSignups(); @@ -466,9 +482,22 @@ export function AdminSignupDashboard() {
+ {signupDetailsLoading ? ( +
+ +
+ ) : ( + <>
-

{selectedSignup.display_name}

+

{(signupDetails ?? selectedSignup).display_name}

-

{selectedSignup.email}

+

{(signupDetails ?? selectedSignup).email}

-

{selectedSignup.role.replace('_', ' ')}

+

{(signupDetails ?? selectedSignup).role.replace('_', ' ')}

- - {selectedSignup.signup_status} + + {(signupDetails ?? selectedSignup).signup_status}
- {selectedSignup.signup_submitted_at && ( + {(signupDetails ?? selectedSignup).signup_submitted_at && (

- {new Date(selectedSignup.signup_submitted_at).toLocaleString()} + {new Date((signupDetails ?? selectedSignup).signup_submitted_at!).toLocaleString()}

)} - {selectedSignup.signup_reviewed_at && ( + {(signupDetails ?? selectedSignup).signup_reviewed_at && (

- {new Date(selectedSignup.signup_reviewed_at).toLocaleString()} + {new Date((signupDetails ?? selectedSignup).signup_reviewed_at!).toLocaleString()}

)} - {selectedSignup.organization && ( + {(signupDetails ?? selectedSignup).organization && (
-

{selectedSignup.organization.name}

- {!selectedSignup.organization.is_active && ( +

{(signupDetails ?? selectedSignup).organization!.name}

+ {!(signupDetails ?? selectedSignup).organization!.is_active && ( Pending Approval )} - {!selectedSignup.organization.is_active && ( + {!(signupDetails ?? selectedSignup).organization!.is_active && (
- {selectedSignup.profile_data && Object.keys(selectedSignup.profile_data).length > 0 && ( + {(signupDetails ?? selectedSignup).profile_data && Object.keys((signupDetails ?? selectedSignup).profile_data!).length > 0 && (
- {Object.entries(selectedSignup.profile_data).map(([key, value]) => { + {Object.entries((signupDetails ?? selectedSignup).profile_data!).map(([key, value]) => { // Handle objects, arrays, and null/undefined values let displayValue: string; if (value === null || value === undefined) { @@ -813,27 +850,170 @@ export function AdminSignupDashboard() {
)} - {selectedSignup.signup_rejection_reason && ( + {(signupDetails ?? selectedSignup).signup_rejection_reason && (

- {selectedSignup.signup_rejection_reason} + {(signupDetails ?? selectedSignup).signup_rejection_reason}

)} + {/* KYC & Documents section */} + {(signupDetails?.kyc_verification != null || (signupDetails?.kyc_documents?.length ?? 0) > 0) && ( +
+
+ + +
+ {signupDetails?.kyc_verification && ( +
+ KYC status: {(signupDetails.kyc_verification as Record).kyc_status ?? '—'} +
+ )} + {signupDetails?.kyc_documents && signupDetails.kyc_documents.length > 0 && ( +
+ {signupDetails.kyc_documents.map((doc) => ( +
+ + {doc.document_type} / {doc.document_category} + +
+ + {doc.verification_status} + + {doc.verification_status === 'pending' && ( + <> + + + + )} +
+
+ ))} +
+ )} + {signupDetails?.kyc_verification && (signupDetails.kyc_verification as Record).kyc_status === 'pending' && ( + + )} +
+ )} +
+ + )}
diff --git a/client/src/components/BrokerageOnboarding.tsx b/client/src/components/BrokerageOnboarding.tsx index caf30c0..a1c3293 100644 --- a/client/src/components/BrokerageOnboarding.tsx +++ b/client/src/components/BrokerageOnboarding.tsx @@ -7,6 +7,7 @@ */ import { useState, useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; import { usePlaidLink } from 'react-plaid-link'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -16,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { fetchWithAuth } from '@/context/AuthContext'; import { resolveApiUrl } from '@/utils/apiBase'; -import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send, Link2, FileText } from 'lucide-react'; +import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send, Link2, FileText, Settings } from 'lucide-react'; interface BrokerageStatus { has_account: boolean; @@ -53,11 +54,14 @@ export function BrokerageOnboarding() { // Plaid KYC flow const [linkToken, setLinkToken] = useState(null); const [prefill, setPrefill] = useState(null); + const [prefillSource, setPrefillSource] = useState<'plaid' | 'user_settings' | 'both' | 'none'>('none'); + const [prefillMessage, setPrefillMessage] = useState(null); const [prefillLoading, setPrefillLoading] = useState(false); const [agreedCustomer, setAgreedCustomer] = useState(false); const [agreedMargin, setAgreedMargin] = useState(false); const [agreedAt, setAgreedAt] = useState(null); const openedForRef = useRef(null); + const [brokeragePreferences, setBrokeragePreferences] = useState<{ brokerage_plaid_kyc_preferred?: boolean } | null>(null); const fetchStatus = async () => { setError(null); @@ -84,12 +88,19 @@ export function BrokerageOnboarding() { const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/prefill')); if (r.ok) { const d = await r.json(); - setPrefill(d.prefill && Object.keys(d.prefill).length > 0 ? d.prefill : null); + const hasPrefill = d.prefill && typeof d.prefill === 'object' && Object.keys(d.prefill).length > 0; + setPrefill(hasPrefill ? d.prefill : null); + setPrefillSource(d.source || 'none'); + setPrefillMessage(d.message || null); } else { setPrefill(null); + setPrefillSource('none'); + setPrefillMessage(null); } } catch { setPrefill(null); + setPrefillSource('none'); + setPrefillMessage(null); } finally { setPrefillLoading(false); } @@ -99,6 +110,22 @@ export function BrokerageOnboarding() { fetchStatus(); }, []); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const r = await fetchWithAuth(resolveApiUrl('/api/user-settings/preferences')); + if (r.ok && !cancelled) { + const d = await r.json(); + setBrokeragePreferences(d); + } + } catch { + if (!cancelled) setBrokeragePreferences(null); + } + })(); + return () => { cancelled = true; }; + }, []); + // When status loaded and no account yet, fetch prefill once (user may have linked Plaid earlier) const prefillFetchedRef = useRef(false); useEffect(() => { @@ -176,8 +203,11 @@ export function BrokerageOnboarding() { setMessage(null); try { const signedAt = agreedAt || new Date().toISOString(); + const use_plaid_kyc = prefill + ? (brokeragePreferences?.brokerage_plaid_kyc_preferred ?? usePlaidKyc) + : usePlaidKyc; const body = { - use_plaid_kyc: usePlaidKyc, + use_plaid_kyc, agreements: [ { agreement: 'customer_agreement', signed_at: signedAt, ip_address: '0.0.0.0' }, { agreement: 'margin_agreement', signed_at: signedAt, ip_address: '0.0.0.0' }, @@ -326,20 +356,39 @@ export function BrokerageOnboarding() { )}
- {/* Step 2: Prefill from Plaid (optional; show agreements even without prefill) */} + {/* Step 2: Your information (from Plaid and/or User Settings) */} {(prefill !== null || prefillLoading || (prefill === null && !prefillLoading)) && (
+

+ Name, address, and date of birth used for your application. You can edit these in{' '} + + + User Settings → KYC & Identity + + . +

{prefillLoading ? ( ) : prefill && Object.keys(prefill).length > 0 ? (
+ {(prefillSource === 'plaid' || prefillSource === 'both') && ( + + {prefillSource === 'both' ? 'Plaid + User Settings' : 'From Plaid'} + + )} + {prefillSource === 'user_settings' && ( + From User Settings + )} {(prefill.given_name || prefill.family_name) && (

Name: {[prefill.given_name, prefill.family_name].filter(Boolean).join(' ')}

)} + {prefill.date_of_birth && ( +

Date of birth: {prefill.date_of_birth}

+ )} {(prefill.street_address || prefill.city) && (

Address: {[prefill.street_address, prefill.city, prefill.state, prefill.postal_code, prefill.country] @@ -349,8 +398,17 @@ export function BrokerageOnboarding() { )}

) : !prefillLoading ? ( -

No Plaid identity linked. You can still apply without Plaid (admin/legacy flow) below.

+

+ Fill your name, date of birth, and address in{' '} + + User Settings → KYC & Identity + + {' '}to prefill your application. You can also link Plaid above or submit without prefill (admin/legacy flow) below. +

) : null} + {prefillMessage && !prefillLoading && ( +

{prefillMessage}

+ )}
)} @@ -397,6 +455,9 @@ export function BrokerageOnboarding() { {/* Step 4: Submit */}
+ {brokeragePreferences?.brokerage_plaid_kyc_preferred && prefill !== null && ( +

Using Plaid for KYC is preferred in your settings.

+ )} {prefill !== null && (
+ + + + + + Quick links + + Jump to related admin and user areas + + + + {isInstanceAdmin && ( + <> + + + + + + + )} + + diff --git a/client/src/pages/UserSettings.tsx b/client/src/pages/UserSettings.tsx index 8ee46e0..74fc941 100644 --- a/client/src/pages/UserSettings.tsx +++ b/client/src/pages/UserSettings.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { useAuth } from '@/context/AuthContext'; -import { User, Key, Bell, Shield, Mic, TrendingUp, Building2, DollarSign, Link2, Briefcase } from 'lucide-react'; +import { useAuth, fetchWithAuth } from '@/context/AuthContext'; +import { User, Key, Bell, Shield, Mic, TrendingUp, Building2, DollarSign, Link2, Briefcase, Settings } from 'lucide-react'; import { LinkAccounts } from '@/components/LinkAccounts'; import { BrokerageOnboarding } from '@/components/BrokerageOnboarding'; @@ -18,6 +19,8 @@ interface UserPreferences { trading_mode: boolean; email_notifications: boolean; push_notifications: boolean; + kyc_brokerage_notifications: boolean; + brokerage_plaid_kyc_preferred: boolean; } interface APIKey { @@ -27,8 +30,27 @@ interface APIKey { created_at: string; } +interface KYCInfo { + legal_name: string; + date_of_birth: string; + address_line1: string; + address_line2: string; + address_city: string; + address_state: string; + address_postal_code: string; + address_country: string; + phone: string; +} + +const SETTINGS_TAB_VALUES = ['profile', 'preferences', 'kyc-identity', 'link-accounts', 'trading-account', 'notifications', 'api-keys'] as const; + export function UserSettings() { const { user } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + const tabFromUrl = searchParams.get('tab'); + const initialTab = tabFromUrl && SETTINGS_TAB_VALUES.includes(tabFromUrl as typeof SETTINGS_TAB_VALUES[number]) + ? tabFromUrl + : 'profile'; const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -46,10 +68,42 @@ export function UserSettings() { trading_mode: false, email_notifications: true, push_notifications: false, + kyc_brokerage_notifications: true, + brokerage_plaid_kyc_preferred: false, }); const [apiKeys, setApiKeys] = useState([]); const [newApiKey, setNewApiKey] = useState({ name: '', key: '' }); + const [kycStatus, setKycStatus] = useState<{ status: string; verification?: Record; evaluation?: Record } | null>(null); + const [kycInfo, setKycInfo] = useState({ + legal_name: '', + date_of_birth: '', + address_line1: '', + address_line2: '', + address_city: '', + address_state: '', + address_postal_code: '', + address_country: '', + phone: '', + }); + const [activeSettingsTab, setActiveSettingsTab] = useState(initialTab); + + // Sync tab from URL when landing on /settings?tab=kyc-identity + useEffect(() => { + const t = searchParams.get('tab'); + if (t && SETTINGS_TAB_VALUES.includes(t as (typeof SETTINGS_TAB_VALUES)[number])) { + setActiveSettingsTab(t); + } + }, [searchParams]); + + const handleTabChange = (value: string) => { + setActiveSettingsTab(value); + if (value && value !== 'profile') { + setSearchParams({ tab: value }, { replace: true }); + } else { + setSearchParams({}, { replace: true }); + } + }; // Load user preferences and profile on mount useEffect(() => { @@ -58,14 +112,18 @@ export function UserSettings() { setLoading(true); // Load preferences - const prefsResponse = await fetch('/api/user-settings/preferences'); + const prefsResponse = await fetchWithAuth('/api/user-settings/preferences'); if (prefsResponse.ok) { const prefsData = await prefsResponse.json(); - setPreferences(prefsData); + setPreferences({ + kyc_brokerage_notifications: true, + brokerage_plaid_kyc_preferred: false, + ...prefsData, + }); } // Load profile - const profileResponse = await fetch('/api/user-settings/profile'); + const profileResponse = await fetchWithAuth('/api/user-settings/profile'); if (profileResponse.ok) { const profileData = await profileResponse.json(); setProfile({ @@ -76,7 +134,7 @@ export function UserSettings() { } // Load API keys - const keysResponse = await fetch('/api/user-settings/api-keys'); + const keysResponse = await fetchWithAuth('/api/user-settings/api-keys'); if (keysResponse.ok) { const keysData = await keysResponse.json(); setApiKeys(keysData.map((k: any) => ({ @@ -85,6 +143,32 @@ export function UserSettings() { created_at: k.created_at, }))); } + + // Load KYC status + const kycResponse = await fetchWithAuth('/api/kyc/status'); + if (kycResponse.ok) { + const kycData = await kycResponse.json(); + setKycStatus(kycData); + } else { + setKycStatus(null); + } + + // Load KYC info (legal name, DOB, address, phone) + const kycInfoResponse = await fetchWithAuth('/api/user-settings/kyc-info'); + if (kycInfoResponse.ok) { + const kycInfoData = await kycInfoResponse.json(); + setKycInfo({ + legal_name: kycInfoData.legal_name ?? '', + date_of_birth: kycInfoData.date_of_birth ?? '', + address_line1: kycInfoData.address_line1 ?? '', + address_line2: kycInfoData.address_line2 ?? '', + address_city: kycInfoData.address_city ?? '', + address_state: kycInfoData.address_state ?? '', + address_postal_code: kycInfoData.address_postal_code ?? '', + address_country: kycInfoData.address_country ?? '', + phone: kycInfoData.phone ?? '', + }); + } } catch (error) { console.error('Failed to load data:', error); } finally { @@ -100,7 +184,7 @@ export function UserSettings() { const handleSaveProfile = async () => { try { setSaving(true); - const response = await fetch('/api/user-settings/profile', { + const response = await fetchWithAuth('/api/user-settings/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile), @@ -120,7 +204,7 @@ export function UserSettings() { const handleSavePreferences = async () => { try { setSaving(true); - const response = await fetch('/api/user-settings/preferences', { + const response = await fetchWithAuth('/api/user-settings/preferences', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preferences), @@ -143,7 +227,7 @@ export function UserSettings() { } try { setSaving(true); - const response = await fetch('/api/user-settings/api-keys', { + const response = await fetchWithAuth('/api/user-settings/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newApiKey), @@ -176,7 +260,7 @@ export function UserSettings() { } try { setSaving(true); - const response = await fetch(`/api/user-settings/api-keys/${keyId}`, { + const response = await fetchWithAuth(`/api/user-settings/api-keys/${keyId}`, { method: 'DELETE', }); if (response.ok) { @@ -193,6 +277,27 @@ export function UserSettings() { } }; + const handleSaveKycInfo = async () => { + try { + setSaving(true); + const response = await fetchWithAuth('/api/user-settings/kyc-info', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(kycInfo), + }); + if (response.ok) { + alert('KYC information saved successfully'); + } else { + alert('Failed to save KYC information'); + } + } catch (error) { + console.error('Failed to save KYC information:', error); + alert('Failed to save KYC information'); + } finally { + setSaving(false); + } + }; + if (loading) { return (
@@ -203,12 +308,27 @@ export function UserSettings() { return (
-

User Settings

+
+

User Settings

+ {user?.role === 'admin' && ( + + + Admin Settings + + )} +
- + Profile Preferences + + + KYC & Identity + Link Accounts @@ -222,45 +342,154 @@ export function UserSettings() { - - - Profile Information - Update your personal information - - -
- - setProfile({ ...profile, display_name: e.target.value })} - /> -
-
- - -

Email cannot be changed

-
-
- - setProfile({ ...profile, profile_image: e.target.value })} - placeholder="https://..." - /> -
- -
-
+
+ + + Profile Information + Update your personal information + + +
+ + setProfile({ ...profile, display_name: e.target.value })} + /> +
+
+ + +

Email cannot be changed

+
+
+ + setProfile({ ...profile, profile_image: e.target.value })} + placeholder="https://..." + /> +
+ +
+
+ + + + + + KYC information + + + Name, date of birth, address, and phone used for identity verification (e.g. brokerage application). Also available in the{' '} + + {' '}tab. + + + +
+
+ + setKycInfo({ ...kycInfo, legal_name: e.target.value })} + placeholder="Full legal name" + /> +
+
+ + setKycInfo({ ...kycInfo, date_of_birth: e.target.value })} + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_line1: e.target.value })} + placeholder="Street address" + /> +
+
+ + setKycInfo({ ...kycInfo, address_line2: e.target.value })} + placeholder="Apt, suite, etc." + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_city: e.target.value })} + placeholder="City" + /> +
+
+ + setKycInfo({ ...kycInfo, address_state: e.target.value })} + placeholder="State or province" + /> +
+
+ + setKycInfo({ ...kycInfo, address_postal_code: e.target.value })} + placeholder="ZIP / Postal code" + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_country: e.target.value })} + placeholder="Country" + /> +
+
+ + setKycInfo({ ...kycInfo, phone: e.target.value })} + placeholder="Phone number (e.g. E.164)" + /> +
+ +
+
+
@@ -344,6 +573,40 @@ export function UserSettings() { onCheckedChange={(checked) => setPreferences({ ...preferences, trading_mode: checked })} />
+ +
+

KYC & Brokerage

+
+
+
+ +
+ +

Notify when KYC or brokerage account status changes

+
+
+ setPreferences({ ...preferences, kyc_brokerage_notifications: checked })} + /> +
+
+
+ +
+ +

When applying for a trading account, prefer using Plaid identity when available

+
+
+ setPreferences({ ...preferences, brokerage_plaid_kyc_preferred: checked })} + /> +
+
+
+ {' '}tab. + + + +
+
+ + setKycInfo({ ...kycInfo, legal_name: e.target.value })} + placeholder="Full legal name" + /> +
+
+ + setKycInfo({ ...kycInfo, date_of_birth: e.target.value })} + placeholder="YYYY-MM-DD" + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_line1: e.target.value })} + placeholder="Street address" + /> +
+
+ + setKycInfo({ ...kycInfo, address_line2: e.target.value })} + placeholder="Apt, suite, etc." + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_city: e.target.value })} + placeholder="City" + /> +
+
+ + setKycInfo({ ...kycInfo, address_state: e.target.value })} + placeholder="State or province" + /> +
+
+ + setKycInfo({ ...kycInfo, address_postal_code: e.target.value })} + placeholder="ZIP / Postal code" + /> +
+
+
+ + setKycInfo({ ...kycInfo, address_country: e.target.value })} + placeholder="Country" + /> +
+
+ + setKycInfo({ ...kycInfo, phone: e.target.value })} + placeholder="Phone number (e.g. E.164)" + /> +
+ +
+ + + + KYC & Identity status + Your identity verification status for KYC and brokerage + + + {kycStatus === null ? ( +

Loading KYC status...

+ ) : kycStatus.status === 'not_initiated' ? ( + <> +

KYC has not been started.

+

You can start identity verification when you open a trading account.

+ + + ) : ( + <> +
+ Status + {kycStatus.status} +
+ {kycStatus.verification && typeof kycStatus.verification === 'object' && 'kyc_level' in kycStatus.verification && ( +
+ Level + {String((kycStatus.verification as Record).kyc_level)} +
+ )} + + + )} +
+
+
+
From 5f6ae2b751283b2f25a9c1866ecc0bf9be9c33de Mon Sep 17 00:00:00 2001 From: Joseph Pollack Date: Fri, 30 Jan 2026 16:26:15 +0100 Subject: [PATCH 4/7] some bug fixes --- app/api/brokerage_routes.py | 63 ++++++- app/api/polymarket_surveillance_routes.py | 42 ++++- app/api/user_settings_routes.py | 4 + app/services/alpaca_account_service.py | 108 +++++++++-- app/services/alpaca_broker_service.py | 61 ++++++- app/services/chronos_model_manager.py | 24 ++- .../polymarket_surveillance_service.py | 84 +++++++-- chronos_modal/__init__.py | 5 + {modal => chronos_modal}/app.py | 4 +- {modal => chronos_modal}/image.py | 0 client/src/components/BrokerageOnboarding.tsx | 79 +++++++- client/src/components/LinkAccounts.tsx | 30 +++- .../polymarket/SurveillanceAlertsPanel.tsx | 17 +- .../trading/StructuredProductsTab.tsx | 2 +- client/src/components/ui/button.tsx | 21 ++- client/src/pages/UserSettings.tsx | 60 ++++++- ...lymarket-surveillance-alerts-resolution.md | 44 +++++ docs/guides/stock-prediction-setup.md | 6 +- modal/__init__.py | 5 - scripts/{ => dev}/fix_typescript_errors.sh | 0 scripts/dev/fund_alpaca_sandbox_account.py | 170 ++++++++++++++++++ 21 files changed, 746 insertions(+), 83 deletions(-) create mode 100644 chronos_modal/__init__.py rename {modal => chronos_modal}/app.py (96%) rename {modal => chronos_modal}/image.py (100%) create mode 100644 docs/guides/polymarket-surveillance-alerts-resolution.md delete mode 100644 modal/__init__.py rename scripts/{ => dev}/fix_typescript_errors.sh (100%) create mode 100644 scripts/dev/fund_alpaca_sandbox_account.py diff --git a/app/api/brokerage_routes.py b/app/api/brokerage_routes.py index e40e896..1b1d575 100644 --- a/app/api/brokerage_routes.py +++ b/app/api/brokerage_routes.py @@ -12,7 +12,11 @@ from app.db import get_db from app.db.models import User, AlpacaCustomerAccount from app.db.models import AuditAction -from app.services.alpaca_account_service import open_alpaca_account, AlpacaAccountServiceError +from app.services.alpaca_account_service import ( + open_alpaca_account, + AlpacaAccountServiceError, + sync_alpaca_account_status, +) from app.services.alpaca_broker_service import get_broker_client, AlpacaBrokerAPIError from app.services.plaid_service import ( create_link_token_for_brokerage, @@ -27,9 +31,11 @@ class AccountStatusResponse(BaseModel): - """Brokerage account status for the current user.""" + """Brokerage account status for the current user (equities + crypto per Alpaca).""" has_account: bool - status: Optional[str] = None + status: Optional[str] = None # Equities: SUBMITTED, ACTIVE, ACTION_REQUIRED, REJECTED + crypto_status: Optional[str] = None # Crypto: INACTIVE, ACTIVE, SUBMISSION_FAILED + enabled_assets: Optional[List[str]] = None # e.g. ["us_equity"] when equities active alpaca_account_id: Optional[str] = None account_number: Optional[str] = None action_required_reason: Optional[str] = None @@ -44,7 +50,7 @@ class AgreementItem(BaseModel): class ApplyRequest(BaseModel): - """Brokerage apply request: optional agreements (from UI) and Plaid KYC flag.""" + """Brokerage apply request: optional agreements (from UI), Plaid KYC flag, and asset classes.""" agreements: Optional[List[AgreementItem]] = Field( None, description="Client-provided agreement acceptances (signed_at from UI). Required for Plaid KYC flow.", @@ -57,6 +63,10 @@ class ApplyRequest(BaseModel): None, description="Optional prefill from Plaid identity (given_name, family_name, address, etc.).", ) + enabled_assets: Optional[List[str]] = Field( + None, + description="Asset classes to enable: e.g. ['us_equity', 'crypto']. Defaults to ['us_equity'] if omitted.", + ) @router.get("/link-token", response_model=Dict[str, Any]) @@ -189,9 +199,11 @@ async def brokerage_account_apply( agreements_override = None prefill_override = None use_plaid_kyc = False + enabled_assets = None if body: use_plaid_kyc = body.use_plaid_kyc prefill_override = body.prefill + enabled_assets = body.enabled_assets if body.agreements and len(body.agreements) >= 2: agreements_override = [ {"agreement": a.agreement, "signed_at": a.signed_at, "ip_address": a.ip_address or "0.0.0.0"} @@ -204,6 +216,7 @@ async def brokerage_account_apply( agreements_override=agreements_override, prefill_override=prefill_override, use_plaid_kyc=use_plaid_kyc, + enabled_assets=enabled_assets, ) return { "status": "submitted", @@ -220,7 +233,7 @@ async def brokerage_account_status( db: Session = Depends(get_db), current_user: User = Depends(require_auth), ): - """Get current user's Alpaca Broker account status.""" + """Get current user's Alpaca Broker account status. Syncs from Alpaca so refresh returns status, crypto_status, and enabled_assets.""" acc = ( db.query(AlpacaCustomerAccount) .filter(AlpacaCustomerAccount.user_id == current_user.id) @@ -228,9 +241,47 @@ async def brokerage_account_status( ) if not acc: return AccountStatusResponse(has_account=False, currency="USD") + # Always sync from Alpaca so response includes equities status, crypto_status, enabled_assets + _, alpaca_data = sync_alpaca_account_status(acc, db) + db.refresh(acc) + # If sync returned no data (e.g. client unavailable), try direct fetch so we still show ACTIVE when Alpaca says so + if not alpaca_data: + client = get_broker_client() + if client: + try: + alpaca_data = client.get_account(acc.alpaca_account_id) + # Persist so DB matches Alpaca and next request does not need to re-fetch + if alpaca_data: + _s = (alpaca_data.get("status") or "").upper() + if _s and (_s != (acc.status or "").upper() or acc.account_number != (alpaca_data.get("account_number") or acc.account_number)): + acc.status = _s + acc.account_number = alpaca_data.get("account_number") or acc.account_number + acc.action_required_reason = alpaca_data.get("action_required_reason") or alpaca_data.get("reason") or acc.action_required_reason + db.commit() + db.refresh(acc) + except AlpacaBrokerAPIError as e: + logger.warning("Brokerage status fallback get_account failed for %s: %s", acc.alpaca_account_id, e) + if alpaca_data: + _status = (alpaca_data.get("status") or acc.status) or "" + _status = (_status.upper() if isinstance(_status, str) else str(_status)) or acc.status + _crypto = alpaca_data.get("crypto_status") + if _crypto and isinstance(_crypto, str): + _crypto = _crypto.upper() + return AccountStatusResponse( + has_account=True, + status=_status, + crypto_status=_crypto, + enabled_assets=alpaca_data.get("enabled_assets") if isinstance(alpaca_data.get("enabled_assets"), list) else None, + alpaca_account_id=acc.alpaca_account_id, + account_number=alpaca_data.get("account_number") or acc.account_number, + action_required_reason=alpaca_data.get("action_required_reason") or alpaca_data.get("reason") or acc.action_required_reason, + currency=alpaca_data.get("currency") or acc.currency or "USD", + ) return AccountStatusResponse( has_account=True, - status=acc.status, + status=(acc.status or "").upper() or None, + crypto_status=None, + enabled_assets=None, alpaca_account_id=acc.alpaca_account_id, account_number=acc.account_number, action_required_reason=acc.action_required_reason, diff --git a/app/api/polymarket_surveillance_routes.py b/app/api/polymarket_surveillance_routes.py index a954fda..8cfc9e0 100644 --- a/app/api/polymarket_surveillance_routes.py +++ b/app/api/polymarket_surveillance_routes.py @@ -32,10 +32,20 @@ class ReviewAlertRequest(BaseModel): def require_surveillance_access(request: Request, user: User, db: Session) -> None: """ Ensure the user has access to market intelligence / surveillance. + Instance admin (admin role or first user) always has access. If SURVEILLANCE_REQUIRES_PRO is False, returns without raising. Otherwise checks RevenueCat (via PaymentRouterService) or SubscriptionService tier. Raises HTTPException 403 with X-Upgrade-Url when access is denied. """ + # Instance admin always has access (admin role, is_instance_admin, or first user) + if getattr(user, "role", None) == "admin": + return + if getattr(user, "is_instance_admin", False): + return + first_user = db.query(User).order_by(User.id.asc()).limit(1).first() + if first_user and first_user.id == user.id: + return + if not getattr(settings, "SURVEILLANCE_REQUIRES_PRO", True): return @@ -59,6 +69,16 @@ def require_surveillance_access(request: Request, user: User, db: Session) -> No ) +def _is_instance_admin(user: User, db: Session) -> bool: + """True if user is admin role, is_instance_admin, or first user.""" + if getattr(user, "role", None) == "admin": + return True + if getattr(user, "is_instance_admin", False): + return True + first = db.query(User).order_by(User.id.asc()).limit(1).first() + return first is not None and first.id == user.id + + @router.get("/alerts", response_model=List[dict]) async def list_surveillance_alerts( request: Request, @@ -68,15 +88,31 @@ async def list_surveillance_alerts( reviewed: Optional[bool] = Query(None, description="Filter by reviewed status"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), + run_cycle_if_empty: bool = Query(False, description="If true and instance admin: run detection cycle once when list is empty"), ) -> List[dict]: """ List Polymarket surveillance alerts. Requires Pro (or higher) when SURVEILLANCE_REQUIRES_PRO is True. Returns 403 with upgrade CTA otherwise. + + Alerts are populated only when a detection cycle has been run (POST /run-cycle). + Set POLYMARKET_SURVEILLANCE_ENABLED=true and POLYMARKET_DATA_API_URL for the cycle to fetch data. + Instance admins can pass run_cycle_if_empty=1 to trigger one cycle when the list is empty. """ require_surveillance_access(request, current_user, db) - return PolymarketSurveillanceService(db).list_alerts( - severity=severity, reviewed=reviewed, limit=limit, offset=offset - ) + svc = PolymarketSurveillanceService(db) + alerts = svc.list_alerts(severity=severity, reviewed=reviewed, limit=limit, offset=offset) + if ( + run_cycle_if_empty + and len(alerts) == 0 + and _is_instance_admin(current_user, db) + and getattr(settings, "POLYMARKET_SURVEILLANCE_ENABLED", False) + ): + try: + svc.run_detection_cycle(markets=None) + alerts = svc.list_alerts(severity=severity, reviewed=reviewed, limit=limit, offset=offset) + except Exception as e: + logger.debug("run_cycle_if_empty failed: %s", e) + return alerts @router.post("/alerts/{alert_id}/review", response_model=dict) diff --git a/app/api/user_settings_routes.py b/app/api/user_settings_routes.py index 57c7785..4d47cdf 100644 --- a/app/api/user_settings_routes.py +++ b/app/api/user_settings_routes.py @@ -240,6 +240,8 @@ class UserKYCInfoUpdate(BaseModel): address_postal_code: Optional[str] = None address_country: Optional[str] = None phone: Optional[str] = None + tax_id: Optional[str] = None # SSN / TIN for brokerage (e.g. USA: XXX-XX-XXXX) + tax_id_type: Optional[str] = None # e.g. USA_SSN, USA_TIN @router.get("/kyc-info") @@ -261,6 +263,8 @@ async def get_user_kyc_info( "address_postal_code": kyc.get("address_postal_code") or "", "address_country": kyc.get("address_country") or "", "phone": kyc.get("phone") or "", + "tax_id": kyc.get("tax_id") or "", + "tax_id_type": kyc.get("tax_id_type") or "USA_SSN", } diff --git a/app/services/alpaca_account_service.py b/app/services/alpaca_account_service.py index 681a638..a725351 100644 --- a/app/services/alpaca_account_service.py +++ b/app/services/alpaca_account_service.py @@ -9,7 +9,7 @@ import logging from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.orm import Session @@ -21,6 +21,23 @@ logger = logging.getLogger(__name__) +# ISO 3166-1 alpha-2 -> alpha-3 for contact.country (Alpaca requires alpha-3) +_COUNTRY_ALPHA2_TO_ALPHA3: Dict[str, str] = { + "US": "USA", "CA": "CAN", "GB": "GBR", "DE": "DEU", "FR": "FRA", "IT": "ITA", + "ES": "ESP", "AU": "AUS", "JP": "JPN", "CN": "CHN", "IN": "IND", "BR": "BRA", + "MX": "MEX", "NL": "NLD", "CH": "CHE", "SE": "SWE", "PL": "POL", "IE": "IRL", +} + + +def _country_to_alpha3(country: str) -> str: + """Return ISO 3166-1 alpha-3 code; Alpaca contact.country requires alpha-3.""" + s = (str(country) or "").strip().upper() + if len(s) == 3 and s.isalpha(): + return s[:3] + if len(s) >= 2: + return _COUNTRY_ALPHA2_TO_ALPHA3.get(s[:2], "USA") + return "USA" + class AlpacaAccountServiceError(Exception): """Raised when account opening or status update fails.""" @@ -46,6 +63,7 @@ def _build_account_payload( *, prefill_override: Optional[Dict[str, Any]] = None, agreements_override: Optional[List[Dict[str, Any]]] = None, + enabled_assets: Optional[List[str]] = None, ) -> Dict[str, Any]: """Build Alpaca Broker API account creation payload from User, KYCVerification, and optional Plaid prefill/agreements.""" email = getattr(user, "email", None) or "" @@ -66,13 +84,16 @@ def _build_account_payload( if isinstance(profile_data, dict): # Prefer user-configured KYC info from User Settings when present phone = (kyc.get("phone") or profile_data.get("phone") or profile_data.get("phone_number") or "").strip() - street = ( + # Normalize street: prefill/API may send string or list + _raw_street = ( (prefill_override or {}).get("street_address") or kyc.get("address_line1") or profile_data.get("street_address") or profile_data.get("address") or "" - ).strip() + ) + street = (_raw_street[0] if isinstance(_raw_street, list) and _raw_street else str(_raw_street or "")).strip() + unit = (kyc.get("address_line2") or (prefill_override or {}).get("unit") or "").strip()[:32] city = ( (prefill_override or {}).get("city") or kyc.get("address_city") @@ -105,17 +126,39 @@ def _build_account_payload( else: phone = "" street = (prefill_override or {}).get("street_address") or "" + street = (street[0] if isinstance(street, list) and street else str(street or "")).strip() + unit = "" city = (prefill_override or {}).get("city") or "" state = (prefill_override or {}).get("state") or "" postal_code = (prefill_override or {}).get("postal_code") or "" country = (prefill_override or {}).get("country") or "USA" + # Alpaca requires contact.street_address (array of Latin strings); invalid/empty can return "required" + # Use valid Latin placeholders when user has not provided address (sandbox only) + _street_val = str(street)[:64].strip() if street else "123 Application Pending" + _city_val = (str(city)[:32]).strip() if city else "New York" + _state_val = (str(state)[:32]).strip() if state else "NY" + _postal_val = str(postal_code)[:10].strip() if postal_code else "10001" + _country_contact = (str(country)[:2].upper() if country and len(str(country)) >= 2 else "US") + if len(_country_contact) != 2: + _country_contact = "US" + + # Alpaca contact.country must be ISO 3166-1 alpha-3 (e.g. USA, CAN, GBR) + _country_alpha3 = _country_to_alpha3(country) # Alpaca account opening payload (contact, identity, address) # https://docs.alpaca.markets/reference/createaccount + # contact: street_address (array), city, postal_code, state, country, unit (optional) contact = { "email_address": email, "phone_number": str(phone)[:20] if phone else "", + "street_address": [_street_val], + "city": _city_val, + "postal_code": _postal_val, + "state": _state_val, + "country": _country_alpha3, } + if unit: + contact["unit"] = str(unit)[:32] dob = "1990-01-01" # Placeholder if not in profile; Alpaca may require or return ACTION_REQUIRED if kyc.get("date_of_birth"): dob = str(kyc["date_of_birth"])[:10] @@ -123,18 +166,33 @@ def _build_account_payload( meta = verification.verification_metadata or {} if isinstance(meta, dict) and meta.get("date_of_birth"): dob = str(meta["date_of_birth"])[:10] + # Alpaca identity country fields must be 3 characters (ISO 3166-1 alpha-3), same as contact.country + # Tax ID: use from profile_data.kyc if present, else sandbox placeholder (Alpaca requires it for account creation) + tax_id = (kyc.get("tax_id") or "").strip() if isinstance(kyc, dict) else "" + tax_id_type = (kyc.get("tax_id_type") or "USA_SSN").strip() if isinstance(kyc, dict) else "USA_SSN" + if not tax_id or len(tax_id.replace("-", "").replace(".", "")) < 9: + # Sandbox placeholder: 9 digits required for USA_SSN; do not use in production without user-provided SSN + tax_id = "111-22-3333" + tax_id_type = "USA_SSN" + # identity per dev/alpaca.md: country_* in alpha-3, funding_source required in sample identity = { "given_name": str(given_name)[:50], "family_name": str(family_name)[:50], "date_of_birth": dob, + "country_of_citizenship": _country_alpha3, + "country_of_birth": _country_alpha3, + "country_of_tax_residence": _country_alpha3, + "tax_id": str(tax_id)[:40], + "tax_id_type": tax_id_type, + "funding_source": ["employment_income"], } address = { - "street_address": [str(street)[:64]] if street else ["N/A"], - "city": (str(city)[:32]) if city else "N/A", - "state": (str(state)[:32]) if state else "NY", - "postal_code": str(postal_code)[:10] if postal_code else "10001", - "country": (str(country)[:2]) if country and len(str(country)) == 2 else "US", + "street_address": [_street_val], + "city": _city_val, + "state": _state_val, + "postal_code": _postal_val, + "country": _country_alpha3, } # Agreements: use client-provided (Plaid KYC flow) or server-generated @@ -154,12 +212,19 @@ def _build_account_payload( {"agreement": "margin_agreement", "signed_at": signed_at, "ip_address": "0.0.0.0"}, ] + # Alpaca enabled_assets: us_equity (equities), crypto, us_option, etc. Default equities only. + assets: List[str] = ( + [str(a).strip() for a in enabled_assets if isinstance(enabled_assets, list) and str(a).strip()][:10] + if enabled_assets + else ["us_equity"] + ) return { "contact": contact, "identity": identity, "disclosures": { "is_control_person": False, "is_affiliated_exchange_or_finra": False, + "is_affiliated_exchange_or_iiroc": False, "is_politically_exposed": False, "immediate_family_exposed": False, }, @@ -171,6 +236,7 @@ def _build_account_payload( "email_address": email, }, "address": address, + "enabled_assets": assets, } @@ -205,6 +271,7 @@ def open_alpaca_account( agreements_override: Optional[List[Dict[str, Any]]] = None, prefill_override: Optional[Dict[str, Any]] = None, use_plaid_kyc: bool = False, + enabled_assets: Optional[List[str]] = None, ) -> AlpacaCustomerAccount: """ Open an Alpaca Broker account for the user. @@ -252,8 +319,19 @@ def open_alpaca_account( user, verification, prefill_override=prefill_override, agreements_override=agreements_override, + enabled_assets=enabled_assets, ) + # Log payload structure (no PII) for debugging Alpaca 400/422 + _contact = payload.get("contact") + _identity = payload.get("identity") + _contact_keys = list(_contact.keys()) if isinstance(_contact, dict) else type(_contact).__name__ + _identity_keys = list(_identity.keys()) if isinstance(_identity, dict) else type(_identity).__name__ + _street_ok = bool(_contact.get("street_address")) if isinstance(_contact, dict) else False + logger.info( + "Alpaca account apply: user_id=%s contact_keys=%s identity_keys=%s street_provided=%s", + user_id, _contact_keys, _identity_keys, _street_ok, + ) try: result = client.create_account(payload) except AlpacaBrokerAPIError as e: @@ -298,19 +376,20 @@ def open_alpaca_account( _FINAL_STATUSES = frozenset({"ACTIVE", "REJECTED"}) -def sync_alpaca_account_status(rec: AlpacaCustomerAccount, db: Session) -> bool: +def sync_alpaca_account_status(rec: AlpacaCustomerAccount, db: Session) -> Tuple[bool, Optional[Dict[str, Any]]]: """ Poll Alpaca Broker API for account status and update local record. - Returns True if status or account_number/action_required_reason changed. + Returns (changed, data): changed True if status/account_number/action_required_reason changed; + data is the raw Alpaca account dict when available (for crypto_status, enabled_assets in API response). """ client = get_broker_client() if not client: - return False + return False, None try: data = client.get_account(rec.alpaca_account_id) except AlpacaBrokerAPIError as e: logger.warning("Alpaca get_account failed for %s: %s", rec.alpaca_account_id, e) - return False + return False, None status = (data.get("status") or rec.status).upper() account_number = data.get("account_number") or rec.account_number @@ -355,7 +434,7 @@ def sync_alpaca_account_status(rec: AlpacaCustomerAccount, db: Session) -> bool: notify_kyc_brokerage_status(db, rec.user_id, subject, msg) except Exception as exc: logger.warning("KYC/brokerage notification failed after Alpaca status sync: %s", exc) - return changed + return changed, data def sync_all_pending_alpaca_accounts(db: Session) -> Dict[str, Any]: @@ -373,7 +452,8 @@ def sync_all_pending_alpaca_accounts(db: Session) -> Dict[str, Any]: errors = 0 for rec in pending: try: - if sync_alpaca_account_status(rec, db): + changed, _ = sync_alpaca_account_status(rec, db) + if changed: synced += 1 except Exception as e: logger.warning("Sync failed for Alpaca account %s: %s", rec.alpaca_account_id, e) diff --git a/app/services/alpaca_broker_service.py b/app/services/alpaca_broker_service.py index 49fd695..2a5b260 100644 --- a/app/services/alpaca_broker_service.py +++ b/app/services/alpaca_broker_service.py @@ -142,8 +142,12 @@ def list_orders( return data.get("orders") if isinstance(data.get("orders"), list) else [] def get_positions(self, account_id: str) -> List[Dict[str, Any]]: - """GET /v1/trading/accounts/{account_id}/positions.""" + """GET /v1/trading/accounts/{account_id}/positions. API may return list or { positions: [] }.""" data = self._request("GET", f"/v1/trading/accounts/{account_id}/positions") + if isinstance(data, list): + return data + if not isinstance(data, dict): + return [] return data.get("positions") if isinstance(data.get("positions"), list) else [] def get_account_portfolio(self, account_id: str) -> Dict[str, Any]: @@ -187,6 +191,61 @@ def upload_document( return {} return r.json() + # ------------------------------------------------------------------------- + # ACH & Transfers (funding) + # ------------------------------------------------------------------------- + + def list_ach_relationships(self, account_id: str) -> List[Dict[str, Any]]: + """ + GET /v1/accounts/{account_id}/ach_relationships — List ACH relationships. + In sandbox, relationships move from QUEUED to APPROVED after ~1 minute. + """ + data = self._request("GET", f"/v1/accounts/{account_id}/ach_relationships") + return data if isinstance(data, list) else data.get("ach_relationships") or [] + + def create_ach_relationship( + self, + account_id: str, + account_owner_name: str, + bank_account_type: str, + bank_account_number: str, + bank_routing_number: str, + nickname: str, + ) -> Dict[str, Any]: + """ + POST /v1/accounts/{account_id}/ach_relationships — Create ACH relationship. + Sandbox accepts test values (e.g. bank_account_number "32131231abc", routing "123103716"). + """ + payload = { + "account_owner_name": account_owner_name, + "bank_account_type": bank_account_type, + "bank_account_number": bank_account_number, + "bank_routing_number": bank_routing_number, + "nickname": nickname, + } + return self._request("POST", f"/v1/accounts/{account_id}/ach_relationships", json=payload) + + def create_transfer( + self, + account_id: str, + transfer_type: str, + relationship_id: str, + amount: str, + direction: str, + ) -> Dict[str, Any]: + """ + POST /v1/accounts/{account_id}/transfers — Create transfer (deposit/withdrawal). + Sandbox: credit/debit is effective immediately. + direction: INCOMING (deposit) or OUTGOING (withdrawal). + """ + payload = { + "transfer_type": transfer_type, + "relationship_id": relationship_id, + "amount": amount, + "direction": direction, + } + return self._request("POST", f"/v1/accounts/{account_id}/transfers", json=payload) + # ------------------------------------------------------------------------- # CIP (fully-disclosed broker-dealer only) # ------------------------------------------------------------------------- diff --git a/app/services/chronos_model_manager.py b/app/services/chronos_model_manager.py index 8c13c74..59f32a2 100644 --- a/app/services/chronos_model_manager.py +++ b/app/services/chronos_model_manager.py @@ -3,12 +3,16 @@ from __future__ import annotations import logging +from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional from app.core.config import settings logger = logging.getLogger(__name__) +# Thread pool for Modal remote() so it runs outside the async event loop (avoids gRPC errors) +_MODAL_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="modal_chronos") + def _run_local_chronos( model_id: str, context: List[float], horizon: int, device: str @@ -67,13 +71,19 @@ def run_inference( import modal fn = modal.Function.from_name(self._app_name, "chronos_inference") - out = fn.remote( - symbol=symbol, - context=context, - horizon=horizon, - model_id=mid, - device=self._device, - ) + + def _call_remote() -> Dict[str, Any]: + return fn.remote( + symbol=symbol, + context=context, + horizon=horizon, + model_id=mid, + device=self._device, + ) + + # Run Modal remote() in a thread so it doesn't conflict with the async event loop / gRPC + future = _MODAL_EXECUTOR.submit(_call_remote) + out = future.result(timeout=120) if isinstance(out, dict) and "error" in out: return {"forecast": [], "model_id": mid, "symbol": symbol, "error": out["error"]} return out if isinstance(out, dict) else {"forecast": [], "model_id": mid, "error": "invalid response"} diff --git a/app/services/polymarket_surveillance_service.py b/app/services/polymarket_surveillance_service.py index eeaf81d..a98eefa 100644 --- a/app/services/polymarket_surveillance_service.py +++ b/app/services/polymarket_surveillance_service.py @@ -164,35 +164,73 @@ def review_alert(self, alert_id: int, resolution: str, reviewed_by: int) -> Poly self.db.refresh(a) return a + def _wallet_from_item(self, d: Dict[str, Any]) -> Optional[str]: + """Extract wallet/address from a trade or activity item (various API field names).""" + if not d or not isinstance(d, dict): + return None + w = ( + d.get("maker") or d.get("taker") or d.get("user") or d.get("wallet") + or d.get("owner") or d.get("trader") or d.get("address") + or d.get("from") or d.get("to") + ) + if isinstance(w, str) and w.strip(): + return w.strip() + return None + def run_detection_cycle(self, markets: Optional[List[str]] = None) -> Dict[str, Any]: """ Run a detection cycle: fetch Data API, update baselines, create alerts when thresholds exceeded. If POLYMARKET_SURVEILLANCE_ENABLED is False, returns {"skipped": True}. + Fetches each endpoint in isolation so 400/404 on one does not break the cycle. """ if not getattr(settings, "POLYMARKET_SURVEILLANCE_ENABLED", False): return {"skipped": True, "reason": "POLYMARKET_SURVEILLANCE_ENABLED is False"} baselines_updated = 0 alerts_created = 0 + trades: List[Dict[str, Any]] = [] + activity: List[Dict[str, Any]] = [] + vol: Dict[str, Any] = {} + oi: Dict[str, Any] = {} try: - # Fetch from Data API trades = self.client.fetch_trades(limit=200) + except Exception as e: + logger.debug("Polymarket fetch_trades failed: %s", e) + if not isinstance(trades, list): + trades = [] + + try: activity = self.client.fetch_activity(limit=200) - leaderboard = self.client.fetch_leaderboard(limit=50) - vol = self.client.fetch_live_volume(market=markets[0] if markets else None) - oi = self.client.fetch_open_interest(market=markets[0] if markets else None) + except Exception as e: + logger.debug("Polymarket fetch_activity failed: %s", e) + if not isinstance(activity, list): + activity = [] + try: + vol = self.client.fetch_live_volume(market=markets[0] if markets else None) or {} + except Exception as e: + logger.debug("Polymarket fetch_live_volume failed: %s", e) + if not isinstance(vol, dict): + vol = {} + + try: + oi = self.client.fetch_open_interest(market=markets[0] if markets else None) or {} + except Exception as e: + logger.debug("Polymarket fetch_open_interest failed: %s", e) + if not isinstance(oi, dict): + oi = {} + + try: # Aggregations: trade count per wallet (from trades or activity) wallet_trade_count: Dict[str, int] = {} - for t in trades if isinstance(trades, list) else []: - d = t or {} - w = d.get("maker") or d.get("taker") or d.get("user") or d.get("wallet") - if isinstance(w, str) and w: + for t in trades: + w = self._wallet_from_item(t) + if w: wallet_trade_count[w] = wallet_trade_count.get(w, 0) + 1 - for a in activity if isinstance(activity, list) else []: - w = (a or {}).get("user") or (a or {}).get("wallet") or (a or {}).get("address") - if isinstance(w, str) and w: + for a in activity: + w = self._wallet_from_item(a) + if w: wallet_trade_count[w] = wallet_trade_count.get(w, 0) + 1 # Upsert baselines: trade_count per wallet (window=1d) @@ -201,32 +239,44 @@ def run_detection_cycle(self, markets: Optional[List[str]] = None) -> Dict[str, baselines_updated += 1 # Volume baseline - v = vol.get("volume") if isinstance(vol, dict) else 0 + v = vol.get("volume") if isinstance(vol, dict) else None mk = vol.get("market") or "global" if v is not None: self.upsert_baseline("market", str(mk), "1d", "volume", v) baselines_updated += 1 # Open interest baseline - o = oi.get("open_interest") if isinstance(oi, dict) else 0 + o = oi.get("open_interest") if isinstance(oi, dict) else None mk_oi = oi.get("market") or "global" if o is not None: self.upsert_baseline("market", str(mk_oi), "1d", "open_interest", o) baselines_updated += 1 - # Simple threshold: if a wallet has >20 trades in this batch, create low-severity alert + # Threshold alert: wallet with >= 5 trades in this batch (lowered so alerts appear with sparse data) for w, cnt in wallet_trade_count.items(): - if cnt >= 20: + if cnt >= 5: self.create_alert( "outsized_bet", "low", - f"Wallet {w[:10]}... has {cnt} trades in cycle (threshold 20)", + f"Wallet {w[:10]}... has {cnt} trades in cycle (threshold 5)", proxy_wallet=w, - signal_values={"trade_count": cnt, "threshold": 20}, + signal_values={"trade_count": cnt, "threshold": 5}, ) alerts_created += 1 break # one per cycle to avoid flood + # If we got trades but no threshold alert, create one informational so the panel shows activity + if alerts_created == 0 and (len(trades) > 0 or len(wallet_trade_count) > 0): + n_trades = len(trades) + n_wallets = len(wallet_trade_count) + self.create_alert( + "cycle_completed", + "low", + f"Detection cycle completed; {n_trades} trades, {n_wallets} wallets. No threshold exceeded.", + signal_values={"trades_count": n_trades, "wallets_count": n_wallets}, + ) + alerts_created += 1 + except Exception as e: logger.warning("Polymarket run_detection_cycle failed: %s", e) diff --git a/chronos_modal/__init__.py b/chronos_modal/__init__.py new file mode 100644 index 0000000..67e7c4b --- /dev/null +++ b/chronos_modal/__init__.py @@ -0,0 +1,5 @@ +"""Chronos Modal app for CreditNexus stock prediction (GPU inference, market, training).""" + +from chronos_modal.app import app + +__all__ = ["app"] diff --git a/modal/app.py b/chronos_modal/app.py similarity index 96% rename from modal/app.py rename to chronos_modal/app.py index 01eac9d..4cd7e29 100644 --- a/modal/app.py +++ b/chronos_modal/app.py @@ -6,10 +6,10 @@ import modal -from modal.image import chronos_image +from chronos_modal.image import chronos_image # Config from env: MODAL_USE_GPU (1/true/yes) and CHRONOS_DEVICE (cpu, cuda, cuda:0). -# Set when running: modal run modal/app.py or modal deploy, e.g. MODAL_USE_GPU=1 modal deploy +# Set when running: modal run chronos_modal/app.py or modal deploy, e.g. MODAL_USE_GPU=1 modal deploy _MODAL_USE_GPU = os.getenv("MODAL_USE_GPU", "").lower() in ("1", "true", "yes") _CHRONOS_DEVICE = os.getenv("CHRONOS_DEVICE", "cpu") diff --git a/modal/image.py b/chronos_modal/image.py similarity index 100% rename from modal/image.py rename to chronos_modal/image.py diff --git a/client/src/components/BrokerageOnboarding.tsx b/client/src/components/BrokerageOnboarding.tsx index a1c3293..be2b55c 100644 --- a/client/src/components/BrokerageOnboarding.tsx +++ b/client/src/components/BrokerageOnboarding.tsx @@ -21,7 +21,9 @@ import { Loader2, CheckCircle2, AlertTriangle, FileUp, Send, Link2, FileText, Se interface BrokerageStatus { has_account: boolean; - status?: string; + status?: string; // Equities: SUBMITTED, ACTIVE, ACTION_REQUIRED, REJECTED + crypto_status?: string; // Crypto: INACTIVE, ACTIVE, SUBMISSION_FAILED + enabled_assets?: string[]; alpaca_account_id?: string; account_number?: string; action_required_reason?: string; @@ -44,6 +46,7 @@ const ALPACA_MARGIN_AGREEMENT_URL = 'https://alpaca.markets/disclosures'; export function BrokerageOnboarding() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [applyLoading, setApplyLoading] = useState(false); const [docLoading, setDocLoading] = useState(false); const [error, setError] = useState(null); @@ -60,6 +63,8 @@ export function BrokerageOnboarding() { const [agreedCustomer, setAgreedCustomer] = useState(false); const [agreedMargin, setAgreedMargin] = useState(false); const [agreedAt, setAgreedAt] = useState(null); + const [enableEquities, setEnableEquities] = useState(true); + const [enableCrypto, setEnableCrypto] = useState(false); const openedForRef = useRef(null); const [brokeragePreferences, setBrokeragePreferences] = useState<{ brokerage_plaid_kyc_preferred?: boolean } | null>(null); @@ -206,6 +211,10 @@ export function BrokerageOnboarding() { const use_plaid_kyc = prefill ? (brokeragePreferences?.brokerage_plaid_kyc_preferred ?? usePlaidKyc) : usePlaidKyc; + const enabled_assets: string[] = []; + if (enableEquities) enabled_assets.push('us_equity'); + if (enableCrypto) enabled_assets.push('crypto'); + if (enabled_assets.length === 0) enabled_assets.push('us_equity'); const body = { use_plaid_kyc, agreements: [ @@ -213,6 +222,7 @@ export function BrokerageOnboarding() { { agreement: 'margin_agreement', signed_at: signedAt, ip_address: '0.0.0.0' }, ], prefill: prefill || undefined, + enabled_assets, }; const r = await fetchWithAuth(resolveApiUrl('/api/brokerage/account/apply'), { method: 'POST', @@ -319,10 +329,22 @@ export function BrokerageOnboarding() { <>Open brokerage account (Plaid KYC + agreements) )} - - {isActive && status?.account_number && `Account #${status.account_number}`} - {isActionRequired && status?.action_required_reason} - {isPending && 'Your application is under review. Status updates automatically.'} + + {status?.has_account && status.account_number && ( + Account #{status.account_number} + )} + {status?.has_account && (status.status || status.crypto_status) && ( + + Equities: {status.status?.toLowerCase() ?? '—'} + {status.crypto_status != null && ( + <> · Crypto: {status.crypto_status.toLowerCase()} + )} + + )} + {isActionRequired && status?.action_required_reason && ( + {status.action_required_reason} + )} + {isPending && !status?.action_required_reason && 'Your application is under review. Status updates when you refresh.'} {!status?.has_account && 'Complete the steps below to apply.'} @@ -453,6 +475,30 @@ export function BrokerageOnboarding() {
)} + {/* Asset classes: Equities and Crypto */} +
+ +

Choose which products to enable on your brokerage account.

+
+
+ setEnableEquities(!!checked)} + /> + +
+
+ setEnableCrypto(!!checked)} + /> + +
+
+
+ {/* Step 4: Submit */}
{brokeragePreferences?.brokerage_plaid_kyc_preferred && prefill !== null && ( @@ -512,8 +558,27 @@ export function BrokerageOnboarding() { )} {status?.has_account && !loading && ( - )} diff --git a/client/src/components/LinkAccounts.tsx b/client/src/components/LinkAccounts.tsx index 3bc495c..79160cb 100644 --- a/client/src/components/LinkAccounts.tsx +++ b/client/src/components/LinkAccounts.tsx @@ -24,6 +24,8 @@ interface BankingStatus { interface BrokerageStatus { has_account: boolean; status?: string; + crypto_status?: string; + enabled_assets?: string[]; alpaca_account_id?: string; account_number?: string; action_required_reason?: string; @@ -230,15 +232,25 @@ export function LinkAccounts() { {brokerageStatus.has_account ? ( -
- - {brokerageStatus.status === 'ACTIVE' ? : } - - - {brokerageStatus.status === 'ACTIVE' - ? `Active${brokerageStatus.account_number ? ` · #${brokerageStatus.account_number}` : ''}` - : brokerageStatus.status ?? 'Pending'} - +
+
+ + {brokerageStatus.status === 'ACTIVE' ? : } + + + {brokerageStatus.status === 'ACTIVE' + ? `Active${brokerageStatus.account_number ? ` · #${brokerageStatus.account_number}` : ''}` + : brokerageStatus.status ?? 'Pending'} + +
+ {(brokerageStatus.status != null || brokerageStatus.crypto_status != null) && ( +

+ Equities: {(brokerageStatus.status ?? '—').toLowerCase()} + {brokerageStatus.crypto_status != null && ( + <> · Crypto: {brokerageStatus.crypto_status.toLowerCase()} + )} +

+ )} {brokerageStatus.action_required_reason && (

{brokerageStatus.action_required_reason}

)} diff --git a/client/src/components/polymarket/SurveillanceAlertsPanel.tsx b/client/src/components/polymarket/SurveillanceAlertsPanel.tsx index 8a4d53d..1e4a80a 100644 --- a/client/src/components/polymarket/SurveillanceAlertsPanel.tsx +++ b/client/src/components/polymarket/SurveillanceAlertsPanel.tsx @@ -42,7 +42,7 @@ export function SurveillanceAlertsPanel() { const [runCycleLoading, setRunCycleLoading] = useState(false); const [reviewingId, setReviewingId] = useState(null); - const load = useCallback(() => { + const load = useCallback((runCycleIfEmpty = false) => { setLoading(true); setError(null); setSubscriptionRequired(false); @@ -51,6 +51,7 @@ export function SurveillanceAlertsPanel() { if (reviewed === 'yes') params.set('reviewed', 'true'); if (reviewed === 'no') params.set('reviewed', 'false'); params.set('limit', '50'); + if (runCycleIfEmpty) params.set('run_cycle_if_empty', '1'); fetchWithAuth(`/api/polymarket/surveillance/alerts?${params.toString()}`) .then((res) => { if (res.status === 403) { @@ -79,6 +80,15 @@ export function SurveillanceAlertsPanel() { useEffect(() => { load(); }, [load]); + // When alerts load empty, retry once with run_cycle_if_empty so instance admin can auto-populate + const [didRunCycleIfEmpty, setDidRunCycleIfEmpty] = useState(false); + useEffect(() => { + if (!loading && alerts.length === 0 && !subscriptionRequired && !error && !didRunCycleIfEmpty) { + setDidRunCycleIfEmpty(true); + load(true); + } + }, [loading, alerts.length, subscriptionRequired, error, didRunCycleIfEmpty, load]); + const runCycle = async () => { setRunCycleLoading(true); setError(null); @@ -198,7 +208,10 @@ export function SurveillanceAlertsPanel() {
{alerts.length === 0 ? ( -

No alerts. Run a detection cycle to populate.

+

+ No alerts yet. Alerts are created when a detection cycle runs (use "Run cycle" above). + Enable POLYMARKET_SURVEILLANCE_ENABLED and set POLYMARKET_DATA_API_URL on the server for the cycle to fetch data. +

) : (
{alerts.map((a) => ( diff --git a/client/src/components/trading/StructuredProductsTab.tsx b/client/src/components/trading/StructuredProductsTab.tsx index 81c6d0f..f07706c 100644 --- a/client/src/components/trading/StructuredProductsTab.tsx +++ b/client/src/components/trading/StructuredProductsTab.tsx @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { fetchWithAuth, useAuth } from '@/context/AuthContext'; import { resolveApiUrl } from '@/utils/apiBase'; -import { Loader2, Layers, Percent, DollarSign, Plus, Trash2, Package, ShoppingCart, List, Coins } from 'lucide-react'; +import { Loader2, Layers, Percent, DollarSign, Plus, Trash2, Package, ShoppingCart, List, Coins, Sparkles } from 'lucide-react'; interface Pool { id: number; diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index f888739..6e12bf3 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -38,16 +38,27 @@ export interface ButtonProps } const Button = React.forwardRef( - ({ className, variant, size, children, ...props }, ref) => { - const isIconOnly = React.Children.count(children) === 1 && - React.isValidElement(children) && + ({ className, variant, size, asChild = false, children, ...props }, ref) => { + const isIconOnly = React.Children.count(children) === 1 && + React.isValidElement(children) && children.type?.toString().includes('Icon') + const computedClassName = cn(buttonVariants({ variant, size, className })) + const ariaLabel = isIconOnly && !props['aria-label'] ? 'Icon button' : props['aria-label'] + + if (asChild && React.Children.count(children) === 1 && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement<{ className?: string; ref?: React.Ref }>, { + className: cn((children as React.ReactElement).props?.className, computedClassName), + ref, + ...(ariaLabel != null && { 'aria-label': ariaLabel }), + }) + } + return (
+
+
+ + setKycInfo({ ...kycInfo, tax_id: e.target.value })} + placeholder="e.g. XXX-XX-XXXX (USA)" + /> +

Required for brokerage application. Stored securely.

+
+
+ + +
+
@@ -491,7 +523,7 @@ export function UserSettings() {
- + @@ -716,6 +748,32 @@ export function UserSettings() { placeholder="Phone number (e.g. E.164)" />
+
+
+ + setKycInfo({ ...kycInfo, tax_id: e.target.value })} + placeholder="e.g. XXX-XX-XXXX (USA)" + /> +

Required for brokerage application. Stored securely.

+
+
+ + +
+
diff --git a/docs/guides/polymarket-surveillance-alerts-resolution.md b/docs/guides/polymarket-surveillance-alerts-resolution.md new file mode 100644 index 0000000..4e032cd --- /dev/null +++ b/docs/guides/polymarket-surveillance-alerts-resolution.md @@ -0,0 +1,44 @@ +# Polymarket Surveillance Alerts – How It Works & Resolution Plan + +## How it currently works + +1. **GET /api/polymarket/surveillance/alerts** + - Returns alerts from the database table `polymarket_surveillance_alerts`. + - Alerts are **not** fetched from an external API; they are **generated** by the detection cycle and stored in the DB. + +2. **Detection cycle (POST /api/polymarket/surveillance/run-cycle)** + - Runs only when `POLYMARKET_SURVEILLANCE_ENABLED=true`. + - Calls Polymarket Data API (trades, activity, leaderboard, volume, open-interest) via `POLYMARKET_DATA_API_URL`. + - Updates baselines and creates alerts when rules fire (e.g. wallet with ≥20 trades → "outsized_bet" alert). + - If `POLYMARKET_SURVEILLANCE_ENABLED` is false, the cycle returns `{"skipped": true}` and creates no alerts. + +3. **Why the panel is empty** + - Default config: `POLYMARKET_SURVEILLANCE_ENABLED=false`, so the cycle is skipped. + - Even when enabled, alerts only appear after at least one successful run of the detection cycle. + - If `POLYMARKET_DATA_API_URL` is unset or the Data API returns empty/fails, the cycle creates no alerts. + +## How it should work + +- **Config:** Set `POLYMARKET_SURVEILLANCE_ENABLED=true` and `POLYMARKET_DATA_API_URL` (e.g. `https://data-api.polymarket.com`) when you want surveillance. +- **Populate alerts:** Either call **POST /run-cycle** (e.g. "Run cycle" in the UI) or use the optional **GET /alerts?run_cycle_if_empty=1** (instance admin only) so the backend runs one cycle when the list is empty, then returns the list. +- **Ongoing:** Run the cycle on a schedule (cron/scheduler) or manually so new alerts are created over time. + +## Resolution plan (implemented) + +1. **Backend** + - **GET /alerts** accepts optional `run_cycle_if_empty=1`. When the list is empty, the user is an instance admin, and `POLYMARKET_SURVEILLANCE_ENABLED` is true, the backend runs one detection cycle and then returns the (possibly updated) list. + - Instance admin = admin role, `is_instance_admin`, or first user. + - **Detection cycle** fetches each Data API endpoint in isolation (trades, activity, volume, open-interest); 400/404 on one does not break the cycle. Trades are always used when available. + - Wallet extraction from trade/activity items uses multiple field names: maker, taker, user, wallet, owner, trader, address, from, to. + - Threshold alert: wallet with ≥5 trades in the batch (lowered from 20 so alerts appear with sparse data). + - If the cycle fetches trades but no wallet crosses the threshold, one informational "cycle_completed" alert is created so the panel shows activity. + +2. **Frontend** + - When the panel loads and the first response is an empty list (and not 403), it retries once with `run_cycle_if_empty=1` so instance admins can auto-populate without clicking "Run cycle". + - Empty state message explains that alerts come from the detection cycle and that `POLYMARKET_SURVEILLANCE_ENABLED` and `POLYMARKET_DATA_API_URL` must be set on the server. + +3. **Checklist for operators** + - Set `POLYMARKET_SURVEILLANCE_ENABLED=true` and `POLYMARKET_DATA_API_URL` in `.env` when using surveillance. + - Ensure instance admins have access (admin role or `is_instance_admin` or first user). + - Run a detection cycle at least once (UI "Run cycle" or GET with `run_cycle_if_empty=1`) or schedule POST /run-cycle. + - Trades endpoint is required for alerts; activity/leaderboard/volume/open-interest are optional (400/404 are handled). diff --git a/docs/guides/stock-prediction-setup.md b/docs/guides/stock-prediction-setup.md index 573b7b1..7d2fbc3 100644 --- a/docs/guides/stock-prediction-setup.md +++ b/docs/guides/stock-prediction-setup.md @@ -14,7 +14,7 @@ Stock prediction provides daily, hourly, and 15‑minute forecasts (Chronos or t | **Backtesting** | `run_backtest_from_data_source` in `app.stock_prediction_core.backtesting`; strategies: trend, mean_reversion, momentum, volatility, stat_arb, combined | | **Modal** | `chronos_inference`, `chronos_train` (stub), `market_status` — optional when not using local Chronos | -**Code**: `app/services/stock_prediction_service.py`, `app/services/chronos_model_manager.py`, `app/services/market_data_service.py`, `app/stock_prediction_core/backtesting.py`, `modal/app.py`, `app/api/stock_prediction_routes.py` +**Code**: `app/services/stock_prediction_service.py`, `app/services/chronos_model_manager.py`, `app/services/market_data_service.py`, `app/stock_prediction_core/backtesting.py`, `chronos_modal/app.py`, `app/api/stock_prediction_routes.py` --- @@ -69,7 +69,7 @@ pip install chronos-bolt torch | `MODAL_APP_NAME` | Modal app name | `creditnexus-stock-prediction` | | `MODAL_TOKEN_ID` | Modal token ID for server-side client (optional) | — | | `MODAL_TOKEN_SECRET` | Modal token secret | — | -| `MODAL_USE_GPU` | **Env at run/deploy time.** `1`, `true`, or `yes`: Modal `chronos_inference` and `chronos_train` use GPU (T4). Set when running: `MODAL_USE_GPU=1 modal run modal/app.py` or `MODAL_USE_GPU=1 modal deploy` | — | +| `MODAL_USE_GPU` | **Env at run/deploy time.** `1`, `true`, or `yes`: Modal `chronos_inference` and `chronos_train` use GPU (T4). Set when running: `MODAL_USE_GPU=1 modal run chronos_modal/app.py` or `MODAL_USE_GPU=1 modal deploy` | — | Modal image: `modal/image.py` (`chronos-bolt`, `torch`, `alpaca-py`, `pandas`, etc.). @@ -127,5 +127,5 @@ When `RollingCreditsService` is used, `stock_prediction_daily`, `stock_predictio | 403 on prediction/backtest | `STOCK_PREDICTION_ENABLED=true` | | “Insufficient data” in backtest | Date range and symbol; need ≥130 bars. For 1d, use ~6+ months. yahooquery/Alpaca must return OHLCV. | | Chronos “chronos/torch not installed” | Local: `pip install chronos-bolt torch` | -| Modal inference fails | `modal run modal/app.py` or `modal deploy`; `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` if the app invokes Modal. `MODAL_USE_GPU=1` when deploying for GPU. | +| Modal inference fails | `modal run chronos_modal/app.py` or `modal deploy`; `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` if the app invokes Modal. `MODAL_USE_GPU=1` when deploying for GPU. | | No market data | `yahooquery` installed; or Alpaca with `ALPACA_DATA_ENABLED`, valid keys, and `ALPACA_BASE_URL` when also trading. | diff --git a/modal/__init__.py b/modal/__init__.py deleted file mode 100644 index 9880e0e..0000000 --- a/modal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Modal app for CreditNexus stock prediction: GPU inference, market, and training.""" - -from modal.app import app - -__all__ = ["app"] diff --git a/scripts/fix_typescript_errors.sh b/scripts/dev/fix_typescript_errors.sh similarity index 100% rename from scripts/fix_typescript_errors.sh rename to scripts/dev/fix_typescript_errors.sh diff --git a/scripts/dev/fund_alpaca_sandbox_account.py b/scripts/dev/fund_alpaca_sandbox_account.py new file mode 100644 index 0000000..64a491c --- /dev/null +++ b/scripts/dev/fund_alpaca_sandbox_account.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +""" +Fund an Alpaca Broker sandbox account via ACH transfer (paper trading). + +Uses Alpaca Broker API: +1. List or create an ACH relationship for the account (sandbox test bank details). +2. Create an INCOMING transfer to credit the account. + +In sandbox, the transfer is effective immediately. Use only with Broker sandbox +(ALPACA_BROKER_BASE_URL=https://broker-api.sandbox.alpaca.markets). + +Usage: + From project root: + python scripts/fund_alpaca_sandbox_account.py + Or with explicit account/amount: + ACCOUNT_ID=61341496-2272-425d-9f3e-acdcb980e9ce AMOUNT=100000 python scripts/fund_alpaca_sandbox_account.py + +Requires .env (or env) with: + ALPACA_BROKER_API_KEY, ALPACA_BROKER_API_SECRET, ALPACA_BROKER_BASE_URL (sandbox) +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path + +# Project root on path so "app" resolves +_ROOT = Path(__file__).resolve().parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +# Defaults: your account and $100,000 for paper trading +DEFAULT_ACCOUNT_ID = "61341496-2272-425d-9f3e-acdcb980e9ce" +DEFAULT_ACCOUNT_NUMBER = "162368041" +DEFAULT_AMOUNT = "100000" + +# Sandbox test ACH fixture (from Alpaca docs / dev/alpaca.md) +SANDBOX_ACH = { + "account_owner_name": "Sandbox Account Owner", + "bank_account_type": "CHECKING", + "bank_account_number": "32131231abc", + "bank_routing_number": "123103716", + "nickname": "Sandbox Checking", +} + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Fund an Alpaca Broker sandbox account via ACH (paper trading)." + ) + parser.add_argument( + "--account-id", + default=DEFAULT_ACCOUNT_ID, + help=f"Alpaca account ID (default: {DEFAULT_ACCOUNT_ID})", + ) + parser.add_argument( + "--amount", + default=DEFAULT_AMOUNT, + help=f"Amount to deposit in USD (default: {DEFAULT_AMOUNT})", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Only show what would be done; do not call the API", + ) + args = parser.parse_args() + + account_id = args.account_id.strip() + amount = str(args.amount).strip() + + if args.dry_run: + logger.info("Dry run: would fund account_id=%s with amount=%s", account_id, amount) + logger.info("Would ensure ACH relationship then POST transfer INCOMING.") + return 0 + + # Load config and get broker client + from app.core.config import settings + from app.services.alpaca_broker_service import get_broker_client, AlpacaBrokerAPIError + + client = get_broker_client() + if not client: + logger.error( + "Alpaca Broker API not configured. Set ALPACA_BROKER_API_KEY, " + "ALPACA_BROKER_API_SECRET, and ALPACA_BROKER_BASE_URL (sandbox) in .env" + ) + return 1 + + base_url = getattr(settings, "ALPACA_BROKER_BASE_URL", "") or "" + if "sandbox" not in base_url.lower(): + logger.warning("ALPACA_BROKER_BASE_URL does not look like sandbox. Use sandbox for this script.") + + # 1) Get or create ACH relationship + try: + relationships = client.list_ach_relationships(account_id) + except AlpacaBrokerAPIError as e: + logger.error("Failed to list ACH relationships: %s", e) + if getattr(e, "response", None): + logger.error("Response: %s", e.response) + return 1 + + approved = [r for r in relationships if (r.get("status") or "").upper() == "APPROVED"] + if approved: + rel = approved[0] + relationship_id = rel.get("id") + logger.info("Using existing ACH relationship: %s (%s)", relationship_id, rel.get("nickname")) + else: + # Create sandbox ACH relationship + logger.info("No approved ACH relationship; creating one with sandbox test data...") + try: + rel = client.create_ach_relationship( + account_id=account_id, + account_owner_name=SANDBOX_ACH["account_owner_name"], + bank_account_type=SANDBOX_ACH["bank_account_type"], + bank_account_number=SANDBOX_ACH["bank_account_number"], + bank_routing_number=SANDBOX_ACH["bank_routing_number"], + nickname=SANDBOX_ACH["nickname"], + ) + relationship_id = rel.get("id") + status = rel.get("status", "") + logger.info("Created ACH relationship: %s (status=%s)", relationship_id, status) + if (status or "").upper() == "QUEUED": + logger.info("Waiting up to 90s for ACH relationship to become APPROVED...") + for _ in range(18): + time.sleep(5) + relationships = client.list_ach_relationships(account_id) + approved = [r for r in relationships if (r.get("status") or "").upper() == "APPROVED"] + if approved and approved[0].get("id") == relationship_id: + logger.info("ACH relationship is APPROVED.") + break + else: + # Use it anyway; sandbox may still accept the transfer + logger.warning("ACH relationship still not APPROVED; attempting transfer anyway.") + except AlpacaBrokerAPIError as e: + logger.error("Failed to create ACH relationship: %s", e) + if getattr(e, "response", None): + logger.error("Response: %s", e.response) + return 1 + + if not relationship_id: + logger.error("No relationship_id available.") + return 1 + + # 2) Create INCOMING transfer + try: + transfer = client.create_transfer( + account_id=account_id, + transfer_type="ach", + relationship_id=relationship_id, + amount=amount, + direction="INCOMING", + ) + logger.info("Transfer created: %s", transfer.get("id")) + logger.info(" status=%s amount=%s direction=%s", transfer.get("status"), transfer.get("amount"), transfer.get("direction")) + logger.info("Account %s (number %s) funded with $%s for paper trading.", account_id, DEFAULT_ACCOUNT_NUMBER, amount) + return 0 + except AlpacaBrokerAPIError as e: + logger.error("Failed to create transfer: %s", e) + if getattr(e, "response", None): + logger.error("Response: %s", e.response) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From de5bb2e622943d223c440de6586ed408c3ac8ea6 Mon Sep 17 00:00:00 2001 From: Joseph Pollack Date: Fri, 30 Jan 2026 16:36:15 +0100 Subject: [PATCH 5/7] removes debug instrumentation --- app/services/plaid_service.py | 106 ---------------------------------- 1 file changed, 106 deletions(-) diff --git a/app/services/plaid_service.py b/app/services/plaid_service.py index b27ada0..3516305 100644 --- a/app/services/plaid_service.py +++ b/app/services/plaid_service.py @@ -13,7 +13,6 @@ import logging from datetime import date, timedelta -import json import os from typing import Any, Dict, List, Optional @@ -29,30 +28,6 @@ _plaid_config = None -def _agent_debug_log(*, hypothesisId: str, location: str, message: str, data: Dict[str, Any]) -> None: - """ - Debug-mode NDJSON logger (no secrets / no PII). - Writes to workspace debug log path. - """ - try: - path = r"c:\Users\MeMyself\creditnexus\.cursor\debug.log" - payload = { - "sessionId": "debug-session", - "runId": "pre-fix", - "hypothesisId": hypothesisId, - "location": location, - "message": message, - "data": data, - "timestamp": int(__import__("time").time() * 1000), - } - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "a", encoding="utf-8") as f: - f.write(json.dumps(payload, ensure_ascii=False) + "\n") - except Exception: - # Never break runtime on debug logging - pass - - def _get_plaid_client(): """Lazy-init Plaid API client. Returns (api, None) or (None, error_msg).""" global _plaid_api, _plaid_config @@ -531,15 +506,6 @@ def create_transfer( if err: return {"error": err} - # #region agent log - _agent_debug_log( - hypothesisId="H1", - location="app/services/plaid_service.py:create_transfer:entry", - message="create_transfer called", - data={"has_account_id": bool(account_id), "currency": currency, "transfer_type": transfer_type}, - ) - # #endregion - try: from decimal import Decimal from plaid.model.transfer_authorization_create_request import TransferAuthorizationCreateRequest @@ -585,18 +551,6 @@ def create_transfer( auth_resp = api.transfer_authorization_create(auth_req) auth = auth_resp.to_dict() if hasattr(auth_resp, "to_dict") else _plaid_obj_to_dict(auth_resp) - # #region agent log - _agent_debug_log( - hypothesisId="H1", - location="app/services/plaid_service.py:create_transfer:auth", - message="transfer authorization result", - data={ - "decision": auth.get("decision") or auth.get("authorization", {}).get("decision"), - "rationale": auth.get("rationale") or auth.get("authorization", {}).get("rationale"), - }, - ) - # #endregion - # Decision handling decision = (auth.get("decision") or auth.get("authorization", {}).get("decision") or "").lower() if decision and decision not in ("approved",): @@ -616,26 +570,9 @@ def create_transfer( create_resp = api.transfer_create(create_req) transfer = create_resp.to_dict() if hasattr(create_resp, "to_dict") else _plaid_obj_to_dict(create_resp) - # #region agent log - _agent_debug_log( - hypothesisId="H1", - location="app/services/plaid_service.py:create_transfer:created", - message="transfer created", - data={"has_transfer_id": bool((transfer.get("transfer") or {}).get("id") or transfer.get("id"))}, - ) - # #endregion - return {"authorization": auth, "transfer": transfer} except Exception as e: logger.warning("Plaid transfer flow failed: %s", e) - # #region agent log - _agent_debug_log( - hypothesisId="H1", - location="app/services/plaid_service.py:create_transfer:exception", - message="transfer flow exception", - data={"error": str(e)[:300]}, - ) - # #endregion return {"error": str(e)} @@ -662,15 +599,6 @@ def create_payment_initiation( - {"mode": "payment_initiation", "recipient": {...}, "payment": {...}} - {"error": "..."} """ - # #region agent log - _agent_debug_log( - hypothesisId="H2", - location="app/services/plaid_service.py:create_payment_initiation:entry", - message="create_payment_initiation called", - data={"has_recipient": bool(recipient_name and iban), "currency": currency, "payment_type": payment_type}, - ) - # #endregion - # UK/EU Payment Initiation path (requires recipient info) if recipient_name and iban: api, err = _get_plaid_client() @@ -713,26 +641,9 @@ def create_payment_initiation( payment_resp = api.payment_initiation_payment_create(payment_req) payment = payment_resp.to_dict() if hasattr(payment_resp, "to_dict") else _plaid_obj_to_dict(payment_resp) - # #region agent log - _agent_debug_log( - hypothesisId="H2", - location="app/services/plaid_service.py:create_payment_initiation:pi_created", - message="payment initiation recipient+payment created", - data={"has_recipient_id": True, "has_payment_id": bool(payment.get("payment_id") or payment.get("id"))}, - ) - # #endregion - return {"mode": "payment_initiation", "recipient": recipient, "payment": payment} except Exception as e: logger.warning("Plaid payment initiation flow failed: %s", e) - # #region agent log - _agent_debug_log( - hypothesisId="H2", - location="app/services/plaid_service.py:create_payment_initiation:pi_exception", - message="payment initiation exception", - data={"error": str(e)[:300]}, - ) - # #endregion return {"error": str(e)} # Default: US Transfer path @@ -756,15 +667,6 @@ def create_layer_session(*, template_id: str, client_user_id: str) -> Dict[str, if err: return {"error": err} - # #region agent log - _agent_debug_log( - hypothesisId="H3", - location="app/services/plaid_service.py:create_layer_session:entry", - message="create_layer_session called", - data={"has_template_id": bool(template_id), "has_client_user_id": bool(client_user_id)}, - ) - # #endregion - try: # Not all plaid-python versions include Layer models/methods; use getattr defensively. from plaid.model.session_token_create_request import SessionTokenCreateRequest # type: ignore @@ -787,14 +689,6 @@ def create_layer_session(*, template_id: str, client_user_id: str) -> Dict[str, return {"link_token": link_token, "raw": d} except Exception as e: logger.warning("Plaid layer session token create failed: %s", e) - # #region agent log - _agent_debug_log( - hypothesisId="H3", - location="app/services/plaid_service.py:create_layer_session:exception", - message="layer session exception", - data={"error": str(e)[:300]}, - ) - # #endregion return {"error": str(e)} From d3ffe9324195f3dd90235f1f30e131cf24a0a693 Mon Sep 17 00:00:00 2001 From: Biniyam Ajaw Date: Sat, 31 Jan 2026 16:23:51 +0300 Subject: [PATCH 6/7] feat(ui): Phase 1 - Design system foundation with CSS variables and core components --- client/src/components/ui-new/button.tsx | 94 +++++++ client/src/components/ui-new/card.tsx | 118 +++++++++ client/src/components/ui-new/index.ts | 24 ++ client/src/components/ui-new/input.tsx | 196 ++++++++++++++ client/src/styles/design-system.css | 324 ++++++++++++++++++++++++ 5 files changed, 756 insertions(+) create mode 100644 client/src/components/ui-new/button.tsx create mode 100644 client/src/components/ui-new/card.tsx create mode 100644 client/src/components/ui-new/index.ts create mode 100644 client/src/components/ui-new/input.tsx create mode 100644 client/src/styles/design-system.css diff --git a/client/src/components/ui-new/button.tsx b/client/src/components/ui-new/button.tsx new file mode 100644 index 0000000..5c4849f --- /dev/null +++ b/client/src/components/ui-new/button.tsx @@ -0,0 +1,94 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +/** + * Utility function to merge Tailwind classes + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +/** + * Button Component Variants + * Primary, Secondary, Ghost, and Icon variants + */ +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-[#3B82F6] text-white hover:bg-[#2563EB] active:bg-[#1D4ED8] shadow-md hover:shadow-lg focus-visible:ring-[#3B82F6]", + secondary: + "border border-[#3B82F6] bg-transparent text-[#3B82F6] hover:bg-[#3B82F6]/10 active:bg-[#3B82F6]/20 focus-visible:ring-[#3B82F6]", + ghost: + "bg-transparent text-slate-400 hover:bg-slate-800 hover:text-slate-200 focus-visible:ring-slate-400", + danger: + "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 shadow-md hover:shadow-lg focus-visible:ring-red-600", + outline: + "border border-slate-600 bg-transparent text-slate-300 hover:bg-slate-800 hover:text-slate-200 focus-visible:ring-slate-400", + }, + size: { + sm: "h-8 px-3 text-sm", + md: "h-10 px-4 text-base", + lg: "h-12 px-6 text-lg", + icon: "h-10 w-10 p-2", + "icon-sm": "h-8 w-8 p-1.5", + "icon-lg": "h-12 w-12 p-2.5", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + loading?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, loading, disabled, children, ...props }, ref) => { + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/client/src/components/ui-new/card.tsx b/client/src/components/ui-new/card.tsx new file mode 100644 index 0000000..3f796f4 --- /dev/null +++ b/client/src/components/ui-new/card.tsx @@ -0,0 +1,118 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "./button" + +/** + * Card Component Variants + * Three-tier hierarchy: Primary, Secondary, Tertiary + */ +const cardVariants = cva( + "rounded-xl border transition-all duration-200", + { + variants: { + variant: { + // Primary: Critical information with accent + primary: + "bg-slate-800/90 border-slate-700 shadow-lg backdrop-blur-sm border-l-4 border-l-[#3B82F6]", + // Secondary: Supporting information + secondary: + "bg-slate-800/70 border-slate-700/50 shadow-md backdrop-blur-sm", + // Tertiary: Background containers + tertiary: + "bg-slate-900/50 border-slate-700/30", + // Glass: Glassmorphism effect + glass: + "bg-slate-800/40 backdrop-blur-xl border-white/10 shadow-xl hover:bg-slate-800/60 hover:border-white/20", + // Interactive: Hover effects + interactive: + "bg-slate-800/80 border-slate-700 cursor-pointer hover:bg-slate-800 hover:border-slate-600 hover:shadow-lg transition-all", + }, + padding: { + none: "", + sm: "p-4", + md: "p-6", + lg: "p-8", + }, + }, + defaultVariants: { + variant: "secondary", + padding: "md", + }, + } +) + +export interface CardProps + extends React.HTMLAttributes, + VariantProps {} + +const Card = React.forwardRef( + ({ className, variant, padding, ...props }, ref) => ( +
+ ) +) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, cardVariants } diff --git a/client/src/components/ui-new/index.ts b/client/src/components/ui-new/index.ts new file mode 100644 index 0000000..3433518 --- /dev/null +++ b/client/src/components/ui-new/index.ts @@ -0,0 +1,24 @@ +/** + * UI Component Library - New Design System + * CreditNexus Professional Fintech Components + * + * @module ui-new + * @version 1.0.0 + */ + +// Core Components +export { Button, buttonVariants, type ButtonProps } from "./button" +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + cardVariants, + type CardProps +} from "./card" +export { Input, Textarea, inputVariants, type InputProps, type TextareaProps } from "./input" + +// Utility +export { cn } from "./button" diff --git a/client/src/components/ui-new/input.tsx b/client/src/components/ui-new/input.tsx new file mode 100644 index 0000000..21d1aab --- /dev/null +++ b/client/src/components/ui-new/input.tsx @@ -0,0 +1,196 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "./button" + +/** + * Input Component Variants + * Text, Email, Password, and other input types + */ +const inputVariants = cva( + "flex w-full rounded-lg border bg-transparent px-4 py-3 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + { + variants: { + variant: { + default: + "border-slate-600 bg-slate-800/50 text-slate-100 focus-visible:border-[#3B82F6] focus-visible:ring-[#3B82F6]", + ghost: + "border-transparent bg-slate-800/30 text-slate-100 focus-visible:bg-slate-800/50 focus-visible:ring-slate-400", + error: + "border-red-500 bg-red-500/10 text-slate-100 focus-visible:border-red-500 focus-visible:ring-red-500", + success: + "border-emerald-500 bg-emerald-500/10 text-slate-100 focus-visible:border-emerald-500 focus-visible:ring-emerald-500", + }, + size: { + sm: "h-8 px-3 text-sm", + md: "h-12 px-4 text-base", + lg: "h-14 px-4 text-lg", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, + } +) + +export interface InputProps + extends Omit, "size">, + VariantProps { + error?: string + label?: string + helperText?: string +} + +const Input = React.forwardRef( + ({ className, variant, size, error, label, helperText, id, ...props }, ref) => { + const inputId = id || React.useId() + const hasError = !!error || variant === "error" + + return ( +
+ {label && ( + + )} + + {error && ( + + )} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ) + } +) +Input.displayName = "Input" + +/** + * Textarea Component + */ +export interface TextareaProps + extends React.TextareaHTMLAttributes { + error?: string + label?: string + helperText?: string +} + +const Textarea = React.forwardRef( + ({ className, error, label, helperText, id, ...props }, ref) => { + const textareaId = id || React.useId() + const hasError = !!error + + return ( +
+ {label && ( + + )} +