Abdullah-354 commited on
Commit
ca34441
Β·
verified Β·
1 Parent(s): b2bcac3

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -903
app.py DELETED
@@ -1,903 +0,0 @@
1
- # ------------------------------
2
- # Project Structure
3
- # ------------------------------
4
- # Use this as a reference. Files are concatenated below; save them into separate files.
5
- #
6
- # expense-tracker/
7
- # β”œβ”€ app/
8
- # β”‚ β”œβ”€ streamlit_app.py
9
- # β”‚ β”œβ”€ db.py
10
- # β”‚ β”œβ”€ hf_client.py
11
- # β”‚ └─ utils.py
12
- # β”œβ”€ frontend/
13
- # β”‚ β”œβ”€ index.html
14
- # β”‚ β”œβ”€ styles.css
15
- # β”‚ └─ app.js
16
- # β”œβ”€ .env.example
17
- # β”œβ”€ requirements.txt
18
- # └─ README.md
19
-
20
- # ==============================
21
- # requirements.txt
22
- # ==============================
23
- # streamlit
24
- # pandas
25
- # python-dotenv
26
- # requests
27
- # altair
28
- #
29
- # (Optional)
30
- # openpyxl # for Excel export
31
-
32
- # ==============================
33
- # .env.example
34
- # ==============================
35
- # Copy to .env and fill in your token. Get one at https://huggingface.co/settings/tokens
36
- # HF_API_TOKEN=hf_xxx_your_token_here
37
-
38
- # ==============================
39
- # app/db.py
40
- # ==============================
41
- import sqlite3
42
- from contextlib import contextmanager
43
- from pathlib import Path
44
- from typing import Iterable, Optional, Dict, Any
45
-
46
- DB_PATH = Path(__file__).resolve().parent.parent / "data" / "expenses.db"
47
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
48
-
49
- @contextmanager
50
- def get_conn():
51
- conn = sqlite3.connect(DB_PATH)
52
- conn.row_factory = sqlite3.Row
53
- try:
54
- yield conn
55
- finally:
56
- conn.close()
57
-
58
- SCHEMA_SQL = """
59
- CREATE TABLE IF NOT EXISTS expenses (
60
- id INTEGER PRIMARY KEY AUTOINCREMENT,
61
- date TEXT NOT NULL,
62
- description TEXT NOT NULL,
63
- merchant TEXT,
64
- amount REAL NOT NULL,
65
- category TEXT,
66
- created_at TEXT DEFAULT (datetime('now'))
67
- );
68
- """
69
-
70
- def init_db():
71
- with get_conn() as conn:
72
- conn.execute(SCHEMA_SQL)
73
- conn.commit()
74
-
75
- def insert_expense(payload: Dict[str, Any]) -> int:
76
- with get_conn() as conn:
77
- cur = conn.execute(
78
- """
79
- INSERT INTO expenses (date, description, merchant, amount, category)
80
- VALUES (?, ?, ?, ?, ?)
81
- """,
82
- (
83
- payload["date"],
84
- payload["description"],
85
- payload.get("merchant"),
86
- float(payload["amount"]),
87
- payload.get("category"),
88
- ),
89
- )
90
- conn.commit()
91
- return cur.lastrowid
92
-
93
- def update_expense(expense_id: int, updates: Dict[str, Any]):
94
- fields = []
95
- values = []
96
- for k, v in updates.items():
97
- fields.append(f"{k} = ?")
98
- values.append(v)
99
- values.append(expense_id)
100
- with get_conn() as conn:
101
- conn.execute(f"UPDATE expenses SET {', '.join(fields)} WHERE id = ?", values)
102
- conn.commit()
103
-
104
- def delete_expenses(ids: Iterable[int]):
105
- ids = list(ids)
106
- if not ids:
107
- return
108
- qmarks = ",".join(["?"] * len(ids))
109
- with get_conn() as conn:
110
- conn.execute(f"DELETE FROM expenses WHERE id IN ({qmarks})", ids)
111
- conn.commit()
112
-
113
- def fetch_expenses(where_sql: str = "", params: Iterable[Any] = ()): # returns list[dict]
114
- sql = "SELECT * FROM expenses"
115
- if where_sql:
116
- sql += " WHERE " + where_sql
117
- sql += " ORDER BY date DESC, id DESC"
118
- with get_conn() as conn:
119
- rows = conn.execute(sql, params).fetchall()
120
- return [dict(r) for r in rows]
121
-
122
- # ==============================
123
- # app/hf_client.py
124
- # ==============================
125
- import os
126
- import requests
127
- from typing import List, Optional
128
- from dotenv import load_dotenv
129
-
130
- load_dotenv()
131
-
132
- HF_API_TOKEN = os.getenv("HF_API_TOKEN", "")
133
- HF_ZSC_MODEL = os.getenv("HF_ZSC_MODEL", "facebook/bart-large-mnli")
134
- HF_API_URL = f"https://api-inference.huggingface.co/models/{HF_ZSC_MODEL}"
135
-
136
- DEFAULT_LABELS = [
137
- "Food & Dining",
138
- "Groceries",
139
- "Transport",
140
- "Fuel",
141
- "Shopping",
142
- "Bills & Utilities",
143
- "Entertainment",
144
- "Health",
145
- "Travel",
146
- "Rent/Mortgage",
147
- "Education",
148
- "Income",
149
- "Other",
150
- ]
151
-
152
- def zero_shot_category(text: str, labels: Optional[List[str]] = None) -> str:
153
- """Classify an expense description into one of the labels using HF Inference API.
154
- Returns best label (str). Falls back to 'Other' on error or missing token.
155
- """
156
- labels = labels or DEFAULT_LABELS
157
- if not HF_API_TOKEN:
158
- return "Other"
159
- headers = {"Authorization": f"Bearer {HF_API_TOKEN}"}
160
- payload = {
161
- "inputs": text,
162
- "parameters": {
163
- "candidate_labels": labels,
164
- "multi_label": False,
165
- },
166
- }
167
- try:
168
- r = requests.post(HF_API_URL, headers=headers, json=payload, timeout=20)
169
- r.raise_for_status()
170
- data = r.json()
171
- # Expected: {labels: [...], scores: [...]} or a list wrapper
172
- if isinstance(data, list):
173
- data = data[0]
174
- labels_resp = data.get("labels", [])
175
- return labels_resp[0] if labels_resp else "Other"
176
- except Exception:
177
- return "Other"
178
-
179
- # ==============================
180
- # app/utils.py
181
- # ==============================
182
- import pandas as pd
183
- from typing import List
184
-
185
- CATEGORY_COLORS = {
186
- "Food & Dining": "#6ee7b7",
187
- "Groceries": "#93c5fd",
188
- "Transport": "#fca5a5",
189
- "Fuel": "#fcd34d",
190
- "Shopping": "#fdba74",
191
- "Bills & Utilities": "#c4b5fd",
192
- "Entertainment": "#f9a8d4",
193
- "Health": "#86efac",
194
- "Travel": "#a5b4fc",
195
- "Rent/Mortgage": "#a7f3d0",
196
- "Education": "#fef08a",
197
- "Income": "#bbf7d0",
198
- "Other": "#e5e7eb",
199
- }
200
-
201
- ALL_CATEGORIES: List[str] = list(CATEGORY_COLORS.keys())
202
-
203
-
204
- def dataframe_from_records(records: list[dict]) -> pd.DataFrame:
205
- if not records:
206
- return pd.DataFrame(columns=[
207
- "id","date","description","merchant","amount","category","created_at"
208
- ])
209
- df = pd.DataFrame(records)
210
- df["date"] = pd.to_datetime(df["date"], errors="coerce")
211
- df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
212
- return df
213
-
214
- # ==============================
215
- # app/streamlit_app.py
216
- # ==============================
217
- import os
218
- import io
219
- import pandas as pd
220
- import altair as alt
221
- import streamlit as st
222
- from datetime import datetime, date
223
- from db import init_db, insert_expense, update_expense, delete_expenses, fetch_expenses
224
- from utils import dataframe_from_records, ALL_CATEGORIES, CATEGORY_COLORS
225
- from hf_client import zero_shot_category, DEFAULT_LABELS
226
-
227
- st.set_page_config(page_title="Expense Tracker AI", page_icon="πŸ’Έ", layout="wide")
228
-
229
- # Initialize DB
230
- init_db()
231
-
232
- # ------------------------------
233
- # Sidebar: Add / Edit Expense
234
- # ------------------------------
235
- st.sidebar.title("πŸ’Έ Expense Tracker")
236
- with st.sidebar.form("add_expense_form", clear_on_submit=True):
237
- dt = st.date_input("Date", value=date.today())
238
- description = st.text_input("Description *")
239
- merchant = st.text_input("Merchant")
240
- amount = st.number_input("Amount *", min_value=0.0, step=0.01, format="%.2f")
241
- auto_cat = st.toggle("Auto-categorize with Hugging Face", value=True)
242
- manual_category = st.selectbox("Category (optional)", options=[""] + ALL_CATEGORIES, index=0)
243
- submitted = st.form_submit_button("Add Expense")
244
-
245
- if submitted:
246
- if not description or amount <= 0:
247
- st.sidebar.error("Please provide a description and a positive amount.")
248
- else:
249
- cat = manual_category or None
250
- if auto_cat and not cat:
251
- text = f"{description} {merchant or ''}"
252
- cat = zero_shot_category(text, DEFAULT_LABELS)
253
- payload = {
254
- "date": dt.strftime("%Y-%m-%d"),
255
- "description": description.strip(),
256
- "merchant": merchant.strip() if merchant else None,
257
- "amount": amount,
258
- "category": cat or "Other",
259
- }
260
- insert_expense(payload)
261
- st.sidebar.success("Expense added!")
262
- st.experimental_rerun()
263
-
264
- # ------------------------------
265
- # Main: Filters & Table
266
- # ------------------------------
267
- st.title("Expense Dashboard with AI Categorization")
268
-
269
- colf1, colf2, colf3, colf4 = st.columns([1,1,1,1])
270
- with colf1:
271
- start = st.date_input("From", value=date(date.today().year, 1, 1))
272
- with colf2:
273
- end = st.date_input("To", value=date.today())
274
- with colf3:
275
- cat_filter = st.multiselect("Categories", options=ALL_CATEGORIES, default=ALL_CATEGORIES)
276
- with colf4:
277
- q = st.text_input("Search description/merchant", placeholder="e.g., coffee, uber")
278
-
279
- # Build WHERE clause
280
- where = []
281
- params = []
282
- where.append("date BETWEEN ? AND ?")
283
- params += [start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")]
284
- if cat_filter and len(cat_filter) != len(ALL_CATEGORIES):
285
- where.append("category IN (%s)" % ",".join(["?"] * len(cat_filter)))
286
- params += list(cat_filter)
287
- if q:
288
- where.append("(description LIKE ? OR merchant LIKE ?)")
289
- params += [f"%{q}%", f"%{q}%"]
290
-
291
- records = fetch_expenses(" AND ".join(where), params)
292
-
293
- # DataFrame
294
- df = dataframe_from_records(records)
295
-
296
- st.caption(f"Showing {len(df)} expenses between {start} and {end}.")
297
-
298
- # Editable table
299
- if not df.empty:
300
- edited = st.data_editor(
301
- df.sort_values(["date", "id"], ascending=[False, False]),
302
- use_container_width=True,
303
- num_rows="dynamic",
304
- column_order=["id","date","description","merchant","amount","category"],
305
- hide_index=True,
306
- key="editor",
307
- )
308
-
309
- # Detect row-level changes and persist
310
- # Compare by id
311
- if "_original" not in st.session_state:
312
- st.session_state._original = df.copy()
313
-
314
- orig = st.session_state._original.set_index("id")
315
- curr = edited.set_index("id")
316
-
317
- changed_ids = []
318
- for eid in curr.index.intersection(orig.index):
319
- row_new = curr.loc[eid]
320
- row_old = orig.loc[eid]
321
- if not row_new.equals(row_old):
322
- changed_ids.append(int(eid))
323
- update_expense(
324
- int(eid),
325
- {
326
- "date": pd.to_datetime(row_new["date"]).strftime("%Y-%m-%d"),
327
- "description": str(row_new["description"])[:255],
328
- "merchant": (str(row_new["merchant"]) if pd.notna(row_new["merchant"]) else None),
329
- "amount": float(row_new["amount"]),
330
- "category": str(row_new["category"]) if pd.notna(row_new["category"]) else "Other",
331
- },
332
- )
333
-
334
- if changed_ids:
335
- st.success(f"Updated {len(changed_ids)} row(s). Refreshing...")
336
- st.session_state._original = edited.copy()
337
- st.experimental_rerun()
338
-
339
- # Delete selected rows by ID
340
- delete_ids = st.multiselect("Select rows to delete by ID", options=list(curr.index.astype(int)))
341
- if st.button("Delete Selected"):
342
- delete_expenses(delete_ids)
343
- st.warning(f"Deleted {len(delete_ids)} row(s).")
344
- st.experimental_rerun()
345
- else:
346
- st.info("No expenses yet. Add some from the sidebar!")
347
-
348
- # ------------------------------
349
- # Charts
350
- # ------------------------------
351
- st.subheader("Insights")
352
- if not df.empty:
353
- c1, c2 = st.columns(2)
354
-
355
- # Spend by category (bar)
356
- by_cat = (
357
- df.groupby("category", dropna=False)["amount"].sum().reset_index().sort_values("amount", ascending=False)
358
- )
359
- chart_cat = (
360
- alt.Chart(by_cat)
361
- .mark_bar()
362
- .encode(
363
- x=alt.X("amount:Q", title="Total"),
364
- y=alt.Y("category:N", sort='-x', title="Category"),
365
- tooltip=["category", alt.Tooltip("amount", format=",.2f")],
366
- color=alt.Color("category:N", legend=None)
367
- )
368
- .properties(height=350)
369
- )
370
- c1.altair_chart(chart_cat, use_container_width=True)
371
-
372
- # Monthly trend
373
- df_month = df.assign(month=df["date"].dt.to_period("M").dt.to_timestamp())
374
- by_month = df_month.groupby("month")["amount"].sum().reset_index()
375
- chart_month = (
376
- alt.Chart(by_month)
377
- .mark_line(point=True)
378
- .encode(
379
- x=alt.X("month:T", title="Month"),
380
- y=alt.Y("amount:Q", title="Total"),
381
- tooltip=[alt.Tooltip("month:T"), alt.Tooltip("amount:Q", format=",.2f")],
382
- )
383
- .properties(height=350)
384
- )
385
- c2.altair_chart(chart_month, use_container_width=True)
386
-
387
- # ------------------------------
388
- # Export
389
- # ------------------------------
390
- st.subheader("Export")
391
- if not df.empty:
392
- csv_bytes = df.to_csv(index=False).encode("utf-8")
393
- st.download_button("Download CSV", data=csv_bytes, file_name="expenses.csv", mime="text/csv")
394
-
395
- try:
396
- import openpyxl # noqa
397
- xls_io = io.BytesIO()
398
- with pd.ExcelWriter(xls_io, engine="openpyxl") as writer:
399
- df.to_excel(writer, index=False, sheet_name="Expenses")
400
- st.download_button("Download Excel (.xlsx)", data=xls_io.getvalue(), file_name="expenses.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
401
- except Exception:
402
- st.caption("Install 'openpyxl' to enable Excel export.")
403
-
404
- # ------------------------------
405
- # How to embed the custom HTML/CSS/JS frontend inside Streamlit (optional)
406
- # ------------------------------
407
- with st.expander("Embed custom HTML/CSS/JS frontend (optional demo)"):
408
- st.markdown("You can embed the static frontend below using `st.components.v1.html`. This snippet loads the HTML and lets the JS send messages back if you extend it.")
409
- import streamlit.components.v1 as components
410
- demo_html = """
411
- <div class='demo'>
412
- <h3>Custom Frontend Header</h3>
413
- <p>This is a static demo. For a full integration, wire up postMessage() to communicate with Streamlit.</p>
414
- <style>
415
- .demo{font-family: ui-sans-serif, system-ui; padding: 1rem; border:1px solid #e5e7eb; border-radius: 12px}
416
- h3{margin:0 0 .5rem 0}
417
- </style>
418
- </div>
419
- """
420
- components.html(demo_html, height=120)
421
-
422
- # ==============================
423
- # frontend/index.html
424
- # ==============================
425
- # A clean, responsive static UI (vanilla HTML/CSS/JS). You can host it separately
426
- # or embed parts in Streamlit via st.components.v1.html. To fully connect this
427
- # standalone UI to Streamlit as a backend API, you would typically add a thin
428
- # FastAPI/Flask layer. Here it serves as a design-ready template.
429
-
430
- INDEX_HTML = r"""<!doctype html>
431
- <html lang="en">
432
- <head>
433
- <meta charset="utf-8" />
434
- <meta name="viewport" content="width=device-width, initial-scale=1" />
435
- <title>Expense Tracker</title>
436
- <link rel="stylesheet" href="styles.css" />
437
- </head>
438
- <body>
439
- <header class="container header">
440
- <h1>πŸ’Έ Expense Tracker</h1>
441
- <input id="search" type="search" placeholder="Search description or merchant" />
442
- </header>
443
-
444
- <main class="container grid">
445
- <section class="card">
446
- <h2>Add Expense</h2>
447
- <form id="add-form">
448
- <div class="row">
449
- <label>Date <input type="date" name="date" required /></label>
450
- <label>Amount <input type="number" step="0.01" min="0" name="amount" required /></label>
451
- </div>
452
- <label>Description <input type="text" name="description" required /></label>
453
- <label>Merchant <input type="text" name="merchant" /></label>
454
- <div class="row">
455
- <label>Category
456
- <select name="category" id="category"></select>
457
- </label>
458
- <label class="switch">
459
- <input type="checkbox" id="autoCat" checked>
460
- <span>Auto-categorize</span>
461
- </label>
462
- </div>
463
- <button type="submit">Add</button>
464
- </form>
465
- </section>
466
-
467
- <section class="card">
468
- <h2>Expenses</h2>
469
- <div class="filters row">
470
- <label>From <input type="date" id="from" /></label>
471
- <label>To <input type="date" id="to" /></label>
472
- <select id="catFilter" multiple></select>
473
- </div>
474
- <table id="table">
475
- <thead><tr><th>ID</th><th>Date</th><th>Description</th><th>Merchant</th><th>Amount</th><th>Category</th><th></th></tr></thead>
476
- <tbody></tbody>
477
- </table>
478
- </section>
479
-
480
- <section class="card">
481
- <h2>Exports</h2>
482
- <div class="row">
483
- <button id="exportCsv">Download CSV</button>
484
- <button id="exportXlsx">Download Excel</button>
485
- </div>
486
- </section>
487
- </main>
488
-
489
- <script src="app.js"></script>
490
- </body>
491
- </html>
492
- """
493
-
494
- # ==============================
495
- # frontend/styles.css
496
- # ==============================
497
- STYLES_CSS = r""":root{ --radius:16px; --border:#e5e7eb; --bg:#0b1020; --card:#11162a; --text:#e6e8f1 }
498
- *{box-sizing:border-box}
499
- body{margin:0; font-family: ui-sans-serif, system-ui; background:var(--bg); color:var(--text)}
500
- .container{max-width:1100px; margin:0 auto; padding:1rem}
501
- .header{display:flex; align-items:center; justify-content:space-between}
502
- .header input{padding:.6rem .8rem; border-radius:12px; border:1px solid var(--border); background:transparent; color:inherit}
503
- .grid{display:grid; grid-template-columns:1fr; gap:1rem}
504
- @media (min-width: 900px){ .grid{grid-template-columns: 1fr 1fr} }
505
- .card{background:var(--card); border:1px solid var(--border); border-radius:var(--radius); padding:1rem; box-shadow:0 10px 30px rgba(0,0,0,.25)}
506
- .card h2{margin:0 0 .75rem 0}
507
- .row{display:flex; gap:1rem; align-items:center}
508
- label{display:flex; flex-direction:column; gap:.35rem; font-size:.9rem}
509
- input, select, button{padding:.6rem .8rem; border-radius:12px; border:1px solid var(--border); background:#0d1326; color:inherit}
510
- button{cursor:pointer}
511
- #table{width:100%; border-collapse:collapse; margin-top:.5rem}
512
- #table th, #table td{border-bottom:1px solid var(--border); padding:.6rem .4rem; text-align:left}
513
- .switch{display:flex; align-items:center; gap:.5rem; margin-top:1.65rem}
514
- """
515
-
516
- # ==============================
517
- # frontend/app.js
518
- # ==============================
519
- APP_JS = r"""// Demo-only: static dataset and categories. Replace with API calls to your backend.
520
- const CATEGORIES = [
521
- "Food & Dining","Groceries","Transport","Fuel","Shopping","Bills & Utilities",
522
- "Entertainment","Health","Travel","Rent/Mortgage","Education","Income","Other"
523
- ];
524
-
525
- const state = { expenses: [], seq: 1 };
526
-
527
- const $ = (sel) => document.querySelector(sel);
528
- const $$ = (sel) => Array.from(document.querySelectorAll(sel));
529
-
530
- function populateCategories() {
531
- const catEl = $("#category");
532
- const catFilter = $("#catFilter");
533
- CATEGORIES.forEach(c => {
534
- catEl.insertAdjacentHTML('beforeend', `<option>${c}</option>`);
535
- catFilter.insertAdjacentHTML('beforeend', `<option selected>${c}</option>`);
536
- });
537
- }
538
-
539
- function renderTable() {
540
- const tbody = $("#table tbody");
541
- const q = $("#search").value.toLowerCase();
542
- const from = $("#from").value ? new Date($("#from").value) : null;
543
- const to = $("#to").value ? new Date($("#to").value) : null;
544
- const cats = $$("#catFilter option:checked").map(o => o.value);
545
-
546
- tbody.innerHTML = "";
547
- state.expenses
548
- .filter(e => !q || (e.description+e.merchant).toLowerCase().includes(q))
549
- .filter(e => !from || new Date(e.date) >= from)
550
- .filter(e => !to || new Date(e.date) <= to)
551
- .filter(e => cats.includes(e.category))
552
- .sort((a,b) => new Date(b.date) - new Date(a.date))
553
- .forEach(e => {
554
- const tr = document.createElement('tr');
555
- tr.innerHTML = `<td>${e.id}</td><td>${e.date}</td><td>${e.description}</td><td>${e.merchant||''}</td><td>${(+e.amount).toFixed(2)}</td><td>${e.category}</td><td><button data-id="${e.id}" class="del">Delete</button></td>`;
556
- tbody.appendChild(tr);
557
- });
558
- }
559
-
560
- function wireEvents(){
561
- $("#add-form").addEventListener('submit', (ev)=>{
562
- ev.preventDefault();
563
- const fd = new FormData(ev.target);
564
- const exp = Object.fromEntries(fd.entries());
565
- exp.id = state.seq++;
566
- state.expenses.push(exp);
567
- ev.target.reset();
568
- renderTable();
569
- });
570
-
571
- $("#table").addEventListener('click', (ev)=>{
572
- if(ev.target.classList.contains('del')){
573
- const id = +ev.target.dataset.id;
574
- state.expenses = state.expenses.filter(e => e.id !== id);
575
- renderTable();
576
- }
577
- });
578
-
579
- ["search","from","to","catFilter"].forEach(id => $("#"+id).addEventListener('input', renderTable));
580
- }
581
-
582
- populateCategories();
583
- wireEvents();
584
- renderTable();
585
- """
586
-
587
- # ==============================
588
- # README.md (quick start)
589
- # ==============================
590
- README = r"""# Expense Tracker (Streamlit + Hugging Face)
591
-
592
- ## Quick Start
593
- 1. **Clone** this project and create a virtual env.
594
- 2. `pip install -r requirements.txt`
595
- 3. Copy `.env.example` to `.env` and set `HF_API_TOKEN`.
596
- 4. Run: `streamlit run app/streamlit_app.py`
597
-
598
- ## Notes
599
- - Data persists to `data/expenses.db` (SQLite).
600
- - Hugging Face is used for zero-shot categorization via the Inference API; change labels in `hf_client.py` as needed.
601
- - The `frontend/` folder is a standalone vanilla HTML/CSS/JS UI template. For deep integration, either embed parts with `st.components.v1.html` or expose a small REST API (e.g., FastAPI) that Streamlit and the frontend both talk to.
602
- """
603
-
604
- ---
605
-
606
- # **Python Backend (FastAPI) β€” Addendum**
607
-
608
- Below is a production-ready **Python backend** implementation using **FastAPI**. This backend exposes a REST API for the frontend (CRUD, search/filters, exports) and integrates with Hugging Face for auto-categorization. Save these as separate files under `backend/` and run with Uvicorn.
609
-
610
-
611
- ## `backend/requirements.txt`
612
- ```
613
- fastapi
614
- uvicorn[standard]
615
- sqlite3
616
- python-dotenv
617
- requests
618
- pandas
619
- openpyxl
620
- python-multipart
621
- pydantic
622
- ```
623
-
624
-
625
- ## `backend/.env` (copy and fill)
626
- ```
627
- HF_API_TOKEN=hf_xxx_your_token_here
628
- HF_ZSC_MODEL=facebook/bart-large-mnli
629
- DATABASE_URL=sqlite:///./data/expenses.db
630
- ```
631
-
632
-
633
- ## `backend/db.py`
634
- ```python
635
- from pathlib import Path
636
- import sqlite3
637
- from contextlib import contextmanager
638
- from typing import Iterable, Any, Dict
639
-
640
- DB_PATH = Path(__file__).resolve().parent.parent / "data" / "expenses.db"
641
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
642
-
643
- SCHEMA_SQL = """
644
- CREATE TABLE IF NOT EXISTS expenses (
645
- id INTEGER PRIMARY KEY AUTOINCREMENT,
646
- date TEXT NOT NULL,
647
- description TEXT NOT NULL,
648
- merchant TEXT,
649
- amount REAL NOT NULL,
650
- category TEXT,
651
- created_at TEXT DEFAULT (datetime('now'))
652
- );
653
- """
654
-
655
- @contextmanager
656
- def get_conn():
657
- conn = sqlite3.connect(DB_PATH, check_same_thread=False)
658
- conn.row_factory = sqlite3.Row
659
- try:
660
- yield conn
661
- finally:
662
- conn.close()
663
-
664
- def init_db():
665
- with get_conn() as conn:
666
- conn.executescript(SCHEMA_SQL)
667
- conn.commit()
668
-
669
-
670
- def insert_expense(payload: Dict[str, Any]) -> int:
671
- with get_conn() as conn:
672
- cur = conn.execute(
673
- "INSERT INTO expenses (date, description, merchant, amount, category) VALUES (?, ?, ?, ?, ?)",
674
- (payload['date'], payload['description'], payload.get('merchant'), float(payload['amount']), payload.get('category')),
675
- )
676
- conn.commit()
677
- return cur.lastrowid
678
-
679
-
680
- def update_expense(expense_id: int, updates: Dict[str, Any]):
681
- fields = []
682
- values = []
683
- for k, v in updates.items():
684
- fields.append(f"{k} = ?")
685
- values.append(v)
686
- values.append(expense_id)
687
- with get_conn() as conn:
688
- conn.execute(f"UPDATE expenses SET {', '.join(fields)} WHERE id = ?", values)
689
- conn.commit()
690
-
691
-
692
- def delete_expense(expense_id: int):
693
- with get_conn() as conn:
694
- conn.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
695
- conn.commit()
696
-
697
-
698
- def fetch_expenses(where_sql: str = "", params: Iterable[Any] = ()): # returns list[dict]
699
- sql = "SELECT * FROM expenses"
700
- if where_sql:
701
- sql += " WHERE " + where_sql
702
- sql += " ORDER BY date DESC, id DESC"
703
- with get_conn() as conn:
704
- rows = conn.execute(sql, params).fetchall()
705
- return [dict(r) for r in rows]
706
- ```
707
-
708
-
709
- ## `backend/hf_client.py`
710
- ```python
711
- import os
712
- import requests
713
- from typing import List, Optional
714
- from dotenv import load_dotenv
715
-
716
- load_dotenv()
717
- HF_API_TOKEN = os.getenv('HF_API_TOKEN', '')
718
- HF_ZSC_MODEL = os.getenv('HF_ZSC_MODEL', 'facebook/bart-large-mnli')
719
- HF_API_URL = f'https://api-inference.huggingface.co/models/{HF_ZSC_MODEL}'
720
-
721
- DEFAULT_LABELS = [
722
- "Food & Dining","Groceries","Transport","Fuel","Shopping","Bills & Utilities",
723
- "Entertainment","Health","Travel","Rent/Mortgage","Education","Income","Other"
724
- ]
725
-
726
-
727
- def zero_shot_category(text: str, labels: Optional[List[str]] = None) -> str:
728
- labels = labels or DEFAULT_LABELS
729
- if not HF_API_TOKEN:
730
- return 'Other'
731
- headers = {'Authorization': f'Bearer {HF_API_TOKEN}'}
732
- payload = { 'inputs': text, 'parameters': { 'candidate_labels': labels, 'multi_label': False } }
733
- try:
734
- r = requests.post(HF_API_URL, headers=headers, json=payload, timeout=20)
735
- r.raise_for_status()
736
- data = r.json()
737
- if isinstance(data, list):
738
- data = data[0]
739
- labels_resp = data.get('labels', [])
740
- return labels_resp[0] if labels_resp else 'Other'
741
- except Exception:
742
- return 'Other'
743
- ```
744
-
745
-
746
- ## `backend/schemas.py` (Pydantic models)
747
- ```python
748
- from pydantic import BaseModel, Field
749
- from typing import Optional
750
- from datetime import date
751
-
752
- class ExpenseCreate(BaseModel):
753
- date: date
754
- description: str = Field(..., max_length=512)
755
- merchant: Optional[str] = None
756
- amount: float
757
- category: Optional[str] = None
758
-
759
- class ExpenseUpdate(BaseModel):
760
- date: Optional[date]
761
- description: Optional[str]
762
- merchant: Optional[str]
763
- amount: Optional[float]
764
- category: Optional[str]
765
- ```
766
-
767
-
768
- ## `backend/api.py` (FastAPI app)
769
- ```python
770
- from fastapi import FastAPI, HTTPException, Query
771
- from fastapi.middleware.cors import CORSMiddleware
772
- from fastapi.responses import StreamingResponse
773
- from typing import List, Optional
774
- from io import BytesIO
775
- import pandas as pd
776
- from db import init_db, insert_expense, update_expense, delete_expense, fetch_expenses
777
- from hf_client import zero_shot_category, DEFAULT_LABELS
778
- from schemas import ExpenseCreate, ExpenseUpdate
779
-
780
- app = FastAPI(title='Expense Tracker API')
781
-
782
- app.add_middleware(
783
- CORSMiddleware,
784
- allow_origins=["*"],
785
- allow_credentials=True,
786
- allow_methods=["*"],
787
- allow_headers=["*"],
788
- )
789
-
790
- # init db on startup
791
- init_db()
792
-
793
-
794
- @app.post('/expenses', status_code=201)
795
- async def create_expense(payload: ExpenseCreate, auto_cat: bool = True):
796
- cat = payload.category
797
- if auto_cat and not cat:
798
- text = f"{payload.description} {payload.merchant or ''}"
799
- cat = zero_shot_category(text, DEFAULT_LABELS)
800
- data = payload.dict()
801
- data['category'] = cat or 'Other'
802
- data['date'] = data['date'].isoformat()
803
- eid = insert_expense(data)
804
- return { 'id': eid }
805
-
806
-
807
- @app.get('/expenses')
808
- async def list_expenses(
809
- start: Optional[str] = Query(None),
810
- end: Optional[str] = Query(None),
811
- q: Optional[str] = Query(None),
812
- categories: Optional[str] = Query(None), # comma-separated
813
- limit: int = 100,
814
- ):
815
- where = []
816
- params = []
817
- if start and end:
818
- where.append('date BETWEEN ? AND ?')
819
- params += [start, end]
820
- if categories:
821
- cats = categories.split(',')
822
- where.append('category IN (%s)' % ','.join('?'*len(cats)))
823
- params += cats
824
- if q:
825
- where.append('(description LIKE ? OR merchant LIKE ?)')
826
- params += [f'%{q}%', f'%{q}%']
827
- rows = fetch_expenses(' AND '.join(where) if where else '', params)
828
- return rows[:limit]
829
-
830
-
831
- @app.put('/expenses/{expense_id}')
832
- async def edit_expense(expense_id: int, payload: ExpenseUpdate):
833
- updates = {k: v.isoformat() if hasattr(v, 'isoformat') else v for k,v in payload.dict(exclude_none=True).items()}
834
- if not updates:
835
- raise HTTPException(status_code=400, detail='No updates provided')
836
- update_expense(expense_id, updates)
837
- return { 'ok': True }
838
-
839
-
840
- @app.delete('/expenses/{expense_id}')
841
- async def remove_expense(expense_id: int):
842
- delete_expense(expense_id)
843
- return { 'ok': True }
844
-
845
-
846
- @app.get('/categories')
847
- async def categories():
848
- return DEFAULT_LABELS
849
-
850
-
851
- @app.post('/categorize')
852
- async def categorize(text: str):
853
- return { 'category': zero_shot_category(text, DEFAULT_LABELS) }
854
-
855
-
856
- @app.get('/export/csv')
857
- async def export_csv(start: Optional[str] = None, end: Optional[str] = None):
858
- where = []
859
- params = []
860
- if start and end:
861
- where.append('date BETWEEN ? AND ?')
862
- params += [start, end]
863
- rows = fetch_expenses(' AND '.join(where) if where else '', params)
864
- df = pd.DataFrame(rows)
865
- bio = BytesIO()
866
- bio.write(df.to_csv(index=False).encode('utf-8'))
867
- bio.seek(0)
868
- return StreamingResponse(bio, media_type='text/csv', headers={'Content-Disposition':'attachment; filename=expenses.csv'})
869
-
870
-
871
- @app.get('/export/xlsx')
872
- async def export_xlsx(start: Optional[str] = None, end: Optional[str] = None):
873
- where = []
874
- params = []
875
- if start and end:
876
- where.append('date BETWEEN ? AND ?')
877
- params += [start, end]
878
- rows = fetch_expenses(' AND '.join(where) if where else '', params)
879
- df = pd.DataFrame(rows)
880
- bio = BytesIO()
881
- with pd.ExcelWriter(bio, engine='openpyxl') as writer:
882
- df.to_excel(writer, index=False, sheet_name='Expenses')
883
- bio.seek(0)
884
- return StreamingResponse(bio, media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', headers={'Content-Disposition':'attachment; filename=expenses.xlsx'})
885
- ```
886
-
887
-
888
- ## How to run
889
- 1. Create virtual env: `python -m venv .venv && source .venv/bin/activate` (or Windows equivalent)
890
- 2. `pip install -r backend/requirements.txt`
891
- 3. Copy `.env` (set HF_API_TOKEN)
892
- 4. `uvicorn backend.api:app --reload --host 0.0.0.0 --port 8000`
893
-
894
- Your frontend can now call the API at `http://localhost:8000` (e.g. `POST /expenses`, `GET /expenses`, `GET /categories`, `GET /export/csv`).
895
-
896
-
897
- ## Notes & Extensions
898
- - **Authentication**: For production, add token-based auth (JWT) or OAuth.
899
- - **Rate-limits**: Hugging Face Inference API has rate limits; consider caching or batching.
900
- - **Background tasks**: If you need heavy ML processing, use background tasks (FastAPI `BackgroundTasks`) or a job queue.
901
- - **Deployment**: Containerize with Docker, or deploy to Cloud Run / Heroku / Railway.
902
-
903
- ---