Bankbot / backend /app /payments /service.py
mohsin-devs's picture
fix: resolve AI assistant page client crash by converting to HTTP streaming fallback, passing env vars to supervisord, and add payments feature
d581b14
Raw
History Blame Contribute Delete
14.4 kB
"""
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")