""" 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")