simikkk commited on
Commit
eda187d
·
verified ·
1 Parent(s): 104add7

Upload 5 files

Browse files
Files changed (5) hide show
  1. app.py +675 -1267
  2. auth.py +235 -0
  3. backend.py +360 -0
  4. database.py +298 -0
  5. requirements.txt +14 -12
app.py CHANGED
@@ -1,1315 +1,723 @@
1
  """
2
- OmniParse AI — kompletní B2B SaaS pro zpracování faktur.
3
- Architektura: CORE LOGIC (framework-agnostic, dá se volat i z FastAPI/HTML frontendu)
4
- + GRADIO UI LAYER (jen volá CORE funkce, žádná business logika v UI kódu).
5
- Když budeš chtít přejít na vlastní HTML/JS frontend, stačí obalit CORE funkce
6
- do FastAPI endpointů (viz sekce "CORE LOGIC" níže) — nic se v nich měnit nemusí.
 
 
 
 
 
 
7
  """
8
 
9
  import os
10
- import re
11
- import io
12
  import json
13
- import time
14
  import base64
15
- import sqlite3
16
- import secrets
17
- import hashlib
18
- import traceback
19
- from datetime import datetime, timedelta, timezone
20
 
21
  import gradio as gr
22
- import requests
23
- from PIL import Image
24
 
25
- # ---------------------------------------------------------------------------
26
- # VOLITELNÉ KNIHOVNY — nikdy nesmí spadnout celá appka, když chybí balíček
27
- # ---------------------------------------------------------------------------
28
- try:
29
- import bcrypt
30
- BCRYPT_OK = True
31
- except Exception:
32
- BCRYPT_OK = False
33
-
34
- try:
35
- import pytesseract
36
- TESSERACT_OK = True
37
- except Exception:
38
- TESSERACT_OK = False
39
-
40
- try:
41
- from pdf2image import convert_from_bytes
42
- PDF2IMAGE_OK = True
43
- except Exception:
44
- PDF2IMAGE_OK = False
45
-
46
- try:
47
- from groq import Groq
48
- GROQ_SDK_OK = True
49
- except Exception:
50
- GROQ_SDK_OK = False
51
-
52
- try:
53
- import stripe
54
- STRIPE_SDK_OK = True
55
- except Exception:
56
- STRIPE_SDK_OK = False
57
-
58
- try:
59
- from supabase import create_client
60
- SUPABASE_SDK_OK = True
61
- except Exception:
62
- SUPABASE_SDK_OK = False
63
-
64
-
65
- # ===========================================================================
66
- # KONFIGURACE
67
- # ===========================================================================
68
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
69
- GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
70
- SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
71
- SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
72
- GOOGLE_VISION_KEY = os.environ.get("GOOGLE_VISION_KEY", "")
73
- STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
74
- STRIPE_PRICE_BASIC = os.environ.get("STRIPE_PRICE_BASIC", "")
75
- STRIPE_PRICE_PRO = os.environ.get("STRIPE_PRICE_PRO", "")
76
- STRIPE_PRICE_ENTERPRISE = os.environ.get("STRIPE_PRICE_ENTERPRISE", "")
77
- APP_URL = os.environ.get("APP_URL", "http://localhost:7860")
78
-
79
- MAX_FILE_SIZE_MB = 20
80
- MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
81
 
82
  PLAN_LIMITS = {
83
- "free": 20,
84
- "basic": 200,
85
- "pro": 2000,
86
- "enterprise": float("inf"),
87
  }
88
 
89
- PLAN_PRICE_IDS = {
90
- "basic": STRIPE_PRICE_BASIC,
91
- "pro": STRIPE_PRICE_PRO,
92
- "enterprise": STRIPE_PRICE_ENTERPRISE,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
 
94
 
95
- if STRIPE_SDK_OK and STRIPE_SECRET_KEY:
96
- try:
97
- stripe.api_key = STRIPE_SECRET_KEY
98
- except Exception:
99
- pass
100
-
101
- # jednoduchý in-memory rate limiter: {key: [timestamps]}
102
- _RATE_LIMIT_STORE = {}
103
-
104
-
105
- def rate_limited(key: str, max_attempts: int = 5, window_seconds: int = 60) -> bool:
106
- """Vrátí True pokud je klíč (email/IP) aktuálně rate-limitovaný.
107
- Ochrana proti brute-force na login/signup/API endpointy."""
108
- try:
109
- now = time.time()
110
- attempts = _RATE_LIMIT_STORE.get(key, [])
111
- attempts = [t for t in attempts if now - t < window_seconds]
112
- if len(attempts) >= max_attempts:
113
- _RATE_LIMIT_STORE[key] = attempts
114
- return True
115
- attempts.append(now)
116
- _RATE_LIMIT_STORE[key] = attempts
117
- return False
118
- except Exception:
119
- return False # radši nechat projít než appku spadnout
120
-
121
-
122
- # ===========================================================================
123
- # CORE LOGIC — DATABÁZOVÁ VRSTVA (Supabase primárně, SQLite fallback)
124
- # ===========================================================================
125
- class SQLiteDB:
126
- """Fallback databáze, pokud chybí Supabase secrets. Data jsou ephemeral
127
- (zmizí při restartu HF Space), ale appka díky tomu nikdy nespadne."""
128
-
129
- def __init__(self, path="omniparse.db"):
130
- self.path = path
131
- self.conn = sqlite3.connect(self.path, check_same_thread=False)
132
- self._init_schema()
133
-
134
- def _init_schema(self):
135
- c = self.conn.cursor()
136
- c.execute(\"\"\"CREATE TABLE IF NOT EXISTS users (
137
- id INTEGER PRIMARY KEY AUTOINCREMENT,
138
- email TEXT UNIQUE NOT NULL,
139
- name TEXT NOT NULL,
140
- password TEXT NOT NULL,
141
- plan TEXT NOT NULL DEFAULT 'free',
142
- stripe_cid TEXT,
143
- api_key TEXT,
144
- created_at TEXT
145
- )\"\"\")
146
- c.execute(\"\"\"CREATE TABLE IF NOT EXISTS sessions (
147
- token TEXT PRIMARY KEY,
148
- user_id INTEGER NOT NULL,
149
- expires_at TEXT NOT NULL
150
- )\"\"\")
151
- c.execute(\"\"\"CREATE TABLE IF NOT EXISTS invoices (
152
- id INTEGER PRIMARY KEY AUTOINCREMENT,
153
- user_id INTEGER NOT NULL,
154
- filename TEXT,
155
- vendor TEXT,
156
- inv_number TEXT,
157
- inv_date TEXT,
158
- due_date TEXT,
159
- amount REAL,
160
- vat_amount REAL,
161
- total REAL,
162
- currency TEXT DEFAULT 'USD',
163
- status TEXT DEFAULT 'done',
164
- is_duplicate INTEGER DEFAULT 0,
165
- confidence REAL,
166
- raw_json TEXT,
167
- created_at TEXT
168
- )\"\"\")
169
- self.conn.commit()
170
-
171
- def create_user(self, email, name, password_hash, plan="free", api_key=None):
172
- c = self.conn.cursor()
173
- c.execute(
174
- "INSERT INTO users (email, name, password, plan, api_key, created_at) VALUES (?,?,?,?,?,?)",
175
- (email, name, password_hash, plan, api_key, datetime.now(timezone.utc).isoformat()),
176
- )
177
- self.conn.commit()
178
- return c.lastrowid
179
-
180
- def get_user_by_email(self, email):
181
- c = self.conn.cursor()
182
- c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE email=?", (email,))
183
- row = c.fetchone()
184
- if not row:
185
- return None
186
- keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"]
187
- return dict(zip(keys, row))
188
-
189
- def get_user_by_id(self, user_id):
190
- c = self.conn.cursor()
191
- c.execute("SELECT id,email,name,password,plan,stripe_cid,api_key FROM users WHERE id=?", (user_id,))
192
- row = c.fetchone()
193
- if not row:
194
- return None
195
- keys = ["id", "email", "name", "password", "plan", "stripe_cid", "api_key"]
196
- return dict(zip(keys, row))
197
-
198
- def update_user_plan(self, user_id, plan, stripe_cid=None):
199
- c = self.conn.cursor()
200
- if stripe_cid:
201
- c.execute("UPDATE users SET plan=?, stripe_cid=? WHERE id=?", (plan, stripe_cid, user_id))
202
- else:
203
- c.execute("UPDATE users SET plan=? WHERE id=?", (plan, user_id))
204
- self.conn.commit()
205
-
206
- def update_password(self, user_id, password_hash):
207
- c = self.conn.cursor()
208
- c.execute("UPDATE users SET password=? WHERE id=?", (password_hash, user_id))
209
- self.conn.commit()
210
-
211
- def delete_user(self, user_id):
212
- c = self.conn.cursor()
213
- c.execute("DELETE FROM users WHERE id=?", (user_id,))
214
- c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
215
- c.execute("DELETE FROM invoices WHERE user_id=?", (user_id,))
216
- self.conn.commit()
217
-
218
- def create_session(self, token, user_id, expires_at):
219
- c = self.conn.cursor()
220
- c.execute("INSERT INTO sessions (token,user_id,expires_at) VALUES (?,?,?)", (token, user_id, expires_at))
221
- self.conn.commit()
222
-
223
- def get_session(self, token):
224
- c = self.conn.cursor()
225
- c.execute("SELECT token,user_id,expires_at FROM sessions WHERE token=?", (token,))
226
- row = c.fetchone()
227
- if not row:
228
- return None
229
- return {"token": row[0], "user_id": row[1], "expires_at": row[2]}
230
-
231
- def delete_session(self, token):
232
- c = self.conn.cursor()
233
- c.execute("DELETE FROM sessions WHERE token=?", (token,))
234
- self.conn.commit()
235
-
236
- def save_invoice(self, user_id, data):
237
- c = self.conn.cursor()
238
- c.execute(\"\"\"INSERT INTO invoices
239
- (user_id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total,currency,status,is_duplicate,confidence,raw_json,created_at)
240
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\"\"\",
241
- (user_id, data.get("filename"), data.get("vendor"), data.get("inv_number"),
242
- data.get("inv_date"), data.get("due_date"), data.get("amount"), data.get("vat_amount"),
243
- data.get("total"), data.get("currency", "USD"), data.get("status", "done"),
244
- int(data.get("is_duplicate", False)), data.get("confidence"),
245
- json.dumps(data.get("raw_json", {})), datetime.now(timezone.utc).isoformat()))
246
- self.conn.commit()
247
- return c.lastrowid
248
-
249
- def get_invoices(self, user_id):
250
- c = self.conn.cursor()
251
- c.execute(\"\"\"SELECT id,filename,vendor,inv_number,inv_date,due_date,amount,vat_amount,total,
252
- currency,status,is_duplicate,confidence,raw_json,created_at FROM invoices
253
- WHERE user_id=? ORDER BY created_at DESC\"\"\", (user_id,))
254
- rows = c.fetchall()
255
- keys = ["id", "filename", "vendor", "inv_number", "inv_date", "due_date", "amount", "vat_amount",
256
- "total", "currency", "status", "is_duplicate", "confidence", "raw_json", "created_at"]
257
- return [dict(zip(keys, r)) for r in rows]
258
-
259
- def delete_invoice(self, invoice_id, user_id):
260
- c = self.conn.cursor()
261
- c.execute("DELETE FROM invoices WHERE id=? AND user_id=?", (invoice_id, user_id))
262
- self.conn.commit()
263
-
264
- def count_invoices_this_month(self, user_id):
265
- c = self.conn.cursor()
266
- start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
267
- c.execute("SELECT COUNT(*) FROM invoices WHERE user_id=? AND created_at>=?", (user_id, start))
268
- return c.fetchone()[0]
269
-
270
- def count_duplicate(self, user_id, vendor, total):
271
- c = self.conn.cursor()
272
- start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
273
- c.execute(\"\"\"SELECT COUNT(*) FROM invoices WHERE user_id=? AND vendor=? AND ABS(total-?)<0.01
274
- AND created_at>=?\"\"\", (user_id, vendor, total, start))
275
- return c.fetchone()[0]
276
-
277
-
278
- class SupabaseDB:
279
- \"\"\"Wrapper nad Supabase se stejným rozhraním jako SQLiteDB, aby zbytek
280
- kódu vůbec nevěděl, která databáze běží pod kapotou.\"\"\"
281
-
282
- def __init__(self, url, key):
283
- self.client = create_client(url, key)
284
-
285
- def create_user(self, email, name, password_hash, plan="free", api_key=None):
286
- res = self.client.table("users").insert({
287
- "email": email, "name": name, "password": password_hash,
288
- "plan": plan, "api_key": api_key,
289
- }).execute()
290
- return res.data[0]["id"]
291
-
292
- def get_user_by_email(self, email):
293
- res = self.client.table("users").select("*").eq("email", email).execute()
294
- return res.data[0] if res.data else None
295
-
296
- def get_user_by_id(self, user_id):
297
- res = self.client.table("users").select("*").eq("id", user_id).execute()
298
- return res.data[0] if res.data else None
299
-
300
- def update_user_plan(self, user_id, plan, stripe_cid=None):
301
- payload = {"plan": plan}
302
- if stripe_cid:
303
- payload["stripe_cid"] = stripe_cid
304
- self.client.table("users").update(payload).eq("id", user_id).execute()
305
-
306
- def update_password(self, user_id, password_hash):
307
- self.client.table("users").update({"password": password_hash}).eq("id", user_id).execute()
308
-
309
- def delete_user(self, user_id):
310
- self.client.table("invoices").delete().eq("user_id", user_id).execute()
311
- self.client.table("sessions").delete().eq("user_id", user_id).execute()
312
- self.client.table("users").delete().eq("id", user_id).execute()
313
-
314
- def create_session(self, token, user_id, expires_at):
315
- self.client.table("sessions").insert({
316
- "token": token, "user_id": user_id, "expires_at": expires_at
317
- }).execute()
318
-
319
- def get_session(self, token):
320
- res = self.client.table("sessions").select("*").eq("token", token).execute()
321
- return res.data[0] if res.data else None
322
-
323
- def delete_session(self, token):
324
- self.client.table("sessions").delete().eq("token", token).execute()
325
-
326
- def save_invoice(self, user_id, data):
327
- payload = dict(data)
328
- payload["user_id"] = user_id
329
- payload["raw_json"] = json.dumps(payload.get("raw_json", {}))
330
- res = self.client.table("invoices").insert(payload).execute()
331
- return res.data[0]["id"]
332
-
333
- def get_invoices(self, user_id):
334
- res = self.client.table("invoices").select("*").eq("user_id", user_id).order("created_at", desc=True).execute()
335
- return res.data
336
-
337
- def delete_invoice(self, invoice_id, user_id):
338
- self.client.table("invoices").delete().eq("id", invoice_id).eq("user_id", user_id).execute()
339
-
340
- def count_invoices_this_month(self, user_id):
341
- start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
342
- res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).gte("created_at", start).execute()
343
- return res.count or 0
344
-
345
- def count_duplicate(self, user_id, vendor, total):
346
- start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
347
- # Supabase nepodporuje ABS() přes REST snadno -> filtrujeme total v Pythonu
348
- rows = self.client.table("invoices").select("total").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute()
349
- return sum(1 for r in rows.data if abs((r.get("total") or 0) - total) < 0.01)
350
-
351
-
352
- def init_db():
353
- \"\"\"Vybere Supabase pokud jsou secrets nastavené a SDK je dostupné,
354
- jinak spadne zpátky na SQLite. Appka nikdy nespadne na chybějícím secretu.\"\"\"
355
- if SUPABASE_URL and SUPABASE_KEY and SUPABASE_SDK_OK:
356
- try:
357
- db = SupabaseDB(SUPABASE_URL, SUPABASE_KEY)
358
- db.client.table("users").select("id").limit(1).execute()
359
- print("[DB] Připojeno k Supabase.")
360
- return db
361
- except Exception as e:
362
- print(f"[DB] Supabase selhalo ({e}), padám na SQLite fallback.")
363
- return SQLiteDB()
364
- print("[DB] Supabase není nakonfigurováno, používám SQLite.")
365
- return SQLiteDB()
366
-
367
-
368
- DB = init_db()
369
-
370
-
371
- # ===========================================================================
372
- # CORE LOGIC — HESLA, AUTH, VALIDACE (server-side, nikdy jen client-side)
373
- # ===========================================================================
374
- def hash_password(password: str) -> str:
375
- \"\"\"Bcrypt pokud je dostupný (moderní, doporučený), jinak PBKDF2-SHA256
376
- se solí jako bezpečný fallback (rozhodně ne MD5/SHA1).\"\"\"
377
- try:
378
- if BCRYPT_OK:
379
- return "bcrypt$" + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
380
- salt = secrets.token_hex(16)
381
- digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
382
- return f"pbkdf2${salt}${digest}"
383
- except Exception:
384
- salt = secrets.token_hex(16)
385
- digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
386
- return f"pbkdf2${salt}${digest}"
387
-
388
-
389
- def verify_password(password: str, stored_hash: str) -> bool:
390
- try:
391
- if stored_hash.startswith("bcrypt$") and BCRYPT_OK:
392
- return bcrypt.checkpw(password.encode(), stored_hash[len("bcrypt$"):].encode())
393
- if stored_hash.startswith("pbkdf2$"):
394
- _, salt, digest = stored_hash.split("$")
395
- check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
396
- return secrets.compare_digest(check, digest)
397
- return False
398
- except Exception:
399
- return False
400
-
401
-
402
- def is_valid_email(email: str) -> bool:
403
- return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or ""))
404
-
405
-
406
- def password_strength_ok(password: str) -> (bool, str):
407
- if not password or len(password) < 8:
408
- return False, "Heslo musí mít alespoň 8 znaků."
409
- if not re.search(r"[A-Za-z]", password) or not re.search(r"[0-9]", password):
410
- return False, "Heslo musí obsahovat písmena i čísla."
411
- return True, ""
412
-
413
-
414
- def is_password_leaked(password: str) -> bool:
415
- \"\"\"HaveIBeenPwned Pwned Passwords API — k-anonymity model, zdarma bez klíče.
416
- Když je síť nedostupná, prostě kontrolu přeskočíme (fail-open, appka nespadne).\"\"\"
417
- try:
418
- sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
419
- prefix, suffix = sha1[:5], sha1[5:]
420
- resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=4)
421
- if resp.status_code == 200:
422
- return any(line.split(":")[0] == suffix for line in resp.text.splitlines())
423
- return False
424
- except Exception:
425
- return False
426
-
427
-
428
- def generate_api_key() -> str:
429
- return "op_live_" + secrets.token_urlsafe(24)
430
-
431
-
432
- def create_session_token(user_id: int) -> str:
433
- token = secrets.token_urlsafe(32)
434
- expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
435
- try:
436
- DB.create_session(token, user_id, expires)
437
- except Exception as e:
438
- print(f"[AUTH] Session se nepovedlo uložit: {e}")
439
- return token
440
-
441
-
442
- def resolve_session(token: str):
443
- \"\"\"Vrátí user dict pokud je token platný a nevypršel, jinak None.
444
- Token žije v gr.State (server-side per-browser-tab paměť), NE v localStorage,
445
- což řeší XSS riziko klasického 'token v localStorage' problému.\"\"\"
446
- if not token:
447
- return None
448
- try:
449
- sess = DB.get_session(token)
450
- if not sess:
451
- return None
452
- expires = datetime.fromisoformat(sess["expires_at"])
453
- if expires.tzinfo is None:
454
- expires = expires.replace(tzinfo=timezone.utc)
455
- if expires < datetime.now(timezone.utc):
456
- DB.delete_session(token)
457
- return None
458
- return DB.get_user_by_id(sess["user_id"])
459
- except Exception as e:
460
- print(f"[AUTH] resolve_session chyba: {e}")
461
- return None
462
-
463
-
464
- def signup(name, email, password, accepted_terms) -> (bool, str, str):
465
- \"\"\"Vrací (success, message, session_token).\"\"\"
466
- try:
467
- email = (email or "").strip().lower()
468
- name = (name or "").strip()
469
- if rate_limited(f"signup:{email}", max_attempts=5, window_seconds=300):
470
- return False, "Příliš mnoho pokusů o registraci. Zkus to za pár minut.", ""
471
- if not name:
472
- return False, "Vyplň prosím jméno.", ""
473
- if not is_valid_email(email):
474
- return False, "Zadej platný pracovní e-mail.", ""
475
- if not accepted_terms:
476
- return False, "Musíš souhlasit s Terms of Use a Privacy Policy.", ""
477
- ok, msg = password_strength_ok(password)
478
- if not ok:
479
- return False, msg, ""
480
- if is_password_leaked(password):
481
- return False, "Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné.", ""
482
- if DB.get_user_by_email(email):
483
- return False, "Účet s tímto e-mailem už existuje.", ""
484
- pw_hash = hash_password(password)
485
- api_key = generate_api_key()
486
- user_id = DB.create_user(email, name, pw_hash, plan="free", api_key=api_key)
487
- token = create_session_token(user_id)
488
- return True, "Účet vytvořen!", token
489
- except Exception as e:
490
- traceback.print_exc()
491
- return False, f"Chyba při registraci: {e}", ""
492
-
493
-
494
- def login(email, password) -> (bool, str, str):
495
- try:
496
- email = (email or "").strip().lower()
497
- if rate_limited(f"login:{email}", max_attempts=8, window_seconds=300):
498
- return False, "Příliš mnoho pokusů o přihlášení. Zkus to za pár minut.", ""
499
- if not email or not password:
500
- return False, "Vyplň e-mail i heslo.", ""
501
- user = DB.get_user_by_email(email)
502
- if not user or not verify_password(password, user["password"]):
503
- return False, "Nesprávný e-mail nebo heslo.", ""
504
- token = create_session_token(user["id"])
505
- return True, "Přihlášení úspěšné!", token
506
- except Exception as e:
507
- traceback.print_exc()
508
- return False, f"Chyba při přihlašování: {e}", ""
509
-
510
-
511
- def logout(token):
512
- try:
513
- if token:
514
- DB.delete_session(token)
515
- except Exception as e:
516
- print(f"[AUTH] logout chyba: {e}")
517
-
518
-
519
- def bootstrap_demo_account():
520
- try:
521
- if not DB.get_user_by_email("demo@omniparse.ai"):
522
- pw_hash = hash_password("demo1234")
523
- api_key = generate_api_key()
524
- DB.create_user("demo@omniparse.ai", "Demo User", pw_hash, plan="pro", api_key=api_key)
525
- print("[DEMO] Demo účet vytvořen: demo@omniparse.ai / demo1234")
526
- except Exception as e:
527
- print(f"[DEMO] Nepovedlo se vytvořit demo účet: {e}")
528
-
529
-
530
- bootstrap_demo_account()
531
-
532
-
533
- # ===========================================================================
534
- # CORE LOGIC — AI PIPELINE (OCR -> LLM -> regex fallback)
535
- # ===========================================================================
536
- def ocr_google_vision(image_bytes: bytes) -> str:
537
- if not GOOGLE_VISION_KEY:
538
- return ""
539
- try:
540
- b64 = base64.b64encode(image_bytes).decode()
541
- url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_KEY}"
542
- payload = {"requests": [{"image": {"content": b64}, "features": [{"type": "TEXT_DETECTION"}]}]}
543
- resp = requests.post(url, json=payload, timeout=15)
544
- data = resp.json()
545
- return data["responses"][0].get("fullTextAnnotation", {}).get("text", "")
546
- except Exception as e:
547
- print(f"[OCR] Google Vision selhalo: {e}")
548
- return ""
549
-
550
-
551
- def ocr_tesseract(image: Image.Image) -> str:
552
- if not TESSERACT_OK:
553
- return ""
554
- try:
555
- gray = image.convert("L")
556
- return pytesseract.image_to_string(gray, lang="eng+ces")
557
- except Exception:
558
- try:
559
- return pytesseract.image_to_string(image.convert("L"), lang="eng")
560
- except Exception as e:
561
- print(f"[OCR] Tesseract selhalo: {e}")
562
- return ""
563
-
564
-
565
- def file_to_images(file_bytes: bytes, filename: str):
566
- \"\"\"Vrátí list PIL Image objektů — z PDF všechny stránky, z obrázku jednu.\"\"\"
567
- try:
568
- ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
569
- if ext == "pdf":
570
- if not PDF2IMAGE_OK:
571
- raise RuntimeError("pdf2image / poppler není dostupný na tomto Space.")
572
- return convert_from_bytes(file_bytes, dpi=200)
573
- img = Image.open(io.BytesIO(file_bytes))
574
- img.load()
575
- return [img]
576
- except Exception as e:
577
- print(f"[FILE] Nepodařilo se otevřít soubor {filename}: {e}")
578
- return []
579
-
580
-
581
- def extract_text_from_images(images) -> str:
582
- full_text = ""
583
- for img in images[:5]: # bezpečnostní limit — max 5 stránek na fakturu
584
- buf = io.BytesIO()
585
- img.convert("RGB").save(buf, format="JPEG", quality=85)
586
- img_bytes = buf.getvalue()
587
- text = ""
588
- if GOOGLE_VISION_KEY:
589
- text = ocr_google_vision(img_bytes)
590
- if len(text.strip()) < 30:
591
- text = ocr_tesseract(img)
592
- full_text += text + "\n"
593
- return full_text.strip()
594
-
595
-
596
- AI_SYSTEM_PROMPT = (
597
- "You are an invoice data extraction engine. Extract structured data from the raw OCR text "
598
- "of an invoice. Return ONLY valid JSON, no markdown, no explanation, with exactly these keys: "
599
- 'vendor (string), inv_number (string), inv_date (YYYY-MM-DD or empty string), '
600
- 'due_date (YYYY-MM-DD or empty string), amount (number, subtotal before tax), '
601
- 'vat_amount (number), total (number), currency (3-letter code like USD/EUR/CZK), '
602
- 'line_items (array of {description, quantity, unit_price, total}). '
603
- "If a field cannot be found, use empty string or 0. Never invent data you cannot find."
604
- )
605
-
606
-
607
- def ai_extract_groq(ocr_text: str):
608
- if not (GROQ_API_KEY and GROQ_SDK_OK):
609
- return None
610
- try:
611
- client = Groq(api_key=GROQ_API_KEY)
612
- resp = client.chat.completions.create(
613
- model="llama-3.1-8b-instant",
614
- messages=[
615
- {"role": "system", "content": AI_SYSTEM_PROMPT},
616
- {"role": "user", "content": ocr_text[:3000]},
617
- ],
618
- max_tokens=512,
619
- temperature=0.05,
620
- timeout=10,
621
- )
622
- content = resp.choices[0].message.content
623
- content = re.sub(r"^```json|```$", "", content.strip(), flags=re.MULTILINE).strip()
624
- return json.loads(content)
625
- except Exception as e:
626
- print(f"[AI] Groq selhalo: {e}")
627
- return None
628
-
629
-
630
- def ai_extract_hf(ocr_text: str):
631
- if not HF_TOKEN:
632
- return None
633
- try:
634
- url = "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3"
635
- headers = {"Authorization": f"Bearer {HF_TOKEN}"}
636
- prompt = f"<s>[INST] {AI_SYSTEM_PROMPT}\\n\\n{ocr_text[:3000]} [/INST]"
637
- payload = {"inputs": prompt, "parameters": {"max_new_tokens": 512, "temperature": 0.05, "return_full_text": False}}
638
- resp = requests.post(url, headers=headers, json=payload, timeout=45)
639
- if resp.status_code == 503:
640
- time.sleep(25)
641
- resp = requests.post(url, headers=headers, json=payload, timeout=45)
642
- data = resp.json()
643
- text = data[0]["generated_text"] if isinstance(data, list) else data.get("generated_text", "")
644
- text = re.sub(r"^```json|```$", "", text.strip(), flags=re.MULTILINE).strip()
645
- match = re.search(r"\\{.*\\}", text, re.DOTALL)
646
- return json.loads(match.group(0)) if match else None
647
- except Exception as e:
648
- print(f"[AI] HF Inference selhalo: {e}")
649
- return None
650
-
651
-
652
- def regex_extract(ocr_text: str):
653
- try:
654
- inv_number_m = re.search(r"(?:invoice|inv)[#:\\s]+([A-Z0-9\\-]{4,24})", ocr_text, re.I)
655
- dates = re.findall(r"\\d{1,2}[\\/.-]\\d{1,2}[\\/.-]\\d{4}", ocr_text)
656
- total_m = re.search(r"(?:total|amount due)[\\s:$]+([0-9,.\\.]+)", ocr_text, re.I)
657
- vendor = next((l.strip() for l in ocr_text.splitlines() if l.strip()), "Unknown vendor")
658
- total = 0.0
659
- if total_m:
660
- try:
661
- total = float(total_m.group(1).replace(",", ""))
662
- except Exception:
663
- total = 0.0
664
- return {
665
- "vendor": vendor[:120],
666
- "inv_number": inv_number_m.group(1) if inv_number_m else "",
667
- "inv_date": dates[0] if dates else "",
668
- "due_date": dates[1] if len(dates) > 1 else "",
669
- "amount": total,
670
- "vat_amount": 0.0,
671
- "total": total,
672
- "currency": "USD",
673
- "line_items": [],
674
- }
675
- except Exception as e:
676
- print(f"[AI] Regex fallback selhalo: {e}")
677
- return {"vendor": "Unknown", "inv_number": "", "inv_date": "", "due_date": "",
678
- "amount": 0, "vat_amount": 0, "total": 0, "currency": "USD", "line_items": []}
679
-
680
-
681
- def run_ai_pipeline(ocr_text: str):
682
- if not ocr_text.strip():
683
- data = regex_extract("")
684
- data["_ai_source"] = "empty_ocr"
685
- return data
686
- data = ai_extract_groq(ocr_text)
687
- if data:
688
- data["_ai_source"] = "groq"
689
- return data
690
- data = ai_extract_hf(ocr_text)
691
- if data:
692
- data["_ai_source"] = "huggingface"
693
- return data
694
- data = regex_extract(ocr_text)
695
- data["_ai_source"] = "regex_fallback"
696
- return data
697
-
698
-
699
- def validate_invoice(data: dict) -> list:
700
- \"\"\"Cross-field validace — vrací list textových warningů.\"\"\"
701
- warnings = []
702
- try:
703
- amount = float(data.get("amount") or 0)
704
- vat = float(data.get("vat_amount") or 0)
705
- total = float(data.get("total") or 0)
706
- if total > 0 and abs((amount + vat) - total) > 0.10:
707
- warnings.append(f"Subtotal + DPH ({amount + vat:.2f}) neodpovídá total ({total:.2f}).")
708
- if amount > 0 and vat / amount > 0.30:
709
- warnings.append("DPH sazba vyšší než 30 % — zkontroluj ručně.")
710
- inv_date, due_date = data.get("inv_date"), data.get("due_date")
711
- if inv_date and due_date:
712
- try:
713
- d1 = datetime.fromisoformat(inv_date)
714
- d2 = datetime.fromisoformat(due_date)
715
- if d2 < d1:
716
- warnings.append("Splatnost je dřív než datum vystavení faktury.")
717
- except Exception:
718
- pass
719
- if not data.get("vendor"):
720
- warnings.append("Nepodařilo se rozpoznat dodavatele.")
721
- except Exception as e:
722
- warnings.append(f"Validace selhala: {e}")
723
- return warnings
724
-
725
-
726
- def process_invoice_file(user, file_path, filename):
727
- \"\"\"Kompletní pipeline pro jeden soubor. Vrací dict se všemi daty + warnings.\"\"\"
728
- try:
729
- with open(file_path, "rb") as f:
730
- file_bytes = f.read()
731
- if len(file_bytes) > MAX_FILE_SIZE_BYTES:
732
- return {"error": f"Soubor {filename} přesahuje limit {MAX_FILE_SIZE_MB}MB."}
733
-
734
- images = file_to_images(file_bytes, filename)
735
- if not images:
736
- return {"error": f"Nepodařilo se otevřít soubor {filename} (nepodporovaný formát nebo poškozený soubor)."}
737
-
738
- ocr_text = extract_text_from_images(images)
739
- extracted = run_ai_pipeline(ocr_text)
740
-
741
- warnings = validate_invoice(extracted)
742
- plan = user.get("plan", "free")
743
-
744
- is_dup = False
745
- if plan in ("pro", "enterprise") and extracted.get("vendor") and extracted.get("total"):
746
- try:
747
- dup_count = DB.count_duplicate(user["id"], extracted["vendor"], float(extracted["total"] or 0))
748
- is_dup = dup_count > 0
749
- except Exception as e:
750
- print(f"[DUP] kontrola duplicit selhala: {e}")
751
-
752
- status = "review" if warnings else "done"
753
- if is_dup:
754
- status = "duplicate"
755
-
756
- record = {
757
- "filename": filename,
758
- "vendor": extracted.get("vendor", ""),
759
- "inv_number": extracted.get("inv_number", ""),
760
- "inv_date": extracted.get("inv_date", ""),
761
- "due_date": extracted.get("due_date", ""),
762
- "amount": float(extracted.get("amount") or 0),
763
- "vat_amount": float(extracted.get("vat_amount") or 0),
764
- "total": float(extracted.get("total") or 0),
765
- "currency": extracted.get("currency", "USD"),
766
- "status": status,
767
- "is_duplicate": is_dup,
768
- "confidence": 0.95 if extracted.get("_ai_source") in ("groq", "huggingface") else 0.55,
769
- "raw_json": extracted,
770
- }
771
- DB.save_invoice(user["id"], record)
772
- record["warnings"] = warnings
773
- return record
774
- except Exception as e:
775
- traceback.print_exc()
776
- return {"error": f"Zpracování {filename} selhalo: {e}"}
777
-
778
-
779
- # ===========================================================================
780
- # CORE LOGIC — AI CHAT AGENT (Pro+)
781
- # ===========================================================================
782
- def ai_chat_answer(user, question: str, history: list) -> str:
783
- try:
784
- if not question or not question.strip():
785
- return "Napiš prosím otázku k tvým fakturám."
786
- if user.get("plan") not in ("pro", "enterprise"):
787
- return "AI Chat je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
788
- invoices = DB.get_invoices(user["id"])[:200]
789
- context_rows = [
790
- f"- {inv.get('vendor')} | č.{inv.get('inv_number')} | {inv.get('inv_date')} | "
791
- f"total {inv.get('total')} {inv.get('currency')} | status {inv.get('status')}"
792
- for inv in invoices
793
- ]
794
- context = "\\n".join(context_rows) if context_rows else "Uživatel zatím nemá žádné faktury."
795
- if GROQ_API_KEY and GROQ_SDK_OK:
796
- client = Groq(api_key=GROQ_API_KEY)
797
- resp = client.chat.completions.create(
798
- model="llama-3.1-8b-instant",
799
- messages=[
800
- {"role": "system", "content": "You are a helpful assistant answering questions about the user's invoices based ONLY on the data provided below. Be concise."},
801
- {"role": "user", "content": f"Invoices:\\n{context}\\n\\nQuestion: {question}"},
802
- ],
803
- max_tokens=400,
804
- temperature=0.2,
805
- timeout=15,
806
- )
807
- return resp.choices[0].message.content
808
- return "AI chat momentálně není dostupný (chybí GROQ_API_KEY). Zkus to prosím později."
809
- except Exception as e:
810
- traceback.print_exc()
811
- return f"Chyba AI chatu: {e}"
812
-
813
-
814
- # ===========================================================================
815
- # CORE LOGIC — STRIPE PLATBY (bez webhooků, polling)
816
- # ===========================================================================
817
- def create_checkout_url(user, plan: str) -> (bool, str):
818
- try:
819
- if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
820
- return False, f"Platby momentálně nejsou nastavené. Napiš prosím na support a domluvíme upgrade na {plan} ručně."
821
- price_id = PLAN_PRICE_IDS.get(plan)
822
- if not price_id:
823
- return False, "Neplatný plán."
824
- session = stripe.checkout.Session.create(
825
- payment_method_types=["card"],
826
- line_items=[{"price": price_id, "quantity": 1}],
827
- mode="subscription",
828
- success_url=f"{APP_URL}?checkout=success&session_id={{CHECKOUT_SESSION_ID}}",
829
- cancel_url=f"{APP_URL}?checkout=cancel",
830
- customer_email=user["email"],
831
- metadata={"plan": plan, "user_id": str(user["id"])},
832
- )
833
- return True, session.url
834
- except Exception as e:
835
- traceback.print_exc()
836
- return False, f"Chyba Stripe checkoutu: {e}"
837
-
838
-
839
- def poll_payment_status(session_id: str, user_id: int, max_attempts=12, delay=5) -> str:
840
- if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
841
- return "Platby nejsou nakonfigurované."
842
- try:
843
- for _ in range(max_attempts):
844
- session = stripe.checkout.Session.retrieve(session_id)
845
- if session.payment_status == "paid":
846
- plan = session.metadata.get("plan", "basic")
847
- DB.update_user_plan(user_id, plan, stripe_cid=session.customer)
848
- return f"✅ Upgradnuto na {plan}!"
849
- time.sleep(delay)
850
- return "⏳ Platba zatím nebyla potvrzena. Pokud jsi zaplatil/a, obnov stránku za chvíli."
851
- except Exception as e:
852
- traceback.print_exc()
853
- return f"Chyba při ověřování platby: {e}"
854
-
855
-
856
- # ===========================================================================
857
- # CORE LOGIC — EXPORT
858
- # ===========================================================================
859
- def export_csv(user) -> str:
860
- try:
861
- invoices = DB.get_invoices(user["id"])
862
- path = f"/tmp/export_{user['id']}_{int(time.time())}.csv"
863
- import csv
864
- with open(path, "w", newline="", encoding="utf-8") as f:
865
- writer = csv.writer(f)
866
- writer.writerow(["Vendor", "Invoice#", "Invoice Date", "Due Date", "Amount", "VAT", "Total", "Currency", "Status"])
867
- for inv in invoices:
868
- writer.writerow([inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
869
- inv.get("due_date"), inv.get("amount"), inv.get("vat_amount"),
870
- inv.get("total"), inv.get("currency"), inv.get("status")])
871
- return path
872
- except Exception as e:
873
- traceback.print_exc()
874
- raise gr.Error(f"Export CSV selhal: {e}")
875
-
876
-
877
- def export_json(user) -> str:
878
- try:
879
- invoices = DB.get_invoices(user["id"])
880
- path = f"/tmp/export_{user['id']}_{int(time.time())}.json"
881
- with open(path, "w", encoding="utf-8") as f:
882
- json.dump(invoices, f, ensure_ascii=False, indent=2, default=str)
883
- return path
884
- except Exception as e:
885
- traceback.print_exc()
886
- raise gr.Error(f"Export JSON selhal: {e}")
887
-
888
-
889
- # ===========================================================================
890
- # GRADIO UI LAYER — od tohoto místa dolů JEN volání CORE funkcí
891
- # ===========================================================================
892
- CUSTOM_CSS = \"\"\"
893
- .gradio-container {max-width: 1200px !important; margin: auto;}
894
- .op-hero {text-align:center; padding: 40px 20px;}
895
- .op-card {border:1px solid #e5e7eb; border-radius:12px; padding:20px; background:white;}
896
- footer {visibility:hidden}
897
- \"\"\"
898
-
899
- LANDING_HTML = \"\"\"
900
- <div style="font-family:Inter,sans-serif;">
901
- <div style="display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid #eee;">
902
- <div style="font-size:22px;font-weight:800;">⚡ OmniParse AI</div>
903
- <div style="color:#666;font-size:14px;">How it works · Features · Pricing · Legal</div>
904
- </div>
905
- <div class="op-hero">
906
- <h1 style="font-size:42px;font-weight:800;margin-bottom:8px;">Invoice processing in seconds, not hours.</h1>
907
- <p style="font-size:18px;color:#555;max-width:640px;margin:0 auto 20px;">
908
- AI extracts vendor, dates, amounts and line items from any PDF or image.
909
- Export to CSV, JSON or Excel. Connect via API.
910
- </p>
911
- <p style="color:#888;">99.2% accuracy · &lt;4s per invoice · 40+ formats</p>
912
- </div>
913
 
914
- <div class="op-card" style="margin:20px 0;">
915
- <h2>How it works</h2>
916
- <ol>
917
- <li>Upload PDF or image invoice</li>
918
- <li>AI extracts all data</li>
919
- <li>Export wherever you need</li>
920
- </ol>
921
- </div>
922
 
923
- <div class="op-card" style="margin:20px 0;">
924
- <h2>Features</h2>
925
- <ul>
926
- <li>🔍 OCR + LLM — Tesseract + Groq Llama 3.1</li>
927
- <li>🚫 Duplicate Detection — Pro+, catches double payments</li>
928
- <li>🤖 AI Chat Agent — ask about your invoices in plain English</li>
929
- <li>✅ Cross-field Validation — checks totals, dates, tax rates</li>
930
- <li>👥 Human-in-the-loop — Enterprise, manual review of uncertain invoices</li>
931
- <li>🔌 REST API — connect to your own ERP</li>
932
- </ul>
933
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
 
935
- <div class="op-card" style="margin:20px 0;">
936
- <h2>FAQ</h2>
937
- <p><b>Is my data safe?</b> Yes — stored in EU (Frankfurt), encrypted at rest, GDPR compliant.</p>
938
- <p><b>Does it work on Czech invoices?</b> Yes, OCR supports Czech + English.</p>
939
- <p><b>How do payments work?</b> Monthly subscription via Stripe, cancel anytime.</p>
940
- <p><b>Can I cancel anytime?</b> Yes, no lock-in contracts.</p>
941
- <p><b>Do I get a tax invoice?</b> Yes, automatically generated by Stripe after each payment.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
942
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
943
 
944
- <div style="text-align:center;color:#999;padding:20px;border-top:1px solid #eee;">
945
- © 2026 OmniParse AI — Terms · Privacy · Disclaimer (viz Legal tab)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
946
  </div>
947
  </div>
948
- \"\"\"
949
-
950
- LEGAL_TERMS = \"\"\"
951
- ### Terms of Use
952
- OmniParse AI je nástroj pro automatickou extrakci dat z faktur pomocí AI. Používáním služby souhlasíš,
953
- že ji nebudeš zneužívat k nahrávání nelegálního obsahu, pokusům o přetížení systému (DoS) ani reverznímu
954
- inženýrství. Platby probíhají měsíčně přes Stripe, zrušení kdykoliv v sekci Profile. Neposkytujeme záruku
955
- 100% přesnosti extrakce — viz Disclaimer.
956
- \"\"\"
957
-
958
- LEGAL_PRIVACY = \"\"\"
959
- ### Privacy Policy / GDPR
960
- **Co sbíráme:** e-mail, jméno, nahrané faktury a z nich extrahovaná data.
961
- **Kde je to uloženo:** Supabase, EU region (Frankfurt).
962
- **Jak dlouho:** faktury 30 dní, účetní/fakturační záznamy 10 let (zákonná lhůta).
963
- **Tvá práva:** přístup k datům, výmaz (Profile → Delete account), přenositelnost dat (Export).
964
- **Cookies:** pouze technické (session), žádný marketingový tracking.
965
- \"\"\"
966
-
967
- LEGAL_DISCLAIMER = \"\"\"
968
- ### Disclaimer
969
- AI extrakce není 100% přesná — vždy ověř data před zaúčtováním do tvého účetního systému.
970
- OmniParse nenese odpovědnost za chyby vzniklé nesprávnou AI extrakcí. Toto je nástroj usnadňující práci,
971
- nikoliv náhrada za kvalifikovaného účetního.
972
- \"\"\"
973
-
974
-
975
- def status_badge(status):
976
- return {"done": "✅ Done", "review": "⚠️ Review", "duplicate": "🔴 Duplicate", "processing": "⟳ Processing"}.get(status, status)
977
-
978
-
979
- def invoices_to_dataframe(invoices):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  rows = []
981
  for inv in invoices:
982
- rows.append([
983
- inv.get("id"), inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
984
- f"{inv.get('total', 0):.2f} {inv.get('currency', '')}", status_badge(inv.get("status")),
985
- ])
986
- return rows
987
-
988
-
989
- with gr.Blocks(css=CUSTOM_CSS, title="OmniParse AI") as demo:
990
- session_token = gr.State("") # server-side (NE localStorage) — viz resolve_session()
991
- current_view = gr.State("landing")
992
-
993
- # ---- VIEW CONTAINERS ----
994
- with gr.Column(visible=True) as view_landing:
995
- gr.HTML(LANDING_HTML)
996
- with gr.Row():
997
- btn_landing_start = gr.Button("Start Free — 20 invoices →", variant="primary")
998
- btn_landing_login = gr.Button("Log In")
999
- btn_landing_pricing = gr.Button("Pricing")
1000
- btn_landing_legal = gr.Button("Legal")
1001
-
1002
- with gr.Column(visible=False) as view_pricing:
1003
- gr.Markdown("## Pricing")
1004
- with gr.Row():
1005
- with gr.Column():
1006
- gr.Markdown("### Free — $0/mo\\n- 20 invoices/mo\\n- CSV export\\n- 1 user")
1007
- with gr.Column():
1008
- gr.Markdown("### Basic — $29/mo\\n- 200 invoices/mo\\n- JSON+CSV+Excel export\\n- Multi-currency")
1009
- with gr.Column():
1010
- gr.Markdown("### Pro — $129/mo\\n- 2,000 invoices/mo\\n- REST API + AI Chat\\n- Duplicate detection\\n- 3 users")
1011
- with gr.Column():
1012
- gr.Markdown("### Enterprise — $499/mo\\n- Unlimited invoices\\n- Human-in-the-loop\\n- SLA 99.5%\\n- Unlimited users")
1013
- gr.Markdown("_Přihlaš se a v Dashboardu → ⚡ Upgrade vyber plán a zaplať kartou přes Stripe._")
1014
- btn_pricing_back = gr.Button("← Back")
1015
-
1016
- with gr.Column(visible=False) as view_legal:
1017
- gr.Markdown("## Legal")
1018
- with gr.Tab("Terms of Use"):
1019
- gr.Markdown(LEGAL_TERMS)
1020
- with gr.Tab("Privacy Policy"):
1021
- gr.Markdown(LEGAL_PRIVACY)
1022
- with gr.Tab("Disclaimer"):
1023
- gr.Markdown(LEGAL_DISCLAIMER)
1024
- btn_legal_back = gr.Button("← Back")
1025
-
1026
- with gr.Column(visible=False) as view_auth:
1027
- gr.Markdown("## Welcome to OmniParse AI")
1028
- with gr.Tab("Log In"):
1029
- login_email = gr.Textbox(label="Email")
1030
- login_password = gr.Textbox(label="Password", type="password")
1031
- login_btn = gr.Button("Log In", variant="primary")
1032
- login_msg = gr.Markdown()
1033
- gr.Markdown("_Demo účet: `demo@omniparse.ai` / `demo1234` (plán Pro)_")
1034
- with gr.Tab("Sign Up"):
1035
- signup_name = gr.Textbox(label="Full Name")
1036
- signup_email = gr.Textbox(label="Work Email")
1037
- signup_password = gr.Textbox(label="Password (min. 8 znaků)", type="password")
1038
- signup_terms = gr.Checkbox(label="Souhlasím s Terms of Use a Privacy Policy")
1039
- signup_btn = gr.Button("Sign Up", variant="primary")
1040
- signup_msg = gr.Markdown()
1041
- btn_auth_back = gr.Button("← Back to landing")
1042
-
1043
- with gr.Column(visible=False) as view_dashboard:
1044
- with gr.Row():
1045
- gr.Markdown("## Dashboard")
1046
- btn_logout = gr.Button("🚪 Log Out", size="sm")
1047
- user_info_md = gr.Markdown()
1048
-
1049
- with gr.Tab("📤 Upload"):
1050
- upload_files = gr.File(label="Nahraj faktury (PDF/JPG/PNG/TIFF, max 20MB/soubor)", file_count="multiple")
1051
- upload_btn = gr.Button("Zpracovat faktury", variant="primary")
1052
- upload_status = gr.Markdown()
1053
- upload_results = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Výsledky")
1054
- upload_raw_json = gr.JSON(label="Raw AI output (poslední soubor)")
1055
-
1056
- with gr.Tab("📋 My Invoices"):
1057
- refresh_invoices_btn = gr.Button("🔄 Obnovit")
1058
- invoices_table = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Faktury")
1059
-
1060
- with gr.Tab("🤖 AI Chat (Pro+)"):
1061
- chat_history = gr.Chatbot(label="Zeptej se na své faktury", type="messages")
1062
- chat_input = gr.Textbox(label="Otázka", placeholder="What's the total unpaid amount?")
1063
- chat_send = gr.Button("Odeslat")
1064
-
1065
- with gr.Tab("📊 Export"):
1066
- gr.Markdown("CSV export je zdarma pro všechny. JSON od plánu Basic+.")
1067
- export_csv_btn = gr.Button("Export CSV")
1068
- export_csv_file = gr.File(label="Stáhnout CSV")
1069
- export_json_btn = gr.Button("Export JSON (Basic+)")
1070
- export_json_file = gr.File(label="Stáhnout JSON")
1071
- gr.Markdown("Excel export a Google Sheets sync: **Coming soon** 🚧")
1072
-
1073
- with gr.Tab("⚡ Upgrade"):
1074
- plan_dropdown = gr.Dropdown(["basic", "pro", "enterprise"], label="Vyber plán", value="basic")
1075
- upgrade_btn = gr.Button("Přejít na platbu (Stripe)", variant="primary")
1076
- upgrade_link = gr.Markdown()
1077
- gr.Markdown("Test karta ve Stripe test mode: `4242 4242 4242 4242`, libovolné datum/CVC.")
1078
- checkout_session_input = gr.Textbox(label="Po zaplacení: vlož session_id z URL a klikni níže", visible=True)
1079
- confirm_payment_btn = gr.Button("Ověřit platbu")
1080
- payment_status_md = gr.Markdown()
1081
-
1082
- with gr.Tab("🔌 API (Pro+)"):
1083
- api_key_display = gr.Markdown()
1084
- gr.Markdown(\"\"\"
1085
- ```bash
1086
- curl -X POST https://tvuj-space.hf.space/api/extract \\\\
1087
- -H "Authorization: Bearer TVUJ_API_KLIC" \\\\
1088
- -F "file=@faktura.pdf"
1089
- ```
1090
- _(REST endpoint pro přímé API volání se zapojí při přechodu na FastAPI backend — business logika je už připravená v `process_invoice_file()`.)_
1091
- \"\"\")
1092
-
1093
- with gr.Tab("👤 Profile"):
1094
- profile_info = gr.Markdown()
1095
- new_password = gr.Textbox(label="Nové heslo", type="password")
1096
- change_pw_btn = gr.Button("Změnit heslo")
1097
- change_pw_msg = gr.Markdown()
1098
- gr.Markdown("### ⚠️ Danger zone")
1099
- delete_confirm = gr.Checkbox(label="Ano, opravdu chci smazat účet a všechna data")
1100
- delete_btn = gr.Button("Smazat účet natrvalo", variant="stop")
1101
- delete_msg = gr.Markdown()
1102
-
1103
- ALL_VIEWS = [view_landing, view_pricing, view_legal, view_auth, view_dashboard]
1104
-
1105
- def switch_view(target):
1106
- return [gr.update(visible=(v == target)) for v in ["landing", "pricing", "legal", "auth", "dashboard"]]
1107
-
1108
- # ---- NAVIGACE ----
1109
- btn_landing_start.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1110
- btn_landing_login.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1111
- btn_landing_pricing.click(lambda: switch_view("pricing"), outputs=ALL_VIEWS)
1112
- btn_landing_legal.click(lambda: switch_view("legal"), outputs=ALL_VIEWS)
1113
- btn_pricing_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1114
- btn_legal_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1115
- btn_auth_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1116
-
1117
- # ---- AUTH HANDLERY ----
1118
- def handle_signup(name, email, password, terms):
1119
- ok, msg, token = signup(name, email, password, terms)
1120
- if ok:
1121
- user = resolve_session(token)
1122
- info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1123
- views = switch_view("dashboard")
1124
- return [msg, token, info] + views
1125
- views = switch_view("auth")
1126
- return [msg, "", ""] + views
1127
-
1128
- signup_btn.click(
1129
- handle_signup,
1130
- inputs=[signup_name, signup_email, signup_password, signup_terms],
1131
- outputs=[signup_msg, session_token, user_info_md] + ALL_VIEWS,
1132
  )
1133
 
1134
- def handle_login(email, password):
1135
- ok, msg, token = login(email, password)
1136
- if ok:
1137
- user = resolve_session(token)
1138
- info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1139
- views = switch_view("dashboard")
1140
- return [msg, token, info] + views
1141
- views = switch_view("auth")
1142
- return [msg, "", ""] + views
1143
-
1144
- login_btn.click(
1145
- handle_login,
1146
- inputs=[login_email, login_password],
1147
- outputs=[login_msg, session_token, user_info_md] + ALL_VIEWS,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1148
  )
1149
 
1150
- def handle_logout(token):
1151
- logout(token)
1152
- views = switch_view("landing")
1153
- return [""] + views
 
 
 
1154
 
1155
- btn_logout.click(handle_logout, inputs=[session_token], outputs=[session_token] + ALL_VIEWS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1156
 
1157
- # ---- UPLOAD ----
1158
- def handle_upload(token, files):
1159
- user = resolve_session(token)
1160
- if not user:
1161
- return "❌ Nejsi přihlášen/a. Přihlas se prosím znovu.", [], {}
1162
- if not files:
1163
- return "⚠️ Nevybral/a jsi žádný soubor.", [], {}
1164
- used = DB.count_invoices_this_month(user["id"])
1165
- limit = PLAN_LIMITS.get(user["plan"], 20)
1166
- if used >= limit:
1167
- return f"🔴 Vyčerpal/a jsi měsíční limit ({int(limit) if limit != float('inf') else '∞'} faktur). Upgraduj v sekci ⚡ Upgrade.", [], {}
1168
-
1169
- results, last_json, errors = [], {}, []
1170
- for f in files:
1171
- if used >= limit:
1172
- errors.append(f"Limit dosažen, {os.path.basename(f.name)} přeskočen.")
1173
- break
1174
- record = process_invoice_file(user, f.name, os.path.basename(f.name))
1175
- if "error" in record:
1176
- errors.append(record["error"])
1177
- continue
1178
- used += 1
1179
- last_json = record.get("raw_json", {})
1180
- results.append([None, record["vendor"], record["inv_number"], record["inv_date"],
1181
- f"{record['total']:.2f} {record['currency']}", status_badge(record["status"])])
1182
-
1183
- msg = f"✅ Zpracováno {len(results)} faktur. Použito {used}/{int(limit) if limit != float('inf') else '∞'} tento měsíc."
1184
- if errors:
1185
- msg += "\\n\\n⚠️ Chyby:\\n" + "\\n".join(f"- {e}" for e in errors)
1186
- return msg, results, last_json
1187
-
1188
- upload_btn.click(handle_upload, inputs=[session_token, upload_files],
1189
- outputs=[upload_status, upload_results, upload_raw_json])
1190
-
1191
- # ---- MY INVOICES ----
1192
- def handle_refresh_invoices(token):
1193
- user = resolve_session(token)
1194
- if not user:
1195
- return []
1196
- return invoices_to_dataframe(DB.get_invoices(user["id"]))
1197
 
1198
- refresh_invoices_btn.click(handle_refresh_invoices, inputs=[session_token], outputs=[invoices_table])
 
 
 
 
1199
 
1200
- # ---- AI CHAT ----
1201
- def handle_chat(token, message, history):
1202
- user = resolve_session(token)
1203
- if not user:
1204
- history = history or []
1205
- history.append({"role": "assistant", "content": "Nejsi přihlášen/a."})
1206
- return history, ""
1207
- answer = ai_chat_answer(user, message, history)
1208
- history = history or []
1209
- history.append({"role": "user", "content": message})
1210
- history.append({"role": "assistant", "content": answer})
1211
- return history, ""
1212
-
1213
- chat_send.click(handle_chat, inputs=[session_token, chat_input, chat_history], outputs=[chat_history, chat_input])
1214
-
1215
- # ---- EXPORT ----
1216
- def handle_export_csv(token):
1217
- user = resolve_session(token)
1218
  if not user:
1219
- raise gr.Error("Nejsi přihlášen/a.")
1220
- return export_csv(user)
1221
 
1222
- export_csv_btn.click(handle_export_csv, inputs=[session_token], outputs=[export_csv_file])
1223
-
1224
- def handle_export_json(token):
1225
- user = resolve_session(token)
1226
- if not user:
1227
- raise gr.Error("Nejsi přihlášen/a.")
1228
- if user["plan"] == "free":
1229
- raise gr.Error("JSON export je dostupný od plánu Basic. Upgraduj v sekci ⚡ Upgrade.")
1230
- return export_json(user)
1231
 
1232
- export_json_btn.click(handle_export_json, inputs=[session_token], outputs=[export_json_file])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
 
1234
- # ---- UPGRADE / STRIPE ----
1235
- def handle_upgrade(token, plan):
1236
- user = resolve_session(token)
1237
  if not user:
1238
- return "❌ Nejsi přihlášen/a."
1239
- ok, result = create_checkout_url(user, plan)
1240
- if ok:
1241
- return f"[Klikni pro dokončení platby ve Stripe →]({result})"
1242
- return f"⚠️ {result}"
1243
-
1244
- upgrade_btn.click(handle_upgrade, inputs=[session_token, plan_dropdown], outputs=[upgrade_link])
 
 
 
 
 
 
 
 
1245
 
1246
- def handle_confirm_payment(token, session_id):
1247
- user = resolve_session(token)
1248
- if not user:
1249
- return "❌ Nejsi přihlášen/a."
1250
- if not session_id:
1251
- return "Vlož prosím session_id z URL a klikni níže."
1252
- return poll_payment_status(session_id, user["id"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1253
 
1254
- confirm_payment_btn.click(handle_confirm_payment, inputs=[session_token, checkout_session_input], outputs=[payment_status_md])
1255
 
1256
- # ---- API KEY DISPLAY ----
1257
- def handle_show_api_key(token):
1258
- user = resolve_session(token)
1259
- if not user:
1260
- return "Nejsi přihlášen/a."
1261
- if user["plan"] not in ("pro", "enterprise"):
1262
- return "🔒 API přístup je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
1263
- return f"**Tvůj API klíč:** `{user.get('api_key', 'N/A')}`\\n\\n⚠️ Nikdy ho nesdílej veřejně."
1264
-
1265
- # ---- PROFILE ----
1266
- def handle_change_password(token, new_pw):
1267
- user = resolve_session(token)
1268
- if not user:
1269
- return "❌ Nejsi přihlášen/a."
1270
- ok, msg = password_strength_ok(new_pw)
1271
- if not ok:
1272
- return f"⚠️ {msg}"
1273
- if is_password_leaked(new_pw):
1274
- return "⚠️ Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné."
1275
- try:
1276
- DB.update_password(user["id"], hash_password(new_pw))
1277
- return "✅ Heslo změněno."
1278
- except Exception as e:
1279
- return f"❌ Chyba: {e}"
1280
 
1281
- change_pw_btn.click(handle_change_password, inputs=[session_token, new_password], outputs=[change_pw_msg])
1282
 
1283
- def handle_delete_account(token, confirmed):
1284
- user = resolve_session(token)
1285
- if not user:
1286
- return "❌ Nejsi přihlášen/a.", token
1287
- if not confirmed:
1288
- return "⚠️ Zaškrtni prosím potvrzení.", token
1289
- try:
1290
- DB.delete_user(user["id"])
1291
- return "✅ Účet smazán. Sbohem!", ""
1292
- except Exception as e:
1293
- return f"❌ Chyba při mazání: {e}", token
1294
 
1295
- delete_btn.click(handle_delete_account, inputs=[session_token, delete_confirm], outputs=[delete_msg, session_token])
 
 
1296
 
1297
- # ---- Dashboard data loading ----
1298
- def on_dashboard_enter(token):
1299
- user = resolve_session(token)
1300
- if not user:
1301
- return "", "", []
1302
- profile = f"**Jméno:** {user['name']}\\n\\n**Email:** {user['email']}\\n\\n**Plán:** {user['plan']}"
1303
- api_txt = handle_show_api_key(token)
1304
- invoices = invoices_to_dataframe(DB.get_invoices(user["id"]))
1305
- return profile, api_txt, invoices
1306
 
1307
- session_token.change(on_dashboard_enter, inputs=[session_token], outputs=[profile_info, api_key_display, invoices_table])
1308
 
1309
 
1310
  if __name__ == "__main__":
1311
- try:
1312
- demo.queue(max_size=20).launch(server_name="0.0.0.0", server_port=7860)
1313
- except Exception as e:
1314
- print(f"[FATAL] Aplikace se nepodařila spustit: {e}")
1315
- traceback.print_exc()
 
1
  """
2
+ app.py -- OmniParse AI main application.
3
+
4
+ Architecture:
5
+ * Gradio is used purely as a headless API/session backend (Blocks with
6
+ visible=False components acting as an RPC bridge).
7
+ * The actual UI is a single gr.HTML() block containing hand-written
8
+ Tailwind-based HTML/CSS/JS. JS calls into Python by writing into a
9
+ hidden gr.Textbox and firing a hidden gr.Button.click().
10
+ * Gradio 4.x is built on FastAPI, so we mount the Stripe webhook route
11
+ directly on the underlying FastAPI app (needs raw bytes + header
12
+ signature verification, which Gradio components can't provide).
13
  """
14
 
15
  import os
 
 
16
  import json
 
17
  import base64
18
+ import logging
 
 
 
 
19
 
20
  import gradio as gr
 
 
21
 
22
+ import auth
23
+ import backend
24
+ import database as db
25
+
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger("omniparse.app")
28
+
29
+ APP_TITLE = "OmniParse AI"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  PLAN_LIMITS = {
32
+ "free": {"invoices": 20, "users": 1, "label": "Free"},
33
+ "basic": {"invoices": 200, "users": 1, "label": "Basic"},
34
+ "pro": {"invoices": 2000, "users": 3, "label": "Pro"},
35
+ "enterprise": {"invoices": float("inf"), "users": float("inf"), "label": "Enterprise"},
36
  }
37
 
38
+ # ---------------------------------------------------------------------------
39
+ # Shared CSS: hides Gradio chrome, defines tooltips + skeleton loaders.
40
+ # ---------------------------------------------------------------------------
41
+ BASE_CSS = """
42
+ <style>
43
+ footer, .gradio-container .footer, #footer { display: none !important; }
44
+ .gradio-container { max-width: 100% !important; padding: 0 !important; background: #0b0d12 !important; }
45
+ body { margin: 0; font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; }
46
+
47
+ /* ---------------- Tooltip ---------------- */
48
+ .op-tooltip { position: relative; display: inline-flex; }
49
+ .op-tooltip::after {
50
+ content: attr(data-tip);
51
+ position: absolute;
52
+ bottom: 125%; left: 50%; transform: translateX(-50%);
53
+ background: #111827; color: #f9fafb; padding: 6px 10px;
54
+ border-radius: 6px; font-size: 12px; white-space: nowrap;
55
+ opacity: 0; pointer-events: none; transition: opacity 0.15s ease;
56
+ box-shadow: 0 4px 14px rgba(0,0,0,0.35);
57
+ z-index: 50;
58
+ }
59
+ .op-tooltip::before {
60
+ content: ""; position: absolute; bottom: 118%; left: 50%; transform: translateX(-50%);
61
+ border: 5px solid transparent; border-top-color: #111827;
62
+ opacity: 0; transition: opacity 0.15s ease; z-index: 50;
63
  }
64
+ .op-tooltip:hover::after, .op-tooltip:hover::before { opacity: 1; }
65
 
66
+ /* ---------------- Skeleton loaders ---------------- */
67
+ @keyframes op-shimmer {
68
+ 0% { background-position: -400px 0; }
69
+ 100% { background-position: 400px 0; }
70
+ }
71
+ .op-skel {
72
+ background: linear-gradient(90deg, #1a1d27 25%, #232734 37%, #1a1d27 63%);
73
+ background-size: 800px 100%;
74
+ animation: op-shimmer 1.4s ease-in-out infinite;
75
+ border-radius: 8px;
76
+ }
77
+ .op-skel-row { height: 44px; margin-bottom: 8px; }
78
+ .op-skel-text { height: 14px; width: 60%; margin-bottom: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ .op-fade-in { animation: op-fade 0.25s ease both; }
81
+ @keyframes op-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
82
+ </style>
83
+ """
 
 
 
 
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # Landing page HTML
87
+ # ---------------------------------------------------------------------------
88
+ LANDING_HTML = f"""
89
+ {BASE_CSS}
90
+ <script src="https://cdn.tailwindcss.com"></script>
91
+ <div id="op-root" class="min-h-screen bg-[#0b0d12] text-gray-100">
92
+
93
+ <nav class="flex items-center justify-between px-8 py-5 border-b border-gray-800">
94
+ <div class="flex items-center gap-2">
95
+ <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-cyan-400"></div>
96
+ <span class="font-semibold text-lg">{APP_TITLE}</span>
97
+ </div>
98
+ <div class="flex items-center gap-6 text-sm text-gray-300">
99
+ <a href="#pricing" class="hover:text-white">Pricing</a>
100
+ <a href="#faq" class="hover:text-white">FAQ</a>
101
+ <button onclick="opNavigate('legal')" class="hover:text-white">Legal</button>
102
+ <button onclick="opNavigate('login')" class="hover:text-white">Log in</button>
103
+ <button onclick="opNavigate('register')"
104
+ class="op-tooltip bg-indigo-500 hover:bg-indigo-400 text-white px-4 py-2 rounded-lg font-medium"
105
+ data-tip="Start free, no card required">
106
+ Get started
107
+ </button>
108
+ </div>
109
+ </nav>
110
+
111
+ <header class="text-center px-6 py-24 max-w-4xl mx-auto">
112
+ <h1 class="text-5xl font-bold tracking-tight mb-4">Invoice processing in seconds, not hours.</h1>
113
+ <p class="text-gray-400 text-lg mb-6">99.2% accuracy &middot; &lt;4s per invoice &middot; 40+ formats</p>
114
+ <div class="flex items-center justify-center gap-4">
115
+ <button onclick="opNavigate('register')" class="bg-indigo-500 hover:bg-indigo-400 px-6 py-3 rounded-lg font-semibold">Start free</button>
116
+ <button onclick="opNavigate('pricing')" class="border border-gray-700 hover:border-gray-500 px-6 py-3 rounded-lg font-semibold">See pricing</button>
117
+ </div>
118
+ </header>
119
+
120
+ <section class="grid grid-cols-1 md:grid-cols-4 gap-6 px-8 max-w-6xl mx-auto pb-24">
121
+ <div class="op-tooltip bg-[#12141c] border border-gray-800 rounded-xl p-6" data-tip="Google Vision + pytesseract, cross-checked by an LLM">
122
+ <h3 class="font-semibold mb-2">OCR + LLM extraction</h3>
123
+ <p class="text-sm text-gray-400">Combines computer vision OCR with large language models for structured field extraction.</p>
124
+ </div>
125
+ <div class="op-tooltip bg-[#12141c] border border-gray-800 rounded-xl p-6" data-tip="Flags repeated vendor + amount within the same month">
126
+ <h3 class="font-semibold mb-2">Duplicate detection</h3>
127
+ <p class="text-sm text-gray-400">Automatically flags invoices that match an existing vendor and amount this month.</p>
128
+ </div>
129
+ <div class="op-tooltip bg-[#12141c] border border-gray-800 rounded-xl p-6" data-tip="Chat with your invoice history, Pro plan and above">
130
+ <h3 class="font-semibold mb-2">AI Chat</h3>
131
+ <p class="text-sm text-gray-400">Ask questions about your invoice data in plain English.</p>
132
+ </div>
133
+ <div class="op-tooltip bg-[#12141c] border border-gray-800 rounded-xl p-6" data-tip="Subtotal + tax is checked against the total, tolerance 0.10">
134
+ <h3 class="font-semibold mb-2">Cross-field validation</h3>
135
+ <p class="text-sm text-gray-400">Automatically verifies that subtotal plus tax reconciles with the stated total.</p>
136
+ </div>
137
+ </section>
138
+
139
+ <section id="pricing" class="px-8 pb-24 max-w-6xl mx-auto">
140
+ <h2 class="text-3xl font-bold text-center mb-10">Pricing</h2>
141
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
142
+
143
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl p-6 flex flex-col">
144
+ <h3 class="font-semibold text-lg">Free</h3>
145
+ <p class="text-3xl font-bold my-3">$0<span class="text-sm text-gray-500">/mo</span></p>
146
+ <ul class="text-sm text-gray-400 space-y-2 flex-1">
147
+ <li class="op-tooltip" data-tip="Resets on the 1st of each month">20 invoices/mo</li>
148
+ <li>1 user</li>
149
+ <li>CSV export</li>
150
+ </ul>
151
+ <button onclick="opNavigate('register')" class="mt-6 border border-gray-700 hover:border-gray-500 rounded-lg py-2 font-medium">Choose Free</button>
152
+ </div>
153
+
154
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl p-6 flex flex-col">
155
+ <h3 class="font-semibold text-lg">Basic</h3>
156
+ <p class="text-3xl font-bold my-3">$29<span class="text-sm text-gray-500">/mo</span></p>
157
+ <ul class="text-sm text-gray-400 space-y-2 flex-1">
158
+ <li>200 invoices/mo</li>
159
+ <li>1 user</li>
160
+ <li>JSON/Excel export</li>
161
+ </ul>
162
+ <button onclick="opCheckout('basic')" class="mt-6 border border-gray-700 hover:border-gray-500 rounded-lg py-2 font-medium">Choose Basic</button>
163
+ </div>
164
+
165
+ <div class="bg-[#12141c] border-2 border-indigo-500 rounded-xl p-6 flex flex-col relative">
166
+ <span class="absolute -top-3 left-1/2 -translate-x-1/2 bg-indigo-500 text-xs px-3 py-1 rounded-full font-semibold">Most popular</span>
167
+ <h3 class="font-semibold text-lg">Pro</h3>
168
+ <p class="text-3xl font-bold my-3">$129<span class="text-sm text-gray-500">/mo</span></p>
169
+ <ul class="text-sm text-gray-400 space-y-2 flex-1">
170
+ <li>2000 invoices/mo</li>
171
+ <li>3 users</li>
172
+ <li class="op-tooltip" data-tip="Programmatic access via api_key">REST API</li>
173
+ <li>AI Chat</li>
174
+ <li>Duplicate detection</li>
175
+ </ul>
176
+ <button onclick="opCheckout('pro')" class="mt-6 bg-indigo-500 hover:bg-indigo-400 rounded-lg py-2 font-medium">Choose Pro</button>
177
+ </div>
178
+
179
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl p-6 flex flex-col">
180
+ <h3 class="font-semibold text-lg">Enterprise</h3>
181
+ <p class="text-3xl font-bold my-3">$499<span class="text-sm text-gray-500">/mo</span></p>
182
+ <ul class="text-sm text-gray-400 space-y-2 flex-1">
183
+ <li>Unlimited invoices</li>
184
+ <li class="op-tooltip" data-tip="A human reviews low-confidence extractions">Human review UI</li>
185
+ <li>SLA</li>
186
+ </ul>
187
+ <button onclick="opCheckout('enterprise')" class="mt-6 border border-gray-700 hover:border-gray-500 rounded-lg py-2 font-medium">Contact sales</button>
188
+ </div>
189
+ </div>
190
+ </section>
191
+
192
+ <section id="faq" class="px-8 pb-24 max-w-3xl mx-auto">
193
+ <h2 class="text-3xl font-bold text-center mb-10">Frequently asked questions</h2>
194
+ <div id="op-faq-list" class="space-y-3"></div>
195
+ </section>
196
+ </div>
197
 
198
+ <script>
199
+ const OP_FAQ = [
200
+ ["What file formats are supported?", "OmniParse AI accepts PDF invoices and common image formats (PNG, JPG, WEBP) up to 5MB, covering 40+ regional invoice layouts."],
201
+ ["How accurate is the extraction?", "Our OCR + LLM cascade achieves 99.2% field-level accuracy on standard invoice layouts, with automatic flagging of low-confidence results for review."],
202
+ ["What happens if a field can't be validated?", "Invoices where the subtotal and tax don't reconcile with the total are marked 'needs review' instead of being silently accepted."],
203
+ ["Can I cancel anytime?", "Yes, all paid plans are month-to-month and can be cancelled from your dashboard at any time."],
204
+ ["Is my data secure?", "All data is encrypted in transit and at rest, access is enforced via row-level security, and passwords are hashed with bcrypt."]
205
+ ];
206
+
207
+ function opRenderFaq() {{
208
+ const list = document.getElementById('op-faq-list');
209
+ if (!list) return;
210
+ list.innerHTML = OP_FAQ.map((item, i) => `
211
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl overflow-hidden">
212
+ <button onclick="opToggleFaq(${{i}})" class="w-full text-left px-5 py-4 flex justify-between items-center">
213
+ <span class="font-medium">${{item[0]}}</span>
214
+ <span id="op-faq-icon-${{i}}" class="text-gray-500">+</span>
215
+ </button>
216
+ <div id="op-faq-body-${{i}}" class="hidden px-5 pb-4 text-sm text-gray-400">${{item[1]}}</div>
217
+ </div>
218
+ `).join('');
219
+ }}
220
+ function opToggleFaq(i) {{
221
+ const body = document.getElementById('op-faq-body-' + i);
222
+ const icon = document.getElementById('op-faq-icon-' + i);
223
+ const isHidden = body.classList.contains('hidden');
224
+ body.classList.toggle('hidden');
225
+ icon.textContent = isHidden ? '\u2212' : '+';
226
+ }}
227
+ opRenderFaq();
228
+
229
+ function opNavigate(route) {{
230
+ const input = document.querySelector('#op-route-bridge textarea, #op-route-bridge input');
231
+ if (!input) return;
232
+ input.value = route;
233
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
234
+ document.querySelector('#op-route-trigger button').click();
235
+ }}
236
+ function opCheckout(plan) {{
237
+ const input = document.querySelector('#op-checkout-bridge textarea, #op-checkout-bridge input');
238
+ if (!input) return;
239
+ input.value = plan;
240
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
241
+ document.querySelector('#op-checkout-trigger button').click();
242
+ }}
243
+ </script>
244
+ """
245
+
246
+ LEGAL_HTML = f"""
247
+ {BASE_CSS}
248
+ <script src="https://cdn.tailwindcss.com"></script>
249
+ <div class="min-h-screen bg-[#0b0d12] text-gray-100 px-8 py-16 max-w-3xl mx-auto">
250
+ <button onclick="opNavigate('landing')" class="text-sm text-gray-400 hover:text-white mb-8">&larr; Back</button>
251
+ <h1 class="text-3xl font-bold mb-6">Terms of Service &amp; Privacy Policy</h1>
252
+ <p class="text-gray-400 text-sm mb-4">This is placeholder legal copy for demonstration purposes. Replace with counsel-reviewed
253
+ Terms of Service, a Privacy Policy, and a Data Processing Agreement before accepting real customer data.</p>
254
+ </div>
255
+ <script>
256
+ function opNavigate(route) {{
257
+ const input = document.querySelector('#op-route-bridge textarea, #op-route-bridge input');
258
+ if (!input) return;
259
+ input.value = route;
260
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
261
+ document.querySelector('#op-route-trigger button').click();
262
+ }}
263
+ </script>
264
+ """
265
+
266
+ AUTH_HTML_TMPL = """
267
+ {css}
268
+ <script src="https://cdn.tailwindcss.com"></script>
269
+ <div class="min-h-screen bg-[#0b0d12] text-gray-100 flex items-center justify-center px-4">
270
+ <div class="w-full max-w-sm bg-[#12141c] border border-gray-800 rounded-xl p-8">
271
+ <h1 class="text-xl font-bold mb-1">{heading}</h1>
272
+ <p class="text-sm text-gray-500 mb-6">{sub}</p>
273
+ <div id="op-auth-error" class="hidden text-sm text-red-400 bg-red-950/40 border border-red-900 rounded-lg px-3 py-2 mb-4"></div>
274
+ {fields}
275
+ <button onclick="{submit_fn}()" class="w-full bg-indigo-500 hover:bg-indigo-400 rounded-lg py-2 font-medium mt-2">{cta}</button>
276
+ <p class="text-xs text-gray-500 mt-4 text-center">{footer}</p>
277
  </div>
278
+ </div>
279
+ <script>
280
+ function opNavigate(route) {{
281
+ const input = document.querySelector('#op-route-bridge textarea, #op-route-bridge input');
282
+ if (!input) return;
283
+ input.value = route;
284
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
285
+ document.querySelector('#op-route-trigger button').click();
286
+ }}
287
+ function opShowError(msg) {{
288
+ const el = document.getElementById('op-auth-error');
289
+ el.textContent = msg;
290
+ el.classList.remove('hidden');
291
+ }}
292
+ function opSubmitAuth(action) {{
293
+ const payload = {{ action }};
294
+ document.querySelectorAll('.op-field').forEach(f => payload[f.name] = f.value);
295
+ const input = document.querySelector('#op-auth-bridge textarea, #op-auth-bridge input');
296
+ input.value = JSON.stringify(payload);
297
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
298
+ document.querySelector('#op-auth-trigger button').click();
299
+ }}
300
+ function opLogin() {{ opSubmitAuth('login'); }}
301
+ function opRegister() {{ opSubmitAuth('register'); }}
302
+ function opReset() {{ opSubmitAuth('reset'); }}
303
+ </script>
304
+ """
305
+
306
+
307
+ def render_login_html() -> str:
308
+ fields = """
309
+ <input class="op-field w-full bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 mb-3 text-sm" name="email" type="email" placeholder="Email">
310
+ <input class="op-field w-full bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 mb-3 text-sm" name="password" type="password" placeholder="Password">
311
+ """
312
+ return AUTH_HTML_TMPL.format(
313
+ css=BASE_CSS, heading="Welcome back", sub="Log in to your OmniParse AI account.",
314
+ fields=fields, submit_fn="opLogin", cta="Log in",
315
+ footer='No account? <button onclick="opNavigate(\'register\')" class="text-indigo-400">Sign up</button>',
316
+ )
317
+
318
+
319
+ def render_register_html() -> str:
320
+ fields = """
321
+ <input class="op-field w-full bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 mb-3 text-sm" name="name" type="text" placeholder="Full name">
322
+ <input class="op-field w-full bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 mb-3 text-sm" name="email" type="email" placeholder="Email">
323
+ <input class="op-field w-full bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 mb-3 text-sm" name="password" type="password" placeholder="Password (min. 8 characters)">
324
+ """
325
+ return AUTH_HTML_TMPL.format(
326
+ css=BASE_CSS, heading="Create your account", sub="Start with 20 free invoices a month.",
327
+ fields=fields, submit_fn="opRegister", cta="Create account",
328
+ footer='Already have an account? <button onclick="opNavigate(\'login\')" class="text-indigo-400">Log in</button>',
329
+ )
330
+
331
 
332
+ DASHBOARD_HTML_TMPL = """
333
+ {css}
334
+ <script src="https://cdn.tailwindcss.com"></script>
335
+ <div class="min-h-screen bg-[#0b0d12] text-gray-100">
336
+ <nav class="flex items-center justify-between px-8 py-4 border-b border-gray-800">
337
+ <div class="flex items-center gap-2">
338
+ <div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-cyan-400"></div>
339
+ <span class="font-semibold">{app_title}</span>
340
+ <span class="ml-2 text-xs bg-indigo-500/20 text-indigo-300 px-2 py-0.5 rounded-full">{plan_label}</span>
341
+ </div>
342
+ <div class="flex items-center gap-4 text-sm text-gray-300">
343
+ <span class="op-tooltip" data-tip="Invoices processed this calendar month">{used}/{limit} used</span>
344
+ <button onclick="opLogout()" class="hover:text-white">Log out</button>
345
+ </div>
346
+ </nav>
347
+
348
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 p-8">
349
+ <div class="lg:col-span-2 space-y-6">
350
+ <div id="op-dropzone"
351
+ ondragover="event.preventDefault(); this.classList.add('border-indigo-400');"
352
+ ondragleave="this.classList.remove('border-indigo-400');"
353
+ ondrop="opHandleDrop(event)"
354
+ class="op-tooltip border-2 border-dashed border-gray-700 rounded-xl p-10 text-center cursor-pointer"
355
+ data-tip="PDF, PNG or JPG, up to 5MB"
356
+ onclick="document.getElementById('op-file-input').click()">
357
+ <p class="font-medium">Drop an invoice here, or click to browse</p>
358
+ <p class="text-xs text-gray-500 mt-1">PDF, PNG, JPG &middot; max 5MB</p>
359
+ <input id="op-file-input" type="file" accept=".pdf,.png,.jpg,.jpeg,.webp" class="hidden" onchange="opHandleFileInput(event)">
360
+ </div>
361
+
362
+ <div id="op-skeleton-area" class="hidden space-y-2">
363
+ <div class="op-skel op-skel-row"></div>
364
+ <div class="op-skel op-skel-row"></div>
365
+ <div class="op-skel op-skel-row"></div>
366
+ </div>
367
+
368
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl overflow-hidden">
369
+ <table class="w-full text-sm">
370
+ <thead class="bg-[#181b25] text-gray-400 text-xs uppercase">
371
+ <tr>
372
+ <th class="text-left px-4 py-3">Vendor</th>
373
+ <th class="text-left px-4 py-3">Invoice #</th>
374
+ <th class="text-left px-4 py-3">Date</th>
375
+ <th class="text-right px-4 py-3">Total</th>
376
+ <th class="text-left px-4 py-3">Status</th>
377
+ </tr>
378
+ </thead>
379
+ <tbody id="op-invoice-rows">{invoice_rows}</tbody>
380
+ </table>
381
+ </div>
382
+ </div>
383
+
384
+ <div class="bg-[#12141c] border border-gray-800 rounded-xl p-5 flex flex-col h-[560px]">
385
+ <h3 class="font-semibold mb-1 op-tooltip" data-tip="Available on Pro and Enterprise plans">AI Chat</h3>
386
+ <div id="op-chat-log" class="flex-1 overflow-y-auto space-y-3 my-3 text-sm">{chat_placeholder}</div>
387
+ <div class="flex gap-2">
388
+ <input id="op-chat-input" {chat_disabled} class="flex-1 bg-[#0b0d12] border border-gray-700 rounded-lg px-3 py-2 text-sm" placeholder="Ask about your invoices...">
389
+ <button onclick="opSendChat()" {chat_disabled} class="bg-indigo-500 hover:bg-indigo-400 disabled:opacity-40 rounded-lg px-4 text-sm font-medium">Send</button>
390
+ </div>
391
+ </div>
392
  </div>
393
  </div>
394
+
395
+ <script>
396
+ function opNavigate(route) {{
397
+ const input = document.querySelector('#op-route-bridge textarea, #op-route-bridge input');
398
+ if (!input) return;
399
+ input.value = route;
400
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
401
+ document.querySelector('#op-route-trigger button').click();
402
+ }}
403
+ function opLogout() {{ opNavigate('logout'); }}
404
+
405
+ function opHandleDrop(ev) {{
406
+ ev.preventDefault();
407
+ ev.currentTarget.classList.remove('border-indigo-400');
408
+ const file = ev.dataTransfer.files[0];
409
+ if (file) opUploadFile(file);
410
+ }}
411
+ function opHandleFileInput(ev) {{
412
+ const file = ev.target.files[0];
413
+ if (file) opUploadFile(file);
414
+ }}
415
+ function opUploadFile(file) {{
416
+ if (file.size > 5 * 1024 * 1024) {{
417
+ alert('File exceeds the 5MB limit.');
418
+ return;
419
+ }}
420
+ document.getElementById('op-skeleton-area').classList.remove('hidden');
421
+ const reader = new FileReader();
422
+ reader.onload = function() {{
423
+ const payload = JSON.stringify({{ filename: file.name, data: reader.result }});
424
+ const input = document.querySelector('#op-upload-bridge textarea, #op-upload-bridge input');
425
+ input.value = payload;
426
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
427
+ document.querySelector('#op-upload-trigger button').click();
428
+ }};
429
+ reader.readAsDataURL(file);
430
+ }}
431
+ function opSendChat() {{
432
+ const box = document.getElementById('op-chat-input');
433
+ const msg = box.value.trim();
434
+ if (!msg) return;
435
+ box.value = '';
436
+ const log = document.getElementById('op-chat-log');
437
+ log.innerHTML += `<div class="text-right"><span class="inline-block bg-indigo-500/20 rounded-lg px-3 py-2">${{msg}}</span></div>`;
438
+ const input = document.querySelector('#op-chat-bridge textarea, #op-chat-bridge input');
439
+ input.value = msg;
440
+ input.dispatchEvent(new Event('input', {{ bubbles: true }}));
441
+ document.querySelector('#op-chat-trigger button').click();
442
+ }}
443
+ </script>
444
+ """
445
+
446
+
447
+ def render_invoice_rows(invoices: list) -> str:
448
+ if not invoices:
449
+ return '<tr><td colspan="5" class="px-4 py-6 text-center text-gray-500">No invoices processed yet.</td></tr>'
450
  rows = []
451
  for inv in invoices:
452
+ status = inv.get("status", "done")
453
+ badge_color = {
454
+ "done": "bg-emerald-500/20 text-emerald-300",
455
+ "needs_review": "bg-amber-500/20 text-amber-300",
456
+ "error": "bg-red-500/20 text-red-300",
457
+ "processing": "bg-gray-500/20 text-gray-300",
458
+ }.get(status, "bg-gray-500/20 text-gray-300")
459
+ dup_flag = ' <span class="text-xs text-amber-400">(dup)</span>' if inv.get("is_duplicate") else ""
460
+ rows.append(f"""
461
+ <tr class="border-t border-gray-800 op-fade-in">
462
+ <td class="px-4 py-3">{inv.get('vendor') or '—'}{dup_flag}</td>
463
+ <td class="px-4 py-3">{inv.get('inv_number') or '—'}</td>
464
+ <td class="px-4 py-3">{inv.get('inv_date') or '—'}</td>
465
+ <td class="px-4 py-3 text-right">{inv.get('total') if inv.get('total') is not None else '—'} {inv.get('currency') or ''}</td>
466
+ <td class="px-4 py-3"><span class="text-xs px-2 py-1 rounded-full {badge_color}">{status}</span></td>
467
+ </tr>""")
468
+ return "".join(rows)
469
+
470
+
471
+ def render_dashboard_html(user: dict, invoices: list) -> str:
472
+ plan = user.get("plan", "free")
473
+ limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
474
+ used = db.count_invoices_this_month(user["id"]) if user["id"] != -1 else len(invoices)
475
+ limit_display = "∞" if limits["invoices"] == float("inf") else limits["invoices"]
476
+ chat_enabled = plan in ("pro", "enterprise")
477
+ return DASHBOARD_HTML_TMPL.format(
478
+ css=BASE_CSS,
479
+ app_title=APP_TITLE,
480
+ plan_label=limits["label"],
481
+ used=used,
482
+ limit=limit_display,
483
+ invoice_rows=render_invoice_rows(invoices),
484
+ chat_disabled="" if chat_enabled else "disabled",
485
+ chat_placeholder="" if chat_enabled else '<p class="text-gray-500 text-xs">AI Chat is available on Pro and Enterprise plans.</p>',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  )
487
 
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Gradio Blocks: hidden RPC bridge + route switching
491
+ # ---------------------------------------------------------------------------
492
+ with gr.Blocks(title=APP_TITLE, css=".gradio-container {background:#0b0d12}") as demo:
493
+
494
+ session_token = gr.State(value=None)
495
+ current_route = gr.State(value="landing")
496
+
497
+ page = gr.HTML(LANDING_HTML)
498
+
499
+ with gr.Row(visible=False, elem_id="op-route-bridge"):
500
+ route_input = gr.Textbox(elem_id="op-route-input")
501
+ with gr.Row(visible=False, elem_id="op-route-trigger"):
502
+ route_button = gr.Button("route")
503
+
504
+ with gr.Row(visible=False, elem_id="op-auth-bridge"):
505
+ auth_input = gr.Textbox(elem_id="op-auth-input")
506
+ with gr.Row(visible=False, elem_id="op-auth-trigger"):
507
+ auth_button = gr.Button("auth")
508
+
509
+ with gr.Row(visible=False, elem_id="op-upload-bridge"):
510
+ upload_input = gr.Textbox(elem_id="op-upload-input")
511
+ with gr.Row(visible=False, elem_id="op-upload-trigger"):
512
+ upload_button = gr.Button("upload")
513
+
514
+ with gr.Row(visible=False, elem_id="op-checkout-bridge"):
515
+ checkout_input = gr.Textbox(elem_id="op-checkout-input")
516
+ with gr.Row(visible=False, elem_id="op-checkout-trigger"):
517
+ checkout_button = gr.Button("checkout")
518
+
519
+ with gr.Row(visible=False, elem_id="op-chat-bridge"):
520
+ chat_input = gr.Textbox(elem_id="op-chat-input-hidden")
521
+ with gr.Row(visible=False, elem_id="op-chat-trigger"):
522
+ chat_button = gr.Button("chat")
523
+
524
+ # -- Route handling ------------------------------------------------
525
+ def handle_route(route: str, token: str):
526
+ route = (route or "landing").strip()
527
+ if route == "logout":
528
+ auth.logout(token)
529
+ return LANDING_HTML, None, "landing"
530
+ if route == "dashboard":
531
+ user = auth.get_current_user(token) if token else None
532
+ if not user:
533
+ return render_login_html(), token, "login"
534
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
535
+ return render_dashboard_html(user, invoices), token, "dashboard"
536
+ if route == "login":
537
+ return render_login_html(), token, "login"
538
+ if route == "register":
539
+ return render_register_html(), token, "register"
540
+ if route == "legal":
541
+ return LEGAL_HTML, token, "legal"
542
+ if route == "pricing":
543
+ return LANDING_HTML, token, "landing"
544
+ return LANDING_HTML, token, "landing"
545
+
546
+ route_button.click(
547
+ handle_route,
548
+ inputs=[route_input, session_token],
549
+ outputs=[page, session_token, current_route],
550
  )
551
 
552
+ # -- Auth handling ---------------------------------------------------
553
+ def handle_auth(payload_json: str, token: str, request: gr.Request):
554
+ client_ip = request.client.host if request and request.client else "unknown"
555
+ try:
556
+ payload = json.loads(payload_json) if payload_json else {}
557
+ except json.JSONDecodeError:
558
+ return render_login_html(), token, "login"
559
 
560
+ action = payload.get("action")
561
+ try:
562
+ if action == "login":
563
+ result = auth.login(payload.get("email", ""), payload.get("password", ""), client_ip)
564
+ new_token = result["token"]
565
+ user = result["user"]
566
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
567
+ return render_dashboard_html(user, invoices), new_token, "dashboard"
568
+
569
+ if action == "register":
570
+ result = auth.register(
571
+ payload.get("email", ""), payload.get("name", ""),
572
+ payload.get("password", ""), client_ip,
573
+ )
574
+ new_token = result["token"]
575
+ user = result["user"]
576
+ return render_dashboard_html(user, []), new_token, "dashboard"
577
+
578
+ if action == "reset":
579
+ auth.request_password_reset(payload.get("email", ""), client_ip)
580
+ return render_login_html(), token, "login"
581
+
582
+ except auth.AuthError as exc:
583
+ logger.info("Auth error: %s", exc.message)
584
+ error_html = render_login_html() if action == "login" else render_register_html()
585
+ error_html = error_html.replace(
586
+ 'id="op-auth-error" class="hidden',
587
+ f'id="op-auth-error" class="op-fade-in',
588
+ ).replace(
589
+ '></div>', f'>{exc.message}</div>', 1,
590
+ )
591
+ return error_html, token, action or "login"
592
 
593
+ return render_login_html(), token, "login"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
 
595
+ auth_button.click(
596
+ handle_auth,
597
+ inputs=[auth_input, session_token],
598
+ outputs=[page, session_token, current_route],
599
+ )
600
 
601
+ # -- Upload / processing pipeline ------------------------------------
602
+ def handle_upload(payload_json: str, token: str):
603
+ user = auth.get_current_user(token) if token else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  if not user:
605
+ return render_login_html(), token
 
606
 
607
+ try:
608
+ payload = json.loads(payload_json)
609
+ filename = payload["filename"]
610
+ raw_bytes = backend._bytes_from_base64(payload["data"])
611
+ except Exception as exc:
612
+ logger.warning("Bad upload payload: %s", exc)
613
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
614
+ return render_dashboard_html(user, invoices), token
 
615
 
616
+ try:
617
+ parsed = backend.process_invoice_file(raw_bytes, filename)
618
+ if user["id"] != -1:
619
+ db.save_invoice_record(user["id"], filename, parsed)
620
+ except backend.UploadTooLarge:
621
+ logger.info("Upload rejected: file too large.")
622
+ except Exception as exc:
623
+ logger.error("Processing failed for %s: %s", filename, exc)
624
+
625
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
626
+ return render_dashboard_html(user, invoices), token
627
+
628
+ upload_button.click(
629
+ handle_upload,
630
+ inputs=[upload_input, session_token],
631
+ outputs=[page, session_token],
632
+ )
633
 
634
+ # -- Checkout (Stripe) -------------------------------------------------
635
+ def handle_checkout(plan: str, token: str):
636
+ user = auth.get_current_user(token) if token else None
637
  if not user:
638
+ return render_register_html(), token, "register"
639
+ if plan == "enterprise":
640
+ return render_dashboard_html(user, db.list_invoices(user["id"]) if user["id"] != -1 else []), token, "dashboard"
641
+ # In production: create a Stripe Checkout Session here with
642
+ # client_reference_id=user["id"] and redirect the browser to
643
+ # session.url. Left as an integration point tied to your Stripe
644
+ # account's live Price IDs.
645
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
646
+ return render_dashboard_html(user, invoices), token, "dashboard"
647
+
648
+ checkout_button.click(
649
+ handle_checkout,
650
+ inputs=[checkout_input, session_token],
651
+ outputs=[page, session_token, current_route],
652
+ )
653
 
654
+ # -- AI Chat (Pro+) ----------------------------------------------------
655
+ def handle_chat(message: str, token: str):
656
+ user = auth.get_current_user(token) if token else None
657
+ if not user or user.get("plan") not in ("pro", "enterprise"):
658
+ invoices = db.list_invoices(user["id"]) if user and user["id"] != -1 else []
659
+ return render_dashboard_html(user, invoices) if user else render_login_html(), token
660
+
661
+ invoices = db.list_invoices(user["id"]) if user["id"] != -1 else []
662
+ context = json.dumps(invoices[:50], default=str)
663
+ reply = "AI Chat requires GROQ_API_KEY to be configured."
664
+ if backend.GROQ_API_KEY:
665
+ try:
666
+ import requests as _requests
667
+ resp = _requests.post(
668
+ "https://api.groq.com/openai/v1/chat/completions",
669
+ headers={"Authorization": f"Bearer {backend.GROQ_API_KEY}"},
670
+ json={
671
+ "model": "llama-3.1-8b-instant",
672
+ "messages": [
673
+ {"role": "system", "content": f"You answer questions about this invoice data: {context[:6000]}"},
674
+ {"role": "user", "content": message},
675
+ ],
676
+ "temperature": 0.2,
677
+ },
678
+ timeout=15,
679
+ )
680
+ resp.raise_for_status()
681
+ reply = resp.json()["choices"][0]["message"]["content"]
682
+ except Exception as exc:
683
+ logger.warning("Chat request failed: %s", exc)
684
+ reply = "Sorry, the AI Chat service is temporarily unavailable."
685
+
686
+ dashboard = render_dashboard_html(user, invoices)
687
+ safe_reply = reply.replace("`", "'").replace("</", "<\\/")
688
+ dashboard += f"""<script>
689
+ document.getElementById('op-chat-log').innerHTML +=
690
+ `<div><span class="inline-block bg-gray-800 rounded-lg px-3 py-2">{safe_reply}</span></div>`;
691
+ </script>"""
692
+ return dashboard, token
693
+
694
+ chat_button.click(
695
+ handle_chat,
696
+ inputs=[chat_input, session_token],
697
+ outputs=[page, session_token],
698
+ )
699
 
 
700
 
701
+ # ---------------------------------------------------------------------------
702
+ # Mount Gradio on a FastAPI app so we can add a raw-body Stripe webhook route.
703
+ # The webhook needs the unparsed request body + the `stripe-signature` header
704
+ # for signature verification, which Gradio components cannot provide.
705
+ # ---------------------------------------------------------------------------
706
+ from fastapi import FastAPI, Request as FastAPIRequest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
 
708
+ fastapi_app = FastAPI()
709
 
 
 
 
 
 
 
 
 
 
 
 
710
 
711
+ @fastapi_app.post("/stripe/webhook")
712
+ async def stripe_webhook(request: FastAPIRequest):
713
+ return await backend.stripe_webhook_handler(request)
714
 
 
 
 
 
 
 
 
 
 
715
 
716
+ app = gr.mount_gradio_app(fastapi_app, demo, path="/")
717
 
718
 
719
  if __name__ == "__main__":
720
+ import uvicorn
721
+
722
+ demo.queue()
723
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
 
auth.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ auth.py -- Authentication logic for OmniParse AI.
3
+
4
+ Uses bcrypt for password hashing, an in-memory sliding-window rate limiter
5
+ for login/register/reset, and issues opaque session tokens stored server-side
6
+ (see database.py -- sessions table). No tokens or credentials are ever placed
7
+ in LocalStorage; the frontend receives an HttpOnly cookie / Gradio server state.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import time
13
+ import logging
14
+ import threading
15
+ from collections import defaultdict, deque
16
+ from typing import Optional
17
+
18
+ import bcrypt
19
+
20
+ import database as db
21
+
22
+ logger = logging.getLogger("omniparse.auth")
23
+
24
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
25
+
26
+ DEMO_EMAIL = "demo@omniparse.ai"
27
+ DEMO_PASSWORD = "demo1234"
28
+ DEMO_USER = {
29
+ "id": -1,
30
+ "email": DEMO_EMAIL,
31
+ "name": "Demo Account",
32
+ "plan": "pro",
33
+ "stripe_cid": None,
34
+ "api_key": "demo-key-not-usable-for-billing",
35
+ }
36
+
37
+ BCRYPT_ROUNDS = 12
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Simple in-memory rate limiter (per-process). Suitable for a single-instance
42
+ # HF Space; swap for a Redis-backed limiter behind a load balancer.
43
+ # ---------------------------------------------------------------------------
44
+ class RateLimiter:
45
+ def __init__(self, max_attempts: int, window_seconds: int):
46
+ self.max_attempts = max_attempts
47
+ self.window_seconds = window_seconds
48
+ self._hits = defaultdict(deque)
49
+ self._lock = threading.Lock()
50
+
51
+ def check(self, key: str) -> bool:
52
+ """Returns True if the action is allowed, False if rate-limited."""
53
+ now = time.time()
54
+ with self._lock:
55
+ bucket = self._hits[key]
56
+ while bucket and now - bucket[0] > self.window_seconds:
57
+ bucket.popleft()
58
+ if len(bucket) >= self.max_attempts:
59
+ return False
60
+ bucket.append(now)
61
+ return True
62
+
63
+ def seconds_until_reset(self, key: str) -> int:
64
+ with self._lock:
65
+ bucket = self._hits[key]
66
+ if not bucket:
67
+ return 0
68
+ return max(0, int(self.window_seconds - (time.time() - bucket[0])))
69
+
70
+
71
+ login_limiter = RateLimiter(max_attempts=5, window_seconds=300) # 5 / 5 min
72
+ register_limiter = RateLimiter(max_attempts=3, window_seconds=3600) # 3 / hour
73
+ reset_limiter = RateLimiter(max_attempts=3, window_seconds=3600) # 3 / hour
74
+
75
+
76
+ class AuthError(Exception):
77
+ def __init__(self, message: str, code: str = "auth_error"):
78
+ super().__init__(message)
79
+ self.message = message
80
+ self.code = code
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Password hashing
85
+ # ---------------------------------------------------------------------------
86
+ def hash_password(plain_password: str) -> str:
87
+ salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
88
+ return bcrypt.hashpw(plain_password.encode("utf-8"), salt).decode("utf-8")
89
+
90
+
91
+ def verify_password(plain_password: str, hashed: str) -> bool:
92
+ try:
93
+ return bcrypt.checkpw(plain_password.encode("utf-8"), hashed.encode("utf-8"))
94
+ except Exception:
95
+ return False
96
+
97
+
98
+ def _validate_email(email: str) -> None:
99
+ if not email or not EMAIL_RE.match(email.strip()):
100
+ raise AuthError("Please enter a valid email address.", "invalid_email")
101
+
102
+
103
+ def _validate_password_strength(password: str) -> None:
104
+ if not password or len(password) < 8:
105
+ raise AuthError("Password must be at least 8 characters long.", "weak_password")
106
+ if not re.search(r"[A-Za-z]", password) or not re.search(r"[0-9]", password):
107
+ raise AuthError("Password must contain both letters and numbers.", "weak_password")
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Register
112
+ # ---------------------------------------------------------------------------
113
+ def register(email: str, name: str, password: str, client_ip: str = "unknown") -> dict:
114
+ if not register_limiter.check(client_ip):
115
+ wait = register_limiter.seconds_until_reset(client_ip)
116
+ raise AuthError(f"Too many registration attempts. Try again in {wait}s.", "rate_limited")
117
+
118
+ email = email.strip().lower()
119
+ _validate_email(email)
120
+ if not name or len(name.strip()) < 2:
121
+ raise AuthError("Please enter your name.", "invalid_name")
122
+ _validate_password_strength(password)
123
+
124
+ if db.get_user_by_email(email):
125
+ raise AuthError("An account with this email already exists.", "email_taken")
126
+
127
+ hashed = hash_password(password)
128
+ user = db.create_user(email=email, name=name.strip(), hashed_password=hashed, plan="free")
129
+ token = db.create_session(user["id"])
130
+ return {"user": _public_user(user), "token": token}
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Login
135
+ # ---------------------------------------------------------------------------
136
+ def login(email: str, password: str, client_ip: str = "unknown") -> dict:
137
+ rl_key = f"{client_ip}:{email.strip().lower()}"
138
+ if not login_limiter.check(rl_key):
139
+ wait = login_limiter.seconds_until_reset(rl_key)
140
+ raise AuthError(f"Too many login attempts. Try again in {wait}s.", "rate_limited")
141
+
142
+ email = email.strip().lower()
143
+ _validate_email(email)
144
+ if not password:
145
+ raise AuthError("Password is required.", "invalid_password")
146
+
147
+ # Demo bypass: fixed demo credentials log into a dummy Pro account without
148
+ # touching bcrypt or the database. Never allow this path to mutate real data.
149
+ if email == DEMO_EMAIL and password == DEMO_PASSWORD:
150
+ return {"user": DEMO_USER, "token": "demo-session-token"}
151
+
152
+ user = db.get_user_by_email(email)
153
+ if not user or not verify_password(password, user["password"]):
154
+ raise AuthError("Invalid email or password.", "invalid_credentials")
155
+
156
+ token = db.create_session(user["id"])
157
+ return {"user": _public_user(user), "token": token}
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Password reset (OTP placeholder -- wire up an email/SMS provider in production)
162
+ # ---------------------------------------------------------------------------
163
+ def request_password_reset(email: str, client_ip: str = "unknown") -> dict:
164
+ if not reset_limiter.check(client_ip):
165
+ wait = reset_limiter.seconds_until_reset(client_ip)
166
+ raise AuthError(f"Too many reset attempts. Try again in {wait}s.", "rate_limited")
167
+
168
+ email = email.strip().lower()
169
+ _validate_email(email)
170
+ user = db.get_user_by_email(email)
171
+
172
+ # Always return a generic success message regardless of whether the
173
+ # account exists, to avoid leaking which emails are registered.
174
+ if user:
175
+ otp = _generate_otp()
176
+ # TODO(production): send `otp` via a transactional email/SMS provider
177
+ # (e.g. SendGrid, Twilio Verify) instead of logging it.
178
+ logger.info("Password reset OTP generated for user_id=%s", user["id"])
179
+ _pending_otps[email] = {"otp": otp, "expires": time.time() + 600}
180
+
181
+ return {"message": "If that email is registered, a reset code has been sent."}
182
+
183
+
184
+ _pending_otps = {}
185
+
186
+
187
+ def _generate_otp() -> str:
188
+ import secrets
189
+ return f"{secrets.randbelow(1_000_000):06d}"
190
+
191
+
192
+ def confirm_password_reset(email: str, otp: str, new_password: str) -> dict:
193
+ email = email.strip().lower()
194
+ record = _pending_otps.get(email)
195
+ if not record or record["expires"] < time.time() or record["otp"] != otp:
196
+ raise AuthError("Invalid or expired reset code.", "invalid_otp")
197
+
198
+ _validate_password_strength(new_password)
199
+ user = db.get_user_by_email(email)
200
+ if not user:
201
+ raise AuthError("Account not found.", "invalid_credentials")
202
+
203
+ hashed = hash_password(new_password)
204
+ db.get_client().table("users").update({"password": hashed}).eq("id", user["id"]).execute()
205
+ del _pending_otps[email]
206
+ return {"message": "Password updated successfully."}
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Session helpers
211
+ # ---------------------------------------------------------------------------
212
+ def get_current_user(token: str) -> Optional[dict]:
213
+ if token == "demo-session-token":
214
+ return DEMO_USER
215
+ session = db.get_session(token)
216
+ if not session:
217
+ return None
218
+ user = db.get_user_by_id(session["user_id"])
219
+ return _public_user(user) if user else None
220
+
221
+
222
+ def logout(token: str) -> None:
223
+ if token and token != "demo-session-token":
224
+ db.delete_session(token)
225
+
226
+
227
+ def _public_user(user: dict) -> dict:
228
+ """Strip sensitive fields before sending a user object to the frontend."""
229
+ return {
230
+ "id": user["id"],
231
+ "email": user["email"],
232
+ "name": user["name"],
233
+ "plan": user["plan"],
234
+ "api_key": user.get("api_key"),
235
+ }
backend.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend.py -- OCR pipeline, AI extraction cascade, cross-field validation,
3
+ and the Stripe webhook listener for OmniParse AI.
4
+
5
+ Environment variables required:
6
+ GOOGLE_VISION_API_KEY (optional -- OCR tier 1)
7
+ GROQ_API_KEY (AI tier 1)
8
+ HF_API_TOKEN (AI tier 2)
9
+ STRIPE_SECRET_KEY
10
+ STRIPE_WEBHOOK_SECRET
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import io
16
+ import json
17
+ import base64
18
+ import logging
19
+ from typing import Optional
20
+
21
+ import requests
22
+ import stripe
23
+ from PIL import Image
24
+ import pytesseract
25
+ from pdf2image import convert_from_bytes
26
+ from fastapi import Request, HTTPException
27
+ from fastapi.responses import JSONResponse
28
+
29
+ import database as db
30
+
31
+ logger = logging.getLogger("omniparse.backend")
32
+
33
+ MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5MB
34
+
35
+ GOOGLE_VISION_API_KEY = os.getenv("GOOGLE_VISION_API_KEY")
36
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
37
+ HF_API_TOKEN = os.getenv("HF_API_TOKEN")
38
+ STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
39
+ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
40
+
41
+ if STRIPE_SECRET_KEY:
42
+ stripe.api_key = STRIPE_SECRET_KEY
43
+
44
+ PLAN_BY_PRICE_LOOKUP = {
45
+ # Map your Stripe Price lookup_keys / IDs to internal plan names.
46
+ "price_basic_monthly": "basic",
47
+ "price_pro_monthly": "pro",
48
+ "price_enterprise_monthly": "enterprise",
49
+ }
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Upload validation
54
+ # ---------------------------------------------------------------------------
55
+ class UploadTooLarge(Exception):
56
+ pass
57
+
58
+
59
+ def validate_upload_size(raw_bytes: bytes) -> None:
60
+ if len(raw_bytes) > MAX_UPLOAD_BYTES:
61
+ raise UploadTooLarge(f"File exceeds the 5MB limit ({len(raw_bytes)} bytes).")
62
+
63
+
64
+ def _bytes_from_base64(b64_data: str) -> bytes:
65
+ if "," in b64_data and b64_data.strip().startswith("data:"):
66
+ b64_data = b64_data.split(",", 1)[1]
67
+ return base64.b64decode(b64_data)
68
+
69
+
70
+ def _to_images(raw_bytes: bytes, filename: str) -> list:
71
+ """Return a list of PIL Images -- one per page for PDFs, one for images."""
72
+ lower = filename.lower()
73
+ if lower.endswith(".pdf"):
74
+ return convert_from_bytes(raw_bytes, dpi=200)
75
+ return [Image.open(io.BytesIO(raw_bytes)).convert("RGB")]
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # OCR cascade: Google Vision -> pytesseract -> ""
80
+ # ---------------------------------------------------------------------------
81
+ def _ocr_google_vision(image: Image.Image) -> str:
82
+ if not GOOGLE_VISION_API_KEY:
83
+ return ""
84
+ try:
85
+ buf = io.BytesIO()
86
+ image.save(buf, format="PNG")
87
+ img_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
88
+ url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_API_KEY}"
89
+ payload = {
90
+ "requests": [
91
+ {
92
+ "image": {"content": img_b64},
93
+ "features": [{"type": "TEXT_DETECTION"}],
94
+ }
95
+ ]
96
+ }
97
+ resp = requests.post(url, json=payload, timeout=15)
98
+ resp.raise_for_status()
99
+ data = resp.json()
100
+ annotations = data.get("responses", [{}])[0].get("textAnnotations", [])
101
+ if annotations:
102
+ return annotations[0].get("description", "")
103
+ return ""
104
+ except Exception as exc:
105
+ logger.warning("Google Vision OCR failed: %s", exc)
106
+ return ""
107
+
108
+
109
+ def _ocr_pytesseract(image: Image.Image) -> str:
110
+ try:
111
+ return pytesseract.image_to_string(image, lang="eng")
112
+ except Exception as exc:
113
+ logger.warning("pytesseract OCR failed: %s", exc)
114
+ return ""
115
+
116
+
117
+ def run_ocr_cascade(raw_bytes: bytes, filename: str) -> str:
118
+ images = _to_images(raw_bytes, filename)
119
+ full_text = []
120
+ for image in images:
121
+ text = _ocr_google_vision(image)
122
+ if not text.strip():
123
+ text = _ocr_pytesseract(image)
124
+ full_text.append(text)
125
+ return "\n".join(full_text).strip()
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # AI extraction cascade: Groq -> HF -> Regex
130
+ # ---------------------------------------------------------------------------
131
+ EXTRACTION_PROMPT = """You extract structured invoice data. Given raw OCR text from an invoice,
132
+ return STRICT JSON only, no prose, matching exactly this schema:
133
+ {{
134
+ "vendor": string|null,
135
+ "inv_number": string|null,
136
+ "inv_date": string|null,
137
+ "due_date": string|null,
138
+ "amount": number|null,
139
+ "vat_amount": number|null,
140
+ "total": number|null,
141
+ "currency": string|null,
142
+ "confidence": number
143
+ }}
144
+ OCR TEXT:
145
+ ---
146
+ {ocr_text}
147
+ ---
148
+ Respond with JSON only."""
149
+
150
+
151
+ def _extract_json_block(text: str) -> Optional[dict]:
152
+ try:
153
+ start = text.index("{")
154
+ end = text.rindex("}") + 1
155
+ return json.loads(text[start:end])
156
+ except Exception:
157
+ return None
158
+
159
+
160
+ def _ai_groq(ocr_text: str) -> Optional[dict]:
161
+ if not GROQ_API_KEY:
162
+ return None
163
+ try:
164
+ resp = requests.post(
165
+ "https://api.groq.com/openai/v1/chat/completions",
166
+ headers={
167
+ "Authorization": f"Bearer {GROQ_API_KEY}",
168
+ "Content-Type": "application/json",
169
+ },
170
+ json={
171
+ "model": "llama-3.1-8b-instant",
172
+ "messages": [
173
+ {"role": "system", "content": "You output only strict JSON."},
174
+ {"role": "user", "content": EXTRACTION_PROMPT.format(ocr_text=ocr_text[:6000])},
175
+ ],
176
+ "temperature": 0,
177
+ "response_format": {"type": "json_object"},
178
+ },
179
+ timeout=10,
180
+ )
181
+ resp.raise_for_status()
182
+ content = resp.json()["choices"][0]["message"]["content"]
183
+ parsed = _extract_json_block(content)
184
+ if parsed:
185
+ parsed["_source"] = "groq"
186
+ return parsed
187
+ except Exception as exc:
188
+ logger.warning("Groq extraction failed: %s", exc)
189
+ return None
190
+
191
+
192
+ def _ai_huggingface(ocr_text: str) -> Optional[dict]:
193
+ if not HF_API_TOKEN:
194
+ return None
195
+ try:
196
+ resp = requests.post(
197
+ "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3",
198
+ headers={"Authorization": f"Bearer {HF_API_TOKEN}"},
199
+ json={
200
+ "inputs": EXTRACTION_PROMPT.format(ocr_text=ocr_text[:4000]),
201
+ "parameters": {"max_new_tokens": 400, "temperature": 0.0},
202
+ },
203
+ timeout=45,
204
+ )
205
+ resp.raise_for_status()
206
+ data = resp.json()
207
+ text_out = data[0].get("generated_text", "") if isinstance(data, list) else str(data)
208
+ parsed = _extract_json_block(text_out)
209
+ if parsed:
210
+ parsed["_source"] = "huggingface"
211
+ return parsed
212
+ except Exception as exc:
213
+ logger.warning("HF extraction failed: %s", exc)
214
+ return None
215
+
216
+
217
+ INV_NUMBER_RE = re.compile(r"(?:invoice|inv)[#:\s]+([A-Z0-9-]{4,24})", re.IGNORECASE)
218
+ DATE_RE = re.compile(r"\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}")
219
+ AMOUNT_RE = re.compile(r"(?:total|amount due)[\s:$]+([0-9,\.]+)", re.IGNORECASE)
220
+
221
+
222
+ def _ai_regex_fallback(ocr_text: str) -> dict:
223
+ inv_match = INV_NUMBER_RE.search(ocr_text)
224
+ dates = DATE_RE.findall(ocr_text)
225
+ amount_match = AMOUNT_RE.search(ocr_text)
226
+
227
+ total = None
228
+ if amount_match:
229
+ try:
230
+ total = float(amount_match.group(1).replace(",", ""))
231
+ except ValueError:
232
+ total = None
233
+
234
+ return {
235
+ "vendor": None,
236
+ "inv_number": inv_match.group(1) if inv_match else None,
237
+ "inv_date": dates[0] if len(dates) > 0 else None,
238
+ "due_date": dates[1] if len(dates) > 1 else None,
239
+ "amount": None,
240
+ "vat_amount": None,
241
+ "total": total,
242
+ "currency": "USD",
243
+ "confidence": 0.35,
244
+ "_source": "regex",
245
+ }
246
+
247
+
248
+ def run_ai_cascade(ocr_text: str) -> dict:
249
+ if not ocr_text.strip():
250
+ return {
251
+ "vendor": None, "inv_number": None, "inv_date": None, "due_date": None,
252
+ "amount": None, "vat_amount": None, "total": None, "currency": "USD",
253
+ "confidence": 0.0, "_source": "none",
254
+ }
255
+ parsed = _ai_groq(ocr_text)
256
+ if not parsed:
257
+ parsed = _ai_huggingface(ocr_text)
258
+ if not parsed:
259
+ parsed = _ai_regex_fallback(ocr_text)
260
+ return parsed
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Validation
265
+ # ---------------------------------------------------------------------------
266
+ def validate_totals(parsed: dict, tolerance: float = 0.10) -> bool:
267
+ amount = parsed.get("amount")
268
+ vat = parsed.get("vat_amount") or 0
269
+ total = parsed.get("total")
270
+ if amount is None or total is None:
271
+ return False
272
+ return abs((amount + vat) - total) <= tolerance
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # End-to-end pipeline used by app.py
277
+ # ---------------------------------------------------------------------------
278
+ def process_invoice_file(raw_bytes: bytes, filename: str) -> dict:
279
+ validate_upload_size(raw_bytes)
280
+ ocr_text = run_ocr_cascade(raw_bytes, filename)
281
+ parsed = run_ai_cascade(ocr_text)
282
+
283
+ is_valid = validate_totals(parsed)
284
+ status = "done" if (parsed.get("total") is not None) else "needs_review"
285
+ if parsed.get("amount") is not None and parsed.get("total") is not None and not is_valid:
286
+ status = "needs_review"
287
+
288
+ parsed["status"] = status
289
+ parsed["raw_json"] = {"ocr_text": ocr_text[:10000], "cross_field_valid": is_valid}
290
+ return parsed
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Stripe webhook (mounted as a FastAPI route in app.py)
295
+ # ---------------------------------------------------------------------------
296
+ async def stripe_webhook_handler(request: Request):
297
+ payload = await request.body()
298
+ sig_header = request.headers.get("stripe-signature")
299
+
300
+ if not STRIPE_WEBHOOK_SECRET:
301
+ raise HTTPException(status_code=500, detail="Webhook secret not configured.")
302
+
303
+ try:
304
+ event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
305
+ except ValueError:
306
+ raise HTTPException(status_code=400, detail="Invalid payload.")
307
+ except stripe.error.SignatureVerificationError:
308
+ raise HTTPException(status_code=400, detail="Invalid signature.")
309
+
310
+ event_type = event["type"]
311
+ obj = event["data"]["object"]
312
+
313
+ try:
314
+ if event_type == "checkout.session.completed":
315
+ customer_id = obj.get("customer")
316
+ client_ref = obj.get("client_reference_id") # our internal user_id, set at checkout creation
317
+ plan = _plan_from_line_items(obj)
318
+ if client_ref:
319
+ db.update_user_plan(int(client_ref), plan or "basic", stripe_cid=customer_id)
320
+
321
+ elif event_type in ("customer.subscription.updated", "customer.subscription.created"):
322
+ customer_id = obj.get("customer")
323
+ plan = _plan_from_subscription(obj)
324
+ user = db.get_user_by_stripe_customer(customer_id)
325
+ if user and plan:
326
+ db.update_user_plan(user["id"], plan)
327
+
328
+ elif event_type == "customer.subscription.deleted":
329
+ customer_id = obj.get("customer")
330
+ user = db.get_user_by_stripe_customer(customer_id)
331
+ if user:
332
+ db.update_user_plan(user["id"], "free")
333
+
334
+ except Exception as exc:
335
+ logger.error("Error handling Stripe event %s: %s", event_type, exc)
336
+ raise HTTPException(status_code=500, detail="Internal error processing webhook.")
337
+
338
+ return JSONResponse({"received": True})
339
+
340
+
341
+ def _plan_from_line_items(session_obj: dict) -> Optional[str]:
342
+ try:
343
+ for item in session_obj.get("line_items", {}).get("data", []):
344
+ lookup_key = item.get("price", {}).get("lookup_key")
345
+ if lookup_key in PLAN_BY_PRICE_LOOKUP:
346
+ return PLAN_BY_PRICE_LOOKUP[lookup_key]
347
+ except Exception:
348
+ pass
349
+ return None
350
+
351
+
352
+ def _plan_from_subscription(sub_obj: dict) -> Optional[str]:
353
+ try:
354
+ for item in sub_obj.get("items", {}).get("data", []):
355
+ lookup_key = item.get("price", {}).get("lookup_key")
356
+ if lookup_key in PLAN_BY_PRICE_LOOKUP:
357
+ return PLAN_BY_PRICE_LOOKUP[lookup_key]
358
+ except Exception:
359
+ pass
360
+ return None
database.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database.py -- Supabase (PostgreSQL) data layer for OmniParse AI.
3
+
4
+ Requires environment variables:
5
+ SUPABASE_URL
6
+ SUPABASE_KEY (service role key -- used only from the server side)
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import uuid
12
+ import logging
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Optional
15
+
16
+ from supabase import create_client, Client
17
+
18
+ logger = logging.getLogger("omniparse.database")
19
+
20
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
21
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
22
+
23
+ if not SUPABASE_URL or not SUPABASE_KEY:
24
+ logger.warning("SUPABASE_URL / SUPABASE_KEY not set. database.py will fail on first call.")
25
+
26
+ _client: Optional[Client] = None
27
+
28
+
29
+ def get_client() -> Client:
30
+ """Lazily instantiate and return the Supabase client (singleton)."""
31
+ global _client
32
+ if _client is None:
33
+ if not SUPABASE_URL or not SUPABASE_KEY:
34
+ raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set in the environment.")
35
+ _client = create_client(SUPABASE_URL, SUPABASE_KEY)
36
+ return _client
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # SQL schema + Row Level Security policies.
41
+ # Run this once via the Supabase SQL editor (or migrations) before deploying.
42
+ # ---------------------------------------------------------------------------
43
+ SCHEMA_SQL = """
44
+ -- =========================================================
45
+ -- OmniParse AI -- schema.sql
46
+ -- =========================================================
47
+
48
+ create extension if not exists pgcrypto;
49
+
50
+ create table if not exists users (
51
+ id bigserial primary key,
52
+ email text unique not null,
53
+ name text not null,
54
+ password text not null, -- bcrypt hash
55
+ plan text not null default 'free', -- free | basic | pro | enterprise
56
+ stripe_cid text,
57
+ api_key text unique default encode(gen_random_bytes(24), 'hex'),
58
+ created_at timestamptz not null default now()
59
+ );
60
+
61
+ create table if not exists sessions (
62
+ token text primary key,
63
+ user_id bigint not null references users(id) on delete cascade,
64
+ expires_at timestamptz not null
65
+ );
66
+
67
+ create table if not exists invoices (
68
+ id bigserial primary key,
69
+ user_id bigint not null references users(id) on delete cascade,
70
+ filename text not null,
71
+ vendor text,
72
+ inv_number text,
73
+ inv_date text,
74
+ due_date text,
75
+ amount numeric(12,2),
76
+ vat_amount numeric(12,2),
77
+ total numeric(12,2),
78
+ currency text default 'USD',
79
+ status text default 'done', -- processing | done | error | needs_review
80
+ is_duplicate boolean default false,
81
+ confidence numeric(4,3),
82
+ raw_json jsonb,
83
+ created_at timestamptz not null default now()
84
+ );
85
+
86
+ create index if not exists idx_invoices_user_id on invoices(user_id);
87
+ create index if not exists idx_invoices_vendor_amount on invoices(user_id, vendor, total);
88
+
89
+ -- =========================================================
90
+ -- Row Level Security
91
+ -- =========================================================
92
+ alter table users enable row level security;
93
+ alter table sessions enable row level security;
94
+ alter table invoices enable row level security;
95
+
96
+ -- users: a row is only visible/editable by its owner
97
+ drop policy if exists users_select_own on users;
98
+ create policy users_select_own on users
99
+ for select using (auth.uid()::text = id::text);
100
+
101
+ drop policy if exists users_update_own on users;
102
+ create policy users_update_own on users
103
+ for update using (auth.uid()::text = id::text);
104
+
105
+ -- sessions: owner only
106
+ drop policy if exists sessions_all_own on sessions;
107
+ create policy sessions_all_own on sessions
108
+ for all using (auth.uid()::text = user_id::text);
109
+
110
+ -- invoices: owner only, on every operation
111
+ drop policy if exists invoices_select_own on invoices;
112
+ create policy invoices_select_own on invoices
113
+ for select using (auth.uid() = user_id);
114
+
115
+ drop policy if exists invoices_insert_own on invoices;
116
+ create policy invoices_insert_own on invoices
117
+ for insert with check (auth.uid() = user_id);
118
+
119
+ drop policy if exists invoices_update_own on invoices;
120
+ create policy invoices_update_own on invoices
121
+ for update using (auth.uid() = user_id);
122
+
123
+ drop policy if exists invoices_delete_own on invoices;
124
+ create policy invoices_delete_own on invoices
125
+ for delete using (auth.uid() = user_id);
126
+ """
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # User functions
131
+ # ---------------------------------------------------------------------------
132
+ def create_user(email: str, name: str, hashed_password: str, plan: str = "free") -> dict:
133
+ """Insert a new user. Raises on duplicate email (unique constraint)."""
134
+ client = get_client()
135
+ payload = {
136
+ "email": email.strip().lower(),
137
+ "name": name.strip(),
138
+ "password": hashed_password,
139
+ "plan": plan,
140
+ }
141
+ res = client.table("users").insert(payload).execute()
142
+ if not res.data:
143
+ raise RuntimeError("Failed to create user.")
144
+ return res.data[0]
145
+
146
+
147
+ def get_user_by_email(email: str) -> Optional[dict]:
148
+ client = get_client()
149
+ res = (
150
+ client.table("users")
151
+ .select("*")
152
+ .eq("email", email.strip().lower())
153
+ .limit(1)
154
+ .execute()
155
+ )
156
+ return res.data[0] if res.data else None
157
+
158
+
159
+ def get_user_by_id(user_id: int) -> Optional[dict]:
160
+ client = get_client()
161
+ res = client.table("users").select("*").eq("id", user_id).limit(1).execute()
162
+ return res.data[0] if res.data else None
163
+
164
+
165
+ def update_user_plan(user_id: int, plan: str, stripe_cid: Optional[str] = None) -> None:
166
+ client = get_client()
167
+ payload = {"plan": plan}
168
+ if stripe_cid:
169
+ payload["stripe_cid"] = stripe_cid
170
+ client.table("users").update(payload).eq("id", user_id).execute()
171
+
172
+
173
+ def get_user_by_stripe_customer(stripe_cid: str) -> Optional[dict]:
174
+ client = get_client()
175
+ res = (
176
+ client.table("users")
177
+ .select("*")
178
+ .eq("stripe_cid", stripe_cid)
179
+ .limit(1)
180
+ .execute()
181
+ )
182
+ return res.data[0] if res.data else None
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Session functions
187
+ # ---------------------------------------------------------------------------
188
+ def create_session(user_id: int, ttl_hours: int = 72) -> str:
189
+ client = get_client()
190
+ token = uuid.uuid4().hex + uuid.uuid4().hex
191
+ expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
192
+ client.table("sessions").insert(
193
+ {"token": token, "user_id": user_id, "expires_at": expires_at}
194
+ ).execute()
195
+ return token
196
+
197
+
198
+ def get_session(token: str) -> Optional[dict]:
199
+ if not token:
200
+ return None
201
+ client = get_client()
202
+ res = client.table("sessions").select("*").eq("token", token).limit(1).execute()
203
+ if not res.data:
204
+ return None
205
+ session = res.data[0]
206
+ expires_at = datetime.fromisoformat(session["expires_at"].replace("Z", "+00:00"))
207
+ if expires_at < datetime.now(timezone.utc):
208
+ delete_session(token)
209
+ return None
210
+ return session
211
+
212
+
213
+ def delete_session(token: str) -> None:
214
+ client = get_client()
215
+ client.table("sessions").delete().eq("token", token).execute()
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Invoice functions
220
+ # ---------------------------------------------------------------------------
221
+ def check_for_duplicate(user_id: int, vendor: str, total: float) -> bool:
222
+ """A duplicate is the same vendor + exact total already recorded this month."""
223
+ if not vendor or total is None:
224
+ return False
225
+ client = get_client()
226
+ now = datetime.now(timezone.utc)
227
+ month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
228
+ res = (
229
+ client.table("invoices")
230
+ .select("id")
231
+ .eq("user_id", user_id)
232
+ .eq("vendor", vendor)
233
+ .eq("total", total)
234
+ .gte("created_at", month_start)
235
+ .limit(1)
236
+ .execute()
237
+ )
238
+ return bool(res.data)
239
+
240
+
241
+ def save_invoice_record(user_id: int, filename: str, parsed: dict) -> dict:
242
+ """
243
+ parsed is the normalized dict produced by backend.py's AI/OCR cascade:
244
+ {vendor, inv_number, inv_date, due_date, amount, vat_amount, total,
245
+ currency, confidence, status, raw_json}
246
+ """
247
+ client = get_client()
248
+ vendor = parsed.get("vendor")
249
+ total = parsed.get("total")
250
+ is_dup = check_for_duplicate(user_id, vendor, total) if vendor and total is not None else False
251
+
252
+ payload = {
253
+ "user_id": user_id,
254
+ "filename": filename,
255
+ "vendor": vendor,
256
+ "inv_number": parsed.get("inv_number"),
257
+ "inv_date": parsed.get("inv_date"),
258
+ "due_date": parsed.get("due_date"),
259
+ "amount": parsed.get("amount"),
260
+ "vat_amount": parsed.get("vat_amount"),
261
+ "total": total,
262
+ "currency": parsed.get("currency", "USD"),
263
+ "status": parsed.get("status", "done"),
264
+ "is_duplicate": is_dup,
265
+ "confidence": parsed.get("confidence"),
266
+ "raw_json": json.dumps(parsed.get("raw_json", {})),
267
+ }
268
+ res = client.table("invoices").insert(payload).execute()
269
+ if not res.data:
270
+ raise RuntimeError("Failed to save invoice record.")
271
+ return res.data[0]
272
+
273
+
274
+ def list_invoices(user_id: int, limit: int = 200) -> list:
275
+ client = get_client()
276
+ res = (
277
+ client.table("invoices")
278
+ .select("*")
279
+ .eq("user_id", user_id)
280
+ .order("created_at", desc=True)
281
+ .limit(limit)
282
+ .execute()
283
+ )
284
+ return res.data or []
285
+
286
+
287
+ def count_invoices_this_month(user_id: int) -> int:
288
+ client = get_client()
289
+ now = datetime.now(timezone.utc)
290
+ month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
291
+ res = (
292
+ client.table("invoices")
293
+ .select("id", count="exact")
294
+ .eq("user_id", user_id)
295
+ .gte("created_at", month_start)
296
+ .execute()
297
+ )
298
+ return res.count or 0
requirements.txt CHANGED
@@ -1,12 +1,14 @@
1
- gradio
2
- requests
3
- Pillow
4
- bcrypt
5
- pytesseract
6
- pdf2image
7
- groq
8
- stripe
9
- supabase
10
- python-multipart
11
- fastapi
12
- uvicorn
 
 
 
1
+ gradio==4.42.0
2
+ bcrypt>=4.0.1
3
+ pydub==0.25.1
4
+ requests>=2.31.0
5
+ stripe>=10.0.0
6
+ Pillow>=10.0.0
7
+ pytesseract>=0.3.10
8
+ pdf2image>=1.17.0
9
+ python-multipart>=0.0.9
10
+ psycopg2-binary>=2.9.9
11
+ groq>=0.9.0
12
+ supabase>=2.4.0
13
+ fastapi>=0.110.0
14
+ uvicorn>=0.29.0