devZenaight commited on
Commit
479f206
·
1 Parent(s): 6286dd8

npm run dev

Browse files
backend/README.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Slip Scanner Backend (FastAPI)
2
+
3
+ FastAPI service exposing a /scan endpoint to process receipt images:
4
+
5
+ - Validates Supabase user via Authorization: Bearer <supabase_jwt>
6
+ - Detects edges and crops the receipt (OpenCV, perspective transform)
7
+ - Performs OCR via Hugging Face Inference API (TrOCR)
8
+ - Uploads the cropped image to Supabase Storage
9
+ - Inserts a row into the slips table with extracted metadata
10
+
11
+ ## Prerequisites
12
+
13
+ - Python 3.11+
14
+ - Supabase project with Storage and Postgres
15
+ - Hugging Face account and API token
16
+
17
+ ## Environment Variables
18
+
19
+ Create a .env file in backend/ with:
20
+
21
+ SUPABASE_URL=...
22
+ SUPABASE_ANON_KEY=...
23
+ SUPABASE_SERVICE_ROLE_KEY=...
24
+ SUPABASE_STORAGE_BUCKET=slips
25
+
26
+ HUGGINGFACE_API_TOKEN=...
27
+ HF_OCR_MODEL=microsoft/trocr-base-printed
28
+
29
+ FRONTEND_CORS_ORIGIN=http://localhost:3000
30
+
31
+ Notes:
32
+
33
+ - Use the Service Role key server-side only. Never expose it to the frontend.
34
+ - If you prefer signed URLs, adjust upload_bytes_to_supabase_storage accordingly and do not make the bucket public.
35
+
36
+ ## Install & Run (Windows PowerShell)
37
+
38
+ cd backend
39
+ python -m venv .venv
40
+ . .venv\Scripts\Activate.ps1
41
+ pip install -r requirements.txt
42
+ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
43
+
44
+ ## API
45
+
46
+ ### Health
47
+
48
+ GET http://localhost:8000/health
49
+
50
+ ### Scan
51
+
52
+ POST http://localhost:8000/scan
53
+ Authorization: Bearer <supabase_jwt>
54
+ Content-Type: multipart/form-data
55
+ file=@/path/to/receipt.jpg
56
+
57
+ cURL example:
58
+
59
+ curl -X POST http://localhost:8000/scan \
60
+ -H "Authorization: Bearer $SUPABASE_JWT" \
61
+ -F "file=@receipt.jpg"
62
+
63
+ Response:
64
+ {
65
+ "ok": true,
66
+ "slip": {
67
+ "id": "...",
68
+ "user_id": "...",
69
+ "image_url": "https://...",
70
+ "amount": 250.0,
71
+ "category": "Food",
72
+ "raw_text": "...",
73
+ "created_at": "2025-09-15T10:00:00Z",
74
+ "crop_confidence": 0.8
75
+ }
76
+ }
77
+
78
+ ## Supabase Setup
79
+
80
+ ### Storage Bucket (public or signed)
81
+
82
+ - Create bucket slips.
83
+ - For public previews, enable public read on the bucket or a specific folder per user. Alternatively, keep private and generate signed URLs server-side.
84
+
85
+ ### Table
86
+
87
+ Create table slips (adjust types as desired):
88
+
89
+ create table if not exists public.slips (
90
+ id uuid primary key default gen_random_uuid(),
91
+ user_id uuid not null,
92
+ image_path text not null,
93
+ image_url text,
94
+ raw_text text,
95
+ amount numeric,
96
+ category text,
97
+ crop_confidence real,
98
+ created_at timestamptz not null default now()
99
+ );
100
+
101
+ alter table public.slips enable row level security;
102
+
103
+ RLS policies (only owner reads/writes their slips):
104
+
105
+ create policy "slips select own" on public.slips
106
+ for select
107
+ using (auth.uid() = user_id);
108
+
109
+ create policy "slips insert own" on public.slips
110
+ for insert
111
+ with check (auth.uid() = user_id);
112
+
113
+ create policy "slips update own" on public.slips
114
+ for update
115
+ using (auth.uid() = user_id);
116
+
117
+ ### Frontend Config
118
+
119
+ In Next.js .env.local:
120
+
121
+ NEXT_PUBLIC_API_URL=http://localhost:8000
122
+ NEXT_PUBLIC_SUPABASE_URL=...
123
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
124
+
125
+ From the client, include the Supabase session JWT as the Authorization header when calling /scan.
126
+
127
+ ## Design Notes (rules.md alignment)
128
+
129
+ - Mobile-first UX is handled in the Next.js app; backend is stateless and fast.
130
+ - No sensitive keys in frontend; backend uses Service Role for Storage/DB.
131
+ - Performance: edge detection and OCR happen server-side; images are resized and optimized.
132
+ - Security: RLS ensures users only see their own slips; Storage access can be scoped.
133
+
134
+ ## Dev Tips
135
+
136
+ - OpenAPI docs: http://localhost:8000/docs
137
+ - If HF model cold-starts and returns 503, retry the request after a few seconds.
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Makes backend a package for uvicorn import paths
2
+
backend/main.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Header
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from starlette.responses import JSONResponse
4
+ import io
5
+ import os
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+
9
+ from utils.image_processing import crop_receipt_from_image
10
+ from utils.ocr import run_hf_ocr
11
+ from utils.supabase_helpers import (
12
+ fetch_user_from_supabase_token,
13
+ upload_bytes_to_supabase_storage,
14
+ insert_slip_row,
15
+ )
16
+
17
+
18
+ APP_NAME = "Slip Scanner Backend"
19
+
20
+ try:
21
+ from dotenv import load_dotenv # type: ignore
22
+ load_dotenv()
23
+ except Exception:
24
+ # Optional dependency; ignore if not present
25
+ pass
26
+
27
+ app = FastAPI(title=APP_NAME)
28
+
29
+
30
+ frontend_origin = os.getenv("FRONTEND_CORS_ORIGIN", "*")
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=[frontend_origin] if frontend_origin != "*" else ["*"],
34
+ allow_credentials=True,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+
40
+ @app.get("/health")
41
+ def health():
42
+ required_env = [
43
+ "SUPABASE_URL",
44
+ "SUPABASE_ANON_KEY",
45
+ "SUPABASE_SERVICE_ROLE_KEY",
46
+ "SUPABASE_STORAGE_BUCKET",
47
+ "HUGGINGFACE_API_TOKEN",
48
+ ]
49
+ missing = [k for k in required_env if not os.getenv(k)]
50
+ return {
51
+ "service": APP_NAME,
52
+ "status": "ok" if not missing else "degraded",
53
+ "missing_env": missing,
54
+ }
55
+
56
+
57
+ @app.post("/scan")
58
+ async def scan(
59
+ file: UploadFile = File(...),
60
+ authorization: str | None = Header(default=None, convert_underscores=False),
61
+ ):
62
+ # Validate auth header
63
+ if not authorization or not authorization.lower().startswith("bearer "):
64
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
65
+
66
+ user_jwt = authorization.split(" ", 1)[1]
67
+ user = fetch_user_from_supabase_token(user_jwt)
68
+ if not user or not user.get("id"):
69
+ raise HTTPException(status_code=401, detail="Invalid Supabase session")
70
+
71
+ user_id = user["id"]
72
+
73
+ # Read upload
74
+ original_bytes = await file.read()
75
+ if not original_bytes:
76
+ raise HTTPException(status_code=400, detail="Empty file upload")
77
+
78
+ # Crop receipt via edge detection & perspective transform
79
+ try:
80
+ cropped_bytes, crop_meta = crop_receipt_from_image(original_bytes)
81
+ except Exception as exc:
82
+ raise HTTPException(status_code=422, detail=f"Failed to process image: {exc}")
83
+
84
+ # OCR via Hugging Face Inference API
85
+ try:
86
+ ocr_text = run_hf_ocr(cropped_bytes)
87
+ except Exception as exc:
88
+ raise HTTPException(status_code=502, detail=f"OCR failed: {exc}")
89
+
90
+ # Extract naive amount (ZAR or generic currency) and a fallback category
91
+ amount = _extract_amount(ocr_text)
92
+ category = _infer_category(ocr_text)
93
+
94
+ # Upload cropped image to Supabase Storage
95
+ bucket = os.getenv("SUPABASE_STORAGE_BUCKET", "slips")
96
+ object_name = f"{user_id}/{uuid.uuid4()}.jpg"
97
+ try:
98
+ public_url = upload_bytes_to_supabase_storage(
99
+ bucket=bucket,
100
+ object_path=object_name,
101
+ content_bytes=cropped_bytes,
102
+ content_type="image/jpeg",
103
+ )
104
+ except Exception as exc:
105
+ raise HTTPException(status_code=502, detail=f"Upload failed: {exc}")
106
+
107
+ # Insert DB row into slips table
108
+ try:
109
+ inserted = insert_slip_row(
110
+ {
111
+ "user_id": user_id,
112
+ "image_path": object_name,
113
+ "image_url": public_url,
114
+ "raw_text": ocr_text,
115
+ "amount": amount,
116
+ "category": category,
117
+ "created_at": datetime.now(timezone.utc).isoformat(),
118
+ "crop_confidence": crop_meta.get("confidence"),
119
+ }
120
+ )
121
+ except Exception as exc:
122
+ raise HTTPException(status_code=502, detail=f"Database insert failed: {exc}")
123
+
124
+ return JSONResponse(
125
+ {
126
+ "ok": True,
127
+ "slip": inserted,
128
+ }
129
+ )
130
+
131
+
132
+ def _extract_amount(text: str) -> float | None:
133
+ import re
134
+
135
+ if not text:
136
+ return None
137
+ # Look for amounts with optional currency symbol/letter (e.g., R250.00, $12.34)
138
+ candidates: list[float] = []
139
+ for match in re.finditer(r"(?:R|\$)?\s*(\d{1,3}(?:[\,\s]\d{3})*(?:\.\d{2})?|\d+(?:\.\d{2}))", text, flags=re.IGNORECASE):
140
+ num_str = match.group(1).replace(",", "").replace(" ", "")
141
+ try:
142
+ candidates.append(float(num_str))
143
+ except ValueError:
144
+ continue
145
+ return max(candidates) if candidates else None
146
+
147
+
148
+ def _infer_category(text: str) -> str:
149
+ text_l = (text or "").lower()
150
+ if any(k in text_l for k in ["fuel", "petrol", "gas"]):
151
+ return "Fuel"
152
+ if any(k in text_l for k in ["restaurant", "meal", "food", "dine"]):
153
+ return "Food"
154
+ if any(k in text_l for k in ["grocery", "supermarket", "market", "store"]):
155
+ return "Grocery"
156
+ return "Uncategorized"
157
+
158
+
backend/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.32.0
3
+ python-multipart==0.0.9
4
+ requests==2.32.3
5
+ opencv-python-headless==4.10.0.84
6
+ numpy==2.1.3
7
+ supabase==2.7.4
8
+ starlette==0.41.3
9
+ python-dotenv==1.0.1
10
+
backend/utils/image_processing.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from typing import Tuple, Dict
4
+ import io
5
+
6
+
7
+ def crop_receipt_from_image(image_bytes: bytes) -> Tuple[bytes, Dict]:
8
+ """Detect receipt edges and return a perspective-corrected JPEG.
9
+
10
+ Returns (jpeg_bytes, meta)
11
+ meta: { confidence: float, width: int, height: int }
12
+ """
13
+ image_array = np.frombuffer(image_bytes, dtype=np.uint8)
14
+ img = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
15
+ if img is None:
16
+ raise ValueError("Invalid image data")
17
+
18
+ orig = img.copy()
19
+ ratio = 500.0 / max(img.shape[0], img.shape[1])
20
+ if ratio < 1.0:
21
+ img = cv2.resize(img, None, fx=ratio, fy=ratio, interpolation=cv2.INTER_AREA)
22
+
23
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
24
+ gray = cv2.GaussianBlur(gray, (5, 5), 0)
25
+ edged = cv2.Canny(gray, 50, 150)
26
+
27
+ contours, _ = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
28
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)[:10]
29
+
30
+ receipt_contour = None
31
+ for c in contours:
32
+ peri = cv2.arcLength(c, True)
33
+ approx = cv2.approxPolyDP(c, 0.02 * peri, True)
34
+ if len(approx) == 4:
35
+ receipt_contour = approx
36
+ break
37
+
38
+ if receipt_contour is None:
39
+ # Fallback: treat whole image as receipt
40
+ h, w = img.shape[:2]
41
+ receipt_contour = np.array([[[0, 0]], [[w - 1, 0]], [[w - 1, h - 1]], [[0, h - 1]]])
42
+ confidence = 0.2
43
+ else:
44
+ confidence = 0.8
45
+
46
+ pts = receipt_contour.reshape(4, 2).astype("float32")
47
+ # Order points: top-left, top-right, bottom-right, bottom-left
48
+ rect = _order_points(pts)
49
+ (tl, tr, br, bl) = rect
50
+
51
+ width_top = np.linalg.norm(tr - tl)
52
+ width_bottom = np.linalg.norm(br - bl)
53
+ max_width = int(max(width_top, width_bottom))
54
+
55
+ height_right = np.linalg.norm(br - tr)
56
+ height_left = np.linalg.norm(bl - tl)
57
+ max_height = int(max(height_right, height_left))
58
+
59
+ dst = np.array(
60
+ [[0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1]],
61
+ dtype="float32",
62
+ )
63
+
64
+ M = cv2.getPerspectiveTransform(rect, dst)
65
+ warped = cv2.warpPerspective(img, M, (max_width, max_height))
66
+
67
+ # Slight contrast enhancement and grayscale for OCR readiness
68
+ warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
69
+ warped_eq = cv2.equalizeHist(warped_gray)
70
+
71
+ # Encode as JPEG
72
+ success, jpeg = cv2.imencode('.jpg', warped_eq, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
73
+ if not success:
74
+ raise ValueError("Failed to encode JPEG")
75
+
76
+ return jpeg.tobytes(), {"confidence": float(confidence), "width": int(max_width), "height": int(max_height)}
77
+
78
+
79
+ def _order_points(pts: np.ndarray) -> np.ndarray:
80
+ x_sorted = pts[np.argsort(pts[:, 0]), :]
81
+ left = x_sorted[:2, :]
82
+ right = x_sorted[2:, :]
83
+
84
+ tl, bl = left[np.argsort(left[:, 1]), :]
85
+ tr, br = right[np.argsort(right[:, 1]), :]
86
+ return np.array([tl, tr, br, bl], dtype="float32")
87
+
88
+
backend/utils/ocr.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+
4
+
5
+ HF_MODEL = os.getenv("HF_OCR_MODEL", "microsoft/trocr-base-printed")
6
+
7
+
8
+ def run_hf_ocr(image_bytes: bytes) -> str:
9
+ token = os.getenv("HUGGINGFACE_API_TOKEN")
10
+ if not token:
11
+ raise RuntimeError("Missing HUGGINGFACE_API_TOKEN")
12
+
13
+ # Use Inference API for text recognition
14
+ url = f"https://api-inference.huggingface.co/models/{HF_MODEL}"
15
+ headers = {"Authorization": f"Bearer {token}"}
16
+ response = requests.post(url, headers=headers, data=image_bytes, timeout=60)
17
+ if response.status_code >= 400:
18
+ raise RuntimeError(f"HF API error: {response.status_code} {response.text}")
19
+
20
+ try:
21
+ data = response.json()
22
+ except Exception as exc:
23
+ raise RuntimeError(f"Invalid HF response: {exc}")
24
+
25
+ # trocr returns dicts with 'generated_text'
26
+ if isinstance(data, list) and data and isinstance(data[0], dict):
27
+ text = data[0].get("generated_text")
28
+ if isinstance(text, str):
29
+ return text
30
+
31
+ # fallback to raw
32
+ return str(data)
33
+
34
+
backend/utils/supabase_helpers.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Dict, Any
3
+ from supabase import create_client, Client
4
+ import requests
5
+
6
+
7
+ def _get_supabase_client() -> Client:
8
+ url = os.getenv("SUPABASE_URL")
9
+ key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_ANON_KEY")
10
+ if not url or not key:
11
+ raise RuntimeError("Missing SUPABASE_URL or SUPABASE_*_KEY")
12
+ return create_client(url, key)
13
+
14
+
15
+ def fetch_user_from_supabase_token(jwt: str) -> Dict[str, Any] | None:
16
+ # Validate JWT via auth endpoint
17
+ supabase_url = os.getenv("SUPABASE_URL")
18
+ if not supabase_url:
19
+ return None
20
+ resp = requests.get(
21
+ f"{supabase_url}/auth/v1/user",
22
+ headers={
23
+ "Authorization": f"Bearer {jwt}",
24
+ "apikey": os.getenv("SUPABASE_ANON_KEY", ""),
25
+ },
26
+ timeout=20,
27
+ )
28
+ if resp.status_code != 200:
29
+ return None
30
+ return resp.json()
31
+
32
+
33
+ def upload_bytes_to_supabase_storage(
34
+ bucket: str,
35
+ object_path: str,
36
+ content_bytes: bytes,
37
+ content_type: str = "application/octet-stream",
38
+ ) -> str:
39
+ client = _get_supabase_client()
40
+ storage = client.storage.from_(bucket)
41
+ storage.upload(object_path, content_bytes, {
42
+ "contentType": content_type,
43
+ "upsert": False,
44
+ })
45
+ # Create public URL (assumes bucket is public or policy allows reads)
46
+ # Handle both string or dict responses across library versions
47
+ url_candidate = storage.get_public_url(object_path)
48
+ if isinstance(url_candidate, dict):
49
+ public_url = (
50
+ url_candidate.get("publicUrl")
51
+ or url_candidate.get("public_url")
52
+ or url_candidate.get("url")
53
+ )
54
+ else:
55
+ public_url = str(url_candidate)
56
+
57
+ if not public_url:
58
+ # Fallback construct (public bucket assumption)
59
+ base = os.getenv("SUPABASE_URL", "").rstrip("/")
60
+ public_url = f"{base}/storage/v1/object/public/{bucket}/{object_path}"
61
+ return public_url
62
+
63
+
64
+ def insert_slip_row(row: Dict[str, Any]) -> Dict[str, Any]:
65
+ client = _get_supabase_client()
66
+ response = client.table("slips").insert(row).select("*").execute()
67
+ if not response.data:
68
+ raise RuntimeError("Insert returned no data")
69
+ return response.data[0]
70
+
71
+