simikkk commited on
Commit
d14d4e3
·
verified ·
1 Parent(s): ec6ba99

Update database.py

Browse files
Files changed (1) hide show
  1. database.py +478 -263
database.py CHANGED
@@ -1,298 +1,513 @@
1
  """
2
- database.py -- Supabase (PostgreSQL) data layer for OmniParse AI.
3
 
4
- Requires environment variables:
5
- SUPABASE_URL
6
- SUPABASE_KEY (service role key -- used only from the server side)
 
 
7
  """
8
 
9
- import os
10
  import json
 
 
 
11
  import uuid
12
- import logging
13
- from datetime import datetime, timedelta, timezone
14
- from typing import Optional
15
 
16
- from supabase import create_client, Client
17
 
18
- logger = logging.getLogger("omniparse.database")
 
 
19
 
20
- SUPABASE_URL = os.getenv("SUPABASE_URL")
21
- SUPABASE_KEY = os.getenv("SUPABASE_KEY")
22
 
23
- if not SUPABASE_URL or not SUPABASE_KEY:
24
- logger.warning("SUPABASE_URL / SUPABASE_KEY not set. database.py will fail on first call.")
25
 
26
- _client: Optional[Client] = None
 
 
 
 
 
 
27
 
28
 
29
- def get_client() -> Client:
30
- """Lazily instantiate and return the Supabase client (singleton)."""
31
- global _client
32
- if _client is None:
33
- if not SUPABASE_URL or not SUPABASE_KEY:
34
- raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set in the environment.")
35
- _client = create_client(SUPABASE_URL, SUPABASE_KEY)
36
- return _client
37
 
38
 
39
  # ---------------------------------------------------------------------------
40
- # SQL schema + Row Level Security policies.
41
- # Run this once via the Supabase SQL editor (or migrations) before deploying.
42
  # ---------------------------------------------------------------------------
43
- SCHEMA_SQL = """
44
- -- =========================================================
45
- -- OmniParse AI -- schema.sql
46
- -- =========================================================
47
-
48
- create extension if not exists pgcrypto;
49
-
50
- create table if not exists users (
51
- id bigserial primary key,
52
- email text unique not null,
53
- name text not null,
54
- password text not null, -- bcrypt hash
55
- plan text not null default 'free', -- free | basic | pro | enterprise
56
- stripe_cid text,
57
- api_key text unique default encode(gen_random_bytes(24), 'hex'),
58
- created_at timestamptz not null default now()
59
- );
60
-
61
- create table if not exists sessions (
62
- token text primary key,
63
- user_id bigint not null references users(id) on delete cascade,
64
- expires_at timestamptz not null
65
- );
66
-
67
- create table if not exists invoices (
68
- id bigserial primary key,
69
- user_id bigint not null references users(id) on delete cascade,
70
- filename text not null,
71
- vendor text,
72
- inv_number text,
73
- inv_date text,
74
- due_date text,
75
- amount numeric(12,2),
76
- vat_amount numeric(12,2),
77
- total numeric(12,2),
78
- currency text default 'USD',
79
- status text default 'done', -- processing | done | error | needs_review
80
- is_duplicate boolean default false,
81
- confidence numeric(4,3),
82
- raw_json jsonb,
83
- created_at timestamptz not null default now()
84
- );
85
-
86
- create index if not exists idx_invoices_user_id on invoices(user_id);
87
- create index if not exists idx_invoices_vendor_amount on invoices(user_id, vendor, total);
88
-
89
- -- =========================================================
90
- -- Row Level Security
91
- -- =========================================================
92
- alter table users enable row level security;
93
- alter table sessions enable row level security;
94
- alter table invoices enable row level security;
95
-
96
- -- users: a row is only visible/editable by its owner
97
- drop policy if exists users_select_own on users;
98
- create policy users_select_own on users
99
- for select using (auth.uid()::text = id::text);
100
-
101
- drop policy if exists users_update_own on users;
102
- create policy users_update_own on users
103
- for update using (auth.uid()::text = id::text);
104
-
105
- -- sessions: owner only
106
- drop policy if exists sessions_all_own on sessions;
107
- create policy sessions_all_own on sessions
108
- for all using (auth.uid()::text = user_id::text);
109
-
110
- -- invoices: owner only, on every operation
111
- drop policy if exists invoices_select_own on invoices;
112
- create policy invoices_select_own on invoices
113
- for select using (auth.uid() = user_id);
114
-
115
- drop policy if exists invoices_insert_own on invoices;
116
- create policy invoices_insert_own on invoices
117
- for insert with check (auth.uid() = user_id);
118
-
119
- drop policy if exists invoices_update_own on invoices;
120
- create policy invoices_update_own on invoices
121
- for update using (auth.uid() = user_id);
122
-
123
- drop policy if exists invoices_delete_own on invoices;
124
- create policy invoices_delete_own on invoices
125
- for delete using (auth.uid() = user_id);
126
- """
127
 
128
 
129
  # ---------------------------------------------------------------------------
130
- # User functions
131
  # ---------------------------------------------------------------------------
132
- def create_user(email: str, name: str, hashed_password: str, plan: str = "free") -> dict:
133
- """Insert a new user. Raises on duplicate email (unique constraint)."""
134
- client = get_client()
135
- payload = {
136
- "email": email.strip().lower(),
137
- "name": name.strip(),
138
- "password": hashed_password,
139
- "plan": plan,
140
- }
141
- res = client.table("users").insert(payload).execute()
142
- if not res.data:
143
- raise RuntimeError("Failed to create user.")
144
- return res.data[0]
145
-
146
-
147
- def get_user_by_email(email: str) -> Optional[dict]:
148
- client = get_client()
149
- res = (
150
- client.table("users")
151
- .select("*")
152
- .eq("email", email.strip().lower())
153
- .limit(1)
154
- .execute()
155
- )
156
- return res.data[0] if res.data else None
157
-
158
-
159
- def get_user_by_id(user_id: int) -> Optional[dict]:
160
- client = get_client()
161
- res = client.table("users").select("*").eq("id", user_id).limit(1).execute()
162
- return res.data[0] if res.data else None
163
-
164
-
165
- def update_user_plan(user_id: int, plan: str, stripe_cid: Optional[str] = None) -> None:
166
- client = get_client()
167
- payload = {"plan": plan}
168
- if stripe_cid:
169
- payload["stripe_cid"] = stripe_cid
170
- client.table("users").update(payload).eq("id", user_id).execute()
171
-
172
-
173
- def get_user_by_stripe_customer(stripe_cid: str) -> Optional[dict]:
174
- client = get_client()
175
- res = (
176
- client.table("users")
177
- .select("*")
178
- .eq("stripe_cid", stripe_cid)
179
- .limit(1)
180
- .execute()
181
- )
182
- return res.data[0] if res.data else None
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
- # ---------------------------------------------------------------------------
186
- # Session functions
187
- # ---------------------------------------------------------------------------
188
- def create_session(user_id: int, ttl_hours: int = 72) -> str:
189
- client = get_client()
190
- token = uuid.uuid4().hex + uuid.uuid4().hex
191
- expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
192
- client.table("sessions").insert(
193
- {"token": token, "user_id": user_id, "expires_at": expires_at}
194
- ).execute()
195
- return token
196
-
197
-
198
- def get_session(token: str) -> Optional[dict]:
199
- if not token:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return None
201
- client = get_client()
202
- res = client.table("sessions").select("*").eq("token", token).limit(1).execute()
203
- if not res.data:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  return None
205
- session = res.data[0]
206
- expires_at = datetime.fromisoformat(session["expires_at"].replace("Z", "+00:00"))
207
- if expires_at < datetime.now(timezone.utc):
208
- delete_session(token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  return None
210
- return session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
 
213
  def delete_session(token: str) -> None:
214
- client = get_client()
215
- client.table("sessions").delete().eq("token", token).execute()
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
 
218
  # ---------------------------------------------------------------------------
219
- # Invoice functions
220
  # ---------------------------------------------------------------------------
221
- def check_for_duplicate(user_id: int, vendor: str, total: float) -> bool:
222
- """A duplicate is the same vendor + exact total already recorded this month."""
223
- if not vendor or total is None:
224
- return False
225
- client = get_client()
226
- now = datetime.now(timezone.utc)
227
- month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
228
- res = (
229
- client.table("invoices")
230
- .select("id")
231
- .eq("user_id", user_id)
232
- .eq("vendor", vendor)
233
- .eq("total", total)
234
- .gte("created_at", month_start)
235
- .limit(1)
236
- .execute()
237
- )
238
- return bool(res.data)
239
-
240
-
241
- def save_invoice_record(user_id: int, filename: str, parsed: dict) -> dict:
242
  """
243
- parsed is the normalized dict produced by backend.py's AI/OCR cascade:
244
- {vendor, inv_number, inv_date, due_date, amount, vat_amount, total,
245
- currency, confidence, status, raw_json}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  """
247
- client = get_client()
248
- vendor = parsed.get("vendor")
249
- total = parsed.get("total")
250
- is_dup = check_for_duplicate(user_id, vendor, total) if vendor and total is not None else False
251
-
252
- payload = {
253
- "user_id": user_id,
254
- "filename": filename,
255
- "vendor": vendor,
256
- "inv_number": parsed.get("inv_number"),
257
- "inv_date": parsed.get("inv_date"),
258
- "due_date": parsed.get("due_date"),
259
- "amount": parsed.get("amount"),
260
- "vat_amount": parsed.get("vat_amount"),
261
- "total": total,
262
- "currency": parsed.get("currency", "USD"),
263
- "status": parsed.get("status", "done"),
264
- "is_duplicate": is_dup,
265
- "confidence": parsed.get("confidence"),
266
- "raw_json": json.dumps(parsed.get("raw_json", {})),
267
- }
268
- res = client.table("invoices").insert(payload).execute()
269
- if not res.data:
270
- raise RuntimeError("Failed to save invoice record.")
271
- return res.data[0]
272
-
273
-
274
- def list_invoices(user_id: int, limit: int = 200) -> list:
275
- client = get_client()
276
- res = (
277
- client.table("invoices")
278
- .select("*")
279
- .eq("user_id", user_id)
280
- .order("created_at", desc=True)
281
- .limit(limit)
282
- .execute()
283
- )
284
- return res.data or []
285
-
286
-
287
- def count_invoices_this_month(user_id: int) -> int:
288
- client = get_client()
289
- now = datetime.now(timezone.utc)
290
- month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
291
- res = (
292
- client.table("invoices")
293
- .select("id", count="exact")
294
- .eq("user_id", user_id)
295
- .gte("created_at", month_start)
296
- .execute()
297
- )
298
- return res.count or 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ OmniParse AI - Database Layer
3
 
4
+ Dual-backend persistence:
5
+ - **Supabase** (PostgreSQL) when SUPABASE_URL and SUPABASE_KEY are set.
6
+ - **SQLite** as the fallback (file-based, suitable for HF Spaces).
7
+
8
+ All public functions return plain dicts / lists — never raw Row / tuple objects.
9
  """
10
 
 
11
  import json
12
+ import os
13
+ import sqlite3
14
+ import time
15
  import uuid
 
 
 
16
 
17
+ from config import USE_SUPABASE, supabase_client, SESSION_TTL_SECONDS
18
 
19
+ # ---------------------------------------------------------------------------
20
+ # SQLite setup
21
+ # ---------------------------------------------------------------------------
22
 
23
+ _DB_PATH = os.environ.get("SQLITE_DB_PATH", "omniparse.db")
 
24
 
 
 
25
 
26
+ def _sqlite_conn() -> sqlite3.Connection:
27
+ """Return a new SQLite connection with row_factory enabled."""
28
+ conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
29
+ conn.row_factory = sqlite3.Row
30
+ conn.execute("PRAGMA journal_mode=WAL")
31
+ conn.execute("PRAGMA foreign_keys=ON")
32
+ return conn
33
 
34
 
35
+ def _row_to_dict(row) -> dict:
36
+ """Convert a sqlite3.Row (or similar) to a plain dict."""
37
+ if row is None:
38
+ return None
39
+ if isinstance(row, dict):
40
+ return row
41
+ return dict(row)
 
42
 
43
 
44
  # ---------------------------------------------------------------------------
45
+ # Initialisation
 
46
  # ---------------------------------------------------------------------------
47
+
48
+ def db_init() -> None:
49
+ """Create tables and indexes if they do not exist yet."""
50
+ if USE_SUPABASE and supabase_client is not None:
51
+ # Supabase tables are expected to be created via migrations;
52
+ # we do not attempt DDL here.
53
+ return
54
+
55
+ conn = _sqlite_conn()
56
+ try:
57
+ conn.executescript("""
58
+ CREATE TABLE IF NOT EXISTS users (
59
+ id TEXT PRIMARY KEY,
60
+ email TEXT NOT NULL UNIQUE,
61
+ name TEXT NOT NULL,
62
+ password TEXT NOT NULL DEFAULT '',
63
+ plan TEXT NOT NULL DEFAULT 'free',
64
+ stripe_cid TEXT DEFAULT '',
65
+ api_key TEXT DEFAULT '',
66
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS sessions (
70
+ token TEXT PRIMARY KEY,
71
+ user_id TEXT NOT NULL,
72
+ expires_at REAL NOT NULL,
73
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS invoices (
77
+ id TEXT PRIMARY KEY,
78
+ user_id TEXT NOT NULL,
79
+ vendor TEXT DEFAULT '',
80
+ inv_number TEXT DEFAULT '',
81
+ inv_date TEXT DEFAULT '',
82
+ due_date TEXT DEFAULT '',
83
+ amount REAL DEFAULT 0,
84
+ vat_amount REAL DEFAULT 0,
85
+ total REAL DEFAULT 0,
86
+ currency TEXT DEFAULT 'USD',
87
+ status TEXT DEFAULT 'done',
88
+ is_duplicate INTEGER DEFAULT 0,
89
+ confidence REAL DEFAULT 0,
90
+ raw_json TEXT DEFAULT '',
91
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
92
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
93
+ );
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_invoices_user_created
96
+ ON invoices(user_id, created_at);
97
+ """)
98
+ conn.commit()
99
+ finally:
100
+ conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  # ---------------------------------------------------------------------------
104
+ # User CRUD
105
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
+ def create_user(email: str, name: str, password: str, plan: str = "free") -> dict | None:
108
+ """Insert a new user. Returns the user dict on success, None on failure."""
109
+ uid = str(uuid.uuid4())
110
+
111
+ if USE_SUPABASE and supabase_client is not None:
112
+ try:
113
+ row = (
114
+ supabase_client.table("users")
115
+ .insert({
116
+ "id": uid,
117
+ "email": email,
118
+ "name": name,
119
+ "password": password,
120
+ "plan": plan,
121
+ "stripe_cid": "",
122
+ "api_key": "",
123
+ })
124
+ .execute()
125
+ )
126
+ if row.data and len(row.data) > 0:
127
+ return row.data[0]
128
+ except Exception:
129
+ return None
130
+ return None
131
 
132
+ # SQLite path
133
+ conn = _sqlite_conn()
134
+ try:
135
+ conn.execute(
136
+ "INSERT INTO users (id, email, name, password, plan) VALUES (?, ?, ?, ?, ?)",
137
+ (uid, email, name, password, plan),
138
+ )
139
+ conn.commit()
140
+ cur = conn.execute("SELECT * FROM users WHERE id = ?", (uid,))
141
+ return _row_to_dict(cur.fetchone())
142
+ except Exception:
143
+ return None
144
+ finally:
145
+ conn.close()
146
+
147
+
148
+ def get_user_by_email(email: str) -> dict | None:
149
+ """Fetch a user by email. Returns user dict or None."""
150
+ if USE_SUPABASE and supabase_client is not None:
151
+ try:
152
+ row = (
153
+ supabase_client.table("users")
154
+ .select("*")
155
+ .eq("email", email)
156
+ .limit(1)
157
+ .execute()
158
+ )
159
+ if row.data and len(row.data) > 0:
160
+ return row.data[0]
161
+ except Exception:
162
+ pass
163
  return None
164
+
165
+ conn = _sqlite_conn()
166
+ try:
167
+ cur = conn.execute("SELECT * FROM users WHERE email = ?", (email,))
168
+ return _row_to_dict(cur.fetchone())
169
+ finally:
170
+ conn.close()
171
+
172
+
173
+ def get_user_by_id(uid: str) -> dict | None:
174
+ """Fetch a user by primary-key ID. Returns user dict or None."""
175
+ if USE_SUPABASE and supabase_client is not None:
176
+ try:
177
+ row = (
178
+ supabase_client.table("users")
179
+ .select("*")
180
+ .eq("id", uid)
181
+ .limit(1)
182
+ .execute()
183
+ )
184
+ if row.data and len(row.data) > 0:
185
+ return row.data[0]
186
+ except Exception:
187
+ pass
188
  return None
189
+
190
+ conn = _sqlite_conn()
191
+ try:
192
+ cur = conn.execute("SELECT * FROM users WHERE id = ?", (uid,))
193
+ return _row_to_dict(cur.fetchone())
194
+ finally:
195
+ conn.close()
196
+
197
+
198
+ def update_user(uid: str, fields: dict) -> dict | None:
199
+ """Update arbitrary fields on a user. Returns updated user dict or None."""
200
+ if not fields:
201
+ return get_user_by_id(uid)
202
+
203
+ allowed = {"name", "password", "plan", "stripe_cid", "api_key"}
204
+ updates = {k: v for k, v in fields.items() if k in allowed}
205
+ if not updates:
206
+ return get_user_by_id(uid)
207
+
208
+ if USE_SUPABASE and supabase_client is not None:
209
+ try:
210
+ (
211
+ supabase_client.table("users")
212
+ .update(updates)
213
+ .eq("id", uid)
214
+ .execute()
215
+ )
216
+ return get_user_by_id(uid)
217
+ except Exception:
218
+ return None
219
+
220
+ # SQLite path
221
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
222
+ values = list(updates.values()) + [uid]
223
+
224
+ conn = _sqlite_conn()
225
+ try:
226
+ conn.execute(f"UPDATE users SET {set_clause} WHERE id = ?", values)
227
+ conn.commit()
228
+ return get_user_by_id(uid)
229
+ except Exception:
230
  return None
231
+ finally:
232
+ conn.close()
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Sessions
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def create_session(user_id: str, token: str) -> None:
240
+ """Create a new session row."""
241
+ expires_at = time.time() + SESSION_TTL_SECONDS
242
+
243
+ if USE_SUPABASE and supabase_client is not None:
244
+ try:
245
+ supabase_client.table("sessions").insert({
246
+ "token": token,
247
+ "user_id": user_id,
248
+ "expires_at": expires_at,
249
+ }).execute()
250
+ except Exception:
251
+ pass
252
+ return
253
+
254
+ conn = _sqlite_conn()
255
+ try:
256
+ conn.execute(
257
+ "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
258
+ (token, user_id, expires_at),
259
+ )
260
+ conn.commit()
261
+ finally:
262
+ conn.close()
263
+
264
+
265
+ def get_session_user(token: str) -> dict | None:
266
+ """Return the user dict for a valid, non-expired session, or None."""
267
+ now = time.time()
268
+
269
+ if USE_SUPABASE and supabase_client is not None:
270
+ try:
271
+ row = (
272
+ supabase_client.table("sessions")
273
+ .select("user_id, expires_at")
274
+ .eq("token", token)
275
+ .limit(1)
276
+ .execute()
277
+ )
278
+ if not row.data or len(row.data) == 0:
279
+ return None
280
+ session = row.data[0]
281
+ if session["expires_at"] < now:
282
+ # Session expired — clean up
283
+ supabase_client.table("sessions").delete().eq("token", token).execute()
284
+ return None
285
+ return get_user_by_id(session["user_id"])
286
+ except Exception:
287
+ return None
288
+
289
+ conn = _sqlite_conn()
290
+ try:
291
+ cur = conn.execute(
292
+ "SELECT user_id, expires_at FROM sessions WHERE token = ?",
293
+ (token,),
294
+ )
295
+ session = cur.fetchone()
296
+ if session is None:
297
+ return None
298
+ if session["expires_at"] < now:
299
+ conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
300
+ conn.commit()
301
+ return None
302
+ return get_user_by_id(session["user_id"])
303
+ finally:
304
+ conn.close()
305
 
306
 
307
  def delete_session(token: str) -> None:
308
+ """Delete a single session (used on logout)."""
309
+ if USE_SUPABASE and supabase_client is not None:
310
+ try:
311
+ supabase_client.table("sessions").delete().eq("token", token).execute()
312
+ except Exception:
313
+ pass
314
+ return
315
+
316
+ conn = _sqlite_conn()
317
+ try:
318
+ conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
319
+ conn.commit()
320
+ finally:
321
+ conn.close()
322
 
323
 
324
  # ---------------------------------------------------------------------------
325
+ # Invoices
326
  # ---------------------------------------------------------------------------
327
+
328
+ def insert_invoice(user_id: str, data: dict) -> dict | None:
329
+ """Insert a parsed invoice. *data* should contain the invoice fields.
330
+
331
+ Returns the inserted invoice dict or None on failure.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  """
333
+ inv_id = str(uuid.uuid4())
334
+ raw_json = json.dumps(data.get("raw_data", data), default=str)
335
+
336
+ if USE_SUPABASE and supabase_client is not None:
337
+ try:
338
+ row = (
339
+ supabase_client.table("invoices")
340
+ .insert({
341
+ "id": inv_id,
342
+ "user_id": user_id,
343
+ "vendor": data.get("vendor", ""),
344
+ "inv_number": data.get("inv_number", ""),
345
+ "inv_date": data.get("inv_date", ""),
346
+ "due_date": data.get("due_date", ""),
347
+ "amount": data.get("amount", 0),
348
+ "vat_amount": data.get("vat_amount", 0),
349
+ "total": data.get("total", 0),
350
+ "currency": data.get("currency", "USD"),
351
+ "status": data.get("status", "done"),
352
+ "is_duplicate": 1 if data.get("is_duplicate") else 0,
353
+ "confidence": data.get("confidence", 0),
354
+ "raw_json": raw_json,
355
+ })
356
+ .execute()
357
+ )
358
+ if row.data and len(row.data) > 0:
359
+ return row.data[0]
360
+ except Exception:
361
+ return None
362
+ return None
363
+
364
+ # SQLite path
365
+ conn = _sqlite_conn()
366
+ try:
367
+ conn.execute(
368
+ """INSERT INTO invoices
369
+ (id, user_id, vendor, inv_number, inv_date, due_date,
370
+ amount, vat_amount, total, currency, status,
371
+ is_duplicate, confidence, raw_json)
372
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
373
+ (
374
+ inv_id,
375
+ user_id,
376
+ data.get("vendor", ""),
377
+ data.get("inv_number", ""),
378
+ data.get("inv_date", ""),
379
+ data.get("due_date", ""),
380
+ data.get("amount", 0),
381
+ data.get("vat_amount", 0),
382
+ data.get("total", 0),
383
+ data.get("currency", "USD"),
384
+ data.get("status", "done"),
385
+ 1 if data.get("is_duplicate") else 0,
386
+ data.get("confidence", 0),
387
+ raw_json,
388
+ ),
389
+ )
390
+ conn.commit()
391
+ cur = conn.execute("SELECT * FROM invoices WHERE id = ?", (inv_id,))
392
+ return _row_to_dict(cur.fetchone())
393
+ except Exception:
394
+ return None
395
+ finally:
396
+ conn.close()
397
+
398
+
399
+ def get_invoices(user_id: str, limit: int = 50, offset: int = 0) -> list[dict]:
400
+ """Return a list of invoice dicts for *user_id*, newest first."""
401
+ if USE_SUPABASE and supabase_client is not None:
402
+ try:
403
+ row = (
404
+ supabase_client.table("invoices")
405
+ .select("*")
406
+ .eq("user_id", user_id)
407
+ .order("created_at", desc=True)
408
+ .range(offset, offset + limit - 1)
409
+ .execute()
410
+ )
411
+ return row.data if row.data else []
412
+ except Exception:
413
+ return []
414
+
415
+ conn = _sqlite_conn()
416
+ try:
417
+ cur = conn.execute(
418
+ "SELECT * FROM invoices WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
419
+ (user_id, limit, offset),
420
+ )
421
+ return [_row_to_dict(r) for r in cur.fetchall()]
422
+ finally:
423
+ conn.close()
424
+
425
+
426
+ def count_invoices_this_month(user_id: str) -> int:
427
+ """Count how many invoices *user_id* created in the current calendar month.
428
+
429
+ Uses an SQL WHERE clause for efficiency (no Python-side filtering).
430
  """
431
+ import datetime
432
+
433
+ now = datetime.datetime.utcnow()
434
+ month_start = now.strftime("%Y-%m-01 00:00:00")
435
+
436
+ if USE_SUPABASE and supabase_client is not None:
437
+ try:
438
+ row = (
439
+ supabase_client.table("invoices")
440
+ .select("id", count="exact")
441
+ .eq("user_id", user_id)
442
+ .gte("created_at", month_start)
443
+ .execute()
444
+ )
445
+ return row.count if hasattr(row, "count") else len(row.data)
446
+ except Exception:
447
+ return 0
448
+
449
+ conn = _sqlite_conn()
450
+ try:
451
+ cur = conn.execute(
452
+ "SELECT COUNT(*) AS cnt FROM invoices WHERE user_id = ? AND created_at >= ?",
453
+ (user_id, month_start),
454
+ )
455
+ row = cur.fetchone()
456
+ return row["cnt"] if row else 0
457
+ finally:
458
+ conn.close()
459
+
460
+
461
+ def check_duplicate(user_id: str, vendor: str, total: float) -> bool:
462
+ """Return True if an invoice with the same vendor and total already exists."""
463
+ if USE_SUPABASE and supabase_client is not None:
464
+ try:
465
+ row = (
466
+ supabase_client.table("invoices")
467
+ .select("id")
468
+ .eq("user_id", user_id)
469
+ .eq("vendor", vendor)
470
+ .eq("total", total)
471
+ .limit(1)
472
+ .execute()
473
+ )
474
+ return bool(row.data and len(row.data) > 0)
475
+ except Exception:
476
+ return False
477
+
478
+ conn = _sqlite_conn()
479
+ try:
480
+ cur = conn.execute(
481
+ "SELECT id FROM invoices WHERE user_id = ? AND vendor = ? AND total = ? LIMIT 1",
482
+ (user_id, vendor, total),
483
+ )
484
+ return cur.fetchone() is not None
485
+ finally:
486
+ conn.close()
487
+
488
+
489
+ def delete_invoice(user_id: str, invoice_id: str) -> bool:
490
+ """Delete an invoice belonging to *user_id*. Returns True if a row was removed."""
491
+ if USE_SUPABASE and supabase_client is not None:
492
+ try:
493
+ row = (
494
+ supabase_client.table("invoices")
495
+ .delete()
496
+ .eq("id", invoice_id)
497
+ .eq("user_id", user_id)
498
+ .execute()
499
+ )
500
+ return bool(row.data and len(row.data) > 0)
501
+ except Exception:
502
+ return False
503
+
504
+ conn = _sqlite_conn()
505
+ try:
506
+ cur = conn.execute(
507
+ "DELETE FROM invoices WHERE id = ? AND user_id = ?",
508
+ (invoice_id, user_id),
509
+ )
510
+ conn.commit()
511
+ return cur.rowcount > 0
512
+ finally:
513
+ conn.close()