diff --git a/app/package-lock.json b/app/package-lock.json index 0088a0fa..e0784fd6 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.84.2", + "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -5862,7 +5863,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/autoprefixer": { @@ -5903,6 +5903,17 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -6127,7 +6138,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6443,7 +6453,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6776,7 +6785,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6866,7 +6874,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6964,7 +6971,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6974,7 +6980,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6984,7 +6989,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6997,7 +7001,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7449,6 +7452,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7466,10 +7489,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7550,7 +7572,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7594,7 +7615,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7690,7 +7710,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7756,7 +7775,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7769,7 +7787,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9473,7 +9490,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9512,7 +9528,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9522,7 +9537,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -10259,6 +10273,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/app/package.json b/app/package.json index 24f692c0..32ce7e43 100644 --- a/app/package.json +++ b/app/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.84.2", + "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -67,13 +68,13 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@tailwindcss/typography": "^0.5.16", - "@types/node": "^22.16.5", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.7", - "@types/jest": "^29.5.14", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.6.1", + "@types/jest": "^29.5.14", + "@types/node": "^22.16.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", "eslint": "^9.32.0", @@ -85,8 +86,8 @@ "jest-environment-jsdom": "^29.7.0", "jest-junit": "^16.0.0", "postcss": "^8.5.6", - "ts-jest": "^29.2.5", "tailwindcss": "^3.4.17", + "ts-jest": "^29.2.5", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..953c52b5 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import { Goals } from "./pages/Goals"; const queryClient = new QueryClient({ defaultOptions: { @@ -94,6 +95,7 @@ const App = () => ( } /> } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/app/src/api/goals.ts b/app/src/api/goals.ts new file mode 100644 index 00000000..4f833ed8 --- /dev/null +++ b/app/src/api/goals.ts @@ -0,0 +1,51 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: "/api/goals", +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +export interface GoalMilestone { + id: number; + name: string; + target_amount: number; + achieved: boolean; +} + +export interface Goal { + id: number; + name: string; + target_amount: number; + current_amount: number; + currency: string; + deadline: string | null; + created_at: string; + milestones: GoalMilestone[]; +} + +export const getGoals = async (): Promise => { + const response = await api.get(""); + return response.data; +}; + +export const createGoal = async (data: any) => { + const response = await api.post("", data); + return response.data; +}; + +export const updateGoal = async (id: number, data: any) => { + const response = await api.put(`/${id}`, data); + return response.data; +}; + +export const deleteGoal = async (id: number) => { + const response = await api.delete(`/${id}`); + return response.data; +}; diff --git a/app/src/pages/Goals.tsx b/app/src/pages/Goals.tsx new file mode 100644 index 00000000..f7483779 --- /dev/null +++ b/app/src/pages/Goals.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { FinancialCard, FinancialCardContent, FinancialCardDescription, FinancialCardHeader, FinancialCardTitle } from "@/components/ui/financial-card"; +import { Button } from "@/components/ui/button"; +import { getGoals, Goal, createGoal } from "@/api/goals"; + +export function Goals() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getGoals().then((data) => { + setGoals(data); + setLoading(false); + }).catch(err => { + console.error(err); + setLoading(false); + }); + }, []); + + const handleCreate = async () => { + const name = prompt("Goal name:"); + if (!name) return; + const target = parseFloat(prompt("Target amount:") || "0"); + if (target <= 0) return; + + await createGoal({ name, target_amount: target, current_amount: 0 }); + const updated = await getGoals(); + setGoals(updated); + }; + + if (loading) return
Loading...
; + + return ( +
+
+

Goals & Milestones

+ +
+ +
+ {goals.map((g) => ( + + + {g.name} + + {g.current_amount} / {g.target_amount} {g.currency} + + + +
+
+
+
+
+ ))} +
+ {goals.length === 0 &&

No goals set yet.

} +
+ ); +} diff --git a/app/tsconfig.app.tsbuildinfo b/app/tsconfig.app.tsbuildinfo index abdef379..7c8dea51 100644 --- a/app/tsconfig.app.tsbuildinfo +++ b/app/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/__tests__/analytics.integration.test.tsx","./src/__tests__/dashboard.integration.test.tsx","./src/__tests__/expenses.integration.test.tsx","./src/__tests__/navbar.test.tsx","./src/__tests__/protectedroute.test.tsx","./src/__tests__/reminders.integration.test.tsx","./src/__tests__/signin.test.tsx","./src/__tests__/apiclient.test.ts","./src/__tests__/auth.test.ts","./src/api/auth.ts","./src/api/bills.ts","./src/api/categories.ts","./src/api/client.ts","./src/api/dashboard.ts","./src/api/expenses.ts","./src/api/insights.ts","./src/api/reminders.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/footer.tsx","./src/components/layout/layout.tsx","./src/components/layout/navbar.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dailog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/financial-card.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/lib/auth.ts","./src/lib/currency.ts","./src/lib/utils.ts","./src/pages/account.tsx","./src/pages/analytics.tsx","./src/pages/bills.tsx","./src/pages/budgets.tsx","./src/pages/categories.tsx","./src/pages/dashboard.tsx","./src/pages/expenses.tsx","./src/pages/index.tsx","./src/pages/landing.tsx","./src/pages/notfound.tsx","./src/pages/register.tsx","./src/pages/reminders.tsx","./src/pages/signin.tsx"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/__tests__/analytics.integration.test.tsx","./src/__tests__/dashboard.integration.test.tsx","./src/__tests__/expenses.integration.test.tsx","./src/__tests__/navbar.test.tsx","./src/__tests__/protectedroute.test.tsx","./src/__tests__/reminders.integration.test.tsx","./src/__tests__/signin.test.tsx","./src/__tests__/apiclient.test.ts","./src/__tests__/auth.test.ts","./src/api/auth.ts","./src/api/bills.ts","./src/api/categories.ts","./src/api/client.ts","./src/api/dashboard.ts","./src/api/expenses.ts","./src/api/goals.ts","./src/api/insights.ts","./src/api/reminders.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/footer.tsx","./src/components/layout/layout.tsx","./src/components/layout/navbar.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dailog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/financial-card.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/lib/auth.ts","./src/lib/currency.ts","./src/lib/utils.ts","./src/pages/account.tsx","./src/pages/analytics.tsx","./src/pages/bills.tsx","./src/pages/budgets.tsx","./src/pages/categories.tsx","./src/pages/dashboard.tsx","./src/pages/expenses.tsx","./src/pages/goals.tsx","./src/pages/index.tsx","./src/pages/landing.tsx","./src/pages/notfound.tsx","./src/pages/register.tsx","./src/pages/reminders.tsx","./src/pages/signin.tsx"],"version":"5.8.3"} \ No newline at end of file diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45..9ec394df 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -103,13 +103,11 @@ def _ensure_schema_compatibility(app: Flask) -> None: conn = db.engine.raw_connection() try: cur = conn.cursor() - cur.execute( - """ + cur.execute(""" ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(10) NOT NULL DEFAULT 'INR' - """ - ) + """) conn.commit() except Exception: app.logger.exception( diff --git a/packages/backend/app/extensions.py b/packages/backend/app/extensions.py index bad98fae..550a15e6 100644 --- a/packages/backend/app/extensions.py +++ b/packages/backend/app/extensions.py @@ -3,7 +3,6 @@ import redis from .config import Settings - db = SQLAlchemy() jwt = JWTManager() diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..383b92e8 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,29 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class Goal(db.Model): + __tablename__ = "goals" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + name = db.Column(db.String(255), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + current_amount = db.Column(db.Numeric(12, 2), default=0.00, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + deadline = db.Column(db.Date, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + milestones = db.relationship( + "GoalMilestone", backref="goal", lazy=True, cascade="all, delete-orphan" + ) + + +class GoalMilestone(db.Model): + __tablename__ = "goal_milestones" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False) + name = db.Column(db.String(255), nullable=False) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + achieved = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..22631662 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .goals import bp as goals_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(goals_bp, url_prefix="/goals") diff --git a/packages/backend/app/routes/goals.py b/packages/backend/app/routes/goals.py new file mode 100644 index 00000000..ba44935d --- /dev/null +++ b/packages/backend/app/routes/goals.py @@ -0,0 +1,118 @@ +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import Goal, GoalMilestone +from datetime import datetime + +bp = Blueprint("goals", __name__) + + +@bp.route("", methods=["GET"]) +@jwt_required() +def get_goals(): + uid = int(get_jwt_identity()) + goals = Goal.query.filter_by(user_id=uid).all() + result = [] + for g in goals: + milestones = GoalMilestone.query.filter_by(goal_id=g.id).all() + result.append( + { + "id": g.id, + "name": g.name, + "target_amount": float(g.target_amount), + "current_amount": float(g.current_amount), + "currency": g.currency, + "deadline": g.deadline.isoformat() if g.deadline else None, + "created_at": g.created_at.isoformat(), + "milestones": [ + { + "id": m.id, + "name": m.name, + "target_amount": float(m.target_amount), + "achieved": m.achieved, + } + for m in milestones + ], + } + ) + return jsonify(result), 200 + + +@bp.route("", methods=["POST"]) +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + data = request.json + try: + deadline = ( + datetime.strptime(data["deadline"], "%Y-%m-%d").date() + if data.get("deadline") + else None + ) + except ValueError: + return jsonify({"error": "Invalid date format, use YYYY-MM-DD"}), 400 + + new_goal = Goal( + user_id=uid, + name=data["name"], + target_amount=data["target_amount"], + current_amount=data.get("current_amount", 0.0), + currency=data.get("currency", "INR"), + deadline=deadline, + ) + db.session.add(new_goal) + db.session.commit() + + if "milestones" in data: + for m in data["milestones"]: + new_ms = GoalMilestone( + goal_id=new_goal.id, + name=m["name"], + target_amount=m["target_amount"], + achieved=m.get("achieved", False), + ) + db.session.add(new_ms) + db.session.commit() + + return jsonify({"message": "Goal created successfully", "id": new_goal.id}), 201 + + +@bp.route("/", methods=["PUT"]) +@jwt_required() +def update_goal(goal_id): + uid = int(get_jwt_identity()) + goal = Goal.query.filter_by(id=goal_id, user_id=uid).first() + if not goal: + return jsonify({"error": "Goal not found"}), 404 + + data = request.json + if "name" in data: + goal.name = data["name"] + if "target_amount" in data: + goal.target_amount = data["target_amount"] + if "current_amount" in data: + goal.current_amount = data["current_amount"] + if "deadline" in data: + try: + goal.deadline = ( + datetime.strptime(data["deadline"], "%Y-%m-%d").date() + if data["deadline"] + else None + ) + except ValueError: + return jsonify({"error": "Invalid date format"}), 400 + + db.session.commit() + return jsonify({"message": "Goal updated successfully"}), 200 + + +@bp.route("/", methods=["DELETE"]) +@jwt_required() +def delete_goal(goal_id): + uid = int(get_jwt_identity()) + goal = Goal.query.filter_by(id=goal_id, user_id=uid).first() + if not goal: + return jsonify({"error": "Goal not found"}), 404 + db.session.delete(goal) + db.session.commit() + return jsonify({"message": "Goal deleted successfully"}), 200 diff --git a/packages/backend/tests/test_goals.py b/packages/backend/tests/test_goals.py new file mode 100644 index 00000000..0012a34f --- /dev/null +++ b/packages/backend/tests/test_goals.py @@ -0,0 +1,20 @@ +def test_get_goals_empty(client, auth_header): + response = client.get("/goals", headers=auth_header) + assert response.status_code == 200 + assert response.json == [] + + +def test_create_goal(client, auth_header): + data = { + "name": "Buy a car", + "target_amount": 10000.0, + "deadline": "2026-12-31", + "milestones": [{"name": "Save 5k", "target_amount": 5000.0}], + } + response = client.post("/goals", json=data, headers=auth_header) + assert response.status_code == 201 + + res2 = client.get("/goals", headers=auth_header) + assert len(res2.json) == 1 + assert res2.json[0]["name"] == "Buy a car" + assert len(res2.json[0]["milestones"]) == 1