from fastapi import FastAPI, File, UploadFile, HTTPException, Header from fastapi.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse import io import os import uuid from datetime import datetime, timezone from utils.image_processing import crop_receipt_from_image from utils.ocr import run_hf_ocr from utils.supabase_helpers import ( fetch_user_from_supabase_token, upload_bytes_to_supabase_storage, insert_slip_row, ) APP_NAME = "Slip Scanner Backend" try: from dotenv import load_dotenv # type: ignore load_dotenv() except Exception: # Optional dependency; ignore if not present pass app = FastAPI(title=APP_NAME) frontend_origin = os.getenv("FRONTEND_CORS_ORIGIN", "*") app.add_middleware( CORSMiddleware, allow_origins=[frontend_origin] if frontend_origin != "*" else ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/health") def health(): required_env = [ "SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_STORAGE_BUCKET", "HUGGINGFACE_API_TOKEN", ] missing = [k for k in required_env if not os.getenv(k)] return { "service": APP_NAME, "status": "ok" if not missing else "degraded", "missing_env": missing, } @app.post("/scan") async def scan( file: UploadFile = File(...), authorization: str | None = Header(default=None, convert_underscores=False), ): # Validate auth header if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") user_jwt = authorization.split(" ", 1)[1] user = fetch_user_from_supabase_token(user_jwt) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Invalid Supabase session") user_id = user["id"] # Read upload original_bytes = await file.read() if not original_bytes: raise HTTPException(status_code=400, detail="Empty file upload") # Crop receipt via edge detection & perspective transform try: cropped_bytes, crop_meta = crop_receipt_from_image(original_bytes) except Exception as exc: raise HTTPException(status_code=422, detail=f"Failed to process image: {exc}") # OCR via Hugging Face Inference API try: ocr_text = run_hf_ocr(cropped_bytes) except Exception as exc: raise HTTPException(status_code=502, detail=f"OCR failed: {exc}") # Extract naive amount (ZAR or generic currency) and a fallback category amount = _extract_amount(ocr_text) category = _infer_category(ocr_text) # Upload cropped image to Supabase Storage bucket = os.getenv("SUPABASE_STORAGE_BUCKET", "slips") object_name = f"{user_id}/{uuid.uuid4()}.jpg" try: public_url = upload_bytes_to_supabase_storage( bucket=bucket, object_path=object_name, content_bytes=cropped_bytes, content_type="image/jpeg", ) except Exception as exc: raise HTTPException(status_code=502, detail=f"Upload failed: {exc}") # Insert DB row into slips table try: inserted = insert_slip_row( { "user_id": user_id, "image_path": object_name, "image_url": public_url, "raw_text": ocr_text, "amount": amount, "category": category, "created_at": datetime.now(timezone.utc).isoformat(), "crop_confidence": crop_meta.get("confidence"), } ) except Exception as exc: raise HTTPException(status_code=502, detail=f"Database insert failed: {exc}") return JSONResponse( { "ok": True, "slip": inserted, } ) def _extract_amount(text: str) -> float | None: import re if not text: return None # Look for amounts with optional currency symbol/letter (e.g., R250.00, $12.34) candidates: list[float] = [] for match in re.finditer(r"(?:R|\$)?\s*(\d{1,3}(?:[\,\s]\d{3})*(?:\.\d{2})?|\d+(?:\.\d{2}))", text, flags=re.IGNORECASE): num_str = match.group(1).replace(",", "").replace(" ", "") try: candidates.append(float(num_str)) except ValueError: continue return max(candidates) if candidates else None def _infer_category(text: str) -> str: text_l = (text or "").lower() if any(k in text_l for k in ["fuel", "petrol", "gas"]): return "Fuel" if any(k in text_l for k in ["restaurant", "meal", "food", "dine"]): return "Food" if any(k in text_l for k in ["grocery", "supermarket", "market", "store"]): return "Grocery" return "Uncategorized"