mohsin-devs commited on
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 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
- GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
 
 
 
 
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
- return AI_BACKEND_AVAILABLE
 
 
 
 
 
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=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=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 { useWebSocketChat, WsStatus } from "@/lib/hooks/useWebSocketChat";
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, label: "Analyze my spending", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
26
- { icon: Shield, label: "Check fraud risk", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
27
- { icon: Zap, label: "Optimize my budget", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
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), 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)"]
73
- : ["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)"],
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({ message, onCopy }: { message: Message; onCopy: (text: string) => void }) {
 
 
 
 
 
 
 
 
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
- <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl ${
112
- isUser
113
- ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white text-xs font-bold"
114
- : "bg-gradient-to-br from-emerald-400 to-cyan-500"
115
- }`}>
 
 
 
116
  {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
117
  </div>
118
 
119
- <div className={`max-w-[75%] ${isUser ? "items-end" : "items-start"} flex flex-col gap-1`}>
120
- <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
121
- isUser
122
- ? "bg-gradient-to-br from-blue-600 to-blue-700 text-white rounded-tr-sm"
123
- : "glass border border-white/10 text-zinc-100 rounded-tl-sm"
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, label: "Copy", action: () => onCopy(message.content) },
133
- { icon: ThumbsUp, label: "Good", action: () => {} },
134
- { icon: ThumbsDown, label: "Bad", action: () => {} },
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() || "U";
 
157
  const [messages, setMessages] = useState<Message[]>([
158
  {
159
  id: "welcome",
160
  role: "assistant",
161
- content: "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?",
 
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 currentStreamId = useRef<string | null>(null);
171
 
 
172
  useEffect(() => {
173
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
174
  }, [messages]);
175
 
176
- // ── WebSocket handlers ────────────────────────────────────────────────────
177
- const handleChunk = useCallback((chunk: string) => {
178
- const id = currentStreamId.current;
179
- if (!id) return;
 
 
180
  setMessages((prev) =>
181
- prev.map((m) => m.id === id ? { ...m, content: m.content + chunk } : m)
182
  );
183
- }, []);
184
 
185
- const handleStart = useCallback(() => {
186
- const id = `ai-${Date.now()}`;
187
- currentStreamId.current = id;
188
- setIsThinking(false);
189
- setStreamingId(id);
190
- setMessages((prev) => [
191
- ...prev,
192
- { id, role: "assistant", content: "", streaming: true, timestamp: new Date() },
193
- ]);
194
- }, []);
195
-
196
- const handleEnd = useCallback(() => {
197
- const id = currentStreamId.current;
198
- if (id) {
199
  setMessages((prev) =>
200
- prev.map((m) => m.id === id ? { ...m, streaming: false } : m)
201
  );
202
- setStreamingId(null);
203
- currentStreamId.current = null;
204
- }
205
  }, []);
206
 
207
- const handleWsError = useCallback((msg: string) => {
208
- setIsThinking(false);
209
- setStreamingId(null);
210
- currentStreamId.current = null;
211
- setMessages((prev) => [
212
- ...prev,
213
- {
214
- id: `err-${Date.now()}`,
215
- role: "assistant",
216
- content: `⚠️ ${msg}`,
217
- timestamp: new Date(),
218
- },
219
- ]);
220
- }, []);
221
 
222
- const { status, sendMessage: wsSend, reconnect } = useWebSocketChat({
223
- userId: user?.user_id,
224
- onChunk: handleChunk,
225
- onStart: handleStart,
226
- onEnd: handleEnd,
227
- onError: handleWsError,
228
- });
229
 
230
- // ── Send message ──────────────────────────────────────────────────────────
231
- const sendMessage = useCallback(async (text?: string) => {
232
- const content = (text || input).trim();
233
- if (!content || isThinking || streamingId) return;
234
 
235
- setInput("");
236
- setMessages((prev) => [
237
- ...prev,
238
- { id: `user-${Date.now()}`, role: "user", content, timestamp: new Date() },
239
- ]);
240
 
241
- const sent = wsSend(content);
 
 
 
 
 
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
- const id = `ai-${Date.now()}`;
251
- setMessages((prev) => [
252
- ...prev,
253
- { id, role: "assistant", content: res.response, streaming: false, timestamp: new Date() },
254
- ]);
255
- } catch {
256
  setIsThinking(false);
257
- setMessages((prev) => [
258
- ...prev,
259
- {
260
- id: `err-${Date.now()}`,
261
- role: "assistant",
262
- content: `⚠️ Could not reach the AI backend. Please check your connection.`,
263
- timestamp: new Date(),
264
- },
265
- ]);
 
 
266
  }
267
- } else {
268
- setIsThinking(true);
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
- <ConnectionBadge status={status} onReconnect={reconnect} />
 
 
 
 
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 Β· Real-time streaming Β· Personalized</p>
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>End-to-end encrypted</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
- <motion.button
318
- key={s.label}
319
- whileHover={{ scale: 1.03 }}
320
- whileTap={{ scale: 0.97 }}
321
- onClick={() => sendMessage(s.label)}
322
- 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`}
323
- >
324
- <s.icon className="h-4 w-4" />
325
- {s.label}
326
- </motion.button>
327
- ))}
 
 
 
328
  </div>
329
  </motion.div>
330
  )}
331
 
332
  {messages.map((msg) => (
333
- <MessageBubble key={msg.id} message={msg} onCopy={copyToClipboard} />
 
 
 
 
 
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
- <button
371
- key={s.label}
372
- onClick={() => sendMessage(s.label)}
373
- disabled={!!streamingId || isThinking}
374
- 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`}
375
- >
376
- <s.icon className="h-3 w-3" />
377
- {s.label}
378
- </button>
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={!!streamingId}
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 || !!streamingId}
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 || "http://localhost:8000";
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, badge: "NEW" },
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
- environment=PYTHONUNBUFFERED="1",PYTHONPATH="/app/backend"
 
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