| 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 |
| load_dotenv() |
| except Exception: |
| |
| 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), |
| ): |
| |
| 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"] |
|
|
| |
| original_bytes = await file.read() |
| if not original_bytes: |
| raise HTTPException(status_code=400, detail="Empty file upload") |
|
|
| |
| 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}") |
|
|
| |
| try: |
| ocr_text = run_hf_ocr(cropped_bytes) |
| except Exception as exc: |
| raise HTTPException(status_code=502, detail=f"OCR failed: {exc}") |
|
|
| |
| amount = _extract_amount(ocr_text) |
| category = _infer_category(ocr_text) |
|
|
| |
| 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}") |
|
|
| |
| 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 |
| |
| 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" |
|
|
|
|
|
|