diff --git a/client/README.md b/client/README.md
index 642cf3d..6f09bca 100644
--- a/client/README.md
+++ b/client/README.md
@@ -29,6 +29,16 @@
- preview: `npm run preview`
- storybook: `npm run storybook`
+
API Client SDK
+
+- Regenerate the client from the server OpenAPI spec:
+
+```bash
+npm run api:generate
+```
+
+- Source spec: `server/docs/Expense Tracker API.yaml`
+
UI Components Overview (client/src/ui/)
- Tooltip (`Tooltip.tsx`)
diff --git a/client/package-lock.json b/client/package-lock.json
index 596ca8c..59efc7a 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.1.11",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
+ "axios": "^1.7.7",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.11"
@@ -34,6 +35,7 @@
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-storybook": "^9.1.2",
"globals": "^16.3.0",
+ "openapi-typescript-codegen": "^0.29.0",
"playwright": "^1.54.2",
"storybook": "^9.1.2",
"typescript": "~5.8.3",
@@ -62,6 +64,24 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "11.9.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
+ "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1117,6 +1137,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@mdx-js/react": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
@@ -2962,6 +2989,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
@@ -2972,6 +3005,17 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3059,6 +3103,19 @@
"node": ">=8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3069,6 +3126,19 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001733",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz",
@@ -3187,6 +3257,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3275,6 +3367,15 @@
"node": ">=8"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -3314,6 +3415,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3348,6 +3463,24 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -3355,6 +3488,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/es-object-atoms": {
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@@ -3793,6 +3953,26 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/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",
@@ -3810,6 +3990,37 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "11.3.1",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
+ "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3828,7 +4039,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3844,6 +4054,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -3917,6 +4164,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -3930,6 +4189,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3940,11 +4221,37 @@
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4623,6 +4930,15 @@
"node": ">=10"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4647,6 +4963,27 @@
"node": ">=8.6"
}
},
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -4758,6 +5095,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -4783,6 +5127,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/openapi-typescript-codegen": {
+ "version": "0.29.0",
+ "resolved": "https://registry.npmjs.org/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz",
+ "integrity": "sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "^11.5.4",
+ "camelcase": "^6.3.0",
+ "commander": "^12.0.0",
+ "fs-extra": "^11.2.0",
+ "handlebars": "^4.7.8"
+ },
+ "bin": {
+ "openapi": "bin/index.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5067,6 +5428,12 @@
"node": ">= 6"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5956,6 +6323,20 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/unicorn-magic": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
@@ -6293,6 +6674,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
diff --git a/client/package.json b/client/package.json
index 978c142..7edf5c5 100644
--- a/client/package.json
+++ b/client/package.json
@@ -9,13 +9,15 @@
"lint": "eslint .",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
- "build-storybook": "storybook build"
+ "build-storybook": "storybook build",
+ "api:generate": "openapi --input \"../server/docs/Expense Tracker API.yaml\" --output src/api --client axios"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@tailwindcss/vite": "^4.1.11",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
+ "axios": "^1.7.7",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.11"
@@ -43,6 +45,7 @@
"vitest": "^3.2.4",
"@vitest/browser": "^3.2.4",
"playwright": "^1.54.2",
- "@vitest/coverage-v8": "^3.2.4"
+ "@vitest/coverage-v8": "^3.2.4",
+ "openapi-typescript-codegen": "^0.29.0"
}
}
diff --git a/client/src/api/core/ApiError.ts b/client/src/api/core/ApiError.ts
new file mode 100644
index 0000000..ec7b16a
--- /dev/null
+++ b/client/src/api/core/ApiError.ts
@@ -0,0 +1,25 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ApiRequestOptions } from './ApiRequestOptions';
+import type { ApiResult } from './ApiResult';
+
+export class ApiError extends Error {
+ public readonly url: string;
+ public readonly status: number;
+ public readonly statusText: string;
+ public readonly body: any;
+ public readonly request: ApiRequestOptions;
+
+ constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
+ super(message);
+
+ this.name = 'ApiError';
+ this.url = response.url;
+ this.status = response.status;
+ this.statusText = response.statusText;
+ this.body = response.body;
+ this.request = request;
+ }
+}
diff --git a/client/src/api/core/ApiRequestOptions.ts b/client/src/api/core/ApiRequestOptions.ts
new file mode 100644
index 0000000..93143c3
--- /dev/null
+++ b/client/src/api/core/ApiRequestOptions.ts
@@ -0,0 +1,17 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type ApiRequestOptions = {
+ readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
+ readonly url: string;
+ readonly path?: Record;
+ readonly cookies?: Record;
+ readonly headers?: Record;
+ readonly query?: Record;
+ readonly formData?: Record;
+ readonly body?: any;
+ readonly mediaType?: string;
+ readonly responseHeader?: string;
+ readonly errors?: Record;
+};
diff --git a/client/src/api/core/ApiResult.ts b/client/src/api/core/ApiResult.ts
new file mode 100644
index 0000000..ee1126e
--- /dev/null
+++ b/client/src/api/core/ApiResult.ts
@@ -0,0 +1,11 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type ApiResult = {
+ readonly url: string;
+ readonly ok: boolean;
+ readonly status: number;
+ readonly statusText: string;
+ readonly body: any;
+};
diff --git a/client/src/api/core/CancelablePromise.ts b/client/src/api/core/CancelablePromise.ts
new file mode 100644
index 0000000..d70de92
--- /dev/null
+++ b/client/src/api/core/CancelablePromise.ts
@@ -0,0 +1,131 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export class CancelError extends Error {
+
+ constructor(message: string) {
+ super(message);
+ this.name = 'CancelError';
+ }
+
+ public get isCancelled(): boolean {
+ return true;
+ }
+}
+
+export interface OnCancel {
+ readonly isResolved: boolean;
+ readonly isRejected: boolean;
+ readonly isCancelled: boolean;
+
+ (cancelHandler: () => void): void;
+}
+
+export class CancelablePromise implements Promise {
+ #isResolved: boolean;
+ #isRejected: boolean;
+ #isCancelled: boolean;
+ readonly #cancelHandlers: (() => void)[];
+ readonly #promise: Promise;
+ #resolve?: (value: T | PromiseLike) => void;
+ #reject?: (reason?: any) => void;
+
+ constructor(
+ executor: (
+ resolve: (value: T | PromiseLike) => void,
+ reject: (reason?: any) => void,
+ onCancel: OnCancel
+ ) => void
+ ) {
+ this.#isResolved = false;
+ this.#isRejected = false;
+ this.#isCancelled = false;
+ this.#cancelHandlers = [];
+ this.#promise = new Promise((resolve, reject) => {
+ this.#resolve = resolve;
+ this.#reject = reject;
+
+ const onResolve = (value: T | PromiseLike): void => {
+ if (this.#isResolved || this.#isRejected || this.#isCancelled) {
+ return;
+ }
+ this.#isResolved = true;
+ if (this.#resolve) this.#resolve(value);
+ };
+
+ const onReject = (reason?: any): void => {
+ if (this.#isResolved || this.#isRejected || this.#isCancelled) {
+ return;
+ }
+ this.#isRejected = true;
+ if (this.#reject) this.#reject(reason);
+ };
+
+ const onCancel = (cancelHandler: () => void): void => {
+ if (this.#isResolved || this.#isRejected || this.#isCancelled) {
+ return;
+ }
+ this.#cancelHandlers.push(cancelHandler);
+ };
+
+ Object.defineProperty(onCancel, 'isResolved', {
+ get: (): boolean => this.#isResolved,
+ });
+
+ Object.defineProperty(onCancel, 'isRejected', {
+ get: (): boolean => this.#isRejected,
+ });
+
+ Object.defineProperty(onCancel, 'isCancelled', {
+ get: (): boolean => this.#isCancelled,
+ });
+
+ return executor(onResolve, onReject, onCancel as OnCancel);
+ });
+ }
+
+ get [Symbol.toStringTag]() {
+ return "Cancellable Promise";
+ }
+
+ public then(
+ onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
+ onRejected?: ((reason: any) => TResult2 | PromiseLike) | null
+ ): Promise {
+ return this.#promise.then(onFulfilled, onRejected);
+ }
+
+ public catch(
+ onRejected?: ((reason: any) => TResult | PromiseLike) | null
+ ): Promise {
+ return this.#promise.catch(onRejected);
+ }
+
+ public finally(onFinally?: (() => void) | null): Promise {
+ return this.#promise.finally(onFinally);
+ }
+
+ public cancel(): void {
+ if (this.#isResolved || this.#isRejected || this.#isCancelled) {
+ return;
+ }
+ this.#isCancelled = true;
+ if (this.#cancelHandlers.length) {
+ try {
+ for (const cancelHandler of this.#cancelHandlers) {
+ cancelHandler();
+ }
+ } catch (error) {
+ console.warn('Cancellation threw an error', error);
+ return;
+ }
+ }
+ this.#cancelHandlers.length = 0;
+ if (this.#reject) this.#reject(new CancelError('Request aborted'));
+ }
+
+ public get isCancelled(): boolean {
+ return this.#isCancelled;
+ }
+}
diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts
new file mode 100644
index 0000000..03da729
--- /dev/null
+++ b/client/src/api/core/OpenAPI.ts
@@ -0,0 +1,32 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ApiRequestOptions } from './ApiRequestOptions';
+
+type Resolver = (options: ApiRequestOptions) => Promise;
+type Headers = Record;
+
+export type OpenAPIConfig = {
+ BASE: string;
+ VERSION: string;
+ WITH_CREDENTIALS: boolean;
+ CREDENTIALS: 'include' | 'omit' | 'same-origin';
+ TOKEN?: string | Resolver | undefined;
+ USERNAME?: string | Resolver | undefined;
+ PASSWORD?: string | Resolver | undefined;
+ HEADERS?: Headers | Resolver | undefined;
+ ENCODE_PATH?: ((path: string) => string) | undefined;
+};
+
+export const OpenAPI: OpenAPIConfig = {
+ BASE: 'http://localhost:8080/api',
+ VERSION: '1.0.0',
+ WITH_CREDENTIALS: false,
+ CREDENTIALS: 'include',
+ TOKEN: undefined,
+ USERNAME: undefined,
+ PASSWORD: undefined,
+ HEADERS: undefined,
+ ENCODE_PATH: undefined,
+};
diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts
new file mode 100644
index 0000000..1dc6fef
--- /dev/null
+++ b/client/src/api/core/request.ts
@@ -0,0 +1,323 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import axios from 'axios';
+import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
+import FormData from 'form-data';
+
+import { ApiError } from './ApiError';
+import type { ApiRequestOptions } from './ApiRequestOptions';
+import type { ApiResult } from './ApiResult';
+import { CancelablePromise } from './CancelablePromise';
+import type { OnCancel } from './CancelablePromise';
+import type { OpenAPIConfig } from './OpenAPI';
+
+export const isDefined = (value: T | null | undefined): value is Exclude => {
+ return value !== undefined && value !== null;
+};
+
+export const isString = (value: any): value is string => {
+ return typeof value === 'string';
+};
+
+export const isStringWithValue = (value: any): value is string => {
+ return isString(value) && value !== '';
+};
+
+export const isBlob = (value: any): value is Blob => {
+ return (
+ typeof value === 'object' &&
+ typeof value.type === 'string' &&
+ typeof value.stream === 'function' &&
+ typeof value.arrayBuffer === 'function' &&
+ typeof value.constructor === 'function' &&
+ typeof value.constructor.name === 'string' &&
+ /^(Blob|File)$/.test(value.constructor.name) &&
+ /^(Blob|File)$/.test(value[Symbol.toStringTag])
+ );
+};
+
+export const isFormData = (value: any): value is FormData => {
+ return value instanceof FormData;
+};
+
+export const isSuccess = (status: number): boolean => {
+ return status >= 200 && status < 300;
+};
+
+export const base64 = (str: string): string => {
+ try {
+ return btoa(str);
+ } catch (err) {
+ // @ts-ignore
+ return Buffer.from(str).toString('base64');
+ }
+};
+
+export const getQueryString = (params: Record): string => {
+ const qs: string[] = [];
+
+ const append = (key: string, value: any) => {
+ qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+ };
+
+ const process = (key: string, value: any) => {
+ if (isDefined(value)) {
+ if (Array.isArray(value)) {
+ value.forEach(v => {
+ process(key, v);
+ });
+ } else if (typeof value === 'object') {
+ Object.entries(value).forEach(([k, v]) => {
+ process(`${key}[${k}]`, v);
+ });
+ } else {
+ append(key, value);
+ }
+ }
+ };
+
+ Object.entries(params).forEach(([key, value]) => {
+ process(key, value);
+ });
+
+ if (qs.length > 0) {
+ return `?${qs.join('&')}`;
+ }
+
+ return '';
+};
+
+const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
+ const encoder = config.ENCODE_PATH || encodeURI;
+
+ const path = options.url
+ .replace('{api-version}', config.VERSION)
+ .replace(/{(.*?)}/g, (substring: string, group: string) => {
+ if (options.path?.hasOwnProperty(group)) {
+ return encoder(String(options.path[group]));
+ }
+ return substring;
+ });
+
+ const url = `${config.BASE}${path}`;
+ if (options.query) {
+ return `${url}${getQueryString(options.query)}`;
+ }
+ return url;
+};
+
+export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
+ if (options.formData) {
+ const formData = new FormData();
+
+ const process = (key: string, value: any) => {
+ if (isString(value) || isBlob(value)) {
+ formData.append(key, value);
+ } else {
+ formData.append(key, JSON.stringify(value));
+ }
+ };
+
+ Object.entries(options.formData)
+ .filter(([_, value]) => isDefined(value))
+ .forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ value.forEach(v => process(key, v));
+ } else {
+ process(key, value);
+ }
+ });
+
+ return formData;
+ }
+ return undefined;
+};
+
+type Resolver = (options: ApiRequestOptions) => Promise;
+
+export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => {
+ if (typeof resolver === 'function') {
+ return (resolver as Resolver)(options);
+ }
+ return resolver;
+};
+
+export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => {
+ const [token, username, password, additionalHeaders] = await Promise.all([
+ resolve(options, config.TOKEN),
+ resolve(options, config.USERNAME),
+ resolve(options, config.PASSWORD),
+ resolve(options, config.HEADERS),
+ ]);
+
+ const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
+
+ const headers = Object.entries({
+ Accept: 'application/json',
+ ...additionalHeaders,
+ ...options.headers,
+ ...formHeaders,
+ })
+ .filter(([_, value]) => isDefined(value))
+ .reduce((headers, [key, value]) => ({
+ ...headers,
+ [key]: String(value),
+ }), {} as Record);
+
+ if (isStringWithValue(token)) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ if (isStringWithValue(username) && isStringWithValue(password)) {
+ const credentials = base64(`${username}:${password}`);
+ headers['Authorization'] = `Basic ${credentials}`;
+ }
+
+ if (options.body !== undefined) {
+ if (options.mediaType) {
+ headers['Content-Type'] = options.mediaType;
+ } else if (isBlob(options.body)) {
+ headers['Content-Type'] = options.body.type || 'application/octet-stream';
+ } else if (isString(options.body)) {
+ headers['Content-Type'] = 'text/plain';
+ } else if (!isFormData(options.body)) {
+ headers['Content-Type'] = 'application/json';
+ }
+ }
+
+ return headers;
+};
+
+export const getRequestBody = (options: ApiRequestOptions): any => {
+ if (options.body) {
+ return options.body;
+ }
+ return undefined;
+};
+
+export const sendRequest = async (
+ config: OpenAPIConfig,
+ options: ApiRequestOptions,
+ url: string,
+ body: any,
+ formData: FormData | undefined,
+ headers: Record,
+ onCancel: OnCancel,
+ axiosClient: AxiosInstance
+): Promise> => {
+ const source = axios.CancelToken.source();
+
+ const requestConfig: AxiosRequestConfig = {
+ url,
+ headers,
+ data: body ?? formData,
+ method: options.method,
+ withCredentials: config.WITH_CREDENTIALS,
+ withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
+ cancelToken: source.token,
+ };
+
+ onCancel(() => source.cancel('The user aborted a request.'));
+
+ try {
+ return await axiosClient.request(requestConfig);
+ } catch (error) {
+ const axiosError = error as AxiosError;
+ if (axiosError.response) {
+ return axiosError.response;
+ }
+ throw error;
+ }
+};
+
+export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => {
+ if (responseHeader) {
+ const content = response.headers[responseHeader];
+ if (isString(content)) {
+ return content;
+ }
+ }
+ return undefined;
+};
+
+export const getResponseBody = (response: AxiosResponse): any => {
+ if (response.status !== 204) {
+ return response.data;
+ }
+ return undefined;
+};
+
+export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
+ const errors: Record = {
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 500: 'Internal Server Error',
+ 502: 'Bad Gateway',
+ 503: 'Service Unavailable',
+ ...options.errors,
+ }
+
+ const error = errors[result.status];
+ if (error) {
+ throw new ApiError(options, result, error);
+ }
+
+ if (!result.ok) {
+ const errorStatus = result.status ?? 'unknown';
+ const errorStatusText = result.statusText ?? 'unknown';
+ const errorBody = (() => {
+ try {
+ return JSON.stringify(result.body, null, 2);
+ } catch (e) {
+ return undefined;
+ }
+ })();
+
+ throw new ApiError(options, result,
+ `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
+ );
+ }
+};
+
+/**
+ * Request method
+ * @param config The OpenAPI configuration object
+ * @param options The request options from the service
+ * @param axiosClient The axios client instance to use
+ * @returns CancelablePromise
+ * @throws ApiError
+ */
+export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => {
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ try {
+ const url = getUrl(config, options);
+ const formData = getFormData(options);
+ const body = getRequestBody(options);
+ const headers = await getHeaders(config, options, formData);
+
+ if (!onCancel.isCancelled) {
+ const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient);
+ const responseBody = getResponseBody(response);
+ const responseHeader = getResponseHeader(response, options.responseHeader);
+
+ const result: ApiResult = {
+ url,
+ ok: isSuccess(response.status),
+ status: response.status,
+ statusText: response.statusText,
+ body: responseHeader ?? responseBody,
+ };
+
+ catchErrorCodes(options, result);
+
+ resolve(result.body);
+ }
+ } catch (error) {
+ reject(error);
+ }
+ });
+};
diff --git a/client/src/api/index.ts b/client/src/api/index.ts
new file mode 100644
index 0000000..4e78275
--- /dev/null
+++ b/client/src/api/index.ts
@@ -0,0 +1,10 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export { ApiError } from './core/ApiError';
+export { CancelablePromise, CancelError } from './core/CancelablePromise';
+export { OpenAPI } from './core/OpenAPI';
+export type { OpenAPIConfig } from './core/OpenAPI';
+
+export { DefaultService } from './services/DefaultService';
diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts
new file mode 100644
index 0000000..50ff9f3
--- /dev/null
+++ b/client/src/api/services/DefaultService.ts
@@ -0,0 +1,408 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { CancelablePromise } from '../core/CancelablePromise';
+import { OpenAPI } from '../core/OpenAPI';
+import { request as __request } from '../core/request';
+export class DefaultService {
+ /**
+ * Register a new user
+ * @param requestBody
+ * @returns any User created
+ * @throws ApiError
+ */
+ public static postAuthSignup(
+ requestBody: {
+ email: string;
+ password: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/auth/signup',
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * Login and receive JWT token
+ * @param requestBody
+ * @returns any Token returned
+ * @throws ApiError
+ */
+ public static postAuthLogin(
+ requestBody: {
+ email: string;
+ password: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/auth/login',
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * List all user expenses
+ * @param start Start date
+ * @param end End date
+ * @param category
+ * @param type
+ * @returns any List of expenses
+ * @throws ApiError
+ */
+ public static getExpenses(
+ start?: string,
+ end?: string,
+ category?: string,
+ type?: 'recurring' | 'one-time',
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/expenses',
+ query: {
+ 'start': start,
+ 'end': end,
+ 'category': category,
+ 'type': type,
+ },
+ });
+ }
+ /**
+ * Create a new expense
+ * @param formData
+ * @returns any Expense created
+ * @throws ApiError
+ */
+ public static postExpenses(
+ formData?: {
+ amount: number;
+ date: string;
+ categoryId: string;
+ description?: string;
+ type?: 'one-time' | 'recurring';
+ startDate?: string;
+ endDate?: string;
+ receipt?: Blob;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/expenses',
+ formData: formData,
+ mediaType: 'multipart/form-data',
+ });
+ }
+ /**
+ * Get a single expense
+ * @param id
+ * @returns any Expense data
+ * @throws ApiError
+ */
+ public static getExpenses1(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/expenses/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * Update an expense
+ * @param id
+ * @param formData
+ * @returns any Expense updated
+ * @throws ApiError
+ */
+ public static putExpenses(
+ id: string,
+ formData?: {
+ amount?: number;
+ date?: string;
+ categoryId?: string;
+ description?: string;
+ type?: 'one-time' | 'recurring';
+ startDate?: string;
+ endDate?: string;
+ receipt?: Blob;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'PUT',
+ url: '/expenses/{id}',
+ path: {
+ 'id': id,
+ },
+ formData: formData,
+ mediaType: 'multipart/form-data',
+ });
+ }
+ /**
+ * Delete an expense
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteExpenses(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'DELETE',
+ url: '/expenses/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * List all incomes
+ * @param start
+ * @param end
+ * @returns any List of incomes
+ * @throws ApiError
+ */
+ public static getIncomes(
+ start?: string,
+ end?: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/incomes',
+ query: {
+ 'start': start,
+ 'end': end,
+ },
+ });
+ }
+ /**
+ * Create new income
+ * @param requestBody
+ * @returns any Income created
+ * @throws ApiError
+ */
+ public static postIncomes(
+ requestBody?: {
+ amount: number;
+ date: string;
+ source?: string;
+ description?: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/incomes',
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * Get an income entry
+ * @param id
+ * @returns any Income data
+ * @throws ApiError
+ */
+ public static getIncomes1(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/incomes/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * Update an income entry
+ * @param id
+ * @param requestBody
+ * @returns any Income updated
+ * @throws ApiError
+ */
+ public static putIncomes(
+ id: string,
+ requestBody?: {
+ amount?: number;
+ date?: string;
+ source?: string;
+ description?: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'PUT',
+ url: '/incomes/{id}',
+ path: {
+ 'id': id,
+ },
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * Delete an income entry
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteIncomes(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'DELETE',
+ url: '/incomes/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * List user categories
+ * @returns any List of categories
+ * @throws ApiError
+ */
+ public static getCategories(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/categories',
+ });
+ }
+ /**
+ * Create new category
+ * @param requestBody
+ * @returns any Category created
+ * @throws ApiError
+ */
+ public static postCategories(
+ requestBody?: {
+ name: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/categories',
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * Rename a category
+ * @param id
+ * @param requestBody
+ * @returns any Category updated
+ * @throws ApiError
+ */
+ public static putCategories(
+ id: string,
+ requestBody?: {
+ name?: string;
+ },
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'PUT',
+ url: '/categories/{id}',
+ path: {
+ 'id': id,
+ },
+ body: requestBody,
+ mediaType: 'application/json',
+ });
+ }
+ /**
+ * Delete a category
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteCategories(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'DELETE',
+ url: '/categories/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * Get current month summary
+ * @param month
+ * @returns any Monthly summary
+ * @throws ApiError
+ */
+ public static getSummaryMonthly(
+ month?: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/summary/monthly',
+ query: {
+ 'month': month,
+ },
+ });
+ }
+ /**
+ * Get summary for custom range
+ * @param start
+ * @param end
+ * @returns any Summary
+ * @throws ApiError
+ */
+ public static getSummary(
+ start?: string,
+ end?: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/summary',
+ query: {
+ 'start': start,
+ 'end': end,
+ },
+ });
+ }
+ /**
+ * Budget overrun alert
+ * @returns any Alert info
+ * @throws ApiError
+ */
+ public static getSummaryAlerts(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/summary/alerts',
+ });
+ }
+ /**
+ * Download/view receipt
+ * @param id
+ * @returns any Receipt file
+ * @throws ApiError
+ */
+ public static getReceipts(
+ id: string,
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/receipts/{id}',
+ path: {
+ 'id': id,
+ },
+ });
+ }
+ /**
+ * Get user profile
+ * @returns any Profile info
+ * @throws ApiError
+ */
+ public static getUserProfile(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/user/profile',
+ });
+ }
+}
diff --git a/server/.env.example b/server/.env.example
index cb097c5..7e35b9b 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -3,7 +3,16 @@ PORT=8080
NODE_ENV=development
# Database Configuration
-# Replace with your actual database connection string
+# Prefer Vercel Postgres variables when deploying on Vercel.
+# For local dev, you can still use DATABASE_URL.
+# Vercel will inject these automatically when you add the Vercel Postgres integration.
+# POSTGRES_URL usually points to a pooled connection (safe to use with clients that do NOT create their own pool).
+# POSTGRES_URL_NON_POOLING is a direct connection string (use with clients that manage their own pool).
+POSTGRES_URL=
+POSTGRES_URL_NON_POOLING=
+POSTGRES_PRISMA_URL=
+
+# Fallback local connection string (used when POSTGRES_* are not present)
DATABASE_URL=postgres://:@localhost:5432/
# JWT Configuration
diff --git a/server/README.md b/server/README.md
index 77209e4..473c6a6 100644
--- a/server/README.md
+++ b/server/README.md
@@ -62,3 +62,13 @@ Key variables in `server/.env`:
- Logging via `morgan` (dev-friendly).
- Security headers via `helmet`.
- CORS configured via `CORS_ORIGIN`.
+
+OpenAPI Spec & Client Generation
+
+- Spec location: `server/docs/Expense Tracker API.yaml`
+- To regenerate the client SDK after spec changes:
+
+```bash
+cd ../client
+npm run api:generate
+```
diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml
new file mode 100644
index 0000000..10fe973
--- /dev/null
+++ b/server/docs/Expense Tracker API.yaml
@@ -0,0 +1,318 @@
+openapi: 3.0.0
+info:
+ title: Smart Personal Expense Tracker API
+ description: REST API for managing personal expenses, incomes, receipts, and categories.
+ version: 1.0.0
+servers:
+ - url: http://localhost:8080/api
+paths:
+ /auth/signup:
+ post:
+ summary: Register a new user
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [email, password]
+ properties:
+ email:
+ type: string
+ password:
+ type: string
+ responses:
+ '201':
+ description: User created
+ /auth/login:
+ post:
+ summary: Login and receive JWT token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [email, password]
+ properties:
+ email:
+ type: string
+ password:
+ type: string
+ responses:
+ '200':
+ description: Token returned
+ /expenses:
+ get:
+ summary: List all user expenses
+ parameters:
+ - in: query
+ name: start
+ schema:
+ type: string
+ description: Start date
+ - in: query
+ name: end
+ schema:
+ type: string
+ description: End date
+ - in: query
+ name: category
+ schema:
+ type: string
+ - in: query
+ name: type
+ schema:
+ type: string
+ enum: [recurring, one-time]
+ responses:
+ '200':
+ description: List of expenses
+ post:
+ summary: Create a new expense
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [amount, date, categoryId]
+ properties:
+ amount:
+ type: number
+ date:
+ type: string
+ categoryId:
+ type: string
+ description:
+ type: string
+ type:
+ type: string
+ enum: [one-time, recurring]
+ startDate:
+ type: string
+ endDate:
+ type: string
+ receipt:
+ type: string
+ format: binary
+ responses:
+ '201':
+ description: Expense created
+ /expenses/{id}:
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Get a single expense
+ responses:
+ '200':
+ description: Expense data
+ put:
+ summary: Update an expense
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ amount:
+ type: number
+ date:
+ type: string
+ categoryId:
+ type: string
+ description:
+ type: string
+ type:
+ type: string
+ enum: [one-time, recurring]
+ startDate:
+ type: string
+ endDate:
+ type: string
+ receipt:
+ type: string
+ format: binary
+ responses:
+ '200':
+ description: Expense updated
+ delete:
+ summary: Delete an expense
+ responses:
+ '204':
+ description: Expense deleted
+ /incomes:
+ get:
+ summary: List all incomes
+ parameters:
+ - in: query
+ name: start
+ schema:
+ type: string
+ - in: query
+ name: end
+ schema:
+ type: string
+ responses:
+ '200':
+ description: List of incomes
+ post:
+ summary: Create new income
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [amount, date]
+ properties:
+ amount:
+ type: number
+ date:
+ type: string
+ source:
+ type: string
+ description:
+ type: string
+ responses:
+ '201':
+ description: Income created
+ /incomes/{id}:
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ get:
+ summary: Get an income entry
+ responses:
+ '200':
+ description: Income data
+ put:
+ summary: Update an income entry
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ amount:
+ type: number
+ date:
+ type: string
+ source:
+ type: string
+ description:
+ type: string
+ responses:
+ '200':
+ description: Income updated
+ delete:
+ summary: Delete an income entry
+ responses:
+ '204':
+ description: Income deleted
+ /categories:
+ get:
+ summary: List user categories
+ responses:
+ '200':
+ description: List of categories
+ post:
+ summary: Create new category
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [name]
+ properties:
+ name:
+ type: string
+ responses:
+ '201':
+ description: Category created
+ /categories/{id}:
+ put:
+ summary: Rename a category
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ responses:
+ '200':
+ description: Category updated
+ delete:
+ summary: Delete a category
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Category deleted
+ /summary/monthly:
+ get:
+ summary: Get current month summary
+ parameters:
+ - in: query
+ name: month
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Monthly summary
+ /summary:
+ get:
+ summary: Get summary for custom range
+ parameters:
+ - in: query
+ name: start
+ schema:
+ type: string
+ - in: query
+ name: end
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Summary
+ /summary/alerts:
+ get:
+ summary: Budget overrun alert
+ responses:
+ '200':
+ description: Alert info
+ /receipts/{id}:
+ get:
+ summary: Download/view receipt
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Receipt file
+ /user/profile:
+ get:
+ summary: Get user profile
+ responses:
+ '200':
+ description: Profile info
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
index 07c0449..97e318d 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -9,16 +9,85 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "@prisma/client": "^5.18.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
- "pg": "^8.11.3",
- "pg-pool": "^3.6.1"
+ "pg": "^8.11.3"
},
"devDependencies": {
- "nodemon": "^3.0.2"
+ "nodemon": "^3.0.2",
+ "prisma": "^5.18.0"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
}
},
"node_modules/accepts": {
@@ -1080,6 +1149,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
diff --git a/server/package.json b/server/package.json
index 38a6804..93b907e 100644
--- a/server/package.json
+++ b/server/package.json
@@ -7,7 +7,11 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "prisma:generate": "prisma generate",
+ "prisma:migrate": "prisma migrate dev",
+ "prisma:deploy": "prisma migrate deploy",
+ "prisma:studio": "prisma studio"
},
"keywords": ["express", "expense-tracker", "api"],
"author": "",
@@ -18,9 +22,11 @@
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
- "pg": "^8.11.3"
+ "pg": "^8.11.3",
+ "@prisma/client": "^5.18.0"
},
"devDependencies": {
- "nodemon": "^3.0.2"
+ "nodemon": "^3.0.2",
+ "prisma": "^5.18.0"
}
}
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
new file mode 100644
index 0000000..bf32d34
--- /dev/null
+++ b/server/prisma/schema.prisma
@@ -0,0 +1,11 @@
+// Prisma schema for Expense Tracker (initial scaffold)
+// No models yet; MCD pending. You can add models later.
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("POSTGRES_PRISMA_URL")
+}
diff --git a/server/server.js b/server/server.js
index cafe86e..dd6bced 100644
--- a/server/server.js
+++ b/server/server.js
@@ -1,6 +1,7 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
+import { PrismaClient } from '@prisma/client';
dotenv.config();
@@ -10,6 +11,9 @@ const PORT = process.env.PORT || 8080;
app.use(cors());
app.use(express.json());
+// Initialize a single Prisma client instance
+const prisma = new PrismaClient();
+
app.get('/', (req, res) => {
res.json({ message: 'Expense Tracker API', status: 'running' });
});
@@ -18,6 +22,19 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'OK' });
});
+// DB health check using Prisma raw query; does not require any models
+app.get('/api/db-check', async (_req, res) => {
+ try {
+ const result = await prisma.$queryRaw`SELECT NOW() as now`;
+ // Result shape differs by driver; normalize to { now }
+ const now = Array.isArray(result) ? result[0]?.now ?? result[0]?.NOW ?? result[0] : result?.now ?? result;
+ res.json({ ok: true, now });
+ } catch (err) {
+ console.error('DB check failed:', err);
+ res.status(500).json({ ok: false, error: 'DB connection failed' });
+ }
+});
+
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});