A web application designed to manage shared expenses, track group activities and plan optimal travels. Built with FastAPI and MySQL. Handles complex group dynamics, transaction settlements and algorithmic travel route calculations (via Hamiltonian paths!).
- User Management & Authentication: Secure user signup and login.
- Group Expense Tracking: Seamlessly create groups, add members and track shared expenses grouped under specific "Events".
- Transaction Settlement: Record debts between users, upload receipt proofs and settle transactions. The system automatically reconciles balances and uses Gemini to OCR-verify that an uploaded receipt is a genuine payment proof.
- Analytics Dashboard: Visualize your financial history, track debt over time and monitor your participation in groups and events.
- Smart Trip Planning: Plan optimal solo or group trips. Just input your destinations and travel dates and the app will calculate the optimal route using the Hamiltonian path algorithm.
- Interactive Chatbot Assistant: Navigate and manage tasks efficiently through an integrated AI chatbot interface. You could ask it to reverse a linked list as well!
- Onboarding: Create an account and log in to access your personal dashboard.
- Form a Group: Start a new group and add friends, family, or colleagues as members.
- Log Expenses: Create an event (e.g., "Weekend Getaway" or "Dinner at Amrita's") within the group and add transactions indicating who paid and who owes whom.
- Settle Up: The owing member can upload a receipt and mark the transaction as paid. The system automatically adjusts everyone's running debt balance.
- Plan a Trip: Going somewhere? Use the Trip Planner module to input multiple destinations and get the most efficient travel itinerary automatically calculated.
I have used MySQL for the current project, therefore it is recommended to create and run a docker instance with the same.
docker run -d \
--name $CONTAINER_NAME \
-e MYSQL_ROOT_PASSWORD=$ROOT_PASS \
-p 3306:3306 \
mysql:8.0docker exec -i $CONTAINER_NAME mysql -uroot -p$DB_ROOT_PASS <<EOF
CREATE DATABASE IF NOT EXISTS expense_tracker;
CREATE USER IF NOT EXISTS '$USER'@'%' IDENTIFIED BY '$PASSWORD';
GRANT ALL PRIVILEGES ON expense_tracker.* TO '$USER'@'%';
FLUSH PRIVILEGES;
EOFcat src/schema.sql | docker exec -i $CONTAINER_NAME mysql -u$USER -p$PASSWORD expense_trackerThe setup process for Google Maps API was daunting and so a workaround using OpenStreetMap + Leaflet has been used in its place.
Make a .env file in the root directory with the necessary passkeys as below:
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DB=expense_tracker
MYSQL_USER=USER
MYSQL_PASS=PASSWORD
AVIATION_API_KEY=AVIATION_KEY
SECRET_PHRASE=a-long-random-string-used-to-sign-login-sessions
GEMINI_API_KEY=GEMINI_KEYSECRET_PHRASE signs the JWT session cookie issued on login — required, and should be a long random value (e.g. python -c "import secrets; print(secrets.token_hex(32))"). GEMINI_API_KEY is optional; without it, receipt OCR analysis is disabled but the rest of the app still runs.
Run the following commands to install dependencies and launch the application:
uv sync
uv run uvicorn src.main:app --reloadAccess the website at the link displayed in the terminal or http://127.0.0.1:8000 after running the above commands.
The following issues and hardening items were identified during an architecture review of the schema, backend, and integrations. Grouped by area, unchecked items are still open.
- Replace
username varchar(100)asUserPK with a surrogateINT AUTO_INCREMENTPK; keepusernameas a unique natural key to avoid expensiveON UPDATE CASCADEand bloated secondary indexes — implemented (User.user_id; every FK that used to storeusername—Group.created_by,GroupMember,Event.created_by,Transaction.created_by/owed_by/owed_to,Trip.created_by,ChatbotState,ChatMessage,ExtractedInfo— now stores the 4-byte int instead) - Use an opaque/UUID identifier for user-facing routes instead of
usernamein query params — partially superseded: the IDOR this was meant to close is already fixed (identity now comes from the verified session cookie, not the URL — see Authentication below), sousernameappearing in a URL is no longer an authorization bypass, just a minor privacy/enumeration nicety. Left open as a cosmetic item. - Add a surrogate
event_id INT AUTO_INCREMENTPK toEvent, keeping(group_name, event_name)as a unique constraint, soTransactionstores a 4-byte FK instead of two strings — implemented - Increase
debt DECIMAL(10,2)precision toDECIMAL(15,2)— implemented. Currency-aware debt aggregation (resolving the mismatch when group members use different currencies) is deliberately deferred — it's really the separate multi-currency feature further down this list, not a schema-precision fix. - Reconsider the
Transaction/PaidTransactionsplit vs. a single table with astatuscolumn + partial index — implemented: merged into oneTransactiontable withstatus ENUM('pending','paid'),paid_at, andpaid_currency(auto-filled by aBEFORE UPDATEtrigger on the pending→paid transition). MySQL/InnoDB has no true partial index, so the closest equivalent — a composite index leading withstatus— is used instead. This does trade away the old design's immutable, append-only audit log; if a strict payment audit trail becomes a requirement later, that's the tradeoff to revisit.
- Make
mark_transaction_paididempotent at the SQL level (UPDATE ... WHERE is_paid = FALSE) to prevent the trigger double-firing and decrementing debt twice under concurrent requests — implemented - Review
ON DELETE CASCADEon financial tables (e.g.Transaction) — considerRESTRICTorSET NULLto prevent accidental data loss from a directDELETE FROM Group
- Replace the single shared DB connection with a proper connection pool (e.g.
aiomysql.create_pool) so concurrent requests don't interleave on the same connection/transaction context - Add explicit
try/except+rollback()around multi-step DB writes instead of relying on implicit rollback on connection close — implemented (upload_receipt,mark_paid, chatbotsend/resetroutes; all commits/rollbacks now go through aget_connectiondependency instead of the privatecur._connection) - Call
get_config.cache_clear()in test teardown so@lru_cache'd settings don't leak across tests or survive secret rotation without a restart (moot until a test suite exists)
- Remove
?userId=query-param-based "auth" — it allows any user to view another user's dashboard (IDOR) — implemented - Hash passwords with bcrypt/argon2 instead of storing/comparing them in plaintext — implemented (bcrypt)
- Implement JWT-based session auth: sign a token on login, store it in an
HttpOnly,Secure,SameSite=Strictcookie, and add aget_current_userdependency used by all protected routes — implemented (src/auth.py; the previously-unusedSECRET_PHRASEconfig value now signs the token)
- Stop accessing the private
cur._connectionattribute; manage transactions via an explicit connection object or context manager — implemented (get_connectiondependency insrc/db.py) - Use
SELECT ... FOR UPDATEor an atomic conditionalUPDATE ... WHERE is_paid = FALSEinmark_transaction_paidto eliminate the race condition described above — implemented - Replace the Python-side running-balance computation in the analytics endpoint with a SQL window function (
SUM(...) OVER (ORDER BY date)) for correctness under equal timestamps and better performance at scale
- Replace the O(n!) backtracking Hamiltonian cycle search with Held-Karp DP (O(2^n · n²)) for better scaling with city count — not done on purpose: the app has no real per-edge flight-cost data (the adjacency matrix only encodes flight existence), so Held-Karp has no meaningful cost function to optimize yet. Revisit once a cost signal exists. In the meantime,
MAX_CITIES = 8bounds the exponential search. - Replace blocking
requests.get()calls to the flight API withhttpx.AsyncClientso the event loop isn't blocked — implemented - Parallelize adjacency-matrix construction with
asyncio.gather()instead of sequential per-pair API calls — implemented - Normalize
TripPathways.path_sequence(JSON-in-TEXT) into a properTripPathStop(pathway_id, stop_order, destination_id)table with FK constraints
- Move the in-process
flight_cachedict to Redis with a TTL (e.g. 1 hour) so caching works across multiple worker processes - Actually implement the client-side rate limiter (
min_request_intervalis currently defined but never enforced); prefer a Redis-backed token bucket shared across workers — implemented in-process (asyncio.Lock+asyncio.sleep); still process-local, so the Redis-backed shared version remains open for multi-worker deployments - Add exponential backoff / retry logic on API 429 responses
- Add an OCR/verification model to confirm uploaded receipts are genuine — implemented
- Use Gemini's structured output (
response_schema/response_mime_type: application/json) instead of relying on prompt instructions for JSON formatting — implemented - Validate Gemini's response against a Pydantic model and reject malformed structures — implemented (
ReceiptAnalysisinsrc/receipts.py) - Add a confidence-score threshold below which the analysis is rejected and the user is asked to re-upload — implemented (threshold: 70)
- Harden against prompt injection embedded in receipt image text — partially addressed (the prompt now tells the model to ignore embedded instructions and score confidence low), but this is a prompt-level mitigation only, not a robust defense
- Validate receipt file size before reading fully into memory; cap uploads (e.g. 10MB) and reject oversized files early — implemented
- Validate actual file magic bytes against the declared
Content-Typeinstead of trusting the client-supplied header — implemented - Stream large uploads directly to object storage rather than buffering the whole file in app memory
- Move
ChatbotStatefrom MySQL to Redis for high-traffic deployments (sub-millisecond reads/writes, shared across instances) - Add a cron job / TTL cleanup for stale
ChatbotStaterows (e.g. inactive > 30 days)
- Fix the N+1 query pattern in
/groups(nested per-group/per-event queries) by rewriting as 2–3 JOIN queries and assembling in Python — implemented (_load_groups_with_detailsinsrc/main.py, shared by/groupsand/api/groupsForUser); measured 5 fixed queries total regardless of group/event/transaction count, down from 1+N+N+ΣM - Add real-time debt notifications (WebSockets or SSE) with Redis Pub/Sub so pushes work across multiple worker processes
- Move receipt storage from local
uploads/to object storage (S3/GCS) with pre-signed, time-limited URLs instead of serving static files - Use UUID4 (not timestamp-based) filenames for uploaded receipts to avoid collisions — implemented (this also closed a path-traversal hole: the old filename was built from the client-supplied
receipt.filenameunsanitized) - Add proper multi-currency support: store
currencyand a snapshotexchange_rateon eachTransactionrather than assuming a single implicit currency - Add a retention policy (e.g. delete/partition
ChatMessageolder than 90 days) for privacy/compliance (GDPR / India DPDP Act) and to support account-deletion flows - Add read replicas for read-heavy analytics/history queries
- Adopt schema migrations (e.g. Alembic) instead of a raw
schema.sqlfile - Add structured logging (replace
printstatements) and health-check endpoints for load balancer probes — implemented (src/logging_config.py; unauthenticatedGET /healthchecks DB connectivity)
These weren't part of the original review but were caught while implementing the items above:
-
/api/groupsForUserreferenced nonexistentgroup_id/user_idcolumns (the schema usesgroup_name/username), so the endpoint threw aKeyErroron every call — fixed - The same endpoint didn't convert
Decimaltransaction amounts tofloat, so JSON serialization crashed once the previous bug was fixed — fixed -
/api/profilereturned the user's bcrypt password hash in the response body — fixed - The Gemini model name (
gemini-1.5-flash) is retired and every OCR call was failing — fixed (nowgemini-flash-latest) - The (blocking, synchronous) Gemini SDK call had no reliable timeout — a slow response would hang the entire single-worker server for all users, and the SDK's own
timeoutoption didn't bound it in testing — fixed (generate_content_asyncwrapped inasyncio.wait_for) -
/api/transactionsnever convertedDecimaltransaction amounts tofloat, so JSON serialization crashed (pre-existing, unrelated to the schema migration — just never previously exercised by a test) — fixed - MySQL forbids a statement from reading a table via subquery when a trigger it fires needs to write to that same table — the natural "resolve
username→user_idvia subquery inside theTransactionINSERT" approach hit this immediately, sinceafter_transaction_insertupdatesUser. Fixed by resolvingcreated_by/owed_by/owed_toto plain ints via a separateSELECTbefore the insert, in all three call sites (main.py×2, the chatbot's add-event flow) — fixed