Spaces:
Sleeping
Sleeping
Commit Β·
d581b14
1
Parent(s): 24cf0c4
fix: resolve AI assistant page client crash by converting to HTTP streaming fallback, passing env vars to supervisord, and add payments feature
Browse files- backend/app/ai/chat.py +2 -2
- backend/app/ai/loan_prediction_model.pkl +3 -0
- backend/app/ai/ollama_integration.py +22 -5
- backend/app/database/models.py +22 -0
- backend/app/main.py +2 -0
- backend/app/payments/__init__.py +0 -0
- backend/app/payments/router.py +212 -0
- backend/app/payments/schemas.py +48 -0
- backend/app/payments/service.py +382 -0
- frontend/src/app/chat/page.tsx +150 -162
- frontend/src/app/payments/page.tsx +895 -0
- frontend/src/app/status/page.tsx +1 -1
- frontend/src/components/layout/Sidebar.tsx +3 -1
- frontend/src/lib/api.ts +76 -0
- hf/start.sh +9 -0
- hf/supervisord.conf +2 -1
backend/app/ai/chat.py
CHANGED
|
@@ -169,7 +169,7 @@ def get_chat_response(db: Session, user_id: str, prompt: str) -> str:
|
|
| 169 |
|
| 170 |
# Determine backend
|
| 171 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 172 |
-
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 173 |
|
| 174 |
response_content = None
|
| 175 |
|
|
@@ -238,7 +238,7 @@ def stream_chat_response(db: Session, user_id: str, prompt: str):
|
|
| 238 |
chat_memory.add_message(user_id, "user", prompt)
|
| 239 |
|
| 240 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 241 |
-
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 242 |
|
| 243 |
complete_reply = ""
|
| 244 |
|
|
|
|
| 169 |
|
| 170 |
# Determine backend
|
| 171 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 172 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
|
| 173 |
|
| 174 |
response_content = None
|
| 175 |
|
|
|
|
| 238 |
chat_memory.add_message(user_id, "user", prompt)
|
| 239 |
|
| 240 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 241 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
|
| 242 |
|
| 243 |
complete_reply = ""
|
| 244 |
|
backend/app/ai/loan_prediction_model.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:17a92ade8661126d286434ab96256939040736511018f552b2a2b78156a5377f
|
| 3 |
+
size 55529
|
backend/app/ai/ollama_integration.py
CHANGED
|
@@ -6,7 +6,11 @@ import time
|
|
| 6 |
# βββ Backend credentials (read once at module load) βββββββββββββββββββββββββββ
|
| 7 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 8 |
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
USE_GROQ = bool(GROQ_API_KEY)
|
| 11 |
|
| 12 |
OLLAMA_URL = "http://127.0.0.1:11434"
|
|
@@ -25,8 +29,13 @@ else:
|
|
| 25 |
AI_BACKEND_AVAILABLE = False
|
| 26 |
|
| 27 |
def has_active_ai_backend() -> bool:
|
| 28 |
-
"""Returns True if OpenAI, Groq, or local Ollama is active and reachable.
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
BANKING_KEYWORDS = [
|
| 32 |
"account", "loan", "card", "balance",
|
|
@@ -190,9 +199,13 @@ def stream_openai_response(prompt, history=None, model=None, language="English")
|
|
| 190 |
|
| 191 |
def get_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 192 |
"""Fetches a response from Groq AI API."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
try:
|
| 194 |
from groq import Groq
|
| 195 |
-
client = Groq(api_key=
|
| 196 |
|
| 197 |
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 198 |
messages = [{"role": "system", "content": sys_prompt}]
|
|
@@ -218,9 +231,13 @@ def get_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", lan
|
|
| 218 |
|
| 219 |
def stream_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 220 |
"""Yields streamed response chunks from Groq AI API."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
try:
|
| 222 |
from groq import Groq
|
| 223 |
-
client = Groq(api_key=
|
| 224 |
|
| 225 |
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 226 |
messages = [{"role": "system", "content": sys_prompt}]
|
|
|
|
| 6 |
# βββ Backend credentials (read once at module load) βββββββββββββββββββββββββββ
|
| 7 |
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 8 |
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 9 |
+
# Accept key from env OR the HF Spaces secret variable name GROQ_API_KEY
|
| 10 |
+
GROQ_API_KEY = (
|
| 11 |
+
os.environ.get("GROQ_API_KEY", "")
|
| 12 |
+
or os.environ.get("GROQ_KEY", "")
|
| 13 |
+
)
|
| 14 |
USE_GROQ = bool(GROQ_API_KEY)
|
| 15 |
|
| 16 |
OLLAMA_URL = "http://127.0.0.1:11434"
|
|
|
|
| 29 |
AI_BACKEND_AVAILABLE = False
|
| 30 |
|
| 31 |
def has_active_ai_backend() -> bool:
|
| 32 |
+
"""Returns True if OpenAI, Groq, or local Ollama is active and reachable.
|
| 33 |
+
Re-reads env vars each call so HF Spaces secrets are always picked up."""
|
| 34 |
+
if os.environ.get("OPENAI_API_KEY", ""):
|
| 35 |
+
return True
|
| 36 |
+
if os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", ""):
|
| 37 |
+
return True
|
| 38 |
+
return AI_BACKEND_AVAILABLE # falls back to the Ollama ping result from startup
|
| 39 |
|
| 40 |
BANKING_KEYWORDS = [
|
| 41 |
"account", "loan", "card", "balance",
|
|
|
|
| 199 |
|
| 200 |
def get_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 201 |
"""Fetches a response from Groq AI API."""
|
| 202 |
+
# Re-read key at call time so HF Spaces secrets are always picked up
|
| 203 |
+
groq_key = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "") or GROQ_API_KEY
|
| 204 |
+
if not groq_key:
|
| 205 |
+
return None
|
| 206 |
try:
|
| 207 |
from groq import Groq
|
| 208 |
+
client = Groq(api_key=groq_key)
|
| 209 |
|
| 210 |
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 211 |
messages = [{"role": "system", "content": sys_prompt}]
|
|
|
|
| 231 |
|
| 232 |
def stream_groq_response(prompt, history=None, model="llama-3.3-70b-versatile", language="English"):
|
| 233 |
"""Yields streamed response chunks from Groq AI API."""
|
| 234 |
+
# Re-read key at call time so HF Spaces secrets are always picked up
|
| 235 |
+
groq_key = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "") or GROQ_API_KEY
|
| 236 |
+
if not groq_key:
|
| 237 |
+
return
|
| 238 |
try:
|
| 239 |
from groq import Groq
|
| 240 |
+
client = Groq(api_key=groq_key)
|
| 241 |
|
| 242 |
sys_prompt = SYSTEM_PROMPT.format(language=language)
|
| 243 |
messages = [{"role": "system", "content": sys_prompt}]
|
backend/app/database/models.py
CHANGED
|
@@ -26,6 +26,7 @@ class User(Base):
|
|
| 26 |
ai_insights = relationship("AIInsight", back_populates="user")
|
| 27 |
notifications = relationship("Notification", back_populates="user")
|
| 28 |
analytics_snapshots = relationship("AnalyticsSnapshot", back_populates="user")
|
|
|
|
| 29 |
|
| 30 |
class Account(Base):
|
| 31 |
__tablename__ = "accounts"
|
|
@@ -145,3 +146,24 @@ class AnalyticsSnapshot(Base):
|
|
| 145 |
trends_json = Column(JSON, default={})
|
| 146 |
|
| 147 |
user = relationship("User", back_populates="analytics_snapshots")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
ai_insights = relationship("AIInsight", back_populates="user")
|
| 27 |
notifications = relationship("Notification", back_populates="user")
|
| 28 |
analytics_snapshots = relationship("AnalyticsSnapshot", back_populates="user")
|
| 29 |
+
payments = relationship("Payment", back_populates="user")
|
| 30 |
|
| 31 |
class Account(Base):
|
| 32 |
__tablename__ = "accounts"
|
|
|
|
| 146 |
trends_json = Column(JSON, default={})
|
| 147 |
|
| 148 |
user = relationship("User", back_populates="analytics_snapshots")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class Payment(Base):
|
| 152 |
+
__tablename__ = "payments"
|
| 153 |
+
|
| 154 |
+
id = Column(String, primary_key=True, index=True, default=generate_uuid)
|
| 155 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 156 |
+
amount = Column(Float, nullable=False)
|
| 157 |
+
currency = Column(String, default="USD")
|
| 158 |
+
recipient_name = Column(String, nullable=False)
|
| 159 |
+
recipient_account = Column(String, nullable=False)
|
| 160 |
+
payment_type = Column(String, nullable=False) # transfer|bill_payment|subscription|incoming|outgoing
|
| 161 |
+
status = Column(String, default="pending") # pending|completed|failed|flagged
|
| 162 |
+
risk_score = Column(Float, default=0.0) # 0β100
|
| 163 |
+
fraud_flag = Column(Boolean, default=False)
|
| 164 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 165 |
+
transaction_reference = Column(String, nullable=True)
|
| 166 |
+
note = Column(String, nullable=True)
|
| 167 |
+
ai_insight = Column(Text, nullable=True)
|
| 168 |
+
|
| 169 |
+
user = relationship("User", back_populates="payments")
|
backend/app/main.py
CHANGED
|
@@ -21,6 +21,7 @@ from app.auth.router import router as auth_router
|
|
| 21 |
from app.dashboard.router import router as dashboard_router
|
| 22 |
from app.notifications.router import router as notifications_router
|
| 23 |
from app.transactions.router import router as transactions_router
|
|
|
|
| 24 |
|
| 25 |
# βββ Observability ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
from app.middleware.logging import RequestLoggingMiddleware, metrics, api_logger
|
|
@@ -130,6 +131,7 @@ app.include_router(ws_router)
|
|
| 130 |
app.include_router(dashboard_router)
|
| 131 |
app.include_router(notifications_router)
|
| 132 |
app.include_router(transactions_router)
|
|
|
|
| 133 |
|
| 134 |
# βββ Core endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
@app.get("/", tags=["Core"])
|
|
|
|
| 21 |
from app.dashboard.router import router as dashboard_router
|
| 22 |
from app.notifications.router import router as notifications_router
|
| 23 |
from app.transactions.router import router as transactions_router
|
| 24 |
+
from app.payments.router import router as payments_router
|
| 25 |
|
| 26 |
# βββ Observability ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
from app.middleware.logging import RequestLoggingMiddleware, metrics, api_logger
|
|
|
|
| 131 |
app.include_router(dashboard_router)
|
| 132 |
app.include_router(notifications_router)
|
| 133 |
app.include_router(transactions_router)
|
| 134 |
+
app.include_router(payments_router)
|
| 135 |
|
| 136 |
# βββ Core endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
@app.get("/", tags=["Core"])
|
backend/app/payments/__init__.py
ADDED
|
File without changes
|
backend/app/payments/router.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payments router β create, list, transfer, verify, delete.
|
| 3 |
+
All endpoints require JWT authentication.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.database.database import get_db
|
| 10 |
+
from app.database.models import Payment, User
|
| 11 |
+
from app.auth.router import get_current_user
|
| 12 |
+
from app.payments.schemas import PaymentCreate, TransferCreate, PaymentVerify
|
| 13 |
+
from app.payments.service import create_payment, create_internal_transfer
|
| 14 |
+
from app.middleware.cache import cache
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/payments", tags=["Payments"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# βββ Create payment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
@router.post("/create", status_code=201)
|
| 22 |
+
def post_create_payment(
|
| 23 |
+
req: PaymentCreate,
|
| 24 |
+
current_user: User = Depends(get_current_user),
|
| 25 |
+
db: Session = Depends(get_db),
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Create a new outgoing payment. Runs fraud scoring, deducts balance,
|
| 29 |
+
creates a Transaction record, fires notification if flagged.
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
payment = create_payment(
|
| 33 |
+
db=db,
|
| 34 |
+
user_id=current_user.id,
|
| 35 |
+
amount=req.amount,
|
| 36 |
+
currency=req.currency,
|
| 37 |
+
recipient_name=req.recipient_name,
|
| 38 |
+
recipient_account=req.recipient_account,
|
| 39 |
+
payment_type=req.payment_type,
|
| 40 |
+
note=req.note,
|
| 41 |
+
)
|
| 42 |
+
except ValueError as e:
|
| 43 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 44 |
+
|
| 45 |
+
# Invalidate dashboard cache so balance updates immediately
|
| 46 |
+
cache.delete(f"dashboard:overview:{current_user.id}")
|
| 47 |
+
|
| 48 |
+
return _serialize(payment)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# βββ Internal transfer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
@router.post("/transfer", status_code=201)
|
| 54 |
+
def post_transfer(
|
| 55 |
+
req: TransferCreate,
|
| 56 |
+
current_user: User = Depends(get_current_user),
|
| 57 |
+
db: Session = Depends(get_db),
|
| 58 |
+
):
|
| 59 |
+
"""
|
| 60 |
+
Move funds between the user's own accounts (checking β savings/investment).
|
| 61 |
+
"""
|
| 62 |
+
try:
|
| 63 |
+
payment = create_internal_transfer(
|
| 64 |
+
db=db,
|
| 65 |
+
user_id=current_user.id,
|
| 66 |
+
amount=req.amount,
|
| 67 |
+
to_account_type=req.to_account_type,
|
| 68 |
+
note=req.note,
|
| 69 |
+
)
|
| 70 |
+
except ValueError as e:
|
| 71 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 72 |
+
|
| 73 |
+
cache.delete(f"dashboard:overview:{current_user.id}")
|
| 74 |
+
return _serialize(payment)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# βββ Payment history ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
|
| 79 |
+
@router.get("/history")
|
| 80 |
+
def get_payment_history(
|
| 81 |
+
page: int = Query(default=1, ge=1),
|
| 82 |
+
limit: int = Query(default=20, ge=1, le=100),
|
| 83 |
+
payment_type: Optional[str] = None,
|
| 84 |
+
status: Optional[str] = None,
|
| 85 |
+
current_user: User = Depends(get_current_user),
|
| 86 |
+
db: Session = Depends(get_db),
|
| 87 |
+
):
|
| 88 |
+
query = db.query(Payment).filter(Payment.user_id == current_user.id)
|
| 89 |
+
if payment_type:
|
| 90 |
+
query = query.filter(Payment.payment_type == payment_type)
|
| 91 |
+
if status:
|
| 92 |
+
query = query.filter(Payment.status == status)
|
| 93 |
+
|
| 94 |
+
total = query.count()
|
| 95 |
+
payments = (
|
| 96 |
+
query.order_by(Payment.created_at.desc())
|
| 97 |
+
.offset((page - 1) * limit)
|
| 98 |
+
.limit(limit)
|
| 99 |
+
.all()
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
total_sent = sum(
|
| 103 |
+
p.amount for p in db.query(Payment)
|
| 104 |
+
.filter(Payment.user_id == current_user.id, Payment.payment_type != "incoming")
|
| 105 |
+
.all()
|
| 106 |
+
)
|
| 107 |
+
total_received = sum(
|
| 108 |
+
p.amount for p in db.query(Payment)
|
| 109 |
+
.filter(Payment.user_id == current_user.id, Payment.payment_type == "incoming")
|
| 110 |
+
.all()
|
| 111 |
+
)
|
| 112 |
+
flagged_count = db.query(Payment).filter(
|
| 113 |
+
Payment.user_id == current_user.id, Payment.fraud_flag == True
|
| 114 |
+
).count()
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"payments": [_serialize(p) for p in payments],
|
| 118 |
+
"total": total,
|
| 119 |
+
"page": page,
|
| 120 |
+
"pages": max(1, (total + limit - 1) // limit),
|
| 121 |
+
"stats": {
|
| 122 |
+
"total_sent": round(total_sent, 2),
|
| 123 |
+
"total_received": round(total_received, 2),
|
| 124 |
+
"flagged_count": flagged_count,
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# βββ Single payment βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
|
| 131 |
+
@router.get("/{payment_id}")
|
| 132 |
+
def get_payment(
|
| 133 |
+
payment_id: str,
|
| 134 |
+
current_user: User = Depends(get_current_user),
|
| 135 |
+
db: Session = Depends(get_db),
|
| 136 |
+
):
|
| 137 |
+
payment = db.query(Payment).filter(
|
| 138 |
+
Payment.id == payment_id,
|
| 139 |
+
Payment.user_id == current_user.id,
|
| 140 |
+
).first()
|
| 141 |
+
if not payment:
|
| 142 |
+
raise HTTPException(status_code=404, detail="Payment not found.")
|
| 143 |
+
return _serialize(payment)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# βββ Verify / confirm a flagged payment ββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
@router.post("/verify")
|
| 149 |
+
def post_verify_payment(
|
| 150 |
+
req: PaymentVerify,
|
| 151 |
+
current_user: User = Depends(get_current_user),
|
| 152 |
+
db: Session = Depends(get_db),
|
| 153 |
+
):
|
| 154 |
+
payment = db.query(Payment).filter(
|
| 155 |
+
Payment.id == req.payment_id,
|
| 156 |
+
Payment.user_id == current_user.id,
|
| 157 |
+
).first()
|
| 158 |
+
if not payment:
|
| 159 |
+
raise HTTPException(status_code=404, detail="Payment not found.")
|
| 160 |
+
|
| 161 |
+
if req.confirm:
|
| 162 |
+
payment.status = "completed"
|
| 163 |
+
payment.fraud_flag = False
|
| 164 |
+
else:
|
| 165 |
+
payment.status = "failed"
|
| 166 |
+
|
| 167 |
+
db.commit()
|
| 168 |
+
db.refresh(payment)
|
| 169 |
+
return {"message": "Payment updated.", "payment": _serialize(payment)}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# βββ Delete / cancel a payment ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
|
| 174 |
+
@router.delete("/{payment_id}")
|
| 175 |
+
def delete_payment(
|
| 176 |
+
payment_id: str,
|
| 177 |
+
current_user: User = Depends(get_current_user),
|
| 178 |
+
db: Session = Depends(get_db),
|
| 179 |
+
):
|
| 180 |
+
payment = db.query(Payment).filter(
|
| 181 |
+
Payment.id == payment_id,
|
| 182 |
+
Payment.user_id == current_user.id,
|
| 183 |
+
).first()
|
| 184 |
+
if not payment:
|
| 185 |
+
raise HTTPException(status_code=404, detail="Payment not found.")
|
| 186 |
+
if payment.status == "completed":
|
| 187 |
+
raise HTTPException(status_code=400, detail="Cannot delete a completed payment.")
|
| 188 |
+
|
| 189 |
+
db.delete(payment)
|
| 190 |
+
db.commit()
|
| 191 |
+
return {"message": "Payment cancelled and removed."}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# βββ Serializer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
+
|
| 196 |
+
def _serialize(p: Payment) -> dict:
|
| 197 |
+
return {
|
| 198 |
+
"id": p.id,
|
| 199 |
+
"user_id": p.user_id,
|
| 200 |
+
"amount": p.amount,
|
| 201 |
+
"currency": p.currency,
|
| 202 |
+
"recipient_name": p.recipient_name,
|
| 203 |
+
"recipient_account": p.recipient_account,
|
| 204 |
+
"payment_type": p.payment_type,
|
| 205 |
+
"status": p.status,
|
| 206 |
+
"risk_score": p.risk_score,
|
| 207 |
+
"fraud_flag": p.fraud_flag,
|
| 208 |
+
"created_at": p.created_at.isoformat() if p.created_at else None,
|
| 209 |
+
"transaction_reference": p.transaction_reference,
|
| 210 |
+
"note": p.note,
|
| 211 |
+
"ai_insight": p.ai_insight,
|
| 212 |
+
}
|
backend/app/payments/schemas.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PaymentCreate(BaseModel):
|
| 7 |
+
amount: float = Field(..., gt=0, description="Payment amount, must be positive")
|
| 8 |
+
currency: str = Field(default="USD", max_length=3)
|
| 9 |
+
recipient_name: str = Field(..., min_length=1, max_length=120)
|
| 10 |
+
recipient_account: str = Field(..., min_length=1, max_length=80)
|
| 11 |
+
payment_type: str = Field(
|
| 12 |
+
default="transfer",
|
| 13 |
+
description="transfer | bill_payment | subscription | incoming | outgoing"
|
| 14 |
+
)
|
| 15 |
+
note: Optional[str] = Field(default=None, max_length=255)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TransferCreate(BaseModel):
|
| 19 |
+
amount: float = Field(..., gt=0)
|
| 20 |
+
to_account_type: str = Field(
|
| 21 |
+
..., description="checking | savings | investment β destination account type"
|
| 22 |
+
)
|
| 23 |
+
note: Optional[str] = Field(default=None, max_length=255)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PaymentVerify(BaseModel):
|
| 27 |
+
payment_id: str
|
| 28 |
+
confirm: bool = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class PaymentResponse(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
user_id: str
|
| 34 |
+
amount: float
|
| 35 |
+
currency: str
|
| 36 |
+
recipient_name: str
|
| 37 |
+
recipient_account: str
|
| 38 |
+
payment_type: str
|
| 39 |
+
status: str
|
| 40 |
+
risk_score: float
|
| 41 |
+
fraud_flag: bool
|
| 42 |
+
created_at: datetime
|
| 43 |
+
transaction_reference: Optional[str]
|
| 44 |
+
note: Optional[str]
|
| 45 |
+
ai_insight: Optional[str]
|
| 46 |
+
|
| 47 |
+
class Config:
|
| 48 |
+
from_attributes = True
|
backend/app/payments/service.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payment service β fraud scoring, ledger writes, AI insights, notifications.
|
| 3 |
+
All payment activity is persisted and wired into the fraud engine.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.database.models import (
|
| 14 |
+
Payment, Transaction, Account, User, Notification, FraudLog, generate_uuid
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# βββ Fraud scoring for payments βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
def score_payment_fraud(
|
| 21 |
+
db: Session,
|
| 22 |
+
user_id: str,
|
| 23 |
+
amount: float,
|
| 24 |
+
recipient_account: str,
|
| 25 |
+
recipient_name: str,
|
| 26 |
+
payment_type: str,
|
| 27 |
+
) -> tuple[float, list[str], bool]:
|
| 28 |
+
"""
|
| 29 |
+
Returns (risk_score 0-100, reasons list, fraud_flag bool).
|
| 30 |
+
Runs 5 heuristic rules against payment history + transaction history.
|
| 31 |
+
"""
|
| 32 |
+
score = 0.0
|
| 33 |
+
reasons: list[str] = []
|
| 34 |
+
|
| 35 |
+
# ββ Historical baseline from transactions βββββββββββββββββββββββββββββββββ
|
| 36 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 37 |
+
account_ids = [a.id for a in accounts]
|
| 38 |
+
|
| 39 |
+
recent_txns = (
|
| 40 |
+
db.query(Transaction)
|
| 41 |
+
.filter(
|
| 42 |
+
Transaction.account_id.in_(account_ids),
|
| 43 |
+
Transaction.type == "debit",
|
| 44 |
+
)
|
| 45 |
+
.order_by(Transaction.timestamp.desc())
|
| 46 |
+
.limit(30)
|
| 47 |
+
.all()
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
if recent_txns:
|
| 51 |
+
amounts = [t.amount for t in recent_txns]
|
| 52 |
+
avg_amt = float(np.mean(amounts))
|
| 53 |
+
std_amt = float(np.std(amounts)) if len(amounts) > 1 else 0.0
|
| 54 |
+
|
| 55 |
+
# Rule 1: Amount spike
|
| 56 |
+
if amount > avg_amt * 4.0:
|
| 57 |
+
score += 40
|
| 58 |
+
reasons.append(
|
| 59 |
+
f"Payment amount ${amount:,.2f} is {amount/max(avg_amt,1):.1f}Γ your historical average."
|
| 60 |
+
)
|
| 61 |
+
elif amount > avg_amt * 2.5:
|
| 62 |
+
score += 20
|
| 63 |
+
reasons.append(
|
| 64 |
+
f"Payment amount ${amount:,.2f} is significantly above your usual spending."
|
| 65 |
+
)
|
| 66 |
+
else:
|
| 67 |
+
avg_amt = 0.0
|
| 68 |
+
|
| 69 |
+
# Rule 2: Late-night timing
|
| 70 |
+
hour = datetime.utcnow().hour
|
| 71 |
+
if hour >= 23 or hour < 4:
|
| 72 |
+
score += 20
|
| 73 |
+
reasons.append("Payment initiated between 11 PM β 4 AM (unusual hours).")
|
| 74 |
+
|
| 75 |
+
# ββ Historical baseline from payments βββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
recent_payments = (
|
| 77 |
+
db.query(Payment)
|
| 78 |
+
.filter(Payment.user_id == user_id)
|
| 79 |
+
.order_by(Payment.created_at.desc())
|
| 80 |
+
.limit(20)
|
| 81 |
+
.all()
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Rule 3: Velocity β more than 3 payments in last 10 minutes
|
| 85 |
+
cutoff = datetime.utcnow() - timedelta(minutes=10)
|
| 86 |
+
rapid = [p for p in recent_payments if p.created_at and p.created_at >= cutoff]
|
| 87 |
+
if len(rapid) >= 3:
|
| 88 |
+
score += 25
|
| 89 |
+
reasons.append(f"{len(rapid)} payments in the last 10 minutes β velocity anomaly.")
|
| 90 |
+
|
| 91 |
+
# Rule 4: Duplicate recipient + amount within 15 minutes
|
| 92 |
+
dup_cutoff = datetime.utcnow() - timedelta(minutes=15)
|
| 93 |
+
for p in recent_payments[:5]:
|
| 94 |
+
if (
|
| 95 |
+
p.recipient_account == recipient_account
|
| 96 |
+
and abs(p.amount - amount) < 0.01
|
| 97 |
+
and p.created_at
|
| 98 |
+
and p.created_at >= dup_cutoff
|
| 99 |
+
):
|
| 100 |
+
score += 30
|
| 101 |
+
reasons.append(
|
| 102 |
+
f"Duplicate payment detected: same recipient and amount within 15 minutes."
|
| 103 |
+
)
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
# Rule 5: New/unknown recipient (never paid before)
|
| 107 |
+
known_recipients = {p.recipient_account for p in recent_payments}
|
| 108 |
+
if recipient_account not in known_recipients and amount > 500:
|
| 109 |
+
score += 15
|
| 110 |
+
reasons.append(
|
| 111 |
+
f"First-time payment to '{recipient_name}' for a large amount (${amount:,.2f})."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
score = min(100.0, score)
|
| 115 |
+
fraud_flag = score >= 50
|
| 116 |
+
return round(score, 1), reasons, fraud_flag
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# βββ AI insight for payment βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
+
|
| 121 |
+
def get_payment_ai_insight(
|
| 122 |
+
amount: float,
|
| 123 |
+
recipient_name: str,
|
| 124 |
+
payment_type: str,
|
| 125 |
+
risk_score: float,
|
| 126 |
+
fraud_flag: bool,
|
| 127 |
+
avg_balance: float,
|
| 128 |
+
) -> str:
|
| 129 |
+
"""
|
| 130 |
+
Generates a concise AI insight string. Uses Groq if available, else rule-based.
|
| 131 |
+
"""
|
| 132 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
|
| 133 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 134 |
+
|
| 135 |
+
prompt = (
|
| 136 |
+
f"Payment of ${amount:,.2f} to '{recipient_name}' ({payment_type}). "
|
| 137 |
+
f"User's average account balance: ${avg_balance:,.2f}. "
|
| 138 |
+
f"Fraud risk score: {risk_score}/100. "
|
| 139 |
+
f"Provide a single concise sentence (max 25 words) of financial advice about this payment."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
if OPENAI_API_KEY:
|
| 143 |
+
try:
|
| 144 |
+
from openai import OpenAI
|
| 145 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 146 |
+
resp = client.chat.completions.create(
|
| 147 |
+
model="gpt-4o-mini",
|
| 148 |
+
messages=[{"role": "user", "content": prompt}],
|
| 149 |
+
max_tokens=60,
|
| 150 |
+
temperature=0.3,
|
| 151 |
+
)
|
| 152 |
+
return resp.choices[0].message.content.strip()
|
| 153 |
+
except Exception:
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
if GROQ_API_KEY:
|
| 157 |
+
try:
|
| 158 |
+
from groq import Groq
|
| 159 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 160 |
+
resp = client.chat.completions.create(
|
| 161 |
+
model="llama-3.3-70b-versatile",
|
| 162 |
+
messages=[{"role": "user", "content": prompt}],
|
| 163 |
+
max_tokens=60,
|
| 164 |
+
temperature=0.3,
|
| 165 |
+
)
|
| 166 |
+
return resp.choices[0].message.content.strip()
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
|
| 170 |
+
# Rule-based fallback
|
| 171 |
+
if fraud_flag:
|
| 172 |
+
return f"β οΈ High-risk payment flagged. Verify recipient '{recipient_name}' before proceeding."
|
| 173 |
+
if amount > avg_balance * 0.3:
|
| 174 |
+
return f"This payment represents {amount/max(avg_balance,1)*100:.0f}% of your balance β review before confirming."
|
| 175 |
+
if payment_type == "subscription":
|
| 176 |
+
return f"Recurring subscription to {recipient_name}. Review usage to ensure value."
|
| 177 |
+
return f"Payment of ${amount:,.2f} to {recipient_name} processed successfully."
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# βββ Core payment creation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
|
| 182 |
+
def create_payment(
|
| 183 |
+
db: Session,
|
| 184 |
+
user_id: str,
|
| 185 |
+
amount: float,
|
| 186 |
+
currency: str,
|
| 187 |
+
recipient_name: str,
|
| 188 |
+
recipient_account: str,
|
| 189 |
+
payment_type: str,
|
| 190 |
+
note: Optional[str] = None,
|
| 191 |
+
) -> Payment:
|
| 192 |
+
"""
|
| 193 |
+
Creates a payment record, runs fraud scoring, deducts from checking account,
|
| 194 |
+
creates a matching Transaction, fires a Notification if flagged, and persists.
|
| 195 |
+
"""
|
| 196 |
+
# ββ Fraud scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
risk_score, fraud_reasons, fraud_flag = score_payment_fraud(
|
| 198 |
+
db, user_id, amount, recipient_account, recipient_name, payment_type
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# ββ Determine status ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 202 |
+
status = "flagged" if fraud_flag else "completed"
|
| 203 |
+
|
| 204 |
+
# ββ Average balance for AI insight ββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 206 |
+
avg_balance = sum(a.balance for a in accounts) / max(len(accounts), 1)
|
| 207 |
+
|
| 208 |
+
# ββ AI insight ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 209 |
+
ai_insight = get_payment_ai_insight(
|
| 210 |
+
amount, recipient_name, payment_type, risk_score, fraud_flag, avg_balance
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# ββ Transaction reference βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
txn_ref = f"PAY-{uuid.uuid4().hex[:10].upper()}"
|
| 215 |
+
|
| 216 |
+
# ββ Persist payment βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 217 |
+
payment = Payment(
|
| 218 |
+
id=generate_uuid(),
|
| 219 |
+
user_id=user_id,
|
| 220 |
+
amount=amount,
|
| 221 |
+
currency=currency,
|
| 222 |
+
recipient_name=recipient_name,
|
| 223 |
+
recipient_account=recipient_account,
|
| 224 |
+
payment_type=payment_type,
|
| 225 |
+
status=status,
|
| 226 |
+
risk_score=risk_score,
|
| 227 |
+
fraud_flag=fraud_flag,
|
| 228 |
+
transaction_reference=txn_ref,
|
| 229 |
+
note=note,
|
| 230 |
+
ai_insight=ai_insight,
|
| 231 |
+
)
|
| 232 |
+
db.add(payment)
|
| 233 |
+
|
| 234 |
+
# ββ Deduct from checking account (outgoing payments) βββββββββββββββββββββ
|
| 235 |
+
if payment_type not in ("incoming",):
|
| 236 |
+
checking = (
|
| 237 |
+
db.query(Account)
|
| 238 |
+
.filter(Account.user_id == user_id, Account.type == "checking")
|
| 239 |
+
.first()
|
| 240 |
+
)
|
| 241 |
+
if checking and checking.balance >= amount:
|
| 242 |
+
checking.balance = round(checking.balance - amount, 2)
|
| 243 |
+
|
| 244 |
+
# Mirror as a Transaction for dashboard/analytics
|
| 245 |
+
txn = Transaction(
|
| 246 |
+
id=generate_uuid(),
|
| 247 |
+
account_id=checking.id,
|
| 248 |
+
amount=amount,
|
| 249 |
+
type="debit",
|
| 250 |
+
category=_payment_type_to_category(payment_type),
|
| 251 |
+
merchant=recipient_name,
|
| 252 |
+
tags=["payment", payment_type],
|
| 253 |
+
ai_generated_metadata={
|
| 254 |
+
"payment_id": payment.id,
|
| 255 |
+
"risk_score": risk_score,
|
| 256 |
+
"fraud_flag": fraud_flag,
|
| 257 |
+
"txn_ref": txn_ref,
|
| 258 |
+
},
|
| 259 |
+
)
|
| 260 |
+
db.add(txn)
|
| 261 |
+
|
| 262 |
+
# Write FraudLog if flagged
|
| 263 |
+
if fraud_flag:
|
| 264 |
+
fraud_log = FraudLog(
|
| 265 |
+
id=generate_uuid(),
|
| 266 |
+
transaction_id=txn.id,
|
| 267 |
+
risk_score=risk_score / 100.0,
|
| 268 |
+
suspicious_activity_details="; ".join(fraud_reasons),
|
| 269 |
+
status="pending",
|
| 270 |
+
)
|
| 271 |
+
db.add(fraud_log)
|
| 272 |
+
|
| 273 |
+
# ββ Notification ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 274 |
+
if fraud_flag:
|
| 275 |
+
notif = Notification(
|
| 276 |
+
id=generate_uuid(),
|
| 277 |
+
user_id=user_id,
|
| 278 |
+
title="π¨ High-Risk Payment Flagged",
|
| 279 |
+
message=(
|
| 280 |
+
f"Payment of ${amount:,.2f} to '{recipient_name}' was flagged "
|
| 281 |
+
f"(risk score: {risk_score:.0f}/100). "
|
| 282 |
+
f"Reason: {fraud_reasons[0] if fraud_reasons else 'Anomaly detected.'} "
|
| 283 |
+
f"Reference: {txn_ref}"
|
| 284 |
+
),
|
| 285 |
+
type="alert",
|
| 286 |
+
read_status=False,
|
| 287 |
+
)
|
| 288 |
+
db.add(notif)
|
| 289 |
+
elif amount >= 500:
|
| 290 |
+
notif = Notification(
|
| 291 |
+
id=generate_uuid(),
|
| 292 |
+
user_id=user_id,
|
| 293 |
+
title="πΈ Large Payment Processed",
|
| 294 |
+
message=(
|
| 295 |
+
f"${amount:,.2f} sent to '{recipient_name}'. "
|
| 296 |
+
f"Reference: {txn_ref}. {ai_insight}"
|
| 297 |
+
),
|
| 298 |
+
type="insight",
|
| 299 |
+
read_status=False,
|
| 300 |
+
)
|
| 301 |
+
db.add(notif)
|
| 302 |
+
|
| 303 |
+
db.commit()
|
| 304 |
+
db.refresh(payment)
|
| 305 |
+
return payment
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def create_internal_transfer(
|
| 309 |
+
db: Session,
|
| 310 |
+
user_id: str,
|
| 311 |
+
amount: float,
|
| 312 |
+
to_account_type: str,
|
| 313 |
+
note: Optional[str] = None,
|
| 314 |
+
) -> Payment:
|
| 315 |
+
"""
|
| 316 |
+
Moves funds between the user's own accounts (e.g. checking β savings).
|
| 317 |
+
"""
|
| 318 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 319 |
+
from_acct = next((a for a in accounts if a.type == "checking"), None)
|
| 320 |
+
to_acct = next((a for a in accounts if a.type == to_account_type), None)
|
| 321 |
+
|
| 322 |
+
if not from_acct:
|
| 323 |
+
raise ValueError("No checking account found.")
|
| 324 |
+
if not to_acct:
|
| 325 |
+
raise ValueError(f"No {to_account_type} account found.")
|
| 326 |
+
if from_acct.id == to_acct.id:
|
| 327 |
+
raise ValueError("Source and destination accounts are the same.")
|
| 328 |
+
if from_acct.balance < amount:
|
| 329 |
+
raise ValueError(f"Insufficient funds. Available: ${from_acct.balance:,.2f}")
|
| 330 |
+
|
| 331 |
+
txn_ref = f"TRF-{uuid.uuid4().hex[:10].upper()}"
|
| 332 |
+
|
| 333 |
+
# Debit source
|
| 334 |
+
from_acct.balance = round(from_acct.balance - amount, 2)
|
| 335 |
+
db.add(Transaction(
|
| 336 |
+
id=generate_uuid(), account_id=from_acct.id,
|
| 337 |
+
amount=amount, type="debit", category="Transfer",
|
| 338 |
+
merchant=f"Transfer to {to_account_type.capitalize()}",
|
| 339 |
+
tags=["transfer", "internal"],
|
| 340 |
+
ai_generated_metadata={"txn_ref": txn_ref},
|
| 341 |
+
))
|
| 342 |
+
|
| 343 |
+
# Credit destination
|
| 344 |
+
to_acct.balance = round(to_acct.balance + amount, 2)
|
| 345 |
+
db.add(Transaction(
|
| 346 |
+
id=generate_uuid(), account_id=to_acct.id,
|
| 347 |
+
amount=amount, type="credit", category="Transfer",
|
| 348 |
+
merchant=f"Transfer from Checking",
|
| 349 |
+
tags=["transfer", "internal"],
|
| 350 |
+
ai_generated_metadata={"txn_ref": txn_ref},
|
| 351 |
+
))
|
| 352 |
+
|
| 353 |
+
payment = Payment(
|
| 354 |
+
id=generate_uuid(),
|
| 355 |
+
user_id=user_id,
|
| 356 |
+
amount=amount,
|
| 357 |
+
currency="USD",
|
| 358 |
+
recipient_name=f"My {to_account_type.capitalize()} Account",
|
| 359 |
+
recipient_account=to_acct.id,
|
| 360 |
+
payment_type="transfer",
|
| 361 |
+
status="completed",
|
| 362 |
+
risk_score=0.0,
|
| 363 |
+
fraud_flag=False,
|
| 364 |
+
transaction_reference=txn_ref,
|
| 365 |
+
note=note or f"Internal transfer to {to_account_type}",
|
| 366 |
+
ai_insight=f"Transferred ${amount:,.2f} to your {to_account_type} account.",
|
| 367 |
+
)
|
| 368 |
+
db.add(payment)
|
| 369 |
+
db.commit()
|
| 370 |
+
db.refresh(payment)
|
| 371 |
+
return payment
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _payment_type_to_category(payment_type: str) -> str:
|
| 375 |
+
mapping = {
|
| 376 |
+
"bill_payment": "Utilities",
|
| 377 |
+
"subscription": "Entertainment",
|
| 378 |
+
"transfer": "Transfer",
|
| 379 |
+
"outgoing": "Payment",
|
| 380 |
+
"incoming": "Income",
|
| 381 |
+
}
|
| 382 |
+
return mapping.get(payment_type, "Payment")
|
frontend/src/app/chat/page.tsx
CHANGED
|
@@ -5,9 +5,8 @@ import { motion, AnimatePresence } from "framer-motion";
|
|
| 5 |
import {
|
| 6 |
Send, Sparkles, TrendingUp, Shield, PieChart,
|
| 7 |
Zap, Copy, ThumbsUp, ThumbsDown, RotateCcw, Mic, Paperclip,
|
| 8 |
-
Wifi, WifiOff, RefreshCw
|
| 9 |
} from "lucide-react";
|
| 10 |
-
import {
|
| 11 |
import { useAuthStore } from "@/lib/stores/authStore";
|
| 12 |
|
| 13 |
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -22,36 +21,11 @@ interface Message {
|
|
| 22 |
// βββ Suggested Prompts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
const suggestions = [
|
| 24 |
{ icon: TrendingUp, label: "Forecast my balance", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
|
| 25 |
-
{ icon: PieChart,
|
| 26 |
-
{ icon: Shield,
|
| 27 |
-
{ icon: Zap,
|
| 28 |
];
|
| 29 |
|
| 30 |
-
// βββ Connection Status Badge ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
-
function ConnectionBadge({ status, onReconnect }: { status: WsStatus; onReconnect: () => void }) {
|
| 32 |
-
const config = {
|
| 33 |
-
connected: { icon: Wifi, color: "text-emerald-400", label: "Live", dot: "bg-emerald-400" },
|
| 34 |
-
connecting: { icon: RefreshCw, color: "text-amber-400", label: "Connecting...", dot: "bg-amber-400" },
|
| 35 |
-
disconnected: { icon: WifiOff, color: "text-zinc-500", label: "Offline", dot: "bg-zinc-500" },
|
| 36 |
-
error: { icon: WifiOff, color: "text-red-400", label: "Error", dot: "bg-red-400" },
|
| 37 |
-
}[status];
|
| 38 |
-
|
| 39 |
-
return (
|
| 40 |
-
<div className="flex items-center gap-2">
|
| 41 |
-
<div className={`h-1.5 w-1.5 rounded-full ${config.dot} ${status === "connecting" ? "animate-pulse" : ""}`} />
|
| 42 |
-
<span className={`text-xs ${config.color}`}>{config.label}</span>
|
| 43 |
-
{(status === "disconnected" || status === "error") && (
|
| 44 |
-
<button
|
| 45 |
-
onClick={onReconnect}
|
| 46 |
-
className="text-xs text-zinc-500 hover:text-zinc-300 underline transition-colors"
|
| 47 |
-
>
|
| 48 |
-
Retry
|
| 49 |
-
</button>
|
| 50 |
-
)}
|
| 51 |
-
</div>
|
| 52 |
-
);
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
// βββ AI Orb βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
function AIOrb({ isThinking }: { isThinking: boolean }) {
|
| 57 |
return (
|
|
@@ -69,8 +43,8 @@ function AIOrb({ isThinking }: { isThinking: boolean }) {
|
|
| 69 |
<motion.div
|
| 70 |
animate={{
|
| 71 |
boxShadow: isThinking
|
| 72 |
-
? ["0 0 20px rgba(16,185,129,0.6),
|
| 73 |
-
: ["0 0 20px rgba(16,185,129,0.3),
|
| 74 |
}}
|
| 75 |
transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
|
| 76 |
className="relative h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 flex items-center justify-center"
|
|
@@ -87,7 +61,15 @@ function AIOrb({ isThinking }: { isThinking: boolean }) {
|
|
| 87 |
}
|
| 88 |
|
| 89 |
// βββ Message Bubble βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
-
function MessageBubble({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const isUser = message.role === "user";
|
| 92 |
|
| 93 |
const renderContent = (text: string) =>
|
|
@@ -108,30 +90,37 @@ function MessageBubble({ message, onCopy }: { message: Message; onCopy: (text: s
|
|
| 108 |
transition={{ duration: 0.3, ease: "easeOut" }}
|
| 109 |
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
|
| 110 |
>
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
{isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
|
| 117 |
</div>
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
| 125 |
<div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
|
| 126 |
{message.streaming && <span className="streaming-cursor" />}
|
| 127 |
</div>
|
| 128 |
|
|
|
|
| 129 |
{!isUser && !message.streaming && (
|
| 130 |
<div className="flex items-center gap-1 px-1">
|
| 131 |
{[
|
| 132 |
-
{ icon: Copy,
|
| 133 |
-
{ icon: ThumbsUp,
|
| 134 |
-
{ icon: ThumbsDown,
|
| 135 |
{ icon: RotateCcw, label: "Retry", action: () => {} },
|
| 136 |
].map(({ icon: Icon, label, action }) => (
|
| 137 |
<button
|
|
@@ -153,121 +142,101 @@ function MessageBubble({ message, onCopy }: { message: Message; onCopy: (text: s
|
|
| 153 |
// βββ Main Chat Page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
export default function ChatPage() {
|
| 155 |
const { user } = useAuthStore();
|
| 156 |
-
const userInitial = user?.name?.charAt(0).toUpperCase()
|
|
|
|
| 157 |
const [messages, setMessages] = useState<Message[]>([
|
| 158 |
{
|
| 159 |
id: "welcome",
|
| 160 |
role: "assistant",
|
| 161 |
-
content:
|
|
|
|
| 162 |
timestamp: new Date(),
|
| 163 |
},
|
| 164 |
]);
|
| 165 |
const [input, setInput] = useState("");
|
| 166 |
const [isThinking, setIsThinking] = useState(false);
|
| 167 |
-
const [streamingId, setStreamingId] = useState<string | null>(null);
|
| 168 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 169 |
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 170 |
-
const
|
| 171 |
|
|
|
|
| 172 |
useEffect(() => {
|
| 173 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 174 |
}, [messages]);
|
| 175 |
|
| 176 |
-
// ββ
|
| 177 |
-
const
|
| 178 |
-
const
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
setMessages((prev) =>
|
| 181 |
-
prev.map((m) => m.id ===
|
| 182 |
);
|
| 183 |
-
}, []);
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
}, []);
|
| 195 |
-
|
| 196 |
-
const handleEnd = useCallback(() => {
|
| 197 |
-
const id = currentStreamId.current;
|
| 198 |
-
if (id) {
|
| 199 |
setMessages((prev) =>
|
| 200 |
-
prev.map((m) => m.id ===
|
| 201 |
);
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
}
|
| 205 |
}, []);
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
...prev,
|
| 213 |
-
{
|
| 214 |
-
id: `err-${Date.now()}`,
|
| 215 |
-
role: "assistant",
|
| 216 |
-
content: `β οΈ ${msg}`,
|
| 217 |
-
timestamp: new Date(),
|
| 218 |
-
},
|
| 219 |
-
]);
|
| 220 |
-
}, []);
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
onStart: handleStart,
|
| 226 |
-
onEnd: handleEnd,
|
| 227 |
-
onError: handleWsError,
|
| 228 |
-
});
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
const content = (text || input).trim();
|
| 233 |
-
if (!content || isThinking || streamingId) return;
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
-
if (!sent) {
|
| 244 |
-
// WebSocket not ready β fall back to HTTP
|
| 245 |
-
setIsThinking(true);
|
| 246 |
try {
|
| 247 |
-
const { aiApi } = await import("@/lib/api");
|
| 248 |
const res = await aiApi.chat(content, user?.user_id);
|
| 249 |
setIsThinking(false);
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
...prev,
|
| 253 |
-
{ id, role: "assistant", content: res.response, streaming: false, timestamp: new Date() },
|
| 254 |
-
]);
|
| 255 |
-
} catch {
|
| 256 |
setIsThinking(false);
|
| 257 |
-
|
| 258 |
-
...
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
-
}
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
}, [input, isThinking, streamingId, wsSend]);
|
| 271 |
|
| 272 |
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 273 |
if (e.key === "Enter" && !e.shiftKey) {
|
|
@@ -280,26 +249,33 @@ export default function ChatPage() {
|
|
| 280 |
navigator.clipboard.writeText(text).catch(() => {});
|
| 281 |
};
|
| 282 |
|
|
|
|
|
|
|
| 283 |
return (
|
| 284 |
<div className="flex h-[calc(100vh-4rem)] flex-col -m-8">
|
| 285 |
-
{/* Header */}
|
| 286 |
<div className="flex items-center justify-between border-b border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4 flex-shrink-0">
|
| 287 |
<div className="flex items-center gap-3">
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
<div className="h-4 w-px bg-white/10" />
|
| 290 |
<div>
|
| 291 |
<h1 className="text-base font-semibold text-white">BankBot AI Assistant</h1>
|
| 292 |
-
<p className="text-xs text-zinc-500">Context-aware Β·
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
| 296 |
<Shield className="h-3.5 w-3.5 text-emerald-400" />
|
| 297 |
-
<span>
|
| 298 |
</div>
|
| 299 |
</div>
|
| 300 |
|
| 301 |
-
{/* Messages */}
|
| 302 |
<div className="flex-1 overflow-y-auto px-8 py-6 space-y-5">
|
|
|
|
| 303 |
{messages.length === 1 && (
|
| 304 |
<motion.div
|
| 305 |
initial={{ opacity: 0, scale: 0.8 }}
|
|
@@ -313,26 +289,35 @@ export default function ChatPage() {
|
|
| 313 |
<p className="text-sm text-zinc-400 mt-1">Ask me anything about your finances</p>
|
| 314 |
</div>
|
| 315 |
<div className="grid grid-cols-2 gap-3 w-full max-w-md">
|
| 316 |
-
{suggestions.map((s) =>
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
| 328 |
</div>
|
| 329 |
</motion.div>
|
| 330 |
)}
|
| 331 |
|
| 332 |
{messages.map((msg) => (
|
| 333 |
-
<MessageBubble
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
))}
|
| 335 |
|
|
|
|
| 336 |
<AnimatePresence>
|
| 337 |
{isThinking && (
|
| 338 |
<motion.div
|
|
@@ -363,24 +348,27 @@ export default function ChatPage() {
|
|
| 363 |
<div ref={messagesEndRef} />
|
| 364 |
</div>
|
| 365 |
|
| 366 |
-
{/* Quick suggestions */}
|
| 367 |
{messages.length > 1 && (
|
| 368 |
<div className="flex gap-2 px-8 pb-2 overflow-x-auto flex-shrink-0">
|
| 369 |
-
{suggestions.map((s) =>
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
| 380 |
</div>
|
| 381 |
)}
|
| 382 |
|
| 383 |
-
{/* Input */}
|
| 384 |
<div className="flex-shrink-0 border-t border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4">
|
| 385 |
<div className="flex items-end gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 focus-within:border-emerald-500/40 transition-all">
|
| 386 |
<button className="text-zinc-500 hover:text-zinc-300 transition-colors mb-0.5">
|
|
@@ -393,7 +381,7 @@ export default function ChatPage() {
|
|
| 393 |
onKeyDown={handleKeyDown}
|
| 394 |
placeholder="Ask about your finances, forecasts, fraud alerts..."
|
| 395 |
rows={1}
|
| 396 |
-
disabled={
|
| 397 |
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none leading-relaxed max-h-32 disabled:opacity-50"
|
| 398 |
style={{ minHeight: "24px" }}
|
| 399 |
/>
|
|
@@ -405,7 +393,7 @@ export default function ChatPage() {
|
|
| 405 |
whileHover={{ scale: 1.05 }}
|
| 406 |
whileTap={{ scale: 0.95 }}
|
| 407 |
onClick={() => sendMessage()}
|
| 408 |
-
disabled={!input.trim() || isThinking ||
|
| 409 |
className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
| 410 |
>
|
| 411 |
<Send className="h-3.5 w-3.5" />
|
|
|
|
| 5 |
import {
|
| 6 |
Send, Sparkles, TrendingUp, Shield, PieChart,
|
| 7 |
Zap, Copy, ThumbsUp, ThumbsDown, RotateCcw, Mic, Paperclip,
|
|
|
|
| 8 |
} from "lucide-react";
|
| 9 |
+
import { aiApi } from "@/lib/api";
|
| 10 |
import { useAuthStore } from "@/lib/stores/authStore";
|
| 11 |
|
| 12 |
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 21 |
// βββ Suggested Prompts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
const suggestions = [
|
| 23 |
{ icon: TrendingUp, label: "Forecast my balance", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
|
| 24 |
+
{ icon: PieChart, label: "Analyze my spending", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
|
| 25 |
+
{ icon: Shield, label: "Check fraud risk", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
|
| 26 |
+
{ icon: Zap, label: "Optimize my budget", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
|
| 27 |
];
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
// βββ AI Orb βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
function AIOrb({ isThinking }: { isThinking: boolean }) {
|
| 31 |
return (
|
|
|
|
| 43 |
<motion.div
|
| 44 |
animate={{
|
| 45 |
boxShadow: isThinking
|
| 46 |
+
? ["0 0 20px rgba(16,185,129,0.6),0 0 60px rgba(59,130,246,0.4)", "0 0 40px rgba(16,185,129,0.8),0 0 80px rgba(59,130,246,0.5)", "0 0 20px rgba(16,185,129,0.6),0 0 60px rgba(59,130,246,0.4)"]
|
| 47 |
+
: ["0 0 20px rgba(16,185,129,0.3),0 0 40px rgba(59,130,246,0.2)", "0 0 30px rgba(16,185,129,0.5),0 0 60px rgba(59,130,246,0.3)", "0 0 20px rgba(16,185,129,0.3),0 0 40px rgba(59,130,246,0.2)"],
|
| 48 |
}}
|
| 49 |
transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
|
| 50 |
className="relative h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 flex items-center justify-center"
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
// βββ Message Bubble βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
function MessageBubble({
|
| 65 |
+
message,
|
| 66 |
+
onCopy,
|
| 67 |
+
userInitial,
|
| 68 |
+
}: {
|
| 69 |
+
message: Message;
|
| 70 |
+
onCopy: (text: string) => void;
|
| 71 |
+
userInitial: string;
|
| 72 |
+
}) {
|
| 73 |
const isUser = message.role === "user";
|
| 74 |
|
| 75 |
const renderContent = (text: string) =>
|
|
|
|
| 90 |
transition={{ duration: 0.3, ease: "easeOut" }}
|
| 91 |
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
|
| 92 |
>
|
| 93 |
+
{/* Avatar */}
|
| 94 |
+
<div
|
| 95 |
+
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
|
| 96 |
+
isUser
|
| 97 |
+
? "bg-gradient-to-br from-blue-500 to-purple-600 text-white"
|
| 98 |
+
: "bg-gradient-to-br from-emerald-400 to-cyan-500"
|
| 99 |
+
}`}
|
| 100 |
+
>
|
| 101 |
{isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
|
| 102 |
</div>
|
| 103 |
|
| 104 |
+
{/* Bubble */}
|
| 105 |
+
<div className={`max-w-[75%] flex flex-col gap-1 ${isUser ? "items-end" : "items-start"}`}>
|
| 106 |
+
<div
|
| 107 |
+
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
|
| 108 |
+
isUser
|
| 109 |
+
? "bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-tr-sm"
|
| 110 |
+
: "glass border border-white/10 text-zinc-100 rounded-tl-sm"
|
| 111 |
+
}`}
|
| 112 |
+
>
|
| 113 |
<div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
|
| 114 |
{message.streaming && <span className="streaming-cursor" />}
|
| 115 |
</div>
|
| 116 |
|
| 117 |
+
{/* Action buttons (AI messages only) */}
|
| 118 |
{!isUser && !message.streaming && (
|
| 119 |
<div className="flex items-center gap-1 px-1">
|
| 120 |
{[
|
| 121 |
+
{ icon: Copy, label: "Copy", action: () => onCopy(message.content) },
|
| 122 |
+
{ icon: ThumbsUp, label: "Good", action: () => {} },
|
| 123 |
+
{ icon: ThumbsDown,label: "Bad", action: () => {} },
|
| 124 |
{ icon: RotateCcw, label: "Retry", action: () => {} },
|
| 125 |
].map(({ icon: Icon, label, action }) => (
|
| 126 |
<button
|
|
|
|
| 142 |
// βββ Main Chat Page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
export default function ChatPage() {
|
| 144 |
const { user } = useAuthStore();
|
| 145 |
+
const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U";
|
| 146 |
+
|
| 147 |
const [messages, setMessages] = useState<Message[]>([
|
| 148 |
{
|
| 149 |
id: "welcome",
|
| 150 |
role: "assistant",
|
| 151 |
+
content:
|
| 152 |
+
"Hello! I'm your AI financial assistant with full context of your accounts, spending patterns, and goals.\n\nWhat would you like to explore today?",
|
| 153 |
timestamp: new Date(),
|
| 154 |
},
|
| 155 |
]);
|
| 156 |
const [input, setInput] = useState("");
|
| 157 |
const [isThinking, setIsThinking] = useState(false);
|
|
|
|
| 158 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 159 |
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 160 |
+
const abortRef = useRef<AbortController | null>(null);
|
| 161 |
|
| 162 |
+
// Auto-scroll on new messages
|
| 163 |
useEffect(() => {
|
| 164 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 165 |
}, [messages]);
|
| 166 |
|
| 167 |
+
// ββ Simulated word-by-word streaming from HTTP response βββββββββββββββββββ
|
| 168 |
+
const streamWords = useCallback((msgId: string, fullText: string) => {
|
| 169 |
+
const words = fullText.split(" ");
|
| 170 |
+
let i = 0;
|
| 171 |
+
|
| 172 |
+
// Mark as streaming
|
| 173 |
setMessages((prev) =>
|
| 174 |
+
prev.map((m) => (m.id === msgId ? { ...m, streaming: true } : m))
|
| 175 |
);
|
|
|
|
| 176 |
|
| 177 |
+
const interval = setInterval(() => {
|
| 178 |
+
if (i >= words.length) {
|
| 179 |
+
clearInterval(interval);
|
| 180 |
+
setMessages((prev) =>
|
| 181 |
+
prev.map((m) => (m.id === msgId ? { ...m, streaming: false } : m))
|
| 182 |
+
);
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
const chunk = words.slice(0, i + 1).join(" ");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
setMessages((prev) =>
|
| 187 |
+
prev.map((m) => (m.id === msgId ? { ...m, content: chunk } : m))
|
| 188 |
);
|
| 189 |
+
i++;
|
| 190 |
+
}, 28); // ~35 words/sec β feels natural
|
|
|
|
| 191 |
}, []);
|
| 192 |
|
| 193 |
+
// ββ Send message ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
const sendMessage = useCallback(
|
| 195 |
+
async (text?: string) => {
|
| 196 |
+
const content = (text ?? input).trim();
|
| 197 |
+
if (!content || isThinking) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
// Cancel any in-flight request
|
| 200 |
+
abortRef.current?.abort();
|
| 201 |
+
abortRef.current = new AbortController();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
+
setInput("");
|
| 204 |
+
setIsThinking(true);
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
// Add user message
|
| 207 |
+
setMessages((prev) => [
|
| 208 |
+
...prev,
|
| 209 |
+
{ id: `user-${Date.now()}`, role: "user", content, timestamp: new Date() },
|
| 210 |
+
]);
|
| 211 |
|
| 212 |
+
// Placeholder AI message
|
| 213 |
+
const aiId = `ai-${Date.now()}`;
|
| 214 |
+
setMessages((prev) => [
|
| 215 |
+
...prev,
|
| 216 |
+
{ id: aiId, role: "assistant", content: "", streaming: true, timestamp: new Date() },
|
| 217 |
+
]);
|
| 218 |
|
|
|
|
|
|
|
|
|
|
| 219 |
try {
|
|
|
|
| 220 |
const res = await aiApi.chat(content, user?.user_id);
|
| 221 |
setIsThinking(false);
|
| 222 |
+
streamWords(aiId, res.response);
|
| 223 |
+
} catch (err) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
setIsThinking(false);
|
| 225 |
+
const errMsg =
|
| 226 |
+
(err as Error).message === "Session expired. Please log in again."
|
| 227 |
+
? "Session expired. Please refresh and log in again."
|
| 228 |
+
: "β οΈ Could not reach the AI backend. Please try again.";
|
| 229 |
+
setMessages((prev) =>
|
| 230 |
+
prev.map((m) =>
|
| 231 |
+
m.id === aiId
|
| 232 |
+
? { ...m, content: errMsg, streaming: false }
|
| 233 |
+
: m
|
| 234 |
+
)
|
| 235 |
+
);
|
| 236 |
}
|
| 237 |
+
},
|
| 238 |
+
[input, isThinking, user?.user_id, streamWords]
|
| 239 |
+
);
|
|
|
|
| 240 |
|
| 241 |
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 242 |
if (e.key === "Enter" && !e.shiftKey) {
|
|
|
|
| 249 |
navigator.clipboard.writeText(text).catch(() => {});
|
| 250 |
};
|
| 251 |
|
| 252 |
+
const isStreaming = messages.some((m) => m.streaming);
|
| 253 |
+
|
| 254 |
return (
|
| 255 |
<div className="flex h-[calc(100vh-4rem)] flex-col -m-8">
|
| 256 |
+
{/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββ */}
|
| 257 |
<div className="flex items-center justify-between border-b border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4 flex-shrink-0">
|
| 258 |
<div className="flex items-center gap-3">
|
| 259 |
+
{/* Live indicator */}
|
| 260 |
+
<div className="flex items-center gap-2">
|
| 261 |
+
<div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
| 262 |
+
<span className="text-xs text-emerald-400">Live AI</span>
|
| 263 |
+
</div>
|
| 264 |
<div className="h-4 w-px bg-white/10" />
|
| 265 |
<div>
|
| 266 |
<h1 className="text-base font-semibold text-white">BankBot AI Assistant</h1>
|
| 267 |
+
<p className="text-xs text-zinc-500">Context-aware Β· Groq-powered Β· Personalized</p>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
| 271 |
<Shield className="h-3.5 w-3.5 text-emerald-400" />
|
| 272 |
+
<span>Encrypted</span>
|
| 273 |
</div>
|
| 274 |
</div>
|
| 275 |
|
| 276 |
+
{/* ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 277 |
<div className="flex-1 overflow-y-auto px-8 py-6 space-y-5">
|
| 278 |
+
{/* Welcome orb (only when just the welcome message exists) */}
|
| 279 |
{messages.length === 1 && (
|
| 280 |
<motion.div
|
| 281 |
initial={{ opacity: 0, scale: 0.8 }}
|
|
|
|
| 289 |
<p className="text-sm text-zinc-400 mt-1">Ask me anything about your finances</p>
|
| 290 |
</div>
|
| 291 |
<div className="grid grid-cols-2 gap-3 w-full max-w-md">
|
| 292 |
+
{suggestions.map((s) => {
|
| 293 |
+
const Icon = s.icon;
|
| 294 |
+
return (
|
| 295 |
+
<motion.button
|
| 296 |
+
key={s.label}
|
| 297 |
+
whileHover={{ scale: 1.03 }}
|
| 298 |
+
whileTap={{ scale: 0.97 }}
|
| 299 |
+
onClick={() => sendMessage(s.label)}
|
| 300 |
+
className={`flex items-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium transition-all ${s.bg} ${s.color} hover:brightness-110`}
|
| 301 |
+
>
|
| 302 |
+
<Icon className="h-4 w-4" />
|
| 303 |
+
{s.label}
|
| 304 |
+
</motion.button>
|
| 305 |
+
);
|
| 306 |
+
})}
|
| 307 |
</div>
|
| 308 |
</motion.div>
|
| 309 |
)}
|
| 310 |
|
| 311 |
{messages.map((msg) => (
|
| 312 |
+
<MessageBubble
|
| 313 |
+
key={msg.id}
|
| 314 |
+
message={msg}
|
| 315 |
+
onCopy={copyToClipboard}
|
| 316 |
+
userInitial={userInitial}
|
| 317 |
+
/>
|
| 318 |
))}
|
| 319 |
|
| 320 |
+
{/* Thinking dots */}
|
| 321 |
<AnimatePresence>
|
| 322 |
{isThinking && (
|
| 323 |
<motion.div
|
|
|
|
| 348 |
<div ref={messagesEndRef} />
|
| 349 |
</div>
|
| 350 |
|
| 351 |
+
{/* ββ Quick suggestions (after first message) ββββββββββββββββββββββββββ */}
|
| 352 |
{messages.length > 1 && (
|
| 353 |
<div className="flex gap-2 px-8 pb-2 overflow-x-auto flex-shrink-0">
|
| 354 |
+
{suggestions.map((s) => {
|
| 355 |
+
const Icon = s.icon;
|
| 356 |
+
return (
|
| 357 |
+
<button
|
| 358 |
+
key={s.label}
|
| 359 |
+
onClick={() => sendMessage(s.label)}
|
| 360 |
+
disabled={isStreaming || isThinking}
|
| 361 |
+
className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all disabled:opacity-40 ${s.bg} ${s.color} hover:brightness-110`}
|
| 362 |
+
>
|
| 363 |
+
<Icon className="h-3 w-3" />
|
| 364 |
+
{s.label}
|
| 365 |
+
</button>
|
| 366 |
+
);
|
| 367 |
+
})}
|
| 368 |
</div>
|
| 369 |
)}
|
| 370 |
|
| 371 |
+
{/* ββ Input ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 372 |
<div className="flex-shrink-0 border-t border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4">
|
| 373 |
<div className="flex items-end gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 focus-within:border-emerald-500/40 transition-all">
|
| 374 |
<button className="text-zinc-500 hover:text-zinc-300 transition-colors mb-0.5">
|
|
|
|
| 381 |
onKeyDown={handleKeyDown}
|
| 382 |
placeholder="Ask about your finances, forecasts, fraud alerts..."
|
| 383 |
rows={1}
|
| 384 |
+
disabled={isStreaming || isThinking}
|
| 385 |
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none leading-relaxed max-h-32 disabled:opacity-50"
|
| 386 |
style={{ minHeight: "24px" }}
|
| 387 |
/>
|
|
|
|
| 393 |
whileHover={{ scale: 1.05 }}
|
| 394 |
whileTap={{ scale: 0.95 }}
|
| 395 |
onClick={() => sendMessage()}
|
| 396 |
+
disabled={!input.trim() || isThinking || isStreaming}
|
| 397 |
className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
| 398 |
>
|
| 399 |
<Send className="h-3.5 w-3.5" />
|
frontend/src/app/payments/page.tsx
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import {
|
| 6 |
+
CreditCard, Send, ArrowLeftRight, RefreshCw, AlertTriangle,
|
| 7 |
+
CheckCircle2, XCircle, Clock, ShieldAlert, Search, Filter,
|
| 8 |
+
TrendingUp, TrendingDown, Zap, ChevronLeft, ChevronRight,
|
| 9 |
+
X, Loader2, Sparkles, DollarSign, Receipt, Ban,
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
import { paymentsApi, Payment } from "@/lib/api";
|
| 12 |
+
|
| 13 |
+
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
type Tab = "send" | "transfer" | "history";
|
| 15 |
+
type PaymentType = "transfer" | "bill_payment" | "subscription" | "outgoing";
|
| 16 |
+
|
| 17 |
+
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
function formatDate(ts: string) {
|
| 19 |
+
if (!ts) return "";
|
| 20 |
+
const d = new Date(ts);
|
| 21 |
+
const now = new Date();
|
| 22 |
+
const diff = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
| 23 |
+
if (diff === 0) return "Today " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
| 24 |
+
if (diff === 1) return "Yesterday";
|
| 25 |
+
if (diff < 7) return `${diff} days ago`;
|
| 26 |
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function StatusBadge({ status, fraudFlag }: { status: string; fraudFlag: boolean }) {
|
| 30 |
+
if (fraudFlag || status === "flagged") {
|
| 31 |
+
return (
|
| 32 |
+
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/15 border border-red-500/30 px-2.5 py-0.5 text-xs font-medium text-red-400">
|
| 33 |
+
<ShieldAlert className="h-3 w-3" /> Flagged
|
| 34 |
+
</span>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
if (status === "completed") {
|
| 38 |
+
return (
|
| 39 |
+
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
|
| 40 |
+
<CheckCircle2 className="h-3 w-3" /> Completed
|
| 41 |
+
</span>
|
| 42 |
+
);
|
| 43 |
+
}
|
| 44 |
+
if (status === "failed") {
|
| 45 |
+
return (
|
| 46 |
+
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 border border-zinc-500/30 px-2.5 py-0.5 text-xs font-medium text-zinc-400">
|
| 47 |
+
<XCircle className="h-3 w-3" /> Failed
|
| 48 |
+
</span>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
return (
|
| 52 |
+
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-xs font-medium text-amber-400">
|
| 53 |
+
<Clock className="h-3 w-3" /> Pending
|
| 54 |
+
</span>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function RiskBar({ score }: { score: number }) {
|
| 59 |
+
const color = score >= 70 ? "#ef4444" : score >= 40 ? "#f59e0b" : "#10b981";
|
| 60 |
+
return (
|
| 61 |
+
<div className="flex items-center gap-2">
|
| 62 |
+
<div className="h-1.5 w-20 rounded-full bg-white/10 overflow-hidden">
|
| 63 |
+
<div
|
| 64 |
+
className="h-full rounded-full transition-all duration-500"
|
| 65 |
+
style={{ width: `${score}%`, background: color }}
|
| 66 |
+
/>
|
| 67 |
+
</div>
|
| 68 |
+
<span className="text-xs font-medium" style={{ color }}>{score.toFixed(0)}</span>
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function Skeleton({ className }: { className?: string }) {
|
| 74 |
+
return <div className={`shimmer rounded-lg ${className}`} />;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const containerVariants = {
|
| 78 |
+
hidden: { opacity: 0 },
|
| 79 |
+
visible: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
| 80 |
+
};
|
| 81 |
+
const itemVariants = {
|
| 82 |
+
hidden: { opacity: 0, y: 14 },
|
| 83 |
+
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
// βββ Send Money Form ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
function SendMoneyForm({ onSuccess }: { onSuccess: () => void }) {
|
| 88 |
+
const [form, setForm] = useState({
|
| 89 |
+
recipient_name: "",
|
| 90 |
+
recipient_account: "",
|
| 91 |
+
amount: "",
|
| 92 |
+
payment_type: "outgoing" as PaymentType,
|
| 93 |
+
note: "",
|
| 94 |
+
currency: "USD",
|
| 95 |
+
});
|
| 96 |
+
const [loading, setLoading] = useState(false);
|
| 97 |
+
const [result, setResult] = useState<Payment | null>(null);
|
| 98 |
+
const [error, setError] = useState<string | null>(null);
|
| 99 |
+
|
| 100 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
setLoading(true);
|
| 103 |
+
setError(null);
|
| 104 |
+
setResult(null);
|
| 105 |
+
try {
|
| 106 |
+
const payment = await paymentsApi.create({
|
| 107 |
+
...form,
|
| 108 |
+
amount: parseFloat(form.amount),
|
| 109 |
+
});
|
| 110 |
+
setResult(payment);
|
| 111 |
+
onSuccess();
|
| 112 |
+
setForm({ recipient_name: "", recipient_account: "", amount: "", payment_type: "outgoing", note: "", currency: "USD" });
|
| 113 |
+
} catch (err) {
|
| 114 |
+
setError((err as Error).message);
|
| 115 |
+
} finally {
|
| 116 |
+
setLoading(false);
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div className="glass-card p-6">
|
| 122 |
+
<div className="flex items-center gap-3 mb-6">
|
| 123 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/15 border border-emerald-500/20">
|
| 124 |
+
<Send className="h-5 w-5 text-emerald-400" />
|
| 125 |
+
</div>
|
| 126 |
+
<div>
|
| 127 |
+
<h2 className="text-base font-semibold text-white">Send Money</h2>
|
| 128 |
+
<p className="text-xs text-zinc-500">Fraud-scored in real time</p>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 133 |
+
<div className="grid grid-cols-2 gap-4">
|
| 134 |
+
<div>
|
| 135 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Recipient Name</label>
|
| 136 |
+
<input
|
| 137 |
+
required
|
| 138 |
+
value={form.recipient_name}
|
| 139 |
+
onChange={(e) => setForm({ ...form, recipient_name: e.target.value })}
|
| 140 |
+
placeholder="John Smith"
|
| 141 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
<div>
|
| 145 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Account / Reference</label>
|
| 146 |
+
<input
|
| 147 |
+
required
|
| 148 |
+
value={form.recipient_account}
|
| 149 |
+
onChange={(e) => setForm({ ...form, recipient_account: e.target.value })}
|
| 150 |
+
placeholder="IBAN / Account No."
|
| 151 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 152 |
+
/>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div className="grid grid-cols-3 gap-4">
|
| 157 |
+
<div>
|
| 158 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Amount</label>
|
| 159 |
+
<div className="relative">
|
| 160 |
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
|
| 161 |
+
<input
|
| 162 |
+
required
|
| 163 |
+
type="number"
|
| 164 |
+
min="0.01"
|
| 165 |
+
step="0.01"
|
| 166 |
+
value={form.amount}
|
| 167 |
+
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
| 168 |
+
placeholder="0.00"
|
| 169 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 pl-7 pr-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 170 |
+
/>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
<div>
|
| 174 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Currency</label>
|
| 175 |
+
<select
|
| 176 |
+
value={form.currency}
|
| 177 |
+
onChange={(e) => setForm({ ...form, currency: e.target.value })}
|
| 178 |
+
className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 179 |
+
>
|
| 180 |
+
{["USD", "EUR", "GBP", "CAD", "AUD"].map((c) => (
|
| 181 |
+
<option key={c} value={c}>{c}</option>
|
| 182 |
+
))}
|
| 183 |
+
</select>
|
| 184 |
+
</div>
|
| 185 |
+
<div>
|
| 186 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Type</label>
|
| 187 |
+
<select
|
| 188 |
+
value={form.payment_type}
|
| 189 |
+
onChange={(e) => setForm({ ...form, payment_type: e.target.value as PaymentType })}
|
| 190 |
+
className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 191 |
+
>
|
| 192 |
+
<option value="outgoing">Payment</option>
|
| 193 |
+
<option value="bill_payment">Bill Payment</option>
|
| 194 |
+
<option value="subscription">Subscription</option>
|
| 195 |
+
<option value="transfer">Transfer</option>
|
| 196 |
+
</select>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div>
|
| 201 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Note (optional)</label>
|
| 202 |
+
<input
|
| 203 |
+
value={form.note}
|
| 204 |
+
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
| 205 |
+
placeholder="Rent, invoice #123, etc."
|
| 206 |
+
maxLength={255}
|
| 207 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 208 |
+
/>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{error && (
|
| 212 |
+
<div className="flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3">
|
| 213 |
+
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
| 214 |
+
<p className="text-xs text-red-400">{error}</p>
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
|
| 218 |
+
<button
|
| 219 |
+
type="submit"
|
| 220 |
+
disabled={loading}
|
| 221 |
+
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-3 text-sm font-semibold text-black transition-all"
|
| 222 |
+
>
|
| 223 |
+
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
| 224 |
+
{loading ? "Processingβ¦" : "Send Payment"}
|
| 225 |
+
</button>
|
| 226 |
+
</form>
|
| 227 |
+
|
| 228 |
+
{/* Result card */}
|
| 229 |
+
<AnimatePresence>
|
| 230 |
+
{result && (
|
| 231 |
+
<motion.div
|
| 232 |
+
initial={{ opacity: 0, y: 10 }}
|
| 233 |
+
animate={{ opacity: 1, y: 0 }}
|
| 234 |
+
exit={{ opacity: 0, y: -10 }}
|
| 235 |
+
className={`mt-4 rounded-xl border p-4 ${
|
| 236 |
+
result.fraud_flag
|
| 237 |
+
? "border-red-500/30 bg-red-500/5"
|
| 238 |
+
: "border-emerald-500/30 bg-emerald-500/5"
|
| 239 |
+
}`}
|
| 240 |
+
>
|
| 241 |
+
<div className="flex items-start justify-between gap-3">
|
| 242 |
+
<div className="flex items-center gap-2">
|
| 243 |
+
{result.fraud_flag
|
| 244 |
+
? <ShieldAlert className="h-5 w-5 text-red-400 flex-shrink-0" />
|
| 245 |
+
: <CheckCircle2 className="h-5 w-5 text-emerald-400 flex-shrink-0" />
|
| 246 |
+
}
|
| 247 |
+
<div>
|
| 248 |
+
<p className={`text-sm font-semibold ${result.fraud_flag ? "text-red-400" : "text-emerald-400"}`}>
|
| 249 |
+
{result.fraud_flag ? "Payment Flagged β Review Required" : "Payment Sent Successfully"}
|
| 250 |
+
</p>
|
| 251 |
+
<p className="text-xs text-zinc-500 mt-0.5">Ref: {result.transaction_reference}</p>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
<button onClick={() => setResult(null)} className="text-zinc-600 hover:text-zinc-400">
|
| 255 |
+
<X className="h-4 w-4" />
|
| 256 |
+
</button>
|
| 257 |
+
</div>
|
| 258 |
+
{result.ai_insight && (
|
| 259 |
+
<div className="mt-3 flex items-start gap-2 rounded-lg bg-white/5 px-3 py-2">
|
| 260 |
+
<Sparkles className="h-3.5 w-3.5 text-cyan-400 flex-shrink-0 mt-0.5" />
|
| 261 |
+
<p className="text-xs text-zinc-300">{result.ai_insight}</p>
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
<div className="mt-2 flex items-center gap-3">
|
| 265 |
+
<RiskBar score={result.risk_score} />
|
| 266 |
+
<span className="text-xs text-zinc-500">risk score</span>
|
| 267 |
+
</div>
|
| 268 |
+
</motion.div>
|
| 269 |
+
)}
|
| 270 |
+
</AnimatePresence>
|
| 271 |
+
</div>
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// βββ Transfer Form ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 276 |
+
function TransferForm({ onSuccess }: { onSuccess: () => void }) {
|
| 277 |
+
const [form, setForm] = useState({ amount: "", to_account_type: "savings", note: "" });
|
| 278 |
+
const [loading, setLoading] = useState(false);
|
| 279 |
+
const [result, setResult] = useState<Payment | null>(null);
|
| 280 |
+
const [error, setError] = useState<string | null>(null);
|
| 281 |
+
|
| 282 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 283 |
+
e.preventDefault();
|
| 284 |
+
setLoading(true);
|
| 285 |
+
setError(null);
|
| 286 |
+
setResult(null);
|
| 287 |
+
try {
|
| 288 |
+
const payment = await paymentsApi.transfer({
|
| 289 |
+
amount: parseFloat(form.amount),
|
| 290 |
+
to_account_type: form.to_account_type,
|
| 291 |
+
note: form.note || undefined,
|
| 292 |
+
});
|
| 293 |
+
setResult(payment);
|
| 294 |
+
onSuccess();
|
| 295 |
+
setForm({ amount: "", to_account_type: "savings", note: "" });
|
| 296 |
+
} catch (err) {
|
| 297 |
+
setError((err as Error).message);
|
| 298 |
+
} finally {
|
| 299 |
+
setLoading(false);
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
return (
|
| 304 |
+
<div className="glass-card p-6">
|
| 305 |
+
<div className="flex items-center gap-3 mb-6">
|
| 306 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/15 border border-blue-500/20">
|
| 307 |
+
<ArrowLeftRight className="h-5 w-5 text-blue-400" />
|
| 308 |
+
</div>
|
| 309 |
+
<div>
|
| 310 |
+
<h2 className="text-base font-semibold text-white">Internal Transfer</h2>
|
| 311 |
+
<p className="text-xs text-zinc-500">Move funds between your accounts</p>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 316 |
+
<div className="rounded-xl border border-white/8 bg-white/3 p-4">
|
| 317 |
+
<div className="flex items-center justify-between text-xs text-zinc-500 mb-3">
|
| 318 |
+
<span>From</span>
|
| 319 |
+
<ArrowLeftRight className="h-3.5 w-3.5" />
|
| 320 |
+
<span>To</span>
|
| 321 |
+
</div>
|
| 322 |
+
<div className="flex items-center justify-between gap-4">
|
| 323 |
+
<div className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-zinc-300">
|
| 324 |
+
Checking Account
|
| 325 |
+
</div>
|
| 326 |
+
<select
|
| 327 |
+
value={form.to_account_type}
|
| 328 |
+
onChange={(e) => setForm({ ...form, to_account_type: e.target.value })}
|
| 329 |
+
className="flex-1 rounded-lg border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500/40 transition-all"
|
| 330 |
+
>
|
| 331 |
+
<option value="savings">Savings Account</option>
|
| 332 |
+
<option value="investment">Investment Account</option>
|
| 333 |
+
</select>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<div>
|
| 338 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Amount</label>
|
| 339 |
+
<div className="relative">
|
| 340 |
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
|
| 341 |
+
<input
|
| 342 |
+
required
|
| 343 |
+
type="number"
|
| 344 |
+
min="0.01"
|
| 345 |
+
step="0.01"
|
| 346 |
+
value={form.amount}
|
| 347 |
+
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
| 348 |
+
placeholder="0.00"
|
| 349 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 pl-7 pr-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/40 transition-all"
|
| 350 |
+
/>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<div>
|
| 355 |
+
<label className="block text-xs font-medium text-zinc-400 mb-1.5">Note (optional)</label>
|
| 356 |
+
<input
|
| 357 |
+
value={form.note}
|
| 358 |
+
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
| 359 |
+
placeholder="Emergency fund, vacation savingsβ¦"
|
| 360 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/40 transition-all"
|
| 361 |
+
/>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
{error && (
|
| 365 |
+
<div className="flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3">
|
| 366 |
+
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
| 367 |
+
<p className="text-xs text-red-400">{error}</p>
|
| 368 |
+
</div>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
<button
|
| 372 |
+
type="submit"
|
| 373 |
+
disabled={loading}
|
| 374 |
+
className="w-full flex items-center justify-center gap-2 rounded-xl bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-3 text-sm font-semibold text-white transition-all"
|
| 375 |
+
>
|
| 376 |
+
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowLeftRight className="h-4 w-4" />}
|
| 377 |
+
{loading ? "Transferringβ¦" : "Transfer Funds"}
|
| 378 |
+
</button>
|
| 379 |
+
</form>
|
| 380 |
+
|
| 381 |
+
<AnimatePresence>
|
| 382 |
+
{result && (
|
| 383 |
+
<motion.div
|
| 384 |
+
initial={{ opacity: 0, y: 10 }}
|
| 385 |
+
animate={{ opacity: 1, y: 0 }}
|
| 386 |
+
exit={{ opacity: 0, y: -10 }}
|
| 387 |
+
className="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/5 p-4"
|
| 388 |
+
>
|
| 389 |
+
<div className="flex items-center justify-between">
|
| 390 |
+
<div className="flex items-center gap-2">
|
| 391 |
+
<CheckCircle2 className="h-5 w-5 text-blue-400" />
|
| 392 |
+
<div>
|
| 393 |
+
<p className="text-sm font-semibold text-blue-400">Transfer Complete</p>
|
| 394 |
+
<p className="text-xs text-zinc-500">Ref: {result.transaction_reference}</p>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
<button onClick={() => setResult(null)} className="text-zinc-600 hover:text-zinc-400">
|
| 398 |
+
<X className="h-4 w-4" />
|
| 399 |
+
</button>
|
| 400 |
+
</div>
|
| 401 |
+
{result.ai_insight && (
|
| 402 |
+
<div className="mt-3 flex items-start gap-2 rounded-lg bg-white/5 px-3 py-2">
|
| 403 |
+
<Sparkles className="h-3.5 w-3.5 text-cyan-400 flex-shrink-0 mt-0.5" />
|
| 404 |
+
<p className="text-xs text-zinc-300">{result.ai_insight}</p>
|
| 405 |
+
</div>
|
| 406 |
+
)}
|
| 407 |
+
</motion.div>
|
| 408 |
+
)}
|
| 409 |
+
</AnimatePresence>
|
| 410 |
+
</div>
|
| 411 |
+
);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
// βββ Payment History ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 415 |
+
function PaymentHistory({ refreshKey }: { refreshKey: number }) {
|
| 416 |
+
const [payments, setPayments] = useState<Payment[]>([]);
|
| 417 |
+
const [stats, setStats] = useState({ total_sent: 0, total_received: 0, flagged_count: 0 });
|
| 418 |
+
const [total, setTotal] = useState(0);
|
| 419 |
+
const [page, setPage] = useState(1);
|
| 420 |
+
const [pages, setPages] = useState(1);
|
| 421 |
+
const [loading, setLoading] = useState(true);
|
| 422 |
+
const [error, setError] = useState<string | null>(null);
|
| 423 |
+
const [search, setSearch] = useState("");
|
| 424 |
+
const [typeFilter, setTypeFilter] = useState("");
|
| 425 |
+
const [statusFilter, setStatusFilter] = useState("");
|
| 426 |
+
const [selected, setSelected] = useState<Payment | null>(null);
|
| 427 |
+
const [actionLoading, setActionLoading] = useState(false);
|
| 428 |
+
|
| 429 |
+
const load = useCallback(async (p = 1) => {
|
| 430 |
+
setLoading(true);
|
| 431 |
+
setError(null);
|
| 432 |
+
try {
|
| 433 |
+
const res = await paymentsApi.history({
|
| 434 |
+
page: p,
|
| 435 |
+
limit: 15,
|
| 436 |
+
payment_type: typeFilter || undefined,
|
| 437 |
+
status: statusFilter || undefined,
|
| 438 |
+
});
|
| 439 |
+
setPayments(res.payments);
|
| 440 |
+
setStats(res.stats);
|
| 441 |
+
setTotal(res.total);
|
| 442 |
+
setPage(res.page);
|
| 443 |
+
setPages(res.pages);
|
| 444 |
+
} catch (err) {
|
| 445 |
+
setError((err as Error).message);
|
| 446 |
+
} finally {
|
| 447 |
+
setLoading(false);
|
| 448 |
+
}
|
| 449 |
+
}, [typeFilter, statusFilter]);
|
| 450 |
+
|
| 451 |
+
useEffect(() => { load(1); }, [load, refreshKey]);
|
| 452 |
+
|
| 453 |
+
const filtered = search
|
| 454 |
+
? payments.filter(
|
| 455 |
+
(p) =>
|
| 456 |
+
p.recipient_name.toLowerCase().includes(search.toLowerCase()) ||
|
| 457 |
+
(p.transaction_reference || "").toLowerCase().includes(search.toLowerCase()) ||
|
| 458 |
+
p.payment_type.toLowerCase().includes(search.toLowerCase())
|
| 459 |
+
)
|
| 460 |
+
: payments;
|
| 461 |
+
|
| 462 |
+
const handleVerify = async (confirm: boolean) => {
|
| 463 |
+
if (!selected) return;
|
| 464 |
+
setActionLoading(true);
|
| 465 |
+
try {
|
| 466 |
+
await paymentsApi.verify(selected.id, confirm);
|
| 467 |
+
setSelected(null);
|
| 468 |
+
load(page);
|
| 469 |
+
} catch (err) {
|
| 470 |
+
alert((err as Error).message);
|
| 471 |
+
} finally {
|
| 472 |
+
setActionLoading(false);
|
| 473 |
+
}
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
const handleCancel = async (id: string) => {
|
| 477 |
+
if (!confirm("Cancel this payment?")) return;
|
| 478 |
+
try {
|
| 479 |
+
await paymentsApi.cancel(id);
|
| 480 |
+
load(page);
|
| 481 |
+
} catch (err) {
|
| 482 |
+
alert((err as Error).message);
|
| 483 |
+
}
|
| 484 |
+
};
|
| 485 |
+
|
| 486 |
+
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
| 487 |
+
transfer: <ArrowLeftRight className="h-4 w-4 text-blue-400" />,
|
| 488 |
+
bill_payment: <Receipt className="h-4 w-4 text-amber-400" />,
|
| 489 |
+
subscription: <Zap className="h-4 w-4 text-purple-400" />,
|
| 490 |
+
outgoing: <Send className="h-4 w-4 text-emerald-400" />,
|
| 491 |
+
incoming: <TrendingUp className="h-4 w-4 text-cyan-400" />,
|
| 492 |
+
};
|
| 493 |
+
|
| 494 |
+
return (
|
| 495 |
+
<div className="space-y-4">
|
| 496 |
+
{/* Stats row */}
|
| 497 |
+
<div className="grid grid-cols-3 gap-4">
|
| 498 |
+
<div className="glass-card p-4">
|
| 499 |
+
<div className="flex items-center gap-2 mb-2">
|
| 500 |
+
<TrendingDown className="h-4 w-4 text-zinc-400" />
|
| 501 |
+
<p className="text-xs text-zinc-400">Total Sent</p>
|
| 502 |
+
</div>
|
| 503 |
+
<p className="text-xl font-bold text-white">${stats.total_sent.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p>
|
| 504 |
+
</div>
|
| 505 |
+
<div className="glass-card p-4">
|
| 506 |
+
<div className="flex items-center gap-2 mb-2">
|
| 507 |
+
<TrendingUp className="h-4 w-4 text-emerald-400" />
|
| 508 |
+
<p className="text-xs text-zinc-400">Total Received</p>
|
| 509 |
+
</div>
|
| 510 |
+
<p className="text-xl font-bold text-emerald-400">${stats.total_received.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p>
|
| 511 |
+
</div>
|
| 512 |
+
<div className="glass-card p-4">
|
| 513 |
+
<div className="flex items-center gap-2 mb-2">
|
| 514 |
+
<ShieldAlert className="h-4 w-4 text-red-400" />
|
| 515 |
+
<p className="text-xs text-zinc-400">Flagged</p>
|
| 516 |
+
</div>
|
| 517 |
+
<p className="text-xl font-bold text-red-400">{stats.flagged_count}</p>
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
{/* Filters */}
|
| 522 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 523 |
+
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
| 524 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
| 525 |
+
<input
|
| 526 |
+
value={search}
|
| 527 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 528 |
+
placeholder="Search recipient, referenceβ¦"
|
| 529 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 py-2 pl-9 pr-3 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-emerald-500/40 transition-all"
|
| 530 |
+
/>
|
| 531 |
+
</div>
|
| 532 |
+
<select
|
| 533 |
+
value={typeFilter}
|
| 534 |
+
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
| 535 |
+
className="rounded-xl border border-white/10 bg-zinc-900 px-3 py-2 text-xs text-zinc-300 focus:outline-none"
|
| 536 |
+
>
|
| 537 |
+
<option value="">All Types</option>
|
| 538 |
+
<option value="outgoing">Payment</option>
|
| 539 |
+
<option value="transfer">Transfer</option>
|
| 540 |
+
<option value="bill_payment">Bill Payment</option>
|
| 541 |
+
<option value="subscription">Subscription</option>
|
| 542 |
+
<option value="incoming">Incoming</option>
|
| 543 |
+
</select>
|
| 544 |
+
<select
|
| 545 |
+
value={statusFilter}
|
| 546 |
+
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
| 547 |
+
className="rounded-xl border border-white/10 bg-zinc-900 px-3 py-2 text-xs text-zinc-300 focus:outline-none"
|
| 548 |
+
>
|
| 549 |
+
<option value="">All Status</option>
|
| 550 |
+
<option value="completed">Completed</option>
|
| 551 |
+
<option value="flagged">Flagged</option>
|
| 552 |
+
<option value="pending">Pending</option>
|
| 553 |
+
<option value="failed">Failed</option>
|
| 554 |
+
</select>
|
| 555 |
+
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
| 556 |
+
<Filter className="h-3.5 w-3.5" />
|
| 557 |
+
<span>{total} total</span>
|
| 558 |
+
</div>
|
| 559 |
+
</div>
|
| 560 |
+
|
| 561 |
+
{error && (
|
| 562 |
+
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/5 px-5 py-3">
|
| 563 |
+
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
|
| 564 |
+
<p className="text-xs text-zinc-400">
|
| 565 |
+
<span className="text-amber-400 font-medium">Could not load payments</span> β {error}
|
| 566 |
+
</p>
|
| 567 |
+
</div>
|
| 568 |
+
)}
|
| 569 |
+
|
| 570 |
+
{/* Table */}
|
| 571 |
+
<div className="glass-card overflow-hidden">
|
| 572 |
+
<div className="grid grid-cols-12 gap-3 border-b border-white/8 px-5 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">
|
| 573 |
+
<div className="col-span-4">Recipient</div>
|
| 574 |
+
<div className="col-span-2">Type</div>
|
| 575 |
+
<div className="col-span-2">Status</div>
|
| 576 |
+
<div className="col-span-2">Risk</div>
|
| 577 |
+
<div className="col-span-1 text-right">Amount</div>
|
| 578 |
+
<div className="col-span-1 text-right">Actions</div>
|
| 579 |
+
</div>
|
| 580 |
+
|
| 581 |
+
<div className="divide-y divide-white/5">
|
| 582 |
+
{loading
|
| 583 |
+
? Array.from({ length: 6 }).map((_, i) => (
|
| 584 |
+
<div key={i} className="grid grid-cols-12 gap-3 px-5 py-4">
|
| 585 |
+
<div className="col-span-4 flex items-center gap-3">
|
| 586 |
+
<Skeleton className="h-9 w-9 rounded-xl flex-shrink-0" />
|
| 587 |
+
<div className="space-y-1.5">
|
| 588 |
+
<Skeleton className="h-3.5 w-24" />
|
| 589 |
+
<Skeleton className="h-3 w-16" />
|
| 590 |
+
</div>
|
| 591 |
+
</div>
|
| 592 |
+
<div className="col-span-2 flex items-center"><Skeleton className="h-5 w-20 rounded-full" /></div>
|
| 593 |
+
<div className="col-span-2 flex items-center"><Skeleton className="h-5 w-20 rounded-full" /></div>
|
| 594 |
+
<div className="col-span-2 flex items-center"><Skeleton className="h-3 w-20 rounded-full" /></div>
|
| 595 |
+
<div className="col-span-1 flex items-center justify-end"><Skeleton className="h-4 w-14" /></div>
|
| 596 |
+
<div className="col-span-1 flex items-center justify-end"><Skeleton className="h-6 w-6 rounded-lg" /></div>
|
| 597 |
+
</div>
|
| 598 |
+
))
|
| 599 |
+
: filtered.map((p, i) => (
|
| 600 |
+
<motion.div
|
| 601 |
+
key={p.id}
|
| 602 |
+
initial={{ opacity: 0, x: -8 }}
|
| 603 |
+
animate={{ opacity: 1, x: 0 }}
|
| 604 |
+
transition={{ delay: i * 0.03 }}
|
| 605 |
+
className={`grid grid-cols-12 gap-3 px-5 py-4 cursor-pointer transition-colors hover:bg-white/2 ${
|
| 606 |
+
p.fraud_flag ? "bg-red-500/3" : ""
|
| 607 |
+
}`}
|
| 608 |
+
onClick={() => setSelected(p)}
|
| 609 |
+
>
|
| 610 |
+
{/* Recipient */}
|
| 611 |
+
<div className="col-span-4 flex items-center gap-3">
|
| 612 |
+
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl text-sm font-bold text-white ${
|
| 613 |
+
p.fraud_flag ? "bg-red-500/20 border border-red-500/30" : "bg-white/8 border border-white/10"
|
| 614 |
+
}`}>
|
| 615 |
+
{p.recipient_name.charAt(0).toUpperCase()}
|
| 616 |
+
</div>
|
| 617 |
+
<div>
|
| 618 |
+
<p className="text-sm font-medium text-white leading-tight">{p.recipient_name}</p>
|
| 619 |
+
<p className="text-xs text-zinc-600 leading-tight font-mono">{formatDate(p.created_at)}</p>
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
|
| 623 |
+
{/* Type */}
|
| 624 |
+
<div className="col-span-2 flex items-center gap-1.5">
|
| 625 |
+
{TYPE_ICONS[p.payment_type] || <DollarSign className="h-4 w-4 text-zinc-500" />}
|
| 626 |
+
<span className="text-xs text-zinc-400 capitalize">{p.payment_type.replace("_", " ")}</span>
|
| 627 |
+
</div>
|
| 628 |
+
|
| 629 |
+
{/* Status */}
|
| 630 |
+
<div className="col-span-2 flex items-center">
|
| 631 |
+
<StatusBadge status={p.status} fraudFlag={p.fraud_flag} />
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
{/* Risk */}
|
| 635 |
+
<div className="col-span-2 flex items-center">
|
| 636 |
+
<RiskBar score={p.risk_score} />
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
{/* Amount */}
|
| 640 |
+
<div className="col-span-1 flex items-center justify-end">
|
| 641 |
+
<span className={`text-sm font-semibold ${
|
| 642 |
+
p.payment_type === "incoming" ? "text-emerald-400" : "text-white"
|
| 643 |
+
}`}>
|
| 644 |
+
{p.payment_type === "incoming" ? "+" : "-"}${p.amount.toFixed(2)}
|
| 645 |
+
</span>
|
| 646 |
+
</div>
|
| 647 |
+
|
| 648 |
+
{/* Actions */}
|
| 649 |
+
<div className="col-span-1 flex items-center justify-end" onClick={(e) => e.stopPropagation()}>
|
| 650 |
+
{(p.status === "pending" || p.status === "flagged") && (
|
| 651 |
+
<button
|
| 652 |
+
onClick={() => handleCancel(p.id)}
|
| 653 |
+
className="rounded-lg p-1.5 text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
| 654 |
+
title="Cancel payment"
|
| 655 |
+
>
|
| 656 |
+
<Ban className="h-3.5 w-3.5" />
|
| 657 |
+
</button>
|
| 658 |
+
)}
|
| 659 |
+
</div>
|
| 660 |
+
</motion.div>
|
| 661 |
+
))}
|
| 662 |
+
</div>
|
| 663 |
+
|
| 664 |
+
{!loading && filtered.length === 0 && (
|
| 665 |
+
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
| 666 |
+
<CreditCard className="h-10 w-10 text-zinc-700" />
|
| 667 |
+
<p className="text-sm text-zinc-500">No payments found</p>
|
| 668 |
+
{(search || typeFilter || statusFilter) && (
|
| 669 |
+
<button
|
| 670 |
+
onClick={() => { setSearch(""); setTypeFilter(""); setStatusFilter(""); }}
|
| 671 |
+
className="text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
| 672 |
+
>
|
| 673 |
+
Clear filters
|
| 674 |
+
</button>
|
| 675 |
+
)}
|
| 676 |
+
</div>
|
| 677 |
+
)}
|
| 678 |
+
</div>
|
| 679 |
+
|
| 680 |
+
{/* Pagination */}
|
| 681 |
+
{pages > 1 && (
|
| 682 |
+
<div className="flex items-center justify-between">
|
| 683 |
+
<p className="text-xs text-zinc-500">Page {page} of {pages} Β· {total} total</p>
|
| 684 |
+
<div className="flex items-center gap-2">
|
| 685 |
+
<button
|
| 686 |
+
onClick={() => load(page - 1)}
|
| 687 |
+
disabled={page <= 1 || loading}
|
| 688 |
+
className="flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
| 689 |
+
>
|
| 690 |
+
<ChevronLeft className="h-3.5 w-3.5" /> Previous
|
| 691 |
+
</button>
|
| 692 |
+
<button
|
| 693 |
+
onClick={() => load(page + 1)}
|
| 694 |
+
disabled={page >= pages || loading}
|
| 695 |
+
className="flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
| 696 |
+
>
|
| 697 |
+
Next <ChevronRight className="h-3.5 w-3.5" />
|
| 698 |
+
</button>
|
| 699 |
+
</div>
|
| 700 |
+
</div>
|
| 701 |
+
)}
|
| 702 |
+
|
| 703 |
+
{/* Detail modal */}
|
| 704 |
+
<AnimatePresence>
|
| 705 |
+
{selected && (
|
| 706 |
+
<motion.div
|
| 707 |
+
initial={{ opacity: 0 }}
|
| 708 |
+
animate={{ opacity: 1 }}
|
| 709 |
+
exit={{ opacity: 0 }}
|
| 710 |
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
| 711 |
+
onClick={() => setSelected(null)}
|
| 712 |
+
>
|
| 713 |
+
<motion.div
|
| 714 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 715 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 716 |
+
exit={{ scale: 0.95, opacity: 0 }}
|
| 717 |
+
className="glass-card w-full max-w-md p-6"
|
| 718 |
+
onClick={(e) => e.stopPropagation()}
|
| 719 |
+
>
|
| 720 |
+
<div className="flex items-start justify-between mb-5">
|
| 721 |
+
<div>
|
| 722 |
+
<h3 className="text-base font-semibold text-white">{selected.recipient_name}</h3>
|
| 723 |
+
<p className="text-xs text-zinc-500 font-mono mt-0.5">{selected.transaction_reference}</p>
|
| 724 |
+
</div>
|
| 725 |
+
<button onClick={() => setSelected(null)} className="text-zinc-600 hover:text-zinc-400">
|
| 726 |
+
<X className="h-5 w-5" />
|
| 727 |
+
</button>
|
| 728 |
+
</div>
|
| 729 |
+
|
| 730 |
+
<div className="space-y-3">
|
| 731 |
+
<div className="grid grid-cols-2 gap-3">
|
| 732 |
+
<div className="rounded-xl bg-white/5 border border-white/8 p-3">
|
| 733 |
+
<p className="text-xs text-zinc-500">Amount</p>
|
| 734 |
+
<p className="text-lg font-bold text-white mt-0.5">${selected.amount.toFixed(2)} {selected.currency}</p>
|
| 735 |
+
</div>
|
| 736 |
+
<div className="rounded-xl bg-white/5 border border-white/8 p-3">
|
| 737 |
+
<p className="text-xs text-zinc-500">Status</p>
|
| 738 |
+
<div className="mt-1"><StatusBadge status={selected.status} fraudFlag={selected.fraud_flag} /></div>
|
| 739 |
+
</div>
|
| 740 |
+
</div>
|
| 741 |
+
|
| 742 |
+
<div className="rounded-xl bg-white/5 border border-white/8 p-3 space-y-2">
|
| 743 |
+
<div className="flex justify-between text-xs">
|
| 744 |
+
<span className="text-zinc-500">Account</span>
|
| 745 |
+
<span className="text-zinc-300 font-mono">{selected.recipient_account}</span>
|
| 746 |
+
</div>
|
| 747 |
+
<div className="flex justify-between text-xs">
|
| 748 |
+
<span className="text-zinc-500">Type</span>
|
| 749 |
+
<span className="text-zinc-300 capitalize">{selected.payment_type.replace("_", " ")}</span>
|
| 750 |
+
</div>
|
| 751 |
+
<div className="flex justify-between text-xs">
|
| 752 |
+
<span className="text-zinc-500">Date</span>
|
| 753 |
+
<span className="text-zinc-300">{formatDate(selected.created_at)}</span>
|
| 754 |
+
</div>
|
| 755 |
+
{selected.note && (
|
| 756 |
+
<div className="flex justify-between text-xs">
|
| 757 |
+
<span className="text-zinc-500">Note</span>
|
| 758 |
+
<span className="text-zinc-300">{selected.note}</span>
|
| 759 |
+
</div>
|
| 760 |
+
)}
|
| 761 |
+
</div>
|
| 762 |
+
|
| 763 |
+
<div className="rounded-xl bg-white/5 border border-white/8 p-3">
|
| 764 |
+
<div className="flex items-center justify-between mb-2">
|
| 765 |
+
<p className="text-xs text-zinc-500">Fraud Risk Score</p>
|
| 766 |
+
<RiskBar score={selected.risk_score} />
|
| 767 |
+
</div>
|
| 768 |
+
{selected.fraud_flag && (
|
| 769 |
+
<p className="text-xs text-red-400 mt-1">β οΈ This payment was flagged by the fraud engine.</p>
|
| 770 |
+
)}
|
| 771 |
+
</div>
|
| 772 |
+
|
| 773 |
+
{selected.ai_insight && (
|
| 774 |
+
<div className="flex items-start gap-2 rounded-xl bg-cyan-500/5 border border-cyan-500/15 p-3">
|
| 775 |
+
<Sparkles className="h-3.5 w-3.5 text-cyan-400 flex-shrink-0 mt-0.5" />
|
| 776 |
+
<p className="text-xs text-zinc-300">{selected.ai_insight}</p>
|
| 777 |
+
</div>
|
| 778 |
+
)}
|
| 779 |
+
|
| 780 |
+
{selected.fraud_flag && (
|
| 781 |
+
<div className="flex gap-2 pt-1">
|
| 782 |
+
<button
|
| 783 |
+
onClick={() => handleVerify(true)}
|
| 784 |
+
disabled={actionLoading}
|
| 785 |
+
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 py-2.5 text-xs font-medium text-emerald-400 hover:bg-emerald-500/25 disabled:opacity-50 transition-all"
|
| 786 |
+
>
|
| 787 |
+
{actionLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
| 788 |
+
Approve
|
| 789 |
+
</button>
|
| 790 |
+
<button
|
| 791 |
+
onClick={() => handleVerify(false)}
|
| 792 |
+
disabled={actionLoading}
|
| 793 |
+
className="flex-1 flex items-center justify-center gap-1.5 rounded-xl bg-red-500/15 border border-red-500/30 py-2.5 text-xs font-medium text-red-400 hover:bg-red-500/25 disabled:opacity-50 transition-all"
|
| 794 |
+
>
|
| 795 |
+
{actionLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
|
| 796 |
+
Reject
|
| 797 |
+
</button>
|
| 798 |
+
</div>
|
| 799 |
+
)}
|
| 800 |
+
</div>
|
| 801 |
+
</motion.div>
|
| 802 |
+
</motion.div>
|
| 803 |
+
)}
|
| 804 |
+
</AnimatePresence>
|
| 805 |
+
</div>
|
| 806 |
+
);
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
// βββ Main Page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 810 |
+
export default function PaymentsPage() {
|
| 811 |
+
const [tab, setTab] = useState<Tab>("history");
|
| 812 |
+
const [refreshKey, setRefreshKey] = useState(0);
|
| 813 |
+
|
| 814 |
+
const handleSuccess = () => {
|
| 815 |
+
setRefreshKey((k) => k + 1);
|
| 816 |
+
// Switch to history after a short delay so user sees the result card first
|
| 817 |
+
setTimeout(() => setTab("history"), 1200);
|
| 818 |
+
};
|
| 819 |
+
|
| 820 |
+
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
| 821 |
+
{ id: "history", label: "Payment History", icon: <CreditCard className="h-4 w-4" /> },
|
| 822 |
+
{ id: "send", label: "Send Money", icon: <Send className="h-4 w-4" /> },
|
| 823 |
+
{ id: "transfer", label: "Transfer", icon: <ArrowLeftRight className="h-4 w-4" /> },
|
| 824 |
+
];
|
| 825 |
+
|
| 826 |
+
return (
|
| 827 |
+
<motion.div
|
| 828 |
+
variants={containerVariants}
|
| 829 |
+
initial="hidden"
|
| 830 |
+
animate="visible"
|
| 831 |
+
className="flex flex-col gap-6"
|
| 832 |
+
>
|
| 833 |
+
{/* Header */}
|
| 834 |
+
<motion.div variants={itemVariants} className="flex items-center justify-between">
|
| 835 |
+
<div>
|
| 836 |
+
<h1 className="text-3xl font-bold tracking-tight text-white">Payments</h1>
|
| 837 |
+
<p className="text-zinc-400 mt-1 text-sm">
|
| 838 |
+
Real-time fraud scoring Β· AI insights Β· Persistent ledger
|
| 839 |
+
</p>
|
| 840 |
+
</div>
|
| 841 |
+
<button
|
| 842 |
+
onClick={() => setRefreshKey((k) => k + 1)}
|
| 843 |
+
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white transition-colors"
|
| 844 |
+
>
|
| 845 |
+
<RefreshCw className="h-3.5 w-3.5" />
|
| 846 |
+
Refresh
|
| 847 |
+
</button>
|
| 848 |
+
</motion.div>
|
| 849 |
+
|
| 850 |
+
{/* Tab bar */}
|
| 851 |
+
<motion.div variants={itemVariants} className="flex items-center gap-1 rounded-2xl border border-white/8 bg-white/3 p-1 w-fit">
|
| 852 |
+
{TABS.map((t) => (
|
| 853 |
+
<button
|
| 854 |
+
key={t.id}
|
| 855 |
+
onClick={() => setTab(t.id)}
|
| 856 |
+
className={`relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
|
| 857 |
+
tab === t.id ? "text-white" : "text-zinc-500 hover:text-zinc-300"
|
| 858 |
+
}`}
|
| 859 |
+
>
|
| 860 |
+
{tab === t.id && (
|
| 861 |
+
<motion.div
|
| 862 |
+
layoutId="activeTab"
|
| 863 |
+
className="absolute inset-0 rounded-xl bg-white/10 border border-white/10"
|
| 864 |
+
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
| 865 |
+
/>
|
| 866 |
+
)}
|
| 867 |
+
<span className="relative">{t.icon}</span>
|
| 868 |
+
<span className="relative">{t.label}</span>
|
| 869 |
+
</button>
|
| 870 |
+
))}
|
| 871 |
+
</motion.div>
|
| 872 |
+
|
| 873 |
+
{/* Tab content */}
|
| 874 |
+
<motion.div variants={itemVariants}>
|
| 875 |
+
<AnimatePresence mode="wait">
|
| 876 |
+
{tab === "send" && (
|
| 877 |
+
<motion.div key="send" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}>
|
| 878 |
+
<SendMoneyForm onSuccess={handleSuccess} />
|
| 879 |
+
</motion.div>
|
| 880 |
+
)}
|
| 881 |
+
{tab === "transfer" && (
|
| 882 |
+
<motion.div key="transfer" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}>
|
| 883 |
+
<TransferForm onSuccess={handleSuccess} />
|
| 884 |
+
</motion.div>
|
| 885 |
+
)}
|
| 886 |
+
{tab === "history" && (
|
| 887 |
+
<motion.div key="history" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}>
|
| 888 |
+
<PaymentHistory refreshKey={refreshKey} />
|
| 889 |
+
</motion.div>
|
| 890 |
+
)}
|
| 891 |
+
</AnimatePresence>
|
| 892 |
+
</motion.div>
|
| 893 |
+
</motion.div>
|
| 894 |
+
);
|
| 895 |
+
}
|
frontend/src/app/status/page.tsx
CHANGED
|
@@ -28,7 +28,7 @@ interface ApiStatus {
|
|
| 28 |
version: string;
|
| 29 |
}
|
| 30 |
|
| 31 |
-
const API = process.env.NEXT_PUBLIC_API_URL || "
|
| 32 |
|
| 33 |
function formatUptime(s: number) {
|
| 34 |
if (s < 60) return `${s}s`;
|
|
|
|
| 28 |
version: string;
|
| 29 |
}
|
| 30 |
|
| 31 |
+
const API = process.env.NEXT_PUBLIC_API_URL || "";
|
| 32 |
|
| 33 |
function formatUptime(s: number) {
|
| 34 |
if (s < 60) return `${s}s`;
|
frontend/src/components/layout/Sidebar.tsx
CHANGED
|
@@ -17,14 +17,16 @@ import {
|
|
| 17 |
Shield,
|
| 18 |
Sparkles,
|
| 19 |
Activity,
|
|
|
|
| 20 |
} from "lucide-react";
|
| 21 |
import { useAuthStore } from "@/lib/stores/authStore";
|
| 22 |
|
| 23 |
const navigation = [
|
| 24 |
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
| 25 |
{ name: "Transactions", href: "/transactions", icon: ArrowRightLeft },
|
|
|
|
| 26 |
{ name: "Analytics", href: "/analytics", icon: BarChart2 },
|
| 27 |
-
{ name: "Simulator", href: "/simulator", icon: Zap
|
| 28 |
{ name: "Loans", href: "/loans", icon: Wallet },
|
| 29 |
{ name: "Goals", href: "/goals", icon: Target },
|
| 30 |
{ name: "AI Assistant", href: "/chat", icon: MessageSquare, badge: "AI" },
|
|
|
|
| 17 |
Shield,
|
| 18 |
Sparkles,
|
| 19 |
Activity,
|
| 20 |
+
CreditCard,
|
| 21 |
} from "lucide-react";
|
| 22 |
import { useAuthStore } from "@/lib/stores/authStore";
|
| 23 |
|
| 24 |
const navigation = [
|
| 25 |
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
| 26 |
{ name: "Transactions", href: "/transactions", icon: ArrowRightLeft },
|
| 27 |
+
{ name: "Payments", href: "/payments", icon: CreditCard, badge: "NEW" },
|
| 28 |
{ name: "Analytics", href: "/analytics", icon: BarChart2 },
|
| 29 |
+
{ name: "Simulator", href: "/simulator", icon: Zap },
|
| 30 |
{ name: "Loans", href: "/loans", icon: Wallet },
|
| 31 |
{ name: "Goals", href: "/goals", icon: Target },
|
| 32 |
{ name: "AI Assistant", href: "/chat", icon: MessageSquare, badge: "AI" },
|
frontend/src/lib/api.ts
CHANGED
|
@@ -260,6 +260,82 @@ export const transactionsApi = {
|
|
| 260 |
},
|
| 261 |
};
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
// βββ WebSocket factory ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
export function createChatWebSocket(userId?: string): WebSocket {
|
| 265 |
const wsBase = getWsBase();
|
|
|
|
| 260 |
},
|
| 261 |
};
|
| 262 |
|
| 263 |
+
// βββ Payments βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
+
export interface Payment {
|
| 265 |
+
id: string;
|
| 266 |
+
user_id: string;
|
| 267 |
+
amount: number;
|
| 268 |
+
currency: string;
|
| 269 |
+
recipient_name: string;
|
| 270 |
+
recipient_account: string;
|
| 271 |
+
payment_type: string;
|
| 272 |
+
status: "pending" | "completed" | "failed" | "flagged";
|
| 273 |
+
risk_score: number;
|
| 274 |
+
fraud_flag: boolean;
|
| 275 |
+
created_at: string;
|
| 276 |
+
transaction_reference: string | null;
|
| 277 |
+
note: string | null;
|
| 278 |
+
ai_insight: string | null;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
export interface PaymentHistoryResponse {
|
| 282 |
+
payments: Payment[];
|
| 283 |
+
total: number;
|
| 284 |
+
page: number;
|
| 285 |
+
pages: number;
|
| 286 |
+
stats: {
|
| 287 |
+
total_sent: number;
|
| 288 |
+
total_received: number;
|
| 289 |
+
flagged_count: number;
|
| 290 |
+
};
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
export const paymentsApi = {
|
| 294 |
+
create: (data: {
|
| 295 |
+
amount: number;
|
| 296 |
+
currency?: string;
|
| 297 |
+
recipient_name: string;
|
| 298 |
+
recipient_account: string;
|
| 299 |
+
payment_type?: string;
|
| 300 |
+
note?: string;
|
| 301 |
+
}) =>
|
| 302 |
+
apiFetch<Payment>("/api/payments/create", {
|
| 303 |
+
method: "POST",
|
| 304 |
+
body: JSON.stringify(data),
|
| 305 |
+
}),
|
| 306 |
+
|
| 307 |
+
transfer: (data: { amount: number; to_account_type: string; note?: string }) =>
|
| 308 |
+
apiFetch<Payment>("/api/payments/transfer", {
|
| 309 |
+
method: "POST",
|
| 310 |
+
body: JSON.stringify(data),
|
| 311 |
+
}),
|
| 312 |
+
|
| 313 |
+
history: (params: {
|
| 314 |
+
page?: number;
|
| 315 |
+
limit?: number;
|
| 316 |
+
payment_type?: string;
|
| 317 |
+
status?: string;
|
| 318 |
+
} = {}) => {
|
| 319 |
+
const qs = new URLSearchParams();
|
| 320 |
+
if (params.page) qs.set("page", String(params.page));
|
| 321 |
+
if (params.limit) qs.set("limit", String(params.limit));
|
| 322 |
+
if (params.payment_type) qs.set("payment_type", params.payment_type);
|
| 323 |
+
if (params.status) qs.set("status", params.status);
|
| 324 |
+
return apiFetch<PaymentHistoryResponse>(`/api/payments/history?${qs}`);
|
| 325 |
+
},
|
| 326 |
+
|
| 327 |
+
get: (id: string) => apiFetch<Payment>(`/api/payments/${id}`),
|
| 328 |
+
|
| 329 |
+
verify: (payment_id: string, confirm: boolean) =>
|
| 330 |
+
apiFetch<{ message: string; payment: Payment }>("/api/payments/verify", {
|
| 331 |
+
method: "POST",
|
| 332 |
+
body: JSON.stringify({ payment_id, confirm }),
|
| 333 |
+
}),
|
| 334 |
+
|
| 335 |
+
cancel: (id: string) =>
|
| 336 |
+
apiFetch<{ message: string }>(`/api/payments/${id}`, { method: "DELETE" }),
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
// βββ WebSocket factory ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 340 |
export function createChatWebSocket(userId?: string): WebSocket {
|
| 341 |
const wsBase = getWsBase();
|
hf/start.sh
CHANGED
|
@@ -37,6 +37,15 @@ fi
|
|
| 37 |
# CORS: allow HF Space domain + localhost
|
| 38 |
export BACKEND_CORS_ORIGINS='["http://localhost:7860","http://localhost:3000","https://*.hf.space","*"]'
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# ββ Initialize database βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
echo "[1/3] Initializing database..."
|
| 42 |
cd /app/backend
|
|
|
|
| 37 |
# CORS: allow HF Space domain + localhost
|
| 38 |
export BACKEND_CORS_ORIGINS='["http://localhost:7860","http://localhost:3000","https://*.hf.space","*"]'
|
| 39 |
|
| 40 |
+
# ββ Log which AI backend is active βββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
if [ -n "$OPENAI_API_KEY" ]; then
|
| 42 |
+
echo "[INFO] AI backend: OpenAI (key detected)"
|
| 43 |
+
elif [ -n "$GROQ_API_KEY" ]; then
|
| 44 |
+
echo "[INFO] AI backend: Groq (key detected)"
|
| 45 |
+
else
|
| 46 |
+
echo "[WARN] No AI API key set β AI will use offline fallback. Set GROQ_API_KEY in HF Secrets for live AI."
|
| 47 |
+
fi
|
| 48 |
+
|
| 49 |
# ββ Initialize database βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
echo "[1/3] Initializing database..."
|
| 51 |
cd /app/backend
|
hf/supervisord.conf
CHANGED
|
@@ -16,7 +16,8 @@ startsecs=5
|
|
| 16 |
stdout_logfile=/var/log/supervisor/fastapi.log
|
| 17 |
stderr_logfile=/var/log/supervisor/fastapi.log
|
| 18 |
stdout_logfile_maxbytes=5MB
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
[program:nextjs]
|
| 22 |
command=node server.js
|
|
|
|
| 16 |
stdout_logfile=/var/log/supervisor/fastapi.log
|
| 17 |
stderr_logfile=/var/log/supervisor/fastapi.log
|
| 18 |
stdout_logfile_maxbytes=5MB
|
| 19 |
+
; Pass all env vars from the parent shell (set by start.sh / HF Spaces secrets)
|
| 20 |
+
environment=PYTHONUNBUFFERED="1",PYTHONPATH="/app/backend",GROQ_API_KEY="%(ENV_GROQ_API_KEY)s",OPENAI_API_KEY="%(ENV_OPENAI_API_KEY)s",DATABASE_URL="%(ENV_DATABASE_URL)s",USE_SQLITE="%(ENV_USE_SQLITE)s",JWT_SECRET_KEY="%(ENV_JWT_SECRET_KEY)s",BACKEND_CORS_ORIGINS="%(ENV_BACKEND_CORS_ORIGINS)s",REDIS_URL="%(ENV_REDIS_URL)s"
|
| 21 |
|
| 22 |
[program:nextjs]
|
| 23 |
command=node server.js
|