diff --git a/README.md b/README.md index 07c9326..32fe1f8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ -# p4-programmers -snuc hackathon +# Fintech Decision Engine + +A full-stack, AI-powered financial decision engine designed to help businesses manage cash flow, prioritize obligations, and simulate financial scenarios smartly. + +This monorepo contains both the **FastAPI Backend** and the **Next.js Frontend**. + +--- + +## Features + +- **Smart Data Ingestion**: Upload bank CSVs or receipt images — OCR extracts and auto-categorizes everything. +- **Live Financial Dashboard**: See your real-time balance, runway days, and month-by-month cash flow chart. +- **Decision Engine**: AI scores every obligation by urgency, penalty, and relationship to determine who to pay first. +- **What-If Simulator**: Safely simulate payment delays without changing any real data. See the impact instantly. +- **Email Generator**: Auto-draft professional payment-delay emails in formal or friendly tones. +- **Reports & Insights**: Filter transactions by date, category, and type to understand spending patterns. + +--- + +## Tech Stack + +**Frontend:** +- [Next.js](https://nextjs.org/) (App Router) +- [Tailwind CSS v4](https://tailwindcss.com/) +- [Chart.js](https://www.chartjs.org/) + react-chartjs-2 +- Axios for API requests + +**Backend:** +- [FastAPI](https://fastapi.tiangolo.com/) (Python) +- SQLite database (via SQLAlchemy ORM) +- Tesseract OCR (via `pytesseract`) +- Pandas for CSV ingestion +- Passlib + Bcrypt + JWT (Authentication) + +--- + +## Getting Started + +### 1. Start the Backend API + +Make sure you have Python 3.9+ installed. + +```bash +cd fintech-backend + +# Create and activate a virtual environment +python -m venv venv +# On Windows: +.\venv\Scripts\activate +# On Mac/Linux: +# source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Seed the database with demo data (run this once) +python -m app.seed + +# Start the FastAPI development server +uvicorn app.main:app --reload --port 8000 +``` +*The backend API will run at http://localhost:8000. Interactive swagger docs are available at `http://localhost:8000/docs`.* + +### 2. Start the Frontend UI + +Open a new terminal window. + +```bash +cd fintech-frontend + +# Install node dependencies +npm install + +# Start the Next.js development server +npm run dev +``` +*The frontend application will securely run at http://localhost:3000.* + +--- + +## Demo Login + +Use these credentials to log in and test the application with the seeded mock data: +- **Email:** `demo@fintech.com` +- **Password:** `password123` + +--- + +## Repository Structure + +``` +. +├── fintech-backend/ # Python FastAPI server +│ ├── app/ # Application code (routes, services, models) +│ ├── main.py # FastAPI entry point +│ ├── seed.py # Generates mock transactions/obligations +│ └── requirements.txt # Python dependencies +│ +└── fintech-frontend/ # Next.js UI Application + ├── app/ # Next.js app router pages + ├── components/ # Reusable React components + ├── lib/ # API Axios setup & Auth context + └── ... # Standard Next.js config files +``` diff --git a/fintech-backend/.gitignore b/fintech-backend/.gitignore new file mode 100644 index 0000000..c821bd7 --- /dev/null +++ b/fintech-backend/.gitignore @@ -0,0 +1,26 @@ +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo + +# Database +fintech.db +*.sqlite3 + +# Diagnostics +*.log diff --git a/fintech-backend/app/__init__.py b/fintech-backend/app/__init__.py new file mode 100644 index 0000000..528caa3 --- /dev/null +++ b/fintech-backend/app/__init__.py @@ -0,0 +1 @@ +"""App package""" diff --git a/fintech-backend/app/database.py b/fintech-backend/app/database.py new file mode 100644 index 0000000..cb5e48c --- /dev/null +++ b/fintech-backend/app/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = "sqlite:///./fintech.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create all tables.""" + from app.models import user, transaction, obligation, receivable, decision # noqa + Base.metadata.create_all(bind=engine) diff --git a/fintech-backend/app/main.py b/fintech-backend/app/main.py new file mode 100644 index 0000000..1bae03e --- /dev/null +++ b/fintech-backend/app/main.py @@ -0,0 +1,45 @@ +"""FastAPI application entry point.""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.database import init_db +from app.routes import auth, upload, dashboard, decision, simulation, email_gen, reports + +app = FastAPI( + title="Fintech Decision Engine", + description="AI-powered cash flow & financial decision system", + version="1.0.0", +) + +# Allow Next.js frontend on port 3000 +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register routers +app.include_router(auth.router) +app.include_router(upload.router) +app.include_router(dashboard.router) +app.include_router(decision.router) +app.include_router(simulation.router) +app.include_router(email_gen.router) +app.include_router(reports.router) + + +@app.on_event("startup") +def on_startup(): + init_db() + + +@app.get("/") +def root(): + return {"message": "Fintech Decision Engine API is running 🚀", "docs": "/docs"} + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/fintech-backend/app/models/__init__.py b/fintech-backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fintech-backend/app/models/decision.py b/fintech-backend/app/models/decision.py new file mode 100644 index 0000000..ab8e3cd --- /dev/null +++ b/fintech-backend/app/models/decision.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey +from app.database import Base + + +class Decision(Base): + __tablename__ = "decisions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + decision_output = Column(Text, nullable=False) + reasoning = Column(Text, default="") + created_at = Column(String, nullable=False) diff --git a/fintech-backend/app/models/obligation.py b/fintech-backend/app/models/obligation.py new file mode 100644 index 0000000..402b1ab --- /dev/null +++ b/fintech-backend/app/models/obligation.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, Float, String, ForeignKey +from app.database import Base + + +class Obligation(Base): + __tablename__ = "obligations" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String, nullable=False) + amount = Column(Float, nullable=False) + due_date = Column(String, nullable=False) # ISO date string + urgency = Column(Float, default=5.0) # 1-10 + penalty = Column(Float, default=5.0) # 1-10 + relationship = Column(Float, default=5.0) # 1-10 + flexibility = Column(Float, default=5.0) # 1-10 + risk_score = Column(Float, default=0.0) + status = Column(String, default="pending") # pending | paid | delayed diff --git a/fintech-backend/app/models/receivable.py b/fintech-backend/app/models/receivable.py new file mode 100644 index 0000000..5934894 --- /dev/null +++ b/fintech-backend/app/models/receivable.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, Float, String, ForeignKey +from app.database import Base + + +class Receivable(Base): + __tablename__ = "receivables" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + source = Column(String, nullable=False) + amount = Column(Float, nullable=False) + expected_date = Column(String, nullable=False) + status = Column(String, default="pending") # pending | received diff --git a/fintech-backend/app/models/transaction.py b/fintech-backend/app/models/transaction.py new file mode 100644 index 0000000..4923e97 --- /dev/null +++ b/fintech-backend/app/models/transaction.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, Float, String, Date, ForeignKey +from app.database import Base + + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + amount = Column(Float, nullable=False) + type = Column(String, nullable=False) # "income" | "expense" + category = Column(String, default="general") + date = Column(String, nullable=False) # stored as ISO string for SQLite + description = Column(String, default="") diff --git a/fintech-backend/app/models/user.py b/fintech-backend/app/models/user.py new file mode 100644 index 0000000..68f1d8e --- /dev/null +++ b/fintech-backend/app/models/user.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, String +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) diff --git a/fintech-backend/app/routes/__init__.py b/fintech-backend/app/routes/__init__.py new file mode 100644 index 0000000..1a52617 --- /dev/null +++ b/fintech-backend/app/routes/__init__.py @@ -0,0 +1 @@ +"""Routes package""" diff --git a/fintech-backend/app/routes/auth.py b/fintech-backend/app/routes/auth.py new file mode 100644 index 0000000..0533b4f --- /dev/null +++ b/fintech-backend/app/routes/auth.py @@ -0,0 +1,49 @@ +"""Auth routes — POST /register, POST /login""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from passlib.context import CryptContext +from jose import jwt +import os + +from app.database import get_db +from app.models.user import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + +SECRET_KEY = os.getenv("SECRET_KEY", "fintech-secret-2026") +ALGORITHM = "HS256" +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class RegisterRequest(BaseModel): + name: str + email: str + password: str + + +class LoginRequest(BaseModel): + email: str + password: str + + +@router.post("/register") +def register(req: RegisterRequest, db: Session = Depends(get_db)): + existing = db.query(User).filter(User.email == req.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + hashed = pwd_context.hash(req.password) + user = User(name=req.name, email=req.email, hashed_password=hashed) + db.add(user) + db.commit() + db.refresh(user) + return {"message": "Registered successfully", "user_id": user.id} + + +@router.post("/login") +def login(req: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == req.email).first() + if not user or not pwd_context.verify(req.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = jwt.encode({"user_id": user.id, "email": user.email}, SECRET_KEY, algorithm=ALGORITHM) + return {"token": token, "user_id": user.id, "name": user.name} diff --git a/fintech-backend/app/routes/dashboard.py b/fintech-backend/app/routes/dashboard.py new file mode 100644 index 0000000..bd8a6bc --- /dev/null +++ b/fintech-backend/app/routes/dashboard.py @@ -0,0 +1,62 @@ +"""Dashboard route — GET /dashboard""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from collections import defaultdict + +from app.database import get_db +from app.models.transaction import Transaction +from app.models.obligation import Obligation +from app.models.receivable import Receivable + +router = APIRouter(tags=["dashboard"]) + +DEMO_USER_ID = 1 + + +@router.get("/dashboard") +def get_dashboard(db: Session = Depends(get_db)): + transactions = db.query(Transaction).filter(Transaction.user_id == DEMO_USER_ID).all() + + income = sum(t.amount for t in transactions if t.type == "income") + expense = sum(t.amount for t in transactions if t.type == "expense") + balance = income - expense + + obligations = db.query(Obligation).filter( + Obligation.user_id == DEMO_USER_ID, + Obligation.status == "pending" + ).all() + + receivables = db.query(Receivable).filter( + Receivable.user_id == DEMO_USER_ID, + Receivable.status == "pending" + ).all() + + avg_daily_expense = expense / 90 if expense > 0 else 1 + runway_days = round(balance / avg_daily_expense) if avg_daily_expense > 0 else 999 + + # Build month-by-month history for chart + monthly: dict[str, float] = defaultdict(float) + for t in sorted(transactions, key=lambda x: x.date): + month = t.date[:7] # "YYYY-MM" + if t.type == "income": + monthly[month] += t.amount + else: + monthly[month] -= t.amount + + running = 0.0 + history = [] + for month in sorted(monthly): + running += monthly[month] + history.append({"date": month, "balance": round(running, 2)}) + + return { + "balance": round(balance, 2), + "income": round(income, 2), + "expense": round(expense, 2), + "runway": runway_days, + "upcoming_obligations": len(obligations), + "upcoming_amount": round(sum(o.amount for o in obligations), 2), + "incoming_receivables": len(receivables), + "incoming_amount": round(sum(r.amount for r in receivables), 2), + "history": history, + } diff --git a/fintech-backend/app/routes/decision.py b/fintech-backend/app/routes/decision.py new file mode 100644 index 0000000..45a4cb3 --- /dev/null +++ b/fintech-backend/app/routes/decision.py @@ -0,0 +1,76 @@ +"""Decision route — GET /decision""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +import json +from datetime import datetime + +from app.database import get_db +from app.models.obligation import Obligation +from app.models.decision import Decision +from app.services.decision_engine import prioritize +from app.services.risk import compute_risk, label_risk + +router = APIRouter(tags=["decision"]) + +DEMO_USER_ID = 1 + + +@router.get("/decision") +def get_decision(db: Session = Depends(get_db)): + obligations = db.query(Obligation).filter( + Obligation.user_id == DEMO_USER_ID, + Obligation.status == "pending" + ).all() + + raw = [ + { + "id": o.id, + "name": o.name, + "amount": o.amount, + "due_date": o.due_date, + "urgency": o.urgency, + "penalty": o.penalty, + "relationship": o.relationship, + "flexibility": o.flexibility, + } + for o in obligations + ] + + ranked = prioritize(raw) + + # Add risk labels + for item in ranked: + rs = compute_risk(item) + item["risk_score"] = rs + item["risk_label"] = label_risk(rs) + + # Persist this decision snapshot + decision_record = Decision( + user_id=DEMO_USER_ID, + decision_output=json.dumps(ranked), + reasoning="Sorted by urgency*0.4 + penalty*0.4 + relationship*0.2", + created_at=datetime.utcnow().isoformat(), + ) + db.add(decision_record) + db.commit() + + return ranked + + +@router.get("/runway") +def get_runway(db: Session = Depends(get_db)): + from app.models.transaction import Transaction + transactions = db.query(Transaction).filter(Transaction.user_id == DEMO_USER_ID).all() + income = sum(t.amount for t in transactions if t.type == "income") + expense = sum(t.amount for t in transactions if t.type == "expense") + balance = income - expense + avg_daily = expense / 90 if expense > 0 else 1 + runway = round(balance / avg_daily) if avg_daily > 0 else 999 + alert = runway < 7 + return { + "balance": round(balance, 2), + "avg_daily_expense": round(avg_daily, 2), + "runway_days": runway, + "alert": alert, + "message": f"⚠️ Cash will run out in {runway} days!" if alert else f"✅ {runway} days of runway remaining", + } diff --git a/fintech-backend/app/routes/email_gen.py b/fintech-backend/app/routes/email_gen.py new file mode 100644 index 0000000..cef3733 --- /dev/null +++ b/fintech-backend/app/routes/email_gen.py @@ -0,0 +1,30 @@ +"""Email generator route — POST /generate-email""" +from fastapi import APIRouter +from pydantic import BaseModel +from app.services.email_generator import generate_email + +router = APIRouter(tags=["email"]) + + +class EmailRequest(BaseModel): + recipient: str + amount: float + delay_days: int + reason: str = "temporary cash flow issue" + relationship: str = "formal" # "formal" | "friendly" + due_date: str = "the agreed date" + name: str = "Invoice" + + +@router.post("/generate-email") +def create_email(req: EmailRequest): + email_text = generate_email( + recipient=req.recipient, + amount=req.amount, + delay_days=req.delay_days, + reason=req.reason, + relationship=req.relationship, + due_date=req.due_date, + name=req.name, + ) + return {"email": email_text, "tone": req.relationship} diff --git a/fintech-backend/app/routes/reports.py b/fintech-backend/app/routes/reports.py new file mode 100644 index 0000000..c880dc5 --- /dev/null +++ b/fintech-backend/app/routes/reports.py @@ -0,0 +1,59 @@ +"""Reports route — GET /reports?start=YYYY-MM-DD&end=YYYY-MM-DD""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from collections import defaultdict + +from app.database import get_db +from app.models.transaction import Transaction + +router = APIRouter(tags=["reports"]) + +DEMO_USER_ID = 1 + + +@router.get("/reports") +def get_reports( + start: str = Query(default="2025-01-01"), + end: str = Query(default="2026-12-31"), + category: str = Query(default=None), + type: str = Query(default=None), + db: Session = Depends(get_db), +): + query = db.query(Transaction).filter( + Transaction.user_id == DEMO_USER_ID, + Transaction.date >= start, + Transaction.date <= end, + ) + if category: + query = query.filter(Transaction.category == category.lower()) + if type: + query = query.filter(Transaction.type == type.lower()) + + transactions = query.order_by(Transaction.date).all() + + # Summary by category + by_category: dict[str, float] = defaultdict(float) + for t in transactions: + by_category[t.category] += t.amount + + total_income = sum(t.amount for t in transactions if t.type == "income") + total_expense = sum(t.amount for t in transactions if t.type == "expense") + + return { + "period": {"start": start, "end": end}, + "total_income": round(total_income, 2), + "total_expense": round(total_expense, 2), + "net": round(total_income - total_expense, 2), + "by_category": {k: round(v, 2) for k, v in by_category.items()}, + "transactions": [ + { + "id": t.id, + "amount": t.amount, + "type": t.type, + "category": t.category, + "date": t.date, + "description": t.description, + } + for t in transactions + ], + } diff --git a/fintech-backend/app/routes/simulation.py b/fintech-backend/app/routes/simulation.py new file mode 100644 index 0000000..0e6f30e --- /dev/null +++ b/fintech-backend/app/routes/simulation.py @@ -0,0 +1,75 @@ +"""Simulation route — POST /simulate (never writes to DB)""" +from copy import deepcopy +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.database import get_db +from app.models.obligation import Obligation +from app.models.transaction import Transaction +from app.services.decision_engine import prioritize +from app.services.risk import compute_risk, label_risk + +router = APIRouter(tags=["simulation"]) + +DEMO_USER_ID = 1 + + +class SimulateRequest(BaseModel): + target: str # obligation name to delay + days: int # days to delay + extra_income: float = 0.0 # optional extra cash injection + + +@router.post("/simulate") +def simulate(req: SimulateRequest, db: Session = Depends(get_db)): + obligations = db.query(Obligation).filter( + Obligation.user_id == DEMO_USER_ID, + Obligation.status == "pending" + ).all() + transactions = db.query(Transaction).filter(Transaction.user_id == DEMO_USER_ID).all() + + # Clone into plain dicts — NEVER touch main DB + raw = [ + { + "id": o.id, + "name": o.name, + "amount": o.amount, + "due_date": o.due_date, + "urgency": o.urgency, + "penalty": o.penalty, + "relationship": o.relationship, + "flexibility": o.flexibility, + } + for o in obligations + ] + cloned = deepcopy(raw) + + # Apply the simulated delay + target_found = False + for item in cloned: + if item["name"].lower() == req.target.lower(): + # Shift urgency down to simulate delay effect + item["urgency"] = max(1.0, item["urgency"] - (req.days / 3)) + target_found = True + + income = sum(t.amount for t in transactions if t.type == "income") + req.extra_income + expense = sum(t.amount for t in transactions if t.type == "expense") + balance = income - expense + avg_daily = expense / 90 if expense > 0 else 1 + new_runway = round(balance / avg_daily) if avg_daily > 0 else 999 + + ranked = prioritize(cloned) + for item in ranked: + rs = compute_risk(item) + item["risk_score"] = rs + item["risk_label"] = label_risk(rs) + + return { + "scenario": f"Delay '{req.target}' by {req.days} days", + "target_found": target_found, + "simulated_balance": round(balance, 2), + "simulated_runway_days": new_runway, + "updated_priorities": ranked, + "note": "⚠️ This is a simulation. No data was changed.", + } diff --git a/fintech-backend/app/routes/upload.py b/fintech-backend/app/routes/upload.py new file mode 100644 index 0000000..8ab5c01 --- /dev/null +++ b/fintech-backend/app/routes/upload.py @@ -0,0 +1,91 @@ +"""Upload routes — POST /upload/csv, POST /upload/image""" +import os +import shutil +import tempfile +from fastapi import APIRouter, Depends, File, UploadFile, HTTPException +from sqlalchemy.orm import Session +import pandas as pd + +from app.database import get_db +from app.models.transaction import Transaction +from app.services.ocr import extract_text_from_image, parse_ocr_text + +router = APIRouter(prefix="/upload", tags=["upload"]) + +# Default demo user_id (in production, extract from JWT) +DEMO_USER_ID = 1 + + +def _classify_category(row: pd.Series) -> str: + """Auto-classify based on description/category column if present.""" + if "category" in row.index and pd.notna(row["category"]): + return str(row["category"]).lower() + desc = str(row.get("description", "")).lower() + for keyword, cat in [ + ("salary", "salary"), ("rent", "rent"), ("vendor", "vendor"), + ("subscription", "subscription"), ("utility", "utility"), + ("loan", "loan"), ("client", "income"), + ]: + if keyword in desc: + return cat + return "general" + + +@router.post("/csv") +async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)): + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only CSV files are supported") + + contents = await file.read() + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") + tmp.write(contents) + tmp.close() + + try: + df = pd.read_csv(tmp.name) + required = {"amount", "date"} + missing = required - set(df.columns.str.lower()) + if missing: + raise HTTPException(status_code=400, detail=f"Missing columns: {missing}") + + df.columns = df.columns.str.lower() + added = 0 + for _, row in df.iterrows(): + tx = Transaction( + user_id=DEMO_USER_ID, + amount=float(row["amount"]), + type=str(row.get("type", "expense")).lower(), + category=_classify_category(row), + date=str(row["date"]), + description=str(row.get("description", "")), + ) + db.add(tx) + added += 1 + db.commit() + return {"message": f"Imported {added} transactions successfully"} + finally: + os.unlink(tmp.name) + + +@router.post("/image") +async def upload_image(file: UploadFile = File(...), db: Session = Depends(get_db)): + allowed = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"} + ext = os.path.splitext(file.filename)[1].lower() + if ext not in allowed: + raise HTTPException(status_code=400, detail="Unsupported image type") + + contents = await file.read() + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext) + tmp.write(contents) + tmp.close() + + try: + raw_text = extract_text_from_image(tmp.name) + parsed = parse_ocr_text(raw_text) + tx = Transaction(user_id=DEMO_USER_ID, **parsed) + db.add(tx) + db.commit() + db.refresh(tx) + return {"message": "OCR successful", "extracted": parsed, "id": tx.id} + finally: + os.unlink(tmp.name) diff --git a/fintech-backend/app/seed.py b/fintech-backend/app/seed.py new file mode 100644 index 0000000..19c9e66 --- /dev/null +++ b/fintech-backend/app/seed.py @@ -0,0 +1,95 @@ +""" +Seed script — populates the DB with realistic demo data. +Run once: python -m app.seed +""" +from app.database import init_db, SessionLocal +from app.models.user import User +from app.models.transaction import Transaction +from app.models.obligation import Obligation +from app.models.receivable import Receivable +from app.services.risk import compute_risk +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def seed(): + init_db() + db = SessionLocal() + + # ── User ───────────────────────────────────────────── + if not db.query(User).filter(User.email == "demo@fintech.com").first(): + user = User( + name="Demo Business", + email="demo@fintech.com", + hashed_password=pwd_context.hash("password123"), + ) + db.add(user) + db.commit() + db.refresh(user) + uid = user.id + else: + uid = db.query(User).filter(User.email == "demo@fintech.com").first().id + + # ── Transactions ───────────────────────────────────── + txns = [ + ("2026-01-05", 200000, "income", "client", "Client A payment"), + ("2026-01-10", 150000, "income", "client", "Client B advance"), + ("2026-01-15", 30000, "expense", "salary", "Staff salaries"), + ("2026-01-20", 15000, "expense", "rent", "Office rent Jan"), + ("2026-01-25", 8000, "expense", "utility", "Electricity bill"), + ("2026-02-05", 120000, "income", "client", "Client C milestone"), + ("2026-02-10", 30000, "expense", "salary", "Staff salaries Feb"), + ("2026-02-18", 25000, "expense", "vendor", "Vendor supplies"), + ("2026-02-20", 15000, "expense", "rent", "Office rent Feb"), + ("2026-03-02", 80000, "income", "client", "Client A renewal"), + ("2026-03-10", 30000, "expense", "salary", "Staff salaries Mar"), + ("2026-03-15", 12000, "expense", "vendor", "Vendor B payment"), + ("2026-03-18", 5000, "expense", "subscription", "SaaS tools"), + ("2026-03-20", 15000, "expense", "rent", "Office rent Mar"), + ] + if db.query(Transaction).filter(Transaction.user_id == uid).count() == 0: + for date, amount, typ, cat, desc in txns: + db.add(Transaction(user_id=uid, amount=amount, type=typ, category=cat, date=date, description=desc)) + db.commit() + + # ── Obligations ────────────────────────────────────── + obligations_data = [ + # name, amount, due_date, urgency, penalty, relationship, flexibility + ("Salary – April", 30000, "2026-04-01", 9, 9, 8, 2), + ("Office Rent", 15000, "2026-04-05", 7, 7, 6, 3), + ("Vendor A Invoice",22000, "2026-04-07", 6, 8, 7, 5), + ("Electricity Bill", 8000, "2026-04-10", 5, 6, 4, 6), + ("SaaS Subscription",5000, "2026-04-15", 3, 3, 2, 9), + ("Vendor B Invoice",18000, "2026-04-20", 5, 6, 5, 7), + ("Bank Loan EMI", 40000, "2026-04-03", 8, 9, 9, 1), + ] + if db.query(Obligation).filter(Obligation.user_id == uid).count() == 0: + for name, amount, due, urg, pen, rel, flex in obligations_data: + o = Obligation( + user_id=uid, name=name, amount=amount, due_date=due, + urgency=urg, penalty=pen, relationship=rel, flexibility=flex + ) + o.risk_score = compute_risk({ + "urgency": urg, "penalty": pen, "flexibility": flex + }) + db.add(o) + db.commit() + + # ── Receivables ────────────────────────────────────── + receivables_data = [ + ("Client D", 50000, "2026-04-08"), + ("Client E", 35000, "2026-04-12"), + ("GST Refund", 12000, "2026-04-20"), + ] + if db.query(Receivable).filter(Receivable.user_id == uid).count() == 0: + for source, amount, exp_date in receivables_data: + db.add(Receivable(user_id=uid, source=source, amount=amount, expected_date=exp_date)) + db.commit() + + db.close() + print("✅ Seed complete! Login: demo@fintech.com / password123") + + +if __name__ == "__main__": + seed() diff --git a/fintech-backend/app/services/__init__.py b/fintech-backend/app/services/__init__.py new file mode 100644 index 0000000..e372604 --- /dev/null +++ b/fintech-backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package""" diff --git a/fintech-backend/app/services/decision_engine.py b/fintech-backend/app/services/decision_engine.py new file mode 100644 index 0000000..e0336cc --- /dev/null +++ b/fintech-backend/app/services/decision_engine.py @@ -0,0 +1,52 @@ +""" +Decision Engine Service +Formula: score = urgency * 0.4 + penalty * 0.4 + relationship * 0.2 +""" +from copy import deepcopy + + +def calculate_score(obligation: dict) -> float: + return ( + obligation.get("urgency", 5) * 0.4 + + obligation.get("penalty", 5) * 0.4 + + obligation.get("relationship", 5) * 0.2 + ) + + +def determine_action(score: float) -> str: + if score >= 7: + return "Pay Immediately" + elif score >= 4: + return "Delay if Needed" + else: + return "Monitor" + + +def get_reason(obligation: dict) -> str: + reasons = [] + if obligation.get("penalty", 5) >= 7: + reasons.append("high penalty risk") + if obligation.get("urgency", 5) >= 7: + reasons.append("urgent due date") + if obligation.get("relationship", 5) >= 7: + reasons.append("key relationship") + return ", ".join(reasons) if reasons else "standard priority" + + +def prioritize(obligations: list[dict]) -> list[dict]: + result = [] + for o in obligations: + o = o.copy() + o["priority_score"] = round(calculate_score(o), 2) + o["action"] = determine_action(o["priority_score"]) + o["reason"] = get_reason(o) + return sorted( + [ + {**o, "priority_score": round(calculate_score(o), 2), + "action": determine_action(round(calculate_score(o), 2)), + "reason": get_reason(o)} + for o in obligations + ], + key=lambda x: x["priority_score"], + reverse=True, + ) diff --git a/fintech-backend/app/services/email_generator.py b/fintech-backend/app/services/email_generator.py new file mode 100644 index 0000000..0d4dd52 --- /dev/null +++ b/fintech-backend/app/services/email_generator.py @@ -0,0 +1,57 @@ +""" +Email Generator Service +Template-based for now; swap generate_email() with OpenAI call later. +""" + + +_FORMAL_TEMPLATE = """\ +Subject: Request for Payment Extension — {name} + +Dear {recipient}, + +I hope this message finds you well. I am writing to respectfully request an extension +of {days} day(s) for the payment of ₹{amount:,.0f} originally due on {due_date}. + +Due to a temporary cash flow constraint, we are unable to meet the payment schedule +at this time. We highly value our relationship and assure you this is a short-term +situation. We commit to completing the payment by the extended date. + +Please let us know if this arrangement is acceptable. We are happy to discuss further. + +Warm regards, +[Your Name / Company] +""" + +_FRIENDLY_TEMPLATE = """\ +Hey {recipient}, + +Hope you're doing great! Just wanted to reach out about the ₹{amount:,.0f} payment +due on {due_date}. We're running into a quick cash-flow hiccup and would love an +extra {days} day(s) if that's okay with you. + +We totally understand if it's tight on your end too — just let us know and we'll +figure something out together. Appreciate you! + +Thanks a ton, +[Your Name] +""" + + +def generate_email( + recipient: str, + amount: float, + delay_days: int, + reason: str, + relationship: str, + due_date: str = "the agreed date", + name: str = "Invoice", +) -> str: + template = _FORMAL_TEMPLATE if relationship == "formal" else _FRIENDLY_TEMPLATE + return template.format( + recipient=recipient, + amount=amount, + days=delay_days, + reason=reason, + due_date=due_date, + name=name, + ) diff --git a/fintech-backend/app/services/ocr.py b/fintech-backend/app/services/ocr.py new file mode 100644 index 0000000..7d55c8a --- /dev/null +++ b/fintech-backend/app/services/ocr.py @@ -0,0 +1,34 @@ +""" +OCR Service — wraps Tesseract. +Falls back to a structured mock response if Tesseract is not installed. +""" +import re + + +def extract_text_from_image(image_path: str) -> str: + try: + from PIL import Image + import pytesseract + + img = Image.open(image_path) + return pytesseract.image_to_string(img) + except Exception: + # Graceful fallback — Tesseract not installed + return "Mock OCR: Amount: 5000, Date: 2026-04-01, Category: vendor" + + +def parse_ocr_text(text: str) -> dict: + """Simple regex-based parser; extend with LLM later.""" + amount_match = re.search(r"(?:Amount|Total|Rs\.?|₹)\s*[:\-]?\s*([\d,]+)", text, re.I) + date_match = re.search(r"(\d{4}-\d{2}-\d{2}|\d{2}/\d{2}/\d{4})", text) + category_match = re.search(r"(?:Category|Type)\s*[:\-]?\s*(\w+)", text, re.I) + + amount_raw = amount_match.group(1).replace(",", "") if amount_match else "0" + + return { + "amount": float(amount_raw) if amount_raw.replace(".", "").isdigit() else 0.0, + "date": date_match.group(1) if date_match else "2026-01-01", + "category": category_match.group(1).lower() if category_match else "general", + "type": "expense", + "description": "OCR extracted", + } diff --git a/fintech-backend/app/services/risk.py b/fintech-backend/app/services/risk.py new file mode 100644 index 0000000..7a24b47 --- /dev/null +++ b/fintech-backend/app/services/risk.py @@ -0,0 +1,22 @@ +""" +Risk Scoring Service +Formula: risk = penalty + urgency - flexibility +""" + + +def compute_risk(obligation: dict) -> float: + penalty = obligation.get("penalty", 5) + urgency = obligation.get("urgency", 5) + flexibility = obligation.get("flexibility", 5) + raw = penalty + urgency - flexibility + # Normalise to 0-10 + normalised = max(0.0, min(10.0, raw / 1.5)) + return round(normalised, 2) + + +def label_risk(score: float) -> str: + if score >= 7: + return "High ❌" + elif score >= 4: + return "Medium ⚠️" + return "Low ✅" diff --git a/fintech-backend/requirements.txt b/fintech-backend/requirements.txt new file mode 100644 index 0000000..9a5442f --- /dev/null +++ b/fintech-backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn[standard] +sqlalchemy +pandas +python-multipart +python-jose[cryptography] +passlib[bcrypt] +bcrypt==4.0.1 +pillow +pytesseract +python-dotenv diff --git a/fintech-frontend/.gitignore b/fintech-frontend/.gitignore new file mode 100644 index 0000000..eb9b91f --- /dev/null +++ b/fintech-frontend/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# local env files +.env*.local +.env + +# IDE / Editor +.vscode/ +.idea/ diff --git a/fintech-frontend/AGENTS.md b/fintech-frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/fintech-frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/fintech-frontend/CLAUDE.md b/fintech-frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/fintech-frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/fintech-frontend/README.md b/fintech-frontend/README.md new file mode 100644 index 0000000..66bb426 --- /dev/null +++ b/fintech-frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/fintech-frontend/app/dashboard/page.js b/fintech-frontend/app/dashboard/page.js new file mode 100644 index 0000000..b1ddef8 --- /dev/null +++ b/fintech-frontend/app/dashboard/page.js @@ -0,0 +1,71 @@ +"use client"; +import { useEffect, useState } from "react"; +import { getDashboard } from "@/lib/api"; +import Card from "@/components/Card"; +import CashFlowChart from "@/components/CashFlowChart"; + +export default function DashboardPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + getDashboard() + .then((res) => setData(res.data)) + .catch(() => setError("Failed to load dashboard. Is the backend running?")) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
Loading dashboard...
; + if (error) return
{error}
; + + const runwayColor = data.runway < 7 ? "red" : data.runway < 20 ? "amber" : "green"; + + return ( +
+
+

Financial Dashboard

+

Real-time overview of your business finances

+
+ + {/* Stat cards */} +
+ + + + +
+ + {/* Runway alert */} + {data.runway < 7 && ( +
+ ⚠️ +
+

Critical Cash Shortage!

+

You have only {data.runway} days of runway. Take action immediately.

+
+
+ )} + + {/* Obligations & Receivables */} +
+
+

📤 Upcoming Obligations

+

₹{data.upcoming_amount?.toLocaleString("en-IN")}

+

{data.upcoming_obligations} pending payments

+
+
+

📥 Incoming Receivables

+

₹{data.incoming_amount?.toLocaleString("en-IN")}

+

{data.incoming_receivables} expected payments

+
+
+ + {/* Chart */} +
+

Cash Flow History

+ +
+
+ ); +} diff --git a/fintech-frontend/app/decision/page.js b/fintech-frontend/app/decision/page.js new file mode 100644 index 0000000..5cfd704 --- /dev/null +++ b/fintech-frontend/app/decision/page.js @@ -0,0 +1,55 @@ +"use client"; +import { useEffect, useState } from "react"; +import { getDecision } from "@/lib/api"; +import DecisionCard from "@/components/DecisionCard"; + +export default function DecisionPage() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + getDecision() + .then((res) => setData(res.data)) + .catch(() => setError("Failed to load decisions. Is the backend running?")) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
Running decision engine...
; + + return ( +
+
+

Decision Engine

+

+ Obligations ranked by urgency × 0.4 + penalty × 0.4 + relationship × 0.2 +

+
+ + {/* Legend */} +
+ Pay Immediately + Delay if Needed + Monitor + {data.length} obligations +
+ + {error &&
{error}
} + + {data.length === 0 && !error && ( +
No obligations found. Add some via the upload page.
+ )} + +
+ {data.map((item, i) => ( +
+
#{i + 1}
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/fintech-frontend/app/email/page.js b/fintech-frontend/app/email/page.js new file mode 100644 index 0000000..8d4cbc9 --- /dev/null +++ b/fintech-frontend/app/email/page.js @@ -0,0 +1,108 @@ +"use client"; +import { useState } from "react"; +import { generateEmail } from "@/lib/api"; + +export default function EmailPage() { + const [form, setForm] = useState({ + recipient: "Vendor A", + amount: 22000, + delay_days: 5, + reason: "temporary cash flow issue", + relationship: "formal", + due_date: "2026-04-07", + name: "Invoice #2024-03", + }); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const handle = (e) => setForm({ ...form, [e.target.name]: e.target.value }); + + const generate = async () => { + setLoading(true); + try { + const res = await generateEmail({ ...form, amount: Number(form.amount), delay_days: Number(form.delay_days) }); + setEmail(res.data.email); + } catch (e) { + setEmail("Failed to generate email."); + } finally { setLoading(false); } + }; + + const copy = () => { + navigator.clipboard.writeText(email); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+

Email Generator

+

Auto-draft professional payment-delay request emails.

+
+ +
+ {/* Form */} +
+

Email Details

+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {/* Output */} +
+
+

Generated Email

+ {email && ( + + )} +
+