Spaces:
Sleeping
Sleeping
fix: resolve AI assistant page client crash by converting to HTTP streaming fallback, passing env vars to supervisord, and add payments feature
d581b14 | """ | |
| Payment service β fraud scoring, ledger writes, AI insights, notifications. | |
| All payment activity is persisted and wired into the fraud engine. | |
| """ | |
| import os | |
| import uuid | |
| from datetime import datetime, timedelta | |
| from typing import Optional | |
| import numpy as np | |
| from sqlalchemy.orm import Session | |
| from app.database.models import ( | |
| Payment, Transaction, Account, User, Notification, FraudLog, generate_uuid | |
| ) | |
| # βββ Fraud scoring for payments βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def score_payment_fraud( | |
| db: Session, | |
| user_id: str, | |
| amount: float, | |
| recipient_account: str, | |
| recipient_name: str, | |
| payment_type: str, | |
| ) -> tuple[float, list[str], bool]: | |
| """ | |
| Returns (risk_score 0-100, reasons list, fraud_flag bool). | |
| Runs 5 heuristic rules against payment history + transaction history. | |
| """ | |
| score = 0.0 | |
| reasons: list[str] = [] | |
| # ββ Historical baseline from transactions βββββββββββββββββββββββββββββββββ | |
| accounts = db.query(Account).filter(Account.user_id == user_id).all() | |
| account_ids = [a.id for a in accounts] | |
| recent_txns = ( | |
| db.query(Transaction) | |
| .filter( | |
| Transaction.account_id.in_(account_ids), | |
| Transaction.type == "debit", | |
| ) | |
| .order_by(Transaction.timestamp.desc()) | |
| .limit(30) | |
| .all() | |
| ) | |
| if recent_txns: | |
| amounts = [t.amount for t in recent_txns] | |
| avg_amt = float(np.mean(amounts)) | |
| std_amt = float(np.std(amounts)) if len(amounts) > 1 else 0.0 | |
| # Rule 1: Amount spike | |
| if amount > avg_amt * 4.0: | |
| score += 40 | |
| reasons.append( | |
| f"Payment amount ${amount:,.2f} is {amount/max(avg_amt,1):.1f}Γ your historical average." | |
| ) | |
| elif amount > avg_amt * 2.5: | |
| score += 20 | |
| reasons.append( | |
| f"Payment amount ${amount:,.2f} is significantly above your usual spending." | |
| ) | |
| else: | |
| avg_amt = 0.0 | |
| # Rule 2: Late-night timing | |
| hour = datetime.utcnow().hour | |
| if hour >= 23 or hour < 4: | |
| score += 20 | |
| reasons.append("Payment initiated between 11 PM β 4 AM (unusual hours).") | |
| # ββ Historical baseline from payments βββββββββββββββββββββββββββββββββββββ | |
| recent_payments = ( | |
| db.query(Payment) | |
| .filter(Payment.user_id == user_id) | |
| .order_by(Payment.created_at.desc()) | |
| .limit(20) | |
| .all() | |
| ) | |
| # Rule 3: Velocity β more than 3 payments in last 10 minutes | |
| cutoff = datetime.utcnow() - timedelta(minutes=10) | |
| rapid = [p for p in recent_payments if p.created_at and p.created_at >= cutoff] | |
| if len(rapid) >= 3: | |
| score += 25 | |
| reasons.append(f"{len(rapid)} payments in the last 10 minutes β velocity anomaly.") | |
| # Rule 4: Duplicate recipient + amount within 15 minutes | |
| dup_cutoff = datetime.utcnow() - timedelta(minutes=15) | |
| for p in recent_payments[:5]: | |
| if ( | |
| p.recipient_account == recipient_account | |
| and abs(p.amount - amount) < 0.01 | |
| and p.created_at | |
| and p.created_at >= dup_cutoff | |
| ): | |
| score += 30 | |
| reasons.append( | |
| f"Duplicate payment detected: same recipient and amount within 15 minutes." | |
| ) | |
| break | |
| # Rule 5: New/unknown recipient (never paid before) | |
| known_recipients = {p.recipient_account for p in recent_payments} | |
| if recipient_account not in known_recipients and amount > 500: | |
| score += 15 | |
| reasons.append( | |
| f"First-time payment to '{recipient_name}' for a large amount (${amount:,.2f})." | |
| ) | |
| score = min(100.0, score) | |
| fraud_flag = score >= 50 | |
| return round(score, 1), reasons, fraud_flag | |
| # βββ AI insight for payment βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_payment_ai_insight( | |
| amount: float, | |
| recipient_name: str, | |
| payment_type: str, | |
| risk_score: float, | |
| fraud_flag: bool, | |
| avg_balance: float, | |
| ) -> str: | |
| """ | |
| Generates a concise AI insight string. Uses Groq if available, else rule-based. | |
| """ | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "") | |
| OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") | |
| prompt = ( | |
| f"Payment of ${amount:,.2f} to '{recipient_name}' ({payment_type}). " | |
| f"User's average account balance: ${avg_balance:,.2f}. " | |
| f"Fraud risk score: {risk_score}/100. " | |
| f"Provide a single concise sentence (max 25 words) of financial advice about this payment." | |
| ) | |
| if OPENAI_API_KEY: | |
| try: | |
| from openai import OpenAI | |
| client = OpenAI(api_key=OPENAI_API_KEY) | |
| resp = client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=60, | |
| temperature=0.3, | |
| ) | |
| return resp.choices[0].message.content.strip() | |
| except Exception: | |
| pass | |
| if GROQ_API_KEY: | |
| try: | |
| from groq import Groq | |
| client = Groq(api_key=GROQ_API_KEY) | |
| resp = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=60, | |
| temperature=0.3, | |
| ) | |
| return resp.choices[0].message.content.strip() | |
| except Exception: | |
| pass | |
| # Rule-based fallback | |
| if fraud_flag: | |
| return f"β οΈ High-risk payment flagged. Verify recipient '{recipient_name}' before proceeding." | |
| if amount > avg_balance * 0.3: | |
| return f"This payment represents {amount/max(avg_balance,1)*100:.0f}% of your balance β review before confirming." | |
| if payment_type == "subscription": | |
| return f"Recurring subscription to {recipient_name}. Review usage to ensure value." | |
| return f"Payment of ${amount:,.2f} to {recipient_name} processed successfully." | |
| # βββ Core payment creation ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_payment( | |
| db: Session, | |
| user_id: str, | |
| amount: float, | |
| currency: str, | |
| recipient_name: str, | |
| recipient_account: str, | |
| payment_type: str, | |
| note: Optional[str] = None, | |
| ) -> Payment: | |
| """ | |
| Creates a payment record, runs fraud scoring, deducts from checking account, | |
| creates a matching Transaction, fires a Notification if flagged, and persists. | |
| """ | |
| # ββ Fraud scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| risk_score, fraud_reasons, fraud_flag = score_payment_fraud( | |
| db, user_id, amount, recipient_account, recipient_name, payment_type | |
| ) | |
| # ββ Determine status ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| status = "flagged" if fraud_flag else "completed" | |
| # ββ Average balance for AI insight ββββββββββββββββββββββββββββββββββββββββ | |
| accounts = db.query(Account).filter(Account.user_id == user_id).all() | |
| avg_balance = sum(a.balance for a in accounts) / max(len(accounts), 1) | |
| # ββ AI insight ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ai_insight = get_payment_ai_insight( | |
| amount, recipient_name, payment_type, risk_score, fraud_flag, avg_balance | |
| ) | |
| # ββ Transaction reference βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| txn_ref = f"PAY-{uuid.uuid4().hex[:10].upper()}" | |
| # ββ Persist payment βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| payment = Payment( | |
| id=generate_uuid(), | |
| user_id=user_id, | |
| amount=amount, | |
| currency=currency, | |
| recipient_name=recipient_name, | |
| recipient_account=recipient_account, | |
| payment_type=payment_type, | |
| status=status, | |
| risk_score=risk_score, | |
| fraud_flag=fraud_flag, | |
| transaction_reference=txn_ref, | |
| note=note, | |
| ai_insight=ai_insight, | |
| ) | |
| db.add(payment) | |
| # ββ Deduct from checking account (outgoing payments) βββββββββββββββββββββ | |
| if payment_type not in ("incoming",): | |
| checking = ( | |
| db.query(Account) | |
| .filter(Account.user_id == user_id, Account.type == "checking") | |
| .first() | |
| ) | |
| if checking and checking.balance >= amount: | |
| checking.balance = round(checking.balance - amount, 2) | |
| # Mirror as a Transaction for dashboard/analytics | |
| txn = Transaction( | |
| id=generate_uuid(), | |
| account_id=checking.id, | |
| amount=amount, | |
| type="debit", | |
| category=_payment_type_to_category(payment_type), | |
| merchant=recipient_name, | |
| tags=["payment", payment_type], | |
| ai_generated_metadata={ | |
| "payment_id": payment.id, | |
| "risk_score": risk_score, | |
| "fraud_flag": fraud_flag, | |
| "txn_ref": txn_ref, | |
| }, | |
| ) | |
| db.add(txn) | |
| # Write FraudLog if flagged | |
| if fraud_flag: | |
| fraud_log = FraudLog( | |
| id=generate_uuid(), | |
| transaction_id=txn.id, | |
| risk_score=risk_score / 100.0, | |
| suspicious_activity_details="; ".join(fraud_reasons), | |
| status="pending", | |
| ) | |
| db.add(fraud_log) | |
| # ββ Notification ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if fraud_flag: | |
| notif = Notification( | |
| id=generate_uuid(), | |
| user_id=user_id, | |
| title="π¨ High-Risk Payment Flagged", | |
| message=( | |
| f"Payment of ${amount:,.2f} to '{recipient_name}' was flagged " | |
| f"(risk score: {risk_score:.0f}/100). " | |
| f"Reason: {fraud_reasons[0] if fraud_reasons else 'Anomaly detected.'} " | |
| f"Reference: {txn_ref}" | |
| ), | |
| type="alert", | |
| read_status=False, | |
| ) | |
| db.add(notif) | |
| elif amount >= 500: | |
| notif = Notification( | |
| id=generate_uuid(), | |
| user_id=user_id, | |
| title="πΈ Large Payment Processed", | |
| message=( | |
| f"${amount:,.2f} sent to '{recipient_name}'. " | |
| f"Reference: {txn_ref}. {ai_insight}" | |
| ), | |
| type="insight", | |
| read_status=False, | |
| ) | |
| db.add(notif) | |
| db.commit() | |
| db.refresh(payment) | |
| return payment | |
| def create_internal_transfer( | |
| db: Session, | |
| user_id: str, | |
| amount: float, | |
| to_account_type: str, | |
| note: Optional[str] = None, | |
| ) -> Payment: | |
| """ | |
| Moves funds between the user's own accounts (e.g. checking β savings). | |
| """ | |
| accounts = db.query(Account).filter(Account.user_id == user_id).all() | |
| from_acct = next((a for a in accounts if a.type == "checking"), None) | |
| to_acct = next((a for a in accounts if a.type == to_account_type), None) | |
| if not from_acct: | |
| raise ValueError("No checking account found.") | |
| if not to_acct: | |
| raise ValueError(f"No {to_account_type} account found.") | |
| if from_acct.id == to_acct.id: | |
| raise ValueError("Source and destination accounts are the same.") | |
| if from_acct.balance < amount: | |
| raise ValueError(f"Insufficient funds. Available: ${from_acct.balance:,.2f}") | |
| txn_ref = f"TRF-{uuid.uuid4().hex[:10].upper()}" | |
| # Debit source | |
| from_acct.balance = round(from_acct.balance - amount, 2) | |
| db.add(Transaction( | |
| id=generate_uuid(), account_id=from_acct.id, | |
| amount=amount, type="debit", category="Transfer", | |
| merchant=f"Transfer to {to_account_type.capitalize()}", | |
| tags=["transfer", "internal"], | |
| ai_generated_metadata={"txn_ref": txn_ref}, | |
| )) | |
| # Credit destination | |
| to_acct.balance = round(to_acct.balance + amount, 2) | |
| db.add(Transaction( | |
| id=generate_uuid(), account_id=to_acct.id, | |
| amount=amount, type="credit", category="Transfer", | |
| merchant=f"Transfer from Checking", | |
| tags=["transfer", "internal"], | |
| ai_generated_metadata={"txn_ref": txn_ref}, | |
| )) | |
| payment = Payment( | |
| id=generate_uuid(), | |
| user_id=user_id, | |
| amount=amount, | |
| currency="USD", | |
| recipient_name=f"My {to_account_type.capitalize()} Account", | |
| recipient_account=to_acct.id, | |
| payment_type="transfer", | |
| status="completed", | |
| risk_score=0.0, | |
| fraud_flag=False, | |
| transaction_reference=txn_ref, | |
| note=note or f"Internal transfer to {to_account_type}", | |
| ai_insight=f"Transferred ${amount:,.2f} to your {to_account_type} account.", | |
| ) | |
| db.add(payment) | |
| db.commit() | |
| db.refresh(payment) | |
| return payment | |
| def _payment_type_to_category(payment_type: str) -> str: | |
| mapping = { | |
| "bill_payment": "Utilities", | |
| "subscription": "Entertainment", | |
| "transfer": "Transfer", | |
| "outgoing": "Payment", | |
| "incoming": "Income", | |
| } | |
| return mapping.get(payment_type, "Payment") | |