simikkk commited on
Commit
0e3f40f
·
verified ·
1 Parent(s): 208d2cb

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +1315 -0
  2. index.html +70 -0
  3. main.py +82 -0
  4. pasted_content.txt +1316 -0
app.py ADDED
@@ -0,0 +1,1315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
index.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OmniParse AI — Invoice SaaS</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ body { font-family: 'Inter', sans-serif; background-color: #f9fafb; }
10
+ .hero-gradient { background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <!-- Navbar -->
15
+ <nav class="bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
16
+ <div class="text-2xl font-extrabold text-indigo-600">⚡ OmniParse AI</div>
17
+ <div class="space-x-6 text-sm font-medium text-gray-600">
18
+ <a href="#" class="hover:text-indigo-600">Features</a>
19
+ <a href="#" class="hover:text-indigo-600">Pricing</a>
20
+ <button onclick="showLogin()" class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition">Get Started</button>
21
+ </div>
22
+ </nav>
23
+
24
+ <!-- Hero Section -->
25
+ <header class="max-w-6xl mx-auto px-6 py-20 text-center">
26
+ <h1 class="text-5xl font-black text-gray-900 mb-6 leading-tight">
27
+ Invoice processing in <span class="text-indigo-600">seconds</span>, not hours.
28
+ </h1>
29
+ <p class="text-xl text-gray-600 max-w-2xl mx-auto mb-10">
30
+ AI-powered data extraction for B2B SaaS. Extract vendor details, dates, and line items with 99.2% accuracy.
31
+ </p>
32
+ <div class="flex justify-center space-x-4">
33
+ <button class="bg-indigo-600 text-white px-8 py-4 rounded-xl font-bold text-lg hover:bg-indigo-700 shadow-lg transition">Start Free Trial</button>
34
+ <button class="bg-white text-gray-900 border border-gray-300 px-8 py-4 rounded-xl font-bold text-lg hover:bg-gray-50 transition">View Demo</button>
35
+ </div>
36
+ </header>
37
+
38
+ <!-- Placeholder for Dashboard (Visible after login) -->
39
+ <div id="dashboard" class="hidden max-w-6xl mx-auto px-6 py-10">
40
+ <div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
41
+ <h2 class="text-2xl font-bold mb-6">Your Dashboard</h2>
42
+ <div class="grid grid-cols-3 gap-6 mb-8">
43
+ <div class="p-6 bg-indigo-50 rounded-xl border border-indigo-100">
44
+ <p class="text-sm text-indigo-600 font-bold uppercase tracking-wider">Processed</p>
45
+ <p class="text-3xl font-black text-indigo-900">128</p>
46
+ </div>
47
+ <div class="p-6 bg-green-50 rounded-xl border border-green-100">
48
+ <p class="text-sm text-green-600 font-bold uppercase tracking-wider">Accuracy</p>
49
+ <p class="text-3xl font-black text-green-900">99.2%</p>
50
+ </div>
51
+ <div class="p-6 bg-purple-50 rounded-xl border border-purple-100">
52
+ <p class="text-sm text-purple-600 font-bold uppercase tracking-wider">Time Saved</p>
53
+ <p class="text-3xl font-black text-purple-900">14.5h</p>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Upload Area -->
58
+ <div class="border-2 border-dashed border-gray-300 rounded-2xl p-12 text-center hover:border-indigo-400 transition cursor-pointer">
59
+ <p class="text-gray-500 font-medium">Click or drag & drop invoices here (PDF, JPG, PNG)</p>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <script>
65
+ function showLogin() {
66
+ alert("This is the HTML UI skeleton. Business logic is currently running in the Gradio UI (app.py). Connect this frontend to main.py via Fetch API to activate.");
67
+ }
68
+ </script>
69
+ </body>
70
+ </html>
main.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ \"\"\"
2
+ OmniParse AI — FastAPI Backend (HTML UI Ready).
3
+ Tento soubor slouží jako most mezi CORE logikou v app.py a budoucím HTML/JS frontendem.
4
+ Všechny business funkce se importují z app.py.
5
+ \"\"\"
6
+
7
+ import os
8
+ from fastapi import FastAPI, UploadFile, File, Header, HTTPException, Depends
9
+ from fastapi.responses import JSONResponse
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ import app as core
12
+
13
+ app = FastAPI(title="OmniParse AI API")
14
+
15
+ # CORS pro HTML/JS frontend
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ # --- AUTH ENDPOINTY ---
24
+
25
+ @app.post("/api/auth/signup")
26
+ async def api_signup(data: dict):
27
+ ok, msg, token = core.signup(data.get("name"), data.get("email"), data.get("password"), data.get("accepted_terms"))
28
+ if not ok:
29
+ raise HTTPException(status_code=400, detail=msg)
30
+ return {"message": msg, "token": token}
31
+
32
+ @app.post("/api/auth/login")
33
+ async def api_login(data: dict):
34
+ ok, msg, token = core.login(data.get("email"), data.get("password"))
35
+ if not ok:
36
+ raise HTTPException(status_code=401, detail=msg)
37
+ return {"message": msg, "token": token}
38
+
39
+ # --- BUSINESS ENDPOINTY (vyžadují token nebo API klíč) ---
40
+
41
+ async def get_current_user(authorization: str = Header(None)):
42
+ if not authorization:
43
+ raise HTTPException(status_code=401, detail="Missing Authorization header")
44
+
45
+ token = authorization.replace("Bearer ", "")
46
+
47
+ # Zkusíme session token (pro UI)
48
+ user = core.resolve_session(token)
49
+ if user:
50
+ return user
51
+
52
+ # Zkusíme API klíč (pro externí integrace)
53
+ # Poznámka: v SQLiteDB/SupabaseDB by se musela přidat funkce get_user_by_api_key
54
+ # Pro demo účely budeme hledat v DB ručně
55
+ db_user = core.DB.get_user_by_email("demo@omniparse.ai") # Placeholder pro ukázku
56
+ if db_user and db_user.get("api_key") == token:
57
+ return db_user
58
+
59
+ raise HTTPException(status_code=401, detail="Invalid token or API key")
60
+
61
+ @app.post("/api/extract")
62
+ async def api_extract(file: UploadFile = File(...), user: dict = Depends(get_current_user)):
63
+ # Uložíme dočasně soubor
64
+ temp_path = f"/tmp/{file.filename}"
65
+ with open(temp_path, "wb") as buffer:
66
+ buffer.write(await file.read())
67
+
68
+ # Zavoláme CORE pipeline
69
+ result = core.process_invoice_file(user, temp_path, file.filename)
70
+
71
+ if "error" in result:
72
+ raise HTTPException(status_code=500, detail=result["error"])
73
+
74
+ return result
75
+
76
+ @app.get("/api/invoices")
77
+ async def api_get_invoices(user: dict = Depends(get_current_user)):
78
+ return core.DB.get_invoices(user["id"])
79
+
80
+ if __name__ == "__main__":
81
+ import uvicorn
82
+ uvicorn.run(app, host="0.0.0.0", port=8000)
pasted_content.txt ADDED
@@ -0,0 +1,1316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ res = self.client.table("invoices").select("id", count="exact").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute()
348
+ # Supabase nepodporuje ABS() přes REST snadno -> filtrujeme total v Pythonu
349
+ rows = self.client.table("invoices").select("total").eq("user_id", user_id).eq("vendor", vendor).gte("created_at", start).execute()
350
+ return sum(1 for r in rows.data if abs((r.get("total") or 0) - total) < 0.01)
351
+
352
+
353
+ def init_db():
354
+ """Vybere Supabase pokud jsou secrets nastavené a SDK je dostupné,
355
+ jinak spadne zpátky na SQLite. Appka nikdy nespadne na chybějícím secretu."""
356
+ if SUPABASE_URL and SUPABASE_KEY and SUPABASE_SDK_OK:
357
+ try:
358
+ db = SupabaseDB(SUPABASE_URL, SUPABASE_KEY)
359
+ db.client.table("users").select("id").limit(1).execute()
360
+ print("[DB] Připojeno k Supabase.")
361
+ return db
362
+ except Exception as e:
363
+ print(f"[DB] Supabase selhalo ({e}), padám na SQLite fallback.")
364
+ return SQLiteDB()
365
+ print("[DB] Supabase secrets nenalezeny, používám SQLite fallback (data jsou dočasná).")
366
+ return SQLiteDB()
367
+
368
+
369
+ DB = init_db()
370
+
371
+
372
+ # ===========================================================================
373
+ # CORE LOGIC — HESLA, AUTH, VALIDACE (server-side, nikdy jen client-side)
374
+ # ===========================================================================
375
+ def hash_password(password: str) -> str:
376
+ """Bcrypt pokud je dostupný (moderní, doporučený), jinak PBKDF2-SHA256
377
+ se solí jako bezpečný fallback (rozhodně ne MD5/SHA1)."""
378
+ try:
379
+ if BCRYPT_OK:
380
+ return "bcrypt$" + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
381
+ salt = secrets.token_hex(16)
382
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
383
+ return f"pbkdf2${salt}${digest}"
384
+ except Exception:
385
+ salt = secrets.token_hex(16)
386
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
387
+ return f"pbkdf2${salt}${digest}"
388
+
389
+
390
+ def verify_password(password: str, stored_hash: str) -> bool:
391
+ try:
392
+ if stored_hash.startswith("bcrypt$") and BCRYPT_OK:
393
+ return bcrypt.checkpw(password.encode(), stored_hash[len("bcrypt$"):].encode())
394
+ if stored_hash.startswith("pbkdf2$"):
395
+ _, salt, digest = stored_hash.split("$")
396
+ check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000).hex()
397
+ return secrets.compare_digest(check, digest)
398
+ return False
399
+ except Exception:
400
+ return False
401
+
402
+
403
+ def is_valid_email(email: str) -> bool:
404
+ return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or ""))
405
+
406
+
407
+ def password_strength_ok(password: str) -> (bool, str):
408
+ if not password or len(password) < 8:
409
+ return False, "Heslo musí mít alespoň 8 znaků."
410
+ if not re.search(r"[A-Za-z]", password) or not re.search(r"[0-9]", password):
411
+ return False, "Heslo musí obsahovat písmena i čísla."
412
+ return True, ""
413
+
414
+
415
+ def is_password_leaked(password: str) -> bool:
416
+ """HaveIBeenPwned Pwned Passwords API — k-anonymity model, zdarma bez klíče.
417
+ Když je síť nedostupná, prostě kontrolu přeskočíme (fail-open, appka nespadne)."""
418
+ try:
419
+ sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
420
+ prefix, suffix = sha1[:5], sha1[5:]
421
+ resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=4)
422
+ if resp.status_code == 200:
423
+ return any(line.split(":")[0] == suffix for line in resp.text.splitlines())
424
+ return False
425
+ except Exception:
426
+ return False
427
+
428
+
429
+ def generate_api_key() -> str:
430
+ return "op_live_" + secrets.token_urlsafe(24)
431
+
432
+
433
+ def create_session_token(user_id: int) -> str:
434
+ token = secrets.token_urlsafe(32)
435
+ expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
436
+ try:
437
+ DB.create_session(token, user_id, expires)
438
+ except Exception as e:
439
+ print(f"[AUTH] Session se nepovedlo uložit: {e}")
440
+ return token
441
+
442
+
443
+ def resolve_session(token: str):
444
+ """Vrátí user dict pokud je token platný a nevypršel, jinak None.
445
+ Token žije v gr.State (server-side per-browser-tab paměť), NE v localStorage,
446
+ což řeší XSS riziko klasického 'token v localStorage' problému."""
447
+ if not token:
448
+ return None
449
+ try:
450
+ sess = DB.get_session(token)
451
+ if not sess:
452
+ return None
453
+ expires = datetime.fromisoformat(sess["expires_at"])
454
+ if expires.tzinfo is None:
455
+ expires = expires.replace(tzinfo=timezone.utc)
456
+ if expires < datetime.now(timezone.utc):
457
+ DB.delete_session(token)
458
+ return None
459
+ return DB.get_user_by_id(sess["user_id"])
460
+ except Exception as e:
461
+ print(f"[AUTH] resolve_session chyba: {e}")
462
+ return None
463
+
464
+
465
+ def signup(name, email, password, accepted_terms) -> (bool, str, str):
466
+ """Vrací (success, message, session_token)."""
467
+ try:
468
+ email = (email or "").strip().lower()
469
+ name = (name or "").strip()
470
+ if rate_limited(f"signup:{email}", max_attempts=5, window_seconds=300):
471
+ return False, "Příliš mnoho pokusů o registraci. Zkus to za pár minut.", ""
472
+ if not name:
473
+ return False, "Vyplň prosím jméno.", ""
474
+ if not is_valid_email(email):
475
+ return False, "Zadej platný pracovní e-mail.", ""
476
+ if not accepted_terms:
477
+ return False, "Musíš souhlasit s Terms of Use a Privacy Policy.", ""
478
+ ok, msg = password_strength_ok(password)
479
+ if not ok:
480
+ return False, msg, ""
481
+ if is_password_leaked(password):
482
+ return False, "Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné.", ""
483
+ if DB.get_user_by_email(email):
484
+ return False, "Účet s tímto e-mailem už existuje.", ""
485
+ pw_hash = hash_password(password)
486
+ api_key = generate_api_key()
487
+ user_id = DB.create_user(email, name, pw_hash, plan="free", api_key=api_key)
488
+ token = create_session_token(user_id)
489
+ return True, "Účet vytvořen!", token
490
+ except Exception as e:
491
+ traceback.print_exc()
492
+ return False, f"Chyba při registraci: {e}", ""
493
+
494
+
495
+ def login(email, password) -> (bool, str, str):
496
+ try:
497
+ email = (email or "").strip().lower()
498
+ if rate_limited(f"login:{email}", max_attempts=8, window_seconds=300):
499
+ return False, "Příliš mnoho pokusů o přihlášení. Zkus to za pár minut.", ""
500
+ if not email or not password:
501
+ return False, "Vyplň e-mail i heslo.", ""
502
+ user = DB.get_user_by_email(email)
503
+ if not user or not verify_password(password, user["password"]):
504
+ return False, "Nesprávný e-mail nebo heslo.", ""
505
+ token = create_session_token(user["id"])
506
+ return True, "Přihlášení úspěšné!", token
507
+ except Exception as e:
508
+ traceback.print_exc()
509
+ return False, f"Chyba při přihlašování: {e}", ""
510
+
511
+
512
+ def logout(token):
513
+ try:
514
+ if token:
515
+ DB.delete_session(token)
516
+ except Exception as e:
517
+ print(f"[AUTH] logout chyba: {e}")
518
+
519
+
520
+ def bootstrap_demo_account():
521
+ try:
522
+ if not DB.get_user_by_email("demo@omniparse.ai"):
523
+ pw_hash = hash_password("demo1234")
524
+ api_key = generate_api_key()
525
+ DB.create_user("demo@omniparse.ai", "Demo User", pw_hash, plan="pro", api_key=api_key)
526
+ print("[DEMO] Demo účet vytvořen: demo@omniparse.ai / demo1234")
527
+ except Exception as e:
528
+ print(f"[DEMO] Nepovedlo se vytvořit demo účet: {e}")
529
+
530
+
531
+ bootstrap_demo_account()
532
+
533
+
534
+ # ===========================================================================
535
+ # CORE LOGIC — AI PIPELINE (OCR -> LLM -> regex fallback)
536
+ # ===========================================================================
537
+ def ocr_google_vision(image_bytes: bytes) -> str:
538
+ if not GOOGLE_VISION_KEY:
539
+ return ""
540
+ try:
541
+ b64 = base64.b64encode(image_bytes).decode()
542
+ url = f"https://vision.googleapis.com/v1/images:annotate?key={GOOGLE_VISION_KEY}"
543
+ payload = {"requests": [{"image": {"content": b64}, "features": [{"type": "TEXT_DETECTION"}]}]}
544
+ resp = requests.post(url, json=payload, timeout=15)
545
+ data = resp.json()
546
+ return data["responses"][0].get("fullTextAnnotation", {}).get("text", "")
547
+ except Exception as e:
548
+ print(f"[OCR] Google Vision selhalo: {e}")
549
+ return ""
550
+
551
+
552
+ def ocr_tesseract(image: Image.Image) -> str:
553
+ if not TESSERACT_OK:
554
+ return ""
555
+ try:
556
+ gray = image.convert("L")
557
+ return pytesseract.image_to_string(gray, lang="eng+ces")
558
+ except Exception:
559
+ try:
560
+ return pytesseract.image_to_string(image.convert("L"), lang="eng")
561
+ except Exception as e:
562
+ print(f"[OCR] Tesseract selhalo: {e}")
563
+ return ""
564
+
565
+
566
+ def file_to_images(file_bytes: bytes, filename: str):
567
+ """Vrátí list PIL Image objektů — z PDF všechny stránky, z obrázku jednu."""
568
+ try:
569
+ ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
570
+ if ext == "pdf":
571
+ if not PDF2IMAGE_OK:
572
+ raise RuntimeError("pdf2image / poppler není dostupný na tomto Space.")
573
+ return convert_from_bytes(file_bytes, dpi=200)
574
+ img = Image.open(io.BytesIO(file_bytes))
575
+ img.load()
576
+ return [img]
577
+ except Exception as e:
578
+ print(f"[FILE] Nepodařilo se otevřít soubor {filename}: {e}")
579
+ return []
580
+
581
+
582
+ def extract_text_from_images(images) -> str:
583
+ full_text = ""
584
+ for img in images[:5]: # bezpečnostní limit — max 5 stránek na fakturu
585
+ buf = io.BytesIO()
586
+ img.convert("RGB").save(buf, format="JPEG", quality=85)
587
+ img_bytes = buf.getvalue()
588
+ text = ""
589
+ if GOOGLE_VISION_KEY:
590
+ text = ocr_google_vision(img_bytes)
591
+ if len(text.strip()) < 30:
592
+ text = ocr_tesseract(img)
593
+ full_text += text + "\n"
594
+ return full_text.strip()
595
+
596
+
597
+ AI_SYSTEM_PROMPT = (
598
+ "You are an invoice data extraction engine. Extract structured data from the raw OCR text "
599
+ "of an invoice. Return ONLY valid JSON, no markdown, no explanation, with exactly these keys: "
600
+ 'vendor (string), inv_number (string), inv_date (YYYY-MM-DD or empty string), '
601
+ 'due_date (YYYY-MM-DD or empty string), amount (number, subtotal before tax), '
602
+ 'vat_amount (number), total (number), currency (3-letter code like USD/EUR/CZK), '
603
+ 'line_items (array of {description, quantity, unit_price, total}). '
604
+ "If a field cannot be found, use empty string or 0. Never invent data you cannot find."
605
+ )
606
+
607
+
608
+ def ai_extract_groq(ocr_text: str):
609
+ if not (GROQ_API_KEY and GROQ_SDK_OK):
610
+ return None
611
+ try:
612
+ client = Groq(api_key=GROQ_API_KEY)
613
+ resp = client.chat.completions.create(
614
+ model="llama-3.1-8b-instant",
615
+ messages=[
616
+ {"role": "system", "content": AI_SYSTEM_PROMPT},
617
+ {"role": "user", "content": ocr_text[:3000]},
618
+ ],
619
+ max_tokens=512,
620
+ temperature=0.05,
621
+ timeout=10,
622
+ )
623
+ content = resp.choices[0].message.content
624
+ content = re.sub(r"^```json|```$", "", content.strip(), flags=re.MULTILINE).strip()
625
+ return json.loads(content)
626
+ except Exception as e:
627
+ print(f"[AI] Groq selhalo: {e}")
628
+ return None
629
+
630
+
631
+ def ai_extract_hf(ocr_text: str):
632
+ if not HF_TOKEN:
633
+ return None
634
+ try:
635
+ url = "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3"
636
+ headers = {"Authorization": f"Bearer {HF_TOKEN}"}
637
+ prompt = f"<s>[INST] {AI_SYSTEM_PROMPT}\n\n{ocr_text[:3000]} [/INST]"
638
+ payload = {"inputs": prompt, "parameters": {"max_new_tokens": 512, "temperature": 0.05, "return_full_text": False}}
639
+ resp = requests.post(url, headers=headers, json=payload, timeout=45)
640
+ if resp.status_code == 503:
641
+ time.sleep(25)
642
+ resp = requests.post(url, headers=headers, json=payload, timeout=45)
643
+ data = resp.json()
644
+ text = data[0]["generated_text"] if isinstance(data, list) else data.get("generated_text", "")
645
+ text = re.sub(r"^```json|```$", "", text.strip(), flags=re.MULTILINE).strip()
646
+ match = re.search(r"\{.*\}", text, re.DOTALL)
647
+ return json.loads(match.group(0)) if match else None
648
+ except Exception as e:
649
+ print(f"[AI] HF Inference selhalo: {e}")
650
+ return None
651
+
652
+
653
+ def regex_extract(ocr_text: str):
654
+ try:
655
+ inv_number_m = re.search(r"(?:invoice|inv)[#:\s]+([A-Z0-9\-]{4,24})", ocr_text, re.I)
656
+ dates = re.findall(r"\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}", ocr_text)
657
+ total_m = re.search(r"(?:total|amount due)[\s:$]+([0-9,\.]+)", ocr_text, re.I)
658
+ vendor = next((l.strip() for l in ocr_text.splitlines() if l.strip()), "Unknown vendor")
659
+ total = 0.0
660
+ if total_m:
661
+ try:
662
+ total = float(total_m.group(1).replace(",", ""))
663
+ except Exception:
664
+ total = 0.0
665
+ return {
666
+ "vendor": vendor[:120],
667
+ "inv_number": inv_number_m.group(1) if inv_number_m else "",
668
+ "inv_date": dates[0] if dates else "",
669
+ "due_date": dates[1] if len(dates) > 1 else "",
670
+ "amount": total,
671
+ "vat_amount": 0.0,
672
+ "total": total,
673
+ "currency": "USD",
674
+ "line_items": [],
675
+ }
676
+ except Exception as e:
677
+ print(f"[AI] Regex fallback selhalo: {e}")
678
+ return {"vendor": "Unknown", "inv_number": "", "inv_date": "", "due_date": "",
679
+ "amount": 0, "vat_amount": 0, "total": 0, "currency": "USD", "line_items": []}
680
+
681
+
682
+ def run_ai_pipeline(ocr_text: str):
683
+ if not ocr_text.strip():
684
+ data = regex_extract("")
685
+ data["_ai_source"] = "empty_ocr"
686
+ return data
687
+ data = ai_extract_groq(ocr_text)
688
+ if data:
689
+ data["_ai_source"] = "groq"
690
+ return data
691
+ data = ai_extract_hf(ocr_text)
692
+ if data:
693
+ data["_ai_source"] = "huggingface"
694
+ return data
695
+ data = regex_extract(ocr_text)
696
+ data["_ai_source"] = "regex_fallback"
697
+ return data
698
+
699
+
700
+ def validate_invoice(data: dict) -> list:
701
+ """Cross-field validace — vrací list textových warningů."""
702
+ warnings = []
703
+ try:
704
+ amount = float(data.get("amount") or 0)
705
+ vat = float(data.get("vat_amount") or 0)
706
+ total = float(data.get("total") or 0)
707
+ if total > 0 and abs((amount + vat) - total) > 0.10:
708
+ warnings.append(f"Subtotal + DPH ({amount + vat:.2f}) neodpovídá total ({total:.2f}).")
709
+ if amount > 0 and vat / amount > 0.30:
710
+ warnings.append("DPH sazba vyšší než 30 % — zkontroluj ručně.")
711
+ inv_date, due_date = data.get("inv_date"), data.get("due_date")
712
+ if inv_date and due_date:
713
+ try:
714
+ d1 = datetime.fromisoformat(inv_date)
715
+ d2 = datetime.fromisoformat(due_date)
716
+ if d2 < d1:
717
+ warnings.append("Splatnost je dřív než datum vystavení faktury.")
718
+ except Exception:
719
+ pass
720
+ if not data.get("vendor"):
721
+ warnings.append("Nepodařilo se rozpoznat dodavatele.")
722
+ except Exception as e:
723
+ warnings.append(f"Validace selhala: {e}")
724
+ return warnings
725
+
726
+
727
+ def process_invoice_file(user, file_path, filename) -> dict:
728
+ """Kompletní pipeline pro jeden soubor. Vrací dict se všemi daty + warnings."""
729
+ try:
730
+ with open(file_path, "rb") as f:
731
+ file_bytes = f.read()
732
+ if len(file_bytes) > MAX_FILE_SIZE_BYTES:
733
+ return {"error": f"Soubor {filename} přesahuje limit {MAX_FILE_SIZE_MB}MB."}
734
+
735
+ images = file_to_images(file_bytes, filename)
736
+ if not images:
737
+ return {"error": f"Nepodařilo se otevřít soubor {filename} (nepodporovaný formát nebo poškozený soubor)."}
738
+
739
+ ocr_text = extract_text_from_images(images)
740
+ extracted = run_ai_pipeline(ocr_text)
741
+
742
+ warnings = validate_invoice(extracted)
743
+ plan = user.get("plan", "free")
744
+
745
+ is_dup = False
746
+ if plan in ("pro", "enterprise") and extracted.get("vendor") and extracted.get("total"):
747
+ try:
748
+ dup_count = DB.count_duplicate(user["id"], extracted["vendor"], float(extracted["total"] or 0))
749
+ is_dup = dup_count > 0
750
+ except Exception as e:
751
+ print(f"[DUP] kontrola duplicit selhala: {e}")
752
+
753
+ status = "review" if warnings else "done"
754
+ if is_dup:
755
+ status = "duplicate"
756
+
757
+ record = {
758
+ "filename": filename,
759
+ "vendor": extracted.get("vendor", ""),
760
+ "inv_number": extracted.get("inv_number", ""),
761
+ "inv_date": extracted.get("inv_date", ""),
762
+ "due_date": extracted.get("due_date", ""),
763
+ "amount": float(extracted.get("amount") or 0),
764
+ "vat_amount": float(extracted.get("vat_amount") or 0),
765
+ "total": float(extracted.get("total") or 0),
766
+ "currency": extracted.get("currency", "USD"),
767
+ "status": status,
768
+ "is_duplicate": is_dup,
769
+ "confidence": 0.95 if extracted.get("_ai_source") in ("groq", "huggingface") else 0.55,
770
+ "raw_json": extracted,
771
+ }
772
+ DB.save_invoice(user["id"], record)
773
+ record["warnings"] = warnings
774
+ return record
775
+ except Exception as e:
776
+ traceback.print_exc()
777
+ return {"error": f"Zpracování {filename} selhalo: {e}"}
778
+
779
+
780
+ # ===========================================================================
781
+ # CORE LOGIC — AI CHAT AGENT (Pro+)
782
+ # ===========================================================================
783
+ def ai_chat_answer(user, question: str, history: list) -> str:
784
+ try:
785
+ if not question or not question.strip():
786
+ return "Napiš prosím otázku k tvým fakturám."
787
+ if user.get("plan") not in ("pro", "enterprise"):
788
+ return "AI Chat je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
789
+ invoices = DB.get_invoices(user["id"])[:200]
790
+ context_rows = [
791
+ f"- {inv.get('vendor')} | č.{inv.get('inv_number')} | {inv.get('inv_date')} | "
792
+ f"total {inv.get('total')} {inv.get('currency')} | status {inv.get('status')}"
793
+ for inv in invoices
794
+ ]
795
+ context = "\n".join(context_rows) if context_rows else "Uživatel zatím nemá žádné faktury."
796
+ if GROQ_API_KEY and GROQ_SDK_OK:
797
+ client = Groq(api_key=GROQ_API_KEY)
798
+ resp = client.chat.completions.create(
799
+ model="llama-3.1-8b-instant",
800
+ messages=[
801
+ {"role": "system", "content": "You are a helpful assistant answering questions about the user's invoices based ONLY on the data provided below. Be concise."},
802
+ {"role": "user", "content": f"Invoices:\n{context}\n\nQuestion: {question}"},
803
+ ],
804
+ max_tokens=400,
805
+ temperature=0.2,
806
+ timeout=15,
807
+ )
808
+ return resp.choices[0].message.content
809
+ return "AI chat momentálně není dostupný (chybí GROQ_API_KEY). Zkus to prosím později."
810
+ except Exception as e:
811
+ traceback.print_exc()
812
+ return f"Chyba AI chatu: {e}"
813
+
814
+
815
+ # ===========================================================================
816
+ # CORE LOGIC — STRIPE PLATBY (bez webhooků, polling)
817
+ # ===========================================================================
818
+ def create_checkout_url(user, plan: str) -> (bool, str):
819
+ try:
820
+ if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
821
+ return False, f"Platby momentálně nejsou nastavené. Napiš prosím na support a domluvíme upgrade na {plan} ručně."
822
+ price_id = PLAN_PRICE_IDS.get(plan)
823
+ if not price_id:
824
+ return False, "Neplatný plán."
825
+ session = stripe.checkout.Session.create(
826
+ payment_method_types=["card"],
827
+ line_items=[{"price": price_id, "quantity": 1}],
828
+ mode="subscription",
829
+ success_url=f"{APP_URL}?checkout=success&session_id={{CHECKOUT_SESSION_ID}}",
830
+ cancel_url=f"{APP_URL}?checkout=cancel",
831
+ customer_email=user["email"],
832
+ metadata={"plan": plan, "user_id": str(user["id"])},
833
+ )
834
+ return True, session.url
835
+ except Exception as e:
836
+ traceback.print_exc()
837
+ return False, f"Chyba Stripe checkoutu: {e}"
838
+
839
+
840
+ def poll_payment_status(session_id: str, user_id: int, max_attempts=12, delay=5) -> str:
841
+ if not (STRIPE_SDK_OK and STRIPE_SECRET_KEY):
842
+ return "Platby nejsou nakonfigurované."
843
+ try:
844
+ for _ in range(max_attempts):
845
+ session = stripe.checkout.Session.retrieve(session_id)
846
+ if session.payment_status == "paid":
847
+ plan = session.metadata.get("plan", "basic")
848
+ DB.update_user_plan(user_id, plan, stripe_cid=session.customer)
849
+ return f"✅ Upgradnuto na {plan}!"
850
+ time.sleep(delay)
851
+ return "⏳ Platba zatím nebyla potvrzena. Pokud jsi zaplatil/a, obnov stránku za chvíli."
852
+ except Exception as e:
853
+ traceback.print_exc()
854
+ return f"Chyba při ověřování platby: {e}"
855
+
856
+
857
+ # ===========================================================================
858
+ # CORE LOGIC — EXPORT
859
+ # ===========================================================================
860
+ def export_csv(user) -> str:
861
+ try:
862
+ invoices = DB.get_invoices(user["id"])
863
+ path = f"/tmp/export_{user['id']}_{int(time.time())}.csv"
864
+ import csv
865
+ with open(path, "w", newline="", encoding="utf-8") as f:
866
+ writer = csv.writer(f)
867
+ writer.writerow(["Vendor", "Invoice#", "Invoice Date", "Due Date", "Amount", "VAT", "Total", "Currency", "Status"])
868
+ for inv in invoices:
869
+ writer.writerow([inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
870
+ inv.get("due_date"), inv.get("amount"), inv.get("vat_amount"),
871
+ inv.get("total"), inv.get("currency"), inv.get("status")])
872
+ return path
873
+ except Exception as e:
874
+ traceback.print_exc()
875
+ raise gr.Error(f"Export CSV selhal: {e}")
876
+
877
+
878
+ def export_json(user) -> str:
879
+ try:
880
+ invoices = DB.get_invoices(user["id"])
881
+ path = f"/tmp/export_{user['id']}_{int(time.time())}.json"
882
+ with open(path, "w", encoding="utf-8") as f:
883
+ json.dump(invoices, f, ensure_ascii=False, indent=2, default=str)
884
+ return path
885
+ except Exception as e:
886
+ traceback.print_exc()
887
+ raise gr.Error(f"Export JSON selhal: {e}")
888
+
889
+
890
+ # ===========================================================================
891
+ # GRADIO UI LAYER — od tohoto místa dolů JEN volání CORE funkcí
892
+ # ===========================================================================
893
+ CUSTOM_CSS = """
894
+ .gradio-container {max-width: 1200px !important; margin: auto;}
895
+ .op-hero {text-align:center; padding: 40px 20px;}
896
+ .op-card {border:1px solid #e5e7eb; border-radius:12px; padding:20px; background:white;}
897
+ footer {visibility:hidden}
898
+ """
899
+
900
+ LANDING_HTML = """
901
+ <div style="font-family:Inter,sans-serif;">
902
+ <div style="display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid #eee;">
903
+ <div style="font-size:22px;font-weight:800;">⚡ OmniParse AI</div>
904
+ <div style="color:#666;font-size:14px;">How it works · Features · Pricing · Legal</div>
905
+ </div>
906
+ <div class="op-hero">
907
+ <h1 style="font-size:42px;font-weight:800;margin-bottom:8px;">Invoice processing in seconds, not hours.</h1>
908
+ <p style="font-size:18px;color:#555;max-width:640px;margin:0 auto 20px;">
909
+ AI extracts vendor, dates, amounts and line items from any PDF or image.
910
+ Export to CSV, JSON or Excel. Connect via API.
911
+ </p>
912
+ <p style="color:#888;">99.2% accuracy · &lt;4s per invoice · 40+ formats</p>
913
+ </div>
914
+
915
+ <div class="op-card" style="margin:20px 0;">
916
+ <h2>How it works</h2>
917
+ <ol>
918
+ <li>Upload PDF or image invoice</li>
919
+ <li>AI extracts all data</li>
920
+ <li>Export wherever you need</li>
921
+ </ol>
922
+ </div>
923
+
924
+ <div class="op-card" style="margin:20px 0;">
925
+ <h2>Features</h2>
926
+ <ul>
927
+ <li>🔍 OCR + LLM — Tesseract + Groq Llama 3.1</li>
928
+ <li>🚫 Duplicate Detection — Pro+, catches double payments</li>
929
+ <li>🤖 AI Chat Agent — ask about your invoices in plain English</li>
930
+ <li>✅ Cross-field Validation — checks totals, dates, tax rates</li>
931
+ <li>👥 Human-in-the-loop — Enterprise, manual review of uncertain invoices</li>
932
+ <li>🔌 REST API — connect to your own ERP</li>
933
+ </ul>
934
+ </div>
935
+
936
+ <div class="op-card" style="margin:20px 0;">
937
+ <h2>FAQ</h2>
938
+ <p><b>Is my data safe?</b> Yes — stored in EU (Frankfurt), encrypted at rest, GDPR compliant.</p>
939
+ <p><b>Does it work on Czech invoices?</b> Yes, OCR supports Czech + English.</p>
940
+ <p><b>How do payments work?</b> Monthly subscription via Stripe, cancel anytime.</p>
941
+ <p><b>Can I cancel anytime?</b> Yes, no lock-in contracts.</p>
942
+ <p><b>Do I get a tax invoice?</b> Yes, automatically generated by Stripe after each payment.</p>
943
+ </div>
944
+
945
+ <div style="text-align:center;color:#999;padding:20px;border-top:1px solid #eee;">
946
+ © 2026 OmniParse AI — Terms · Privacy · Disclaimer (viz Legal tab)
947
+ </div>
948
+ </div>
949
+ """
950
+
951
+ LEGAL_TERMS = """
952
+ ### Terms of Use
953
+ OmniParse AI je nástroj pro automatickou extrakci dat z faktur pomocí AI. Používáním služby souhlasíš,
954
+ ž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
955
+ inženýrství. Platby probíhají měsíčně přes Stripe, zrušení kdykoliv v sekci Profile. Neposkytujeme záruku
956
+ 100% přesnosti extrakce — viz Disclaimer.
957
+ """
958
+
959
+ LEGAL_PRIVACY = """
960
+ ### Privacy Policy / GDPR
961
+ **Co sbíráme:** e-mail, jméno, nahrané faktury a z nich extrahovaná data.
962
+ **Kde je to uloženo:** Supabase, EU region (Frankfurt).
963
+ **Jak dlouho:** faktury 30 dní, účetní/fakturační záznamy 10 let (zákonná lhůta).
964
+ **Tvá práva:** přístup k datům, výmaz (Profile → Delete account), přenositelnost dat (Export).
965
+ **Cookies:** pouze technické (session), žádný marketingový tracking.
966
+ """
967
+
968
+ LEGAL_DISCLAIMER = """
969
+ ### Disclaimer
970
+ AI extrakce není 100% přesná — vždy ověř data před zaúčtováním do tvého účetního systému.
971
+ OmniParse nenese odpovědnost za chyby vzniklé nesprávnou AI extrakcí. Toto je nástroj usnadňující práci,
972
+ nikoliv náhrada za kvalifikovaného účetního.
973
+ """
974
+
975
+
976
+ def status_badge(status):
977
+ return {"done": "✅ Done", "review": "⚠️ Review", "duplicate": "🔴 Duplicate", "processing": "⟳ Processing"}.get(status, status)
978
+
979
+
980
+ def invoices_to_dataframe(invoices):
981
+ rows = []
982
+ for inv in invoices:
983
+ rows.append([
984
+ inv.get("id"), inv.get("vendor"), inv.get("inv_number"), inv.get("inv_date"),
985
+ f"{inv.get('total', 0):.2f} {inv.get('currency', '')}", status_badge(inv.get("status")),
986
+ ])
987
+ return rows
988
+
989
+
990
+ with gr.Blocks(css=CUSTOM_CSS, title="OmniParse AI") as demo:
991
+ session_token = gr.State("") # server-side (NE localStorage) — viz resolve_session()
992
+ current_view = gr.State("landing")
993
+
994
+ # ---- VIEW CONTAINERS ----
995
+ with gr.Column(visible=True) as view_landing:
996
+ gr.HTML(LANDING_HTML)
997
+ with gr.Row():
998
+ btn_landing_start = gr.Button("Start Free — 20 invoices →", variant="primary")
999
+ btn_landing_login = gr.Button("Log In")
1000
+ btn_landing_pricing = gr.Button("Pricing")
1001
+ btn_landing_legal = gr.Button("Legal")
1002
+
1003
+ with gr.Column(visible=False) as view_pricing:
1004
+ gr.Markdown("## Pricing")
1005
+ with gr.Row():
1006
+ with gr.Column():
1007
+ gr.Markdown("### Free — $0/mo\n- 20 invoices/mo\n- CSV export\n- 1 user")
1008
+ with gr.Column():
1009
+ gr.Markdown("### Basic — $29/mo\n- 200 invoices/mo\n- JSON+CSV+Excel export\n- Multi-currency")
1010
+ with gr.Column():
1011
+ gr.Markdown("### Pro — $129/mo\n- 2,000 invoices/mo\n- REST API + AI Chat\n- Duplicate detection\n- 3 users")
1012
+ with gr.Column():
1013
+ gr.Markdown("### Enterprise — $499/mo\n- Unlimited invoices\n- Human-in-the-loop\n- SLA 99.5%\n- Unlimited users")
1014
+ gr.Markdown("_Přihlaš se a v Dashboardu → ⚡ Upgrade vyber plán a zaplať kartou přes Stripe._")
1015
+ btn_pricing_back = gr.Button("← Back")
1016
+
1017
+ with gr.Column(visible=False) as view_legal:
1018
+ gr.Markdown("## Legal")
1019
+ with gr.Tab("Terms of Use"):
1020
+ gr.Markdown(LEGAL_TERMS)
1021
+ with gr.Tab("Privacy Policy"):
1022
+ gr.Markdown(LEGAL_PRIVACY)
1023
+ with gr.Tab("Disclaimer"):
1024
+ gr.Markdown(LEGAL_DISCLAIMER)
1025
+ btn_legal_back = gr.Button("← Back")
1026
+
1027
+ with gr.Column(visible=False) as view_auth:
1028
+ gr.Markdown("## Welcome to OmniParse AI")
1029
+ with gr.Tab("Log In"):
1030
+ login_email = gr.Textbox(label="Email")
1031
+ login_password = gr.Textbox(label="Password", type="password")
1032
+ login_btn = gr.Button("Log In", variant="primary")
1033
+ login_msg = gr.Markdown()
1034
+ gr.Markdown("_Demo účet: `demo@omniparse.ai` / `demo1234` (plán Pro)_")
1035
+ with gr.Tab("Sign Up"):
1036
+ signup_name = gr.Textbox(label="Full Name")
1037
+ signup_email = gr.Textbox(label="Work Email")
1038
+ signup_password = gr.Textbox(label="Password (min. 8 znaků)", type="password")
1039
+ signup_terms = gr.Checkbox(label="Souhlasím s Terms of Use a Privacy Policy")
1040
+ signup_btn = gr.Button("Sign Up", variant="primary")
1041
+ signup_msg = gr.Markdown()
1042
+ btn_auth_back = gr.Button("← Back to landing")
1043
+
1044
+ with gr.Column(visible=False) as view_dashboard:
1045
+ with gr.Row():
1046
+ gr.Markdown("## Dashboard")
1047
+ btn_logout = gr.Button("🚪 Log Out", size="sm")
1048
+ user_info_md = gr.Markdown()
1049
+
1050
+ with gr.Tab("📤 Upload"):
1051
+ upload_files = gr.File(label="Nahraj faktury (PDF/JPG/PNG/TIFF, max 20MB/soubor)", file_count="multiple")
1052
+ upload_btn = gr.Button("Zpracovat faktury", variant="primary")
1053
+ upload_status = gr.Markdown()
1054
+ upload_results = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Výsledky")
1055
+ upload_raw_json = gr.JSON(label="Raw AI output (poslední soubor)")
1056
+
1057
+ with gr.Tab("📋 My Invoices"):
1058
+ refresh_invoices_btn = gr.Button("🔄 Obnovit")
1059
+ invoices_table = gr.Dataframe(headers=["ID", "Vendor", "Invoice#", "Date", "Total", "Status"], label="Faktury")
1060
+
1061
+ with gr.Tab("🤖 AI Chat (Pro+)"):
1062
+ chat_history = gr.Chatbot(label="Zeptej se na své faktury", type="messages")
1063
+ chat_input = gr.Textbox(label="Otázka", placeholder="What's the total unpaid amount?")
1064
+ chat_send = gr.Button("Odeslat")
1065
+
1066
+ with gr.Tab("📊 Export"):
1067
+ gr.Markdown("CSV export je zdarma pro všechny. JSON od plánu Basic+.")
1068
+ export_csv_btn = gr.Button("Export CSV")
1069
+ export_csv_file = gr.File(label="Stáhnout CSV")
1070
+ export_json_btn = gr.Button("Export JSON (Basic+)")
1071
+ export_json_file = gr.File(label="Stáhnout JSON")
1072
+ gr.Markdown("Excel export a Google Sheets sync: **Coming soon** 🚧")
1073
+
1074
+ with gr.Tab("⚡ Upgrade"):
1075
+ plan_dropdown = gr.Dropdown(["basic", "pro", "enterprise"], label="Vyber plán", value="basic")
1076
+ upgrade_btn = gr.Button("Přejít na platbu (Stripe)", variant="primary")
1077
+ upgrade_link = gr.Markdown()
1078
+ gr.Markdown("Test karta ve Stripe test mode: `4242 4242 4242 4242`, libovolné datum/CVC.")
1079
+ checkout_session_input = gr.Textbox(label="Po zaplacení: vlož session_id z URL a klikni níže", visible=True)
1080
+ confirm_payment_btn = gr.Button("Ověřit platbu")
1081
+ payment_status_md = gr.Markdown()
1082
+
1083
+ with gr.Tab("🔌 API (Pro+)"):
1084
+ api_key_display = gr.Markdown()
1085
+ gr.Markdown("""
1086
+ ```bash
1087
+ curl -X POST https://tvuj-space.hf.space/api/extract \\
1088
+ -H "Authorization: Bearer TVUJ_API_KLIC" \\
1089
+ -F "file=@faktura.pdf"
1090
+ ```
1091
+ _(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()`.)_
1092
+ """)
1093
+
1094
+ with gr.Tab("👤 Profile"):
1095
+ profile_info = gr.Markdown()
1096
+ new_password = gr.Textbox(label="Nové heslo", type="password")
1097
+ change_pw_btn = gr.Button("Změnit heslo")
1098
+ change_pw_msg = gr.Markdown()
1099
+ gr.Markdown("### ⚠️ Danger zone")
1100
+ delete_confirm = gr.Checkbox(label="Ano, opravdu chci smazat účet a všechna data")
1101
+ delete_btn = gr.Button("Smazat účet natrvalo", variant="stop")
1102
+ delete_msg = gr.Markdown()
1103
+
1104
+ ALL_VIEWS = [view_landing, view_pricing, view_legal, view_auth, view_dashboard]
1105
+
1106
+ def switch_view(target):
1107
+ return [gr.update(visible=(v == target)) for v in ["landing", "pricing", "legal", "auth", "dashboard"]]
1108
+
1109
+ # ---- NAVIGACE ----
1110
+ btn_landing_start.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1111
+ btn_landing_login.click(lambda: switch_view("auth"), outputs=ALL_VIEWS)
1112
+ btn_landing_pricing.click(lambda: switch_view("pricing"), outputs=ALL_VIEWS)
1113
+ btn_landing_legal.click(lambda: switch_view("legal"), outputs=ALL_VIEWS)
1114
+ btn_pricing_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1115
+ btn_legal_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1116
+ btn_auth_back.click(lambda: switch_view("landing"), outputs=ALL_VIEWS)
1117
+
1118
+ # ---- AUTH HANDLERY ----
1119
+ def handle_signup(name, email, password, terms):
1120
+ ok, msg, token = signup(name, email, password, terms)
1121
+ if ok:
1122
+ user = resolve_session(token)
1123
+ info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1124
+ views = switch_view("dashboard")
1125
+ return [msg, token, info] + views
1126
+ views = switch_view("auth")
1127
+ return [msg, "", ""] + views
1128
+
1129
+ signup_btn.click(
1130
+ handle_signup,
1131
+ inputs=[signup_name, signup_email, signup_password, signup_terms],
1132
+ outputs=[signup_msg, session_token, user_info_md] + ALL_VIEWS,
1133
+ )
1134
+
1135
+ def handle_login(email, password):
1136
+ ok, msg, token = login(email, password)
1137
+ if ok:
1138
+ user = resolve_session(token)
1139
+ info = f"✅ Přihlášen jako **{user['name']}** ({user['email']}) — plán **{user['plan']}**"
1140
+ views = switch_view("dashboard")
1141
+ return [msg, token, info] + views
1142
+ views = switch_view("auth")
1143
+ return [msg, "", ""] + views
1144
+
1145
+ login_btn.click(
1146
+ handle_login,
1147
+ inputs=[login_email, login_password],
1148
+ outputs=[login_msg, session_token, user_info_md] + ALL_VIEWS,
1149
+ )
1150
+
1151
+ def handle_logout(token):
1152
+ logout(token)
1153
+ views = switch_view("landing")
1154
+ return [""] + views
1155
+
1156
+ btn_logout.click(handle_logout, inputs=[session_token], outputs=[session_token] + ALL_VIEWS)
1157
+
1158
+ # ---- UPLOAD ----
1159
+ def handle_upload(token, files):
1160
+ user = resolve_session(token)
1161
+ if not user:
1162
+ return "❌ Nejsi přihlášen/a. Přihlas se prosím znovu.", [], {}
1163
+ if not files:
1164
+ return "⚠️ Nevybral/a jsi žádný soubor.", [], {}
1165
+ used = DB.count_invoices_this_month(user["id"])
1166
+ limit = PLAN_LIMITS.get(user["plan"], 20)
1167
+ if used >= limit:
1168
+ return f"🔴 Vyčerpal/a jsi měsíční limit ({int(limit) if limit != float('inf') else '∞'} faktur). Upgraduj v sekci ⚡ Upgrade.", [], {}
1169
+
1170
+ results, last_json, errors = [], {}, []
1171
+ for f in files:
1172
+ if used >= limit:
1173
+ errors.append(f"Limit dosažen, {os.path.basename(f.name)} přeskočen.")
1174
+ break
1175
+ record = process_invoice_file(user, f.name, os.path.basename(f.name))
1176
+ if "error" in record:
1177
+ errors.append(record["error"])
1178
+ continue
1179
+ used += 1
1180
+ last_json = record.get("raw_json", {})
1181
+ results.append([None, record["vendor"], record["inv_number"], record["inv_date"],
1182
+ f"{record['total']:.2f} {record['currency']}", status_badge(record["status"])])
1183
+
1184
+ msg = f"✅ Zpracováno {len(results)} faktur. Použito {used}/{int(limit) if limit != float('inf') else '∞'} tento měsíc."
1185
+ if errors:
1186
+ msg += "\n\n⚠️ Chyby:\n" + "\n".join(f"- {e}" for e in errors)
1187
+ return msg, results, last_json
1188
+
1189
+ upload_btn.click(handle_upload, inputs=[session_token, upload_files],
1190
+ outputs=[upload_status, upload_results, upload_raw_json])
1191
+
1192
+ # ---- MY INVOICES ----
1193
+ def handle_refresh_invoices(token):
1194
+ user = resolve_session(token)
1195
+ if not user:
1196
+ return []
1197
+ return invoices_to_dataframe(DB.get_invoices(user["id"]))
1198
+
1199
+ refresh_invoices_btn.click(handle_refresh_invoices, inputs=[session_token], outputs=[invoices_table])
1200
+
1201
+ # ---- AI CHAT ----
1202
+ def handle_chat(token, message, history):
1203
+ user = resolve_session(token)
1204
+ if not user:
1205
+ history = history or []
1206
+ history.append({"role": "assistant", "content": "Nejsi přihlášen/a."})
1207
+ return history, ""
1208
+ answer = ai_chat_answer(user, message, history)
1209
+ history = history or []
1210
+ history.append({"role": "user", "content": message})
1211
+ history.append({"role": "assistant", "content": answer})
1212
+ return history, ""
1213
+
1214
+ chat_send.click(handle_chat, inputs=[session_token, chat_input, chat_history], outputs=[chat_history, chat_input])
1215
+
1216
+ # ---- EXPORT ----
1217
+ def handle_export_csv(token):
1218
+ user = resolve_session(token)
1219
+ if not user:
1220
+ raise gr.Error("Nejsi přihlášen/a.")
1221
+ return export_csv(user)
1222
+
1223
+ export_csv_btn.click(handle_export_csv, inputs=[session_token], outputs=[export_csv_file])
1224
+
1225
+ def handle_export_json(token):
1226
+ user = resolve_session(token)
1227
+ if not user:
1228
+ raise gr.Error("Nejsi přihlášen/a.")
1229
+ if user["plan"] == "free":
1230
+ raise gr.Error("JSON export je dostupný od plánu Basic. Upgraduj v sekci ⚡ Upgrade.")
1231
+ return export_json(user)
1232
+
1233
+ export_json_btn.click(handle_export_json, inputs=[session_token], outputs=[export_json_file])
1234
+
1235
+ # ---- UPGRADE / STRIPE ----
1236
+ def handle_upgrade(token, plan):
1237
+ user = resolve_session(token)
1238
+ if not user:
1239
+ return "❌ Nejsi přihlášen/a."
1240
+ ok, result = create_checkout_url(user, plan)
1241
+ if ok:
1242
+ return f"[Klikni pro dokončení platby ve Stripe →]({result})"
1243
+ return f"⚠️ {result}"
1244
+
1245
+ upgrade_btn.click(handle_upgrade, inputs=[session_token, plan_dropdown], outputs=[upgrade_link])
1246
+
1247
+ def handle_confirm_payment(token, session_id):
1248
+ user = resolve_session(token)
1249
+ if not user:
1250
+ return "❌ Nejsi přihlášen/a."
1251
+ if not session_id:
1252
+ return "Vlož prosím session_id z URL po návratu ze Stripe."
1253
+ return poll_payment_status(session_id, user["id"])
1254
+
1255
+ confirm_payment_btn.click(handle_confirm_payment, inputs=[session_token, checkout_session_input], outputs=[payment_status_md])
1256
+
1257
+ # ---- API KEY DISPLAY ----
1258
+ def handle_show_api_key(token):
1259
+ user = resolve_session(token)
1260
+ if not user:
1261
+ return "Nejsi přihlášen/a."
1262
+ if user["plan"] not in ("pro", "enterprise"):
1263
+ return "🔒 API přístup je dostupný od plánu Pro. Upgraduj v sekci ⚡ Upgrade."
1264
+ return f"**Tvůj API klíč:** `{user.get('api_key', 'N/A')}`\n\n⚠️ Nikdy ho nesdílej veřejně."
1265
+
1266
+ # ---- PROFILE ----
1267
+ def handle_change_password(token, new_pw):
1268
+ user = resolve_session(token)
1269
+ if not user:
1270
+ return "❌ Nejsi přihlášen/a."
1271
+ ok, msg = password_strength_ok(new_pw)
1272
+ if not ok:
1273
+ return f"⚠️ {msg}"
1274
+ if is_password_leaked(new_pw):
1275
+ return "⚠️ Toto heslo bylo nalezeno v uniklých databázích. Zvol jiné."
1276
+ try:
1277
+ DB.update_password(user["id"], hash_password(new_pw))
1278
+ return "✅ Heslo změněno."
1279
+ except Exception as e:
1280
+ return f"❌ Chyba: {e}"
1281
+
1282
+ change_pw_btn.click(handle_change_password, inputs=[session_token, new_password], outputs=[change_pw_msg])
1283
+
1284
+ def handle_delete_account(token, confirmed):
1285
+ user = resolve_session(token)
1286
+ if not user:
1287
+ return "❌ Nejsi přihlášen/a.", token
1288
+ if not confirmed:
1289
+ return "⚠️ Zaškrtni prosím potvrzení.", token
1290
+ try:
1291
+ DB.delete_user(user["id"])
1292
+ return "✅ Účet smazán. Sbohem!", ""
1293
+ except Exception as e:
1294
+ return f"❌ Chyba při mazání: {e}", token
1295
+
1296
+ delete_btn.click(handle_delete_account, inputs=[session_token, delete_confirm], outputs=[delete_msg, session_token])
1297
+
1298
+ # ---- Při vstupu do dashboardu doplníme profil / API klíč / faktury ----
1299
+ def on_dashboard_enter(token):
1300
+ user = resolve_session(token)
1301
+ if not user:
1302
+ return "", "", []
1303
+ profile = f"**Jméno:** {user['name']}\n\n**Email:** {user['email']}\n\n**Plán:** {user['plan']}"
1304
+ api_txt = handle_show_api_key(token)
1305
+ invoices = invoices_to_dataframe(DB.get_invoices(user["id"]))
1306
+ return profile, api_txt, invoices
1307
+
1308
+ session_token.change(on_dashboard_enter, inputs=[session_token], outputs=[profile_info, api_key_display, invoices_table])
1309
+
1310
+
1311
+ if __name__ == "__main__":
1312
+ try:
1313
+ demo.queue(max_size=20).launch(server_name="0.0.0.0", server_port=7860)
1314
+ except Exception as e:
1315
+ print(f"[FATAL] Aplikace se nepodařila spustit: {e}")
1316
+ traceback.print_exc()