From e8bbfb5198afbc60a2bdaa16eb8f6780cd632e9e Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Tue, 4 Aug 2026 16:05:55 +0530 Subject: [PATCH 01/12] feat: project setup, nativewind, routing, tab navigation & SafeAreaView --- apps/mobile/app/(tabs)/_layout.tsx | 13 +++++++++++++ apps/mobile/app/(tabs)/finances.tsx | 9 ++++++--- apps/mobile/app/(tabs)/index.tsx | 9 ++++++--- apps/mobile/app/(tabs)/insights.tsx | 9 ++++++--- apps/mobile/app/(tabs)/profile.tsx | 9 ++++++--- apps/mobile/app/{(tabs) => }/finances/[id].tsx | 0 6 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/app/(tabs)/_layout.tsx rename apps/mobile/app/{(tabs) => }/finances/[id].tsx (100%) diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..bee8186 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,13 @@ +import { Tabs } from "expo-router"; + +const TabLayout = () => ( + + + + + + + +); + +export default TabLayout; diff --git a/apps/mobile/app/(tabs)/finances.tsx b/apps/mobile/app/(tabs)/finances.tsx index 7b526fc..e09b243 100644 --- a/apps/mobile/app/(tabs)/finances.tsx +++ b/apps/mobile/app/(tabs)/finances.tsx @@ -1,11 +1,14 @@ +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { Text } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const Finances = () => { return ( - + Finances - + ); }; diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index df4dd59..d06e452 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,12 +1,15 @@ import "@/global.css"; -import { Text, View } from "react-native"; +import { Text } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +import { styled } from "nativewind"; +const SafeAreaView = styled(RNSafeAreaView); export default function App() { return ( - + Welcome to Nativewind! - + ); } diff --git a/apps/mobile/app/(tabs)/insights.tsx b/apps/mobile/app/(tabs)/insights.tsx index c003366..b39c2e4 100644 --- a/apps/mobile/app/(tabs)/insights.tsx +++ b/apps/mobile/app/(tabs)/insights.tsx @@ -1,11 +1,14 @@ +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { Text } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const Insights = () => { return ( - + Insights - + ); }; diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index e85d353..dfde197 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,11 +1,14 @@ +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { Text } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const Profile = () => { return ( - + Profile - + ); }; diff --git a/apps/mobile/app/(tabs)/finances/[id].tsx b/apps/mobile/app/finances/[id].tsx similarity index 100% rename from apps/mobile/app/(tabs)/finances/[id].tsx rename to apps/mobile/app/finances/[id].tsx From 5e526598544e994a40037b6d899564100132b6e4 Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Wed, 5 Aug 2026 12:54:46 +0530 Subject: [PATCH 02/12] feat(mobile): enhance UI/UX with improved styling, tab bar icons, and mock data - Refine mobile frontend styles for a more polished interface - Update tab bar icons for improved navigation and consistency - Add mock data to support UI/UX demonstrations and development until the frontend and backend are fully decoupled - Improve overall visual presentation and user experience --- README.md | 14 ++ apps/backend/.gitignore | 4 + apps/backend/.gitkeep | 0 apps/mobile/.vscode/settings.json | 5 +- apps/mobile/app/(tabs)/_layout.tsx | 62 ++++++- apps/mobile/app/(tabs)/finances.tsx | 240 +++++++++++++++++++++++++- apps/mobile/app/(tabs)/index.tsx | 23 ++- apps/mobile/app/(tabs)/insights.tsx | 148 +++++++++++++++- apps/mobile/app/index.tsx | 5 + apps/mobile/assets/icons/home.png | Bin 0 -> 1625 bytes apps/mobile/assets/icons/insights.png | Bin 0 -> 1992 bytes apps/mobile/assets/icons/wallet.png | Bin 0 -> 2133 bytes apps/mobile/global.css | 43 +++++ packages/ai/.gitkeep | 0 packages/api/.gitkeep | 0 packages/config/.gitkeep | 0 packages/database/.gitkeep | 0 packages/domain/.gitkeep | 0 packages/validation/.gitkeep | 0 19 files changed, 526 insertions(+), 18 deletions(-) create mode 100644 apps/backend/.gitignore delete mode 100644 apps/backend/.gitkeep create mode 100644 apps/mobile/app/index.tsx create mode 100644 apps/mobile/assets/icons/home.png create mode 100644 apps/mobile/assets/icons/insights.png create mode 100644 apps/mobile/assets/icons/wallet.png delete mode 100644 packages/ai/.gitkeep delete mode 100644 packages/api/.gitkeep delete mode 100644 packages/config/.gitkeep delete mode 100644 packages/database/.gitkeep delete mode 100644 packages/domain/.gitkeep delete mode 100644 packages/validation/.gitkeep diff --git a/README.md b/README.md index 7000bce..139b77a 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ # FinanceGPT + +Personal Automated Finance Copilot. + +## MAIN FUNCTIONAL REQUIREMENTS + +- Automated SMS capture of expenses + income and automatically catergorize them (Food, Transport, Bills, etc). +- Scan bill/receipt (OCR). +- AI Chatbot (Gemini AI API) to provid financial insights based on user's finances. + +## TECH STACK + +- React Native + Expo Routing for mobile frontend +- Python FASTAPI for backend +- Supabase (AUTH + Database) diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..a98520d --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env diff --git a/apps/backend/.gitkeep b/apps/backend/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/mobile/.vscode/settings.json b/apps/mobile/.vscode/settings.json index e2798e4..9363af7 100644 --- a/apps/mobile/.vscode/settings.json +++ b/apps/mobile/.vscode/settings.json @@ -3,5 +3,8 @@ "source.fixAll": "explicit", "source.organizeImports": "explicit", "source.sortMembers": "explicit" - } + }, + "css.lint.unknownAtRules": "ignore", + "scss.lint.unknownAtRules": "ignore", + "less.lint.unknownAtRules": "ignore" } diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index bee8186..444c561 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,10 +1,64 @@ import { Tabs } from "expo-router"; +import { Image } from "react-native"; const TabLayout = () => ( - - - - + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> diff --git a/apps/mobile/app/(tabs)/finances.tsx b/apps/mobile/app/(tabs)/finances.tsx index e09b243..fcdc5f7 100644 --- a/apps/mobile/app/(tabs)/finances.tsx +++ b/apps/mobile/app/(tabs)/finances.tsx @@ -1,13 +1,245 @@ import { styled } from "nativewind"; -import React from "react"; -import { Text } from "react-native"; +import React, { useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView); +const SOURCES = ["All", "Auto (SMS)", "Manual"] as const; +type Source = (typeof SOURCES)[number]; + +const TYPES = ["All", "Expenses", "Income"] as const; +type TxType = (typeof TYPES)[number]; + +type Transaction = { + id: string; + title: string; + category: string; + source: Exclude; + amount: number; + group: "Today" | "Yesterday"; +}; + +const TRANSACTIONS: Transaction[] = [ + { + id: "1", + title: "Trader Joe's", + category: "Groceries", + source: "Auto (SMS)", + amount: -47.2, + group: "Today", + }, + { + id: "2", + title: "Freelance payout", + category: "Freelance", + source: "Manual", + amount: 650.0, + group: "Today", + }, + { + id: "3", + title: "Shell Gas Station", + category: "Transport", + source: "Auto (SMS)", + amount: -38.0, + group: "Yesterday", + }, + { + id: "4", + title: "Grocery run", + category: "Groceries", + source: "Manual", + amount: -62.4, + group: "Yesterday", + }, + { + id: "5", + title: "Salary — Acme Corp", + category: "Salary", + source: "Auto (SMS)", + amount: 3200.0, + group: "Yesterday", + }, +]; + +const GROUPS: Array = ["Today", "Yesterday"]; + +const formatAmount = (amount: number) => + `${amount >= 0 ? "+" : "−"}$${Math.abs(amount).toFixed(2)}`; + const Finances = () => { + const [source, setSource] = useState("All"); + const [type, setType] = useState("All"); + return ( - - Finances + + + + Finances + + + + + + + + Jul 1 – Jul 31, 2026 + + + + + + + + + Source + + + {SOURCES.map((s) => { + const isActive = s === source; + return ( + setSource(s)} + className={`flex-row items-center gap-2 px-4 py-2 rounded-full ${ + isActive ? "bg-primary" : "bg-card border border-border" + }`} + > + {s !== "All" && ( + + )} + + {s} + + + ); + })} + + + + + + Type + + + {TYPES.map((t) => { + const isActive = t === type; + return ( + setType(t)} + className={`flex-1 items-center py-2 rounded-full ${ + isActive ? "bg-primary" : "" + }`} + > + + {t} + + + ); + })} + + + + + + + Income + + + +$4,120 + + + + + Expenses + + + −$3,240 + + + + + Net + + + +$880 + + + + + {GROUPS.map((group) => { + const items = TRANSACTIONS.filter((t) => t.group === group); + if (items.length === 0) return null; + return ( + + + {group} + + + {items.map((tx) => ( + + + + + + {tx.title} + + + {tx.category} · {tx.source} + + + + = 0 ? "text-success" : "text-destructive" + }`} + > + {formatAmount(tx.amount)} + + + ))} + + + ); + })} + ); }; diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index d06e452..39b8ffb 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,15 +1,28 @@ import "@/global.css"; -import { Text } from "react-native"; -import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; import { styled } from "nativewind"; +import { Text, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView); export default function App() { return ( - - Welcome to Nativewind! - + + + Total Spent + + + + − $3,240 + + + + + + 12% vs last period + + + ); } diff --git a/apps/mobile/app/(tabs)/insights.tsx b/apps/mobile/app/(tabs)/insights.tsx index b39c2e4..da8984e 100644 --- a/apps/mobile/app/(tabs)/insights.tsx +++ b/apps/mobile/app/(tabs)/insights.tsx @@ -1,13 +1,153 @@ import { styled } from "nativewind"; -import React from "react"; -import { Text } from "react-native"; +import React, { useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView); +const TIME_RANGES = ["Daily", "Weekly", "Monthly", "Yearly"] as const; +type TimeRange = (typeof TIME_RANGES)[number]; + +const CATEGORIES = [ + "All", + "Food", + "Groceries", + "Transport", + "Shopping", + "Bills", + "Subscriptions", +]; + +const TREND_BARS = [40, 65, 30, 95, 55, 30, 80]; +const ACTIVE_BAR_INDICES = [1, 5]; + const Insights = () => { + const [timeRange, setTimeRange] = useState("Monthly"); + const [selectedCategories, setSelectedCategories] = useState([ + "All", + ]); + + const toggleCategory = (category: string) => { + if (category === "All") { + setSelectedCategories(["All"]); + return; + } + setSelectedCategories((prev) => { + const withoutAll = prev.filter((c) => c !== "All"); + const isSelected = withoutAll.includes(category); + const next = isSelected + ? withoutAll.filter((c) => c !== category) + : [...withoutAll, category]; + return next.length === 0 ? ["All"] : next; + }); + }; + return ( - - Insights + + + + Insights + + + + + Time Range + + + {TIME_RANGES.map((range) => { + const isActive = range === timeRange; + return ( + setTimeRange(range)} + className={`flex-1 items-center py-2 rounded-full ${ + isActive ? "bg-primary" : "" + }`} + > + + {range} + + + ); + })} + + + + + + + + + Jul 1 – Jul 31, 2026 + + + + + + + + + Category Filter (multi-select) + + + {CATEGORIES.map((category) => { + const isActive = selectedCategories.includes(category); + return ( + toggleCategory(category)} + className={`px-4 py-2 rounded-full ${ + isActive + ? "bg-primary" + : "bg-card border border-border" + }`} + > + + {category} + + + ); + })} + + + + + + Total (filtered) + + + − $3,240 + + + + + + Trend + + + {TREND_BARS.map((height, index) => ( + + ))} + + + ); }; diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 0000000..010a26c --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function Index() { + return ; +} diff --git a/apps/mobile/assets/icons/home.png b/apps/mobile/assets/icons/home.png new file mode 100644 index 0000000000000000000000000000000000000000..e565dac2fc6e4ab3ca242824250de3826171fb41 GIT binary patch literal 1625 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/icons/wallet.png b/apps/mobile/assets/icons/wallet.png new file mode 100644 index 0000000000000000000000000000000000000000..8e361724c7aa6e13a7f06c2864f86a5738184df0 GIT binary patch literal 2133 zcmV-b2&(sqP)@~0drDELIAGL9O(c600d`2O+f$vv5yPs}6FN@$_H z%@AxKN~euZ4GG7?2tcPT>ax}8nuKw2!c`a#1zQC)B-G^y!aZaIsjKS{A>6G_Z*;oU z>7tY0IAbq$S~)@8BOL`X-(?99$kqrj@|w98K|!3dd|>N9A2*bM&bLH4wmQ8gAr4qA zD=u7j?G1BHg>)}nLCkkJ@SD&a;rd>j>&`hFq_wq`2zmfmhlt}7WdPfI#nBv`+qkkQ zh@i^=zXPO@>DYL#y&1^&61-{maSw6Eo`B!6%F+nyC8NUR?Nuf5?OI28in8m-Pw$S6=h zdaga#G%5+ghBJ-xHE(kb+fQuzkGGx&l2kt)+-JjF`x`2062iJeZ)uLe)`we!5!KJk z+)pvr&Nlr4_r4wYeM_1Pux2=BG$*@$L`BmdQHC2&8M0dkM+b2l+apO%GSg6Sf8DYXTZ}2rL zYZ#8$G#V&Zh?&OZgp4rZnl|BuJAcFdD9QvAXc~FS)*udb>@x4W=6pPAZRh+8@EVrw zWMHD&>&kY4codE$WDLKN&kBdS{mzqV8ZqTd5wIcKFG(rvP=BlH6Bz1_8hzr*LK6VI zLKrJ|13%+7q2Tuj75ar+O~Ww3rqLmu-+%9p-@wn3#z5cbwWkp5C=Kd5SJkdv3h>p_ zwsX!6c*g0whTk+_GkL0M82pag(VeS3;9tVqgY&lFc}q2c-$}al&>Be87a8 zMqR*s(d4b}8VuU`@~YzLz$7^8b$^Cz+el_c1WK ztQU*LLqfifu+9T_e28B>c^}-pkHrH$#Rpk3=d^t>L-f8o*s7~9pXqz}z&-C|z}F2b zF`wYEGAKA3^oOAnV9m&qIlNBHClGz+4rmSC>CKKg9OY~c3YcuDqCSj?`Pg>=*7S}# zd*TE!rg#J18GOg=H`*QSY=sDz`wsTm8wlHMF{a0~?=DI3VT82OX^WobqSIoot_?QR zUIDu|Wyu`=cyO+H5lo%DA|Zx`tTy+3uy)7fSyCY0NO#WE_H}q0^ZaY)y!U$ZhT_;q zqx(wpqamxIu~w|r=}-M^AFZQPn!c*;yXT(__)dyX?zl0@TZCiIeGHmHX9DaesAf!D zA)K|#>l=m2@ALpaO7q6uJY>G}e#GQ_zp!&4?tA04%cGd%`APy_JFfzT`~PeX;R%FC z{8@unviy-UPa%ect1y9q_Njy6VZ!~h>?69{vabLs(_rWS1zkaqK9{_RfMc0VC;usd zv>G1wS zLM=W}0dfSqCZQV6;o4CqIRX~#tzfrZndvFc#d0d_@G@ zGW%bUa1x|yurMACv%jSPQZ-mYB#lSI>~AT6ApvuP&GLcwcr?uZ{7Dz!V!w@txL4A;X<}gEMM3!-ni% zBX0SjV!~Ze0bsr7w-nEgo|a)X(}P;~|HG*ST7z{c_fF-d`}>9a`;y|PH<|Fv#bQPm z5wHPvya;2y;I-J74Q63uvg;1qkr2yTr{8Ks?=7amU0@w5qs=Q4eB9~u zu4Z*qMZkNUpiLLsio|s9X~_!q!x-VHstULZ>@*WImTBf0r8pm_%HE0Gk&V(jY8d9x z1bm1SxcPFi!w%zo%3+ycjf*=TjAP9mWOoRUbSu`|vq#GSj00000 LNkvXXu0mjf@45vM literal 0 HcmV?d00001 diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 9431a57..4252676 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -3,3 +3,46 @@ @import "tailwindcss/utilities.css"; @import "nativewind/theme"; + +@theme { + --color-background: #0b0e11; + --color-foreground: #e6e8eb; + --color-card: #14181c; + --color-surface: #1b2025; + --color-muted: #14181c; + --color-muted-foreground: #8b939b; + --color-placeholder: #5a6068; + --color-primary: #2e6ff2; + --color-primary-foreground: #ffffff; + --color-border: #22272c; + --color-success: #00d26a; + --color-destructive: #ff5c5c; + --color-warning: #ffb84d; + + --spacing-0: 0px; + --spacing-1: 4px; + --spacing-2: 8px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-5: 20px; + --spacing-6: 24px; + --spacing-7: 28px; + --spacing-8: 32px; + --spacing-9: 36px; + --spacing-10: 40px; + --spacing-11: 44px; + --spacing-12: 48px; + --spacing-14: 56px; + --spacing-16: 64px; + --spacing-18: 72px; + --spacing-20: 80px; + --spacing-24: 96px; + --spacing-30: 120px; + + --font-sans: sans-regular; + --font-sans-light: sans-light; + --font-sans-medium: sans-medium; + --font-sans-semibold: sans-semibold; + --font-sans-bold: sans-bold; + --font-sans-extrabold: sans-extrabold; +} diff --git a/packages/ai/.gitkeep b/packages/ai/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/api/.gitkeep b/packages/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/config/.gitkeep b/packages/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/database/.gitkeep b/packages/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/domain/.gitkeep b/packages/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/validation/.gitkeep b/packages/validation/.gitkeep deleted file mode 100644 index e69de29..0000000 From c407981b5b677ff361cf562813a819f485440313 Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Wed, 5 Aug 2026 18:18:38 +0530 Subject: [PATCH 03/12] feat: scaffold Python (FastAPI) backend with Supabase Postgres data layer - Set up FastAPI + Uvicorn project structure (app/ package, routers, /health endpoint) - Add pydantic-settings config layer reading DATABASE_URL/SUPABASE_URL from .env - Wire SQLModel database session against Supabase Postgres (pooler connection) - Define Category and Transaction models with Alembic migrations, applied and verified - Remove empty packages/* placeholders (TS-shaped, conflicted with Python backend) --- apps/backend/.env.example | 8 + apps/backend/.gitignore | 1 + apps/backend/README.md | 55 +++++++ apps/backend/alembic.ini | 149 ++++++++++++++++++ apps/backend/app/__init__.py | 0 apps/backend/app/core/__init__.py | 0 apps/backend/app/core/config.py | 15 ++ apps/backend/app/db/__init__.py | 0 apps/backend/app/db/session.py | 13 ++ apps/backend/app/migrations/README | 1 + apps/backend/app/migrations/env.py | 85 ++++++++++ apps/backend/app/migrations/script.py.mako | 28 ++++ .../9cc188e6e30a_add_categories_table.py | 39 +++++ .../d744ccb27d1f_add_transactions_table.py | 46 ++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/category.py | 10 ++ apps/backend/app/models/transaction.py | 26 +++ apps/backend/app/routers/__init__.py | 0 apps/backend/app/routers/health.py | 8 + apps/backend/main.py | 7 + 20 files changed, 495 insertions(+) create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/README.md create mode 100644 apps/backend/alembic.ini create mode 100644 apps/backend/app/__init__.py create mode 100644 apps/backend/app/core/__init__.py create mode 100644 apps/backend/app/core/config.py create mode 100644 apps/backend/app/db/__init__.py create mode 100644 apps/backend/app/db/session.py create mode 100644 apps/backend/app/migrations/README create mode 100644 apps/backend/app/migrations/env.py create mode 100644 apps/backend/app/migrations/script.py.mako create mode 100644 apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py create mode 100644 apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py create mode 100644 apps/backend/app/models/__init__.py create mode 100644 apps/backend/app/models/category.py create mode 100644 apps/backend/app/models/transaction.py create mode 100644 apps/backend/app/routers/__init__.py create mode 100644 apps/backend/app/routers/health.py create mode 100644 apps/backend/main.py diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..59a7852 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in real values. Never commit .env. + +# Supabase project settings -> Database -> Connection string +DATABASE_URL=postgresql+psycopg://postgres.[YOUR-PROJECT-REF]:[YOUR-PASSWORD]@[YOUR-POOLER-HOST]:6543/postgres + +# Supabase project settings -> API -> Project URL +# Used to derive the JWKS endpoint for verifying auth tokens: /auth/v1/.well-known/jwks.json +SUPABASE_URL=https://[YOUR-PROJECT-REF].supabase.co diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index a98520d..6b82adb 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc .env +BACKEND.md diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..cf1137c --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,55 @@ +# FinanceGPT Backend + +Python FastAPI backend for FinanceGPT, backed by Supabase (Auth + Postgres). + +## Stack + +- **FastAPI** — web framework +- **SQLModel** — ORM (SQLAlchemy + Pydantic combined) for talking to Postgres +- **Alembic** — database schema migrations +- **psycopg** (v3) — Postgres driver +- **Supabase** — hosted Postgres + Auth (JWT-based, asymmetric/JWKS signing keys) + +## Local setup + +1. Create and activate a virtual environment (from this `apps/backend` folder): + + ``` + python -m venv .venv + source .venv/Scripts/activate # Git Bash + .venv\Scripts\Activate.ps1 # PowerShell + ``` + +2. Install dependencies: + + ``` + pip install "fastapi[standard]" sqlmodel "psycopg[binary]" pydantic-settings alembic + ``` + +3. Copy `.env.example` to `.env` and fill in real values from your Supabase project (Project Settings → Database for the connection string, Project Settings → API for the project URL). Use the **pooler** connection string (port `6543`) and the `postgresql+psycopg://` scheme. + +4. Apply database migrations: + + ``` + alembic upgrade head + ``` + +5. Run the dev server: + + ``` + fastapi dev main.py + ``` + + Visit `http://127.0.0.1:8000/health` and `http://127.0.0.1:8000/docs`. + +## Working with migrations + +After changing a model in `app/models/`, remember to add it to `app/models/__init__.py` (this is how Alembic's autogenerate discovers it), then: + +``` +alembic revision --autogenerate -m "describe the change" +``` + +**Always review the generated file before applying it.** Known quirk: autogenerate sometimes references `sqlmodel.sql.sqltypes.AutoString()` for string columns without adding `import sqlmodel` to the file — check for this and add the import manually if needed, or the migration will crash on `alembic upgrade head`. + +Apply with `alembic upgrade head`. Check current state with `alembic current`. diff --git a/apps/backend/alembic.ini b/apps/backend/alembic.ini new file mode 100644 index 0000000..df39977 --- /dev/null +++ b/apps/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/app/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/backend/app/__init__.py b/apps/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/core/__init__.py b/apps/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py new file mode 100644 index 0000000..29cf480 --- /dev/null +++ b/apps/backend/app/core/config.py @@ -0,0 +1,15 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + database_url: str + supabase_url: str + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/apps/backend/app/db/__init__.py b/apps/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/db/session.py b/apps/backend/app/db/session.py new file mode 100644 index 0000000..c54f593 --- /dev/null +++ b/apps/backend/app/db/session.py @@ -0,0 +1,13 @@ +from collections.abc import Generator + +from sqlmodel import Session, create_engine + +from app.core.config import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url) + + +def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session diff --git a/apps/backend/app/migrations/README b/apps/backend/app/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/apps/backend/app/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/backend/app/migrations/env.py b/apps/backend/app/migrations/env.py new file mode 100644 index 0000000..f492591 --- /dev/null +++ b/apps/backend/app/migrations/env.py @@ -0,0 +1,85 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel + +from alembic import context + +from app.core.config import get_settings +# Importing app.models registers every table on SQLModel.metadata below — +# without this import, autogenerate would see no tables at all. Every new +# model must be added to app/models/__init__.py to be picked up here. +from app import models # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Use our own Settings (reads .env) as the single source of truth for the +# DB URL, instead of duplicating it in alembic.ini. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/backend/app/migrations/script.py.mako b/apps/backend/app/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/backend/app/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py b/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py new file mode 100644 index 0000000..8c785b5 --- /dev/null +++ b/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py @@ -0,0 +1,39 @@ +"""add categories table + +Revision ID: 9cc188e6e30a +Revises: +Create Date: 2026-08-05 16:36:31.174854 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '9cc188e6e30a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py b/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py new file mode 100644 index 0000000..0b29efc --- /dev/null +++ b/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py @@ -0,0 +1,46 @@ +"""add transactions table + +Revision ID: d744ccb27d1f +Revises: 9cc188e6e30a +Create Date: 2026-08-05 17:39:59.134059 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'd744ccb27d1f' +down_revision: Union[str, Sequence[str], None] = '9cc188e6e30a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('transactions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=True), + sa.Column('source', sa.Enum('manual', 'sms', name='transactionsource'), nullable=False), + sa.Column('occurred_at', sa.Date(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_transactions_user_id'), 'transactions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_transactions_user_id'), table_name='transactions') + op.drop_table('transactions') + # ### end Alembic commands ### diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py new file mode 100644 index 0000000..4d6e6e5 --- /dev/null +++ b/apps/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.category import Category +from app.models.transaction import Transaction + +__all__ = ["Category", "Transaction"] diff --git a/apps/backend/app/models/category.py b/apps/backend/app/models/category.py new file mode 100644 index 0000000..a87422b --- /dev/null +++ b/apps/backend/app/models/category.py @@ -0,0 +1,10 @@ +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel + + +class Category(SQLModel, table=True): + __tablename__ = "categories" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(unique=True, index=True) diff --git a/apps/backend/app/models/transaction.py b/apps/backend/app/models/transaction.py new file mode 100644 index 0000000..17f2224 --- /dev/null +++ b/apps/backend/app/models/transaction.py @@ -0,0 +1,26 @@ +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel + + +class TransactionSource(str, Enum): + manual = "manual" + sms = "sms" + + +class Transaction(SQLModel, table=True): + __tablename__ = "transactions" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(index=True) + + title: str + amount: Decimal = Field(max_digits=12, decimal_places=2) + category_id: UUID | None = Field(default=None, foreign_key="categories.id") + source: TransactionSource + occurred_at: date + + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/apps/backend/app/routers/__init__.py b/apps/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/routers/health.py b/apps/backend/app/routers/health.py new file mode 100644 index 0000000..b4cb63b --- /dev/null +++ b/apps/backend/app/routers/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"status": "ok"} diff --git a/apps/backend/main.py b/apps/backend/main.py new file mode 100644 index 0000000..56ef3eb --- /dev/null +++ b/apps/backend/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from app.routers import health + +app = FastAPI() + +app.include_router(health.router) From c9595e9a9bf08bb681a45f221e3a7d7ade258bd0 Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Sat, 8 Aug 2026 00:48:49 +0530 Subject: [PATCH 04/12] feat: GET & POST /catergories and JWT/JWKS auth - Verify Supabase JWTs via JWKS (ES256), exposed as a reusable get_current_user_id dependency - Add GET /categories returns shared categories plus the user's own - Add POST /categories lets a user create their own custom category - Return a clear error (409) instead of a crash (500) when a category name already exists - Make sure new rows always get a valid ID, even when added outside the app --- apps/backend/app/core/security.py | 30 ++++++++++++++ apps/backend/app/deps.py | 23 +++++++++++ ...3_add_user_id_to_categories_for_custom_.py | 40 +++++++++++++++++++ ...12_add_server_side_uuid_default_for_id_.py | 37 +++++++++++++++++ apps/backend/app/models/category.py | 15 +++++-- apps/backend/app/models/transaction.py | 7 +++- apps/backend/app/routers/categories.py | 33 +++++++++++++++ apps/backend/app/schemas/__init__.py | 0 apps/backend/app/schemas/category.py | 13 ++++++ apps/backend/main.py | 3 +- 10 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 apps/backend/app/core/security.py create mode 100644 apps/backend/app/deps.py create mode 100644 apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py create mode 100644 apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py create mode 100644 apps/backend/app/routers/categories.py create mode 100644 apps/backend/app/schemas/__init__.py create mode 100644 apps/backend/app/schemas/category.py diff --git a/apps/backend/app/core/security.py b/apps/backend/app/core/security.py new file mode 100644 index 0000000..a013a2c --- /dev/null +++ b/apps/backend/app/core/security.py @@ -0,0 +1,30 @@ +import jwt +from fastapi import HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials + +from app.core.config import get_settings + +settings = get_settings() + +# PyJWT's PyJWKClient fetches Supabase's public signing keys and caches them, +# so we don't hit the JWKS endpoint on every single request. +_jwks_client = jwt.PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json") + + +def decode_supabase_jwt(credentials: HTTPAuthorizationCredentials) -> dict: + """Verify a Supabase-issued JWT and return its claims.""" + token = credentials.credentials + try: + signing_key = _jwks_client.get_signing_key_from_jwt(token) + return jwt.decode( + token, + signing_key.key, + algorithms=["ES256"], + audience="authenticated", + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc diff --git a/apps/backend/app/deps.py b/apps/backend/app/deps.py new file mode 100644 index 0000000..85aeb30 --- /dev/null +++ b/apps/backend/app/deps.py @@ -0,0 +1,23 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlmodel import Session + +from app.core.security import decode_supabase_jwt +from app.db.session import get_session + +SessionDep = Annotated[Session, Depends(get_session)] + +bearer_scheme = HTTPBearer() + + +def get_current_user_id( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], +) -> UUID: + claims = decode_supabase_jwt(credentials) + return UUID(claims["sub"]) + + +CurrentUserId = Annotated[UUID, Depends(get_current_user_id)] diff --git a/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py b/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py new file mode 100644 index 0000000..cca8a52 --- /dev/null +++ b/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py @@ -0,0 +1,40 @@ +"""add user_id to categories for custom categories + +Revision ID: 301e0705bd43 +Revises: 5c29549a0a12 +Create Date: 2026-08-07 23:35:27.329365 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '301e0705bd43' +down_revision: Union[str, Sequence[str], None] = '5c29549a0a12' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('categories', sa.Column('user_id', sa.Uuid(), nullable=True)) + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False) + op.create_index(op.f('ix_categories_user_id'), 'categories', ['user_id'], unique=False) + op.create_unique_constraint('uq_category_user_name', 'categories', ['user_id', 'name']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_category_user_name', 'categories', type_='unique') + op.drop_index(op.f('ix_categories_user_id'), table_name='categories') + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + op.drop_column('categories', 'user_id') + # ### end Alembic commands ### diff --git a/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py b/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py new file mode 100644 index 0000000..36ec4fa --- /dev/null +++ b/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py @@ -0,0 +1,37 @@ +"""add server-side uuid default for id columns + +Revision ID: 5c29549a0a12 +Revises: d744ccb27d1f +Create Date: 2026-08-07 22:01:36.800395 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5c29549a0a12' +down_revision: Union[str, Sequence[str], None] = 'd744ccb27d1f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto') + op.alter_column( + 'categories', 'id', + server_default=sa.text('gen_random_uuid()'), + ) + op.alter_column( + 'transactions', 'id', + server_default=sa.text('gen_random_uuid()'), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.alter_column('categories', 'id', server_default=None) + op.alter_column('transactions', 'id', server_default=None) diff --git a/apps/backend/app/models/category.py b/apps/backend/app/models/category.py index a87422b..77930d1 100644 --- a/apps/backend/app/models/category.py +++ b/apps/backend/app/models/category.py @@ -1,10 +1,19 @@ from uuid import UUID, uuid4 -from sqlmodel import Field, SQLModel +from sqlalchemy import text +from sqlmodel import Field, SQLModel, UniqueConstraint class Category(SQLModel, table=True): __tablename__ = "categories" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_category_user_name"), + ) - id: UUID = Field(default_factory=uuid4, primary_key=True) - name: str = Field(unique=True, index=True) + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"server_default": text("gen_random_uuid()")}, + ) + user_id: UUID | None = Field(default=None, index=True) + name: str = Field(index=True) diff --git a/apps/backend/app/models/transaction.py b/apps/backend/app/models/transaction.py index 17f2224..ab96f3a 100644 --- a/apps/backend/app/models/transaction.py +++ b/apps/backend/app/models/transaction.py @@ -3,6 +3,7 @@ from enum import Enum from uuid import UUID, uuid4 +from sqlalchemy import text from sqlmodel import Field, SQLModel @@ -14,7 +15,11 @@ class TransactionSource(str, Enum): class Transaction(SQLModel, table=True): __tablename__ = "transactions" - id: UUID = Field(default_factory=uuid4, primary_key=True) + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"server_default": text("gen_random_uuid()")}, + ) user_id: UUID = Field(index=True) title: str diff --git a/apps/backend/app/routers/categories.py b/apps/backend/app/routers/categories.py new file mode 100644 index 0000000..fa844dd --- /dev/null +++ b/apps/backend/app/routers/categories.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlmodel import or_, select + +from app.deps import CurrentUserId, SessionDep +from app.models import Category +from app.schemas.category import CategoryCreate, CategoryRead + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("", response_model=list[CategoryRead]) +def list_categories(session: SessionDep, user_id: CurrentUserId): + statement = select(Category).where( + or_(Category.user_id.is_(None), Category.user_id == user_id) + ) + return session.exec(statement).all() + + +@router.post("", response_model=CategoryRead) +def create_category(category: CategoryCreate, session: SessionDep, user_id: CurrentUserId): + db_category = Category(name=category.name, user_id=user_id) + session.add(db_category) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Category '{category.name}' already exists", + ) + session.refresh(db_category) + return db_category diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/schemas/category.py b/apps/backend/app/schemas/category.py new file mode 100644 index 0000000..9367081 --- /dev/null +++ b/apps/backend/app/schemas/category.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from sqlmodel import SQLModel + + +class CategoryRead(SQLModel): + id: UUID + user_id: UUID | None + name: str + + +class CategoryCreate(SQLModel): + name: str diff --git a/apps/backend/main.py b/apps/backend/main.py index 56ef3eb..fa8b29e 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI -from app.routers import health +from app.routers import categories, health app = FastAPI() app.include_router(health.router) +app.include_router(categories.router) From 47eca68283d643c23b563a514be244448c329932 Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Sun, 9 Aug 2026 23:17:18 +0530 Subject: [PATCH 05/12] feat: add transactions API with CRUD, filters, and summaries - Add POST /transactions for manual expense/income entry - Add GET /transactions with filters for date range, source, category, and income/expense - Add GET/PATCH/DELETE for a single transaction, only visible to its owner - Add GET /transactions/summary for income, expenses, and net totals - Add GET /transactions/trend for totals grouped by day, week, month, or year --- apps/backend/app/routers/transactions.py | 179 +++++++++++++++++++++++ apps/backend/app/schemas/transaction.py | 45 ++++++ apps/backend/main.py | 3 +- 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/routers/transactions.py create mode 100644 apps/backend/app/schemas/transaction.py diff --git a/apps/backend/app/routers/transactions.py b/apps/backend/app/routers/transactions.py new file mode 100644 index 0000000..71459f2 --- /dev/null +++ b/apps/backend/app/routers/transactions.py @@ -0,0 +1,179 @@ +from datetime import date +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +from app.deps import CurrentUserId, SessionDep +from app.models import Transaction +from app.models.transaction import TransactionSource +from app.schemas.transaction import ( + TransactionCreate, + TransactionRead, + TransactionSummary, + TransactionTrendPoint, + TransactionUpdate, +) + +_TREND_BUCKET_UNIT = { + "daily": "day", + "weekly": "week", + "monthly": "month", + "yearly": "year", +} + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +def _get_owned_transaction(session: SessionDep, transaction_id: UUID, user_id: UUID) -> Transaction: + transaction = session.get(Transaction, transaction_id) + if transaction is None or transaction.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Transaction not found") + return transaction + + +def _apply_date_and_category_filters(statement, date_from, date_to, category_id): + if date_from is not None: + statement = statement.where(Transaction.occurred_at >= date_from) + if date_to is not None: + statement = statement.where(Transaction.occurred_at <= date_to) + if category_id is not None: + statement = statement.where(Transaction.category_id == category_id) + return statement + + +@router.post("", response_model=TransactionRead) +def create_transaction( + transaction: TransactionCreate, session: SessionDep, user_id: CurrentUserId +): + db_transaction = Transaction( + **transaction.model_dump(), + user_id=user_id, + source=TransactionSource.manual, + ) + session.add(db_transaction) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category_id", + ) + session.refresh(db_transaction) + return db_transaction + + +@router.get("", response_model=list[TransactionRead]) +def list_transactions( + session: SessionDep, + user_id: CurrentUserId, + date_from: date | None = None, + date_to: date | None = None, + source: TransactionSource | None = None, + category_id: UUID | None = None, + type: Literal["income", "expense"] | None = None, +): + statement = select(Transaction).where(Transaction.user_id == user_id) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + if source is not None: + statement = statement.where(Transaction.source == source) + if type == "income": + statement = statement.where(Transaction.amount > 0) + elif type == "expense": + statement = statement.where(Transaction.amount < 0) + return session.exec(statement).all() + + +@router.get("/summary", response_model=TransactionSummary) +def get_transaction_summary( + session: SessionDep, + user_id: CurrentUserId, + date_from: date | None = None, + date_to: date | None = None, + category_id: UUID | None = None, +): + statement = select( + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount > 0), 0).label( + "income" + ), + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount < 0), 0).label( + "expenses" + ), + func.coalesce(func.sum(Transaction.amount), 0).label("net"), + ).where(Transaction.user_id == user_id) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + + income, expenses, net = session.exec(statement).one() + return TransactionSummary(income=income, expenses=abs(expenses), net=net) + + +@router.get("/trend", response_model=list[TransactionTrendPoint]) +def get_transaction_trend( + session: SessionDep, + user_id: CurrentUserId, + range: Literal["daily", "weekly", "monthly", "yearly"], + date_from: date | None = None, + date_to: date | None = None, + category_id: UUID | None = None, +): + bucket = func.date_trunc(_TREND_BUCKET_UNIT[range], Transaction.occurred_at).label("bucket") + statement = ( + select( + bucket, + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount > 0), 0).label( + "income" + ), + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount < 0), 0).label( + "expenses" + ), + func.coalesce(func.sum(Transaction.amount), 0).label("net"), + ) + .where(Transaction.user_id == user_id) + .group_by(bucket) + .order_by(bucket) + ) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + + return [ + TransactionTrendPoint(bucket=row.bucket, income=row.income, expenses=abs(row.expenses), net=row.net) + for row in session.exec(statement).all() + ] + + +@router.get("/{transaction_id}", response_model=TransactionRead) +def get_transaction(transaction_id: UUID, session: SessionDep, user_id: CurrentUserId): + return _get_owned_transaction(session, transaction_id, user_id) + + +@router.patch("/{transaction_id}", response_model=TransactionRead) +def update_transaction( + transaction_id: UUID, + update: TransactionUpdate, + session: SessionDep, + user_id: CurrentUserId, +): + transaction = _get_owned_transaction(session, transaction_id, user_id) + for field, value in update.model_dump(exclude_unset=True).items(): + setattr(transaction, field, value) + session.add(transaction) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category_id", + ) + session.refresh(transaction) + return transaction + + +@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_transaction(transaction_id: UUID, session: SessionDep, user_id: CurrentUserId): + transaction = _get_owned_transaction(session, transaction_id, user_id) + session.delete(transaction) + session.commit() diff --git a/apps/backend/app/schemas/transaction.py b/apps/backend/app/schemas/transaction.py new file mode 100644 index 0000000..5b8913e --- /dev/null +++ b/apps/backend/app/schemas/transaction.py @@ -0,0 +1,45 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from sqlmodel import SQLModel + +from app.models.transaction import TransactionSource + + +class TransactionCreate(SQLModel): + title: str + amount: Decimal + category_id: UUID | None = None + occurred_at: date + + +class TransactionUpdate(SQLModel): + title: str | None = None + amount: Decimal | None = None + category_id: UUID | None = None + occurred_at: date | None = None + + +class TransactionRead(SQLModel): + id: UUID + user_id: UUID + title: str + amount: Decimal + category_id: UUID | None + source: TransactionSource + occurred_at: date + created_at: datetime + + +class TransactionSummary(SQLModel): + income: Decimal + expenses: Decimal + net: Decimal + + +class TransactionTrendPoint(SQLModel): + bucket: date + income: Decimal + expenses: Decimal + net: Decimal diff --git a/apps/backend/main.py b/apps/backend/main.py index fa8b29e..85a72c3 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -1,8 +1,9 @@ from fastapi import FastAPI -from app.routers import categories, health +from app.routers import categories, health, transactions app = FastAPI() app.include_router(health.router) app.include_router(categories.router) +app.include_router(transactions.router) From b39b1916bff0b1ae273c110a4a0dfc8372c05cbb Mon Sep 17 00:00:00 2001 From: Ryan Mario Date: Tue, 11 Aug 2026 19:25:51 +0530 Subject: [PATCH 06/12] feat: add Supabase Auth for sign-in and sign-up - Add the Supabase client (lib/supabase.ts) with persistent sessions - Add sign-in and sign-up logic as reusable hooks - Wire the sign-in/sign-up bare-bones UIs to real Supabase Auth - Redirect users to sign-in or the main app based on whether they're logged in - Add a sign-out button to the Profile tab --- apps/mobile/.env.example | 8 ++ apps/mobile/.gitignore | 1 + apps/mobile/app/(auth)/sign-in.tsx | 33 ++++++- apps/mobile/app/(auth)/sign-up.tsx | 39 ++++++++- apps/mobile/app/(tabs)/profile.tsx | 10 ++- apps/mobile/app/index.tsx | 20 ++++- apps/mobile/hooks/useSignIn.ts | 23 +++++ apps/mobile/hooks/useSignUp.ts | 24 +++++ apps/mobile/lib/supabase.ts | 14 +++ apps/mobile/package-lock.json | 136 +++++++++++++++++++++++++++++ apps/mobile/package.json | 2 + 11 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/.env.example create mode 100644 apps/mobile/hooks/useSignIn.ts create mode 100644 apps/mobile/hooks/useSignUp.ts create mode 100644 apps/mobile/lib/supabase.ts diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..67b9c78 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in real values. Never commit .env. +# Must be prefixed EXPO_PUBLIC_ to be readable in client code (Expo convention). + +# Supabase project settings -> API -> Project URL +EXPO_PUBLIC_SUPABASE_URL=https://[YOUR-PROJECT-REF].supabase.co + +# Supabase project settings -> API Keys -> Publishable key +EXPO_PUBLIC_SUPABASE_ANON_KEY=[YOUR-PUBLISHABLE-KEY] diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index f8c6c2e..0c2c5f2 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -31,6 +31,7 @@ yarn-error.* *.pem # local env files +.env .env*.local # typescript diff --git a/apps/mobile/app/(auth)/sign-in.tsx b/apps/mobile/app/(auth)/sign-in.tsx index 69fc750..01db7ac 100644 --- a/apps/mobile/app/(auth)/sign-in.tsx +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -1,10 +1,39 @@ -import { Link } from "expo-router"; -import { Text, View } from "react-native"; +import { useSignIn } from "@/hooks/useSignIn"; +import { Link, router } from "expo-router"; +import { Button, Text, TextInput, View } from "react-native"; const SignIn = () => { + const { email, setEmail, password, setPassword, loading, error, signIn } = useSignIn(); + + const handleSubmit = async () => { + const success = await signIn(); + if (success) { + router.replace("/(tabs)"); + } + }; + return ( Sign In + + + {error && {error}} +