├── .gitIgnore ├── README.md ├── TECHWITHEMMA-LICENSE.md ├── backend ├── package-lock.json ├── package.json ├── src │ ├── @types │ │ ├── analytics.type.ts │ │ ├── index.d.ts │ │ └── report.type.ts │ ├── config │ │ ├── cloudinary.config.ts │ │ ├── database.config.ts │ │ ├── env.config.ts │ │ ├── google-ai.config.ts │ │ ├── http.config.ts │ │ ├── passport.config.ts │ │ └── resend.config.ts │ ├── controllers │ │ ├── analytics.controller.ts │ │ ├── auth.controller.ts │ │ ├── report.controller.ts │ │ ├── transaction.controller.ts │ │ └── user.controller.ts │ ├── cron │ │ ├── index.ts │ │ ├── jobs │ │ │ ├── report.job.ts │ │ │ └── transaction.job.ts │ │ └── scheduler.ts │ ├── enums │ │ ├── date-range.enum.ts │ │ └── error-code.enum.ts │ ├── index.ts │ ├── mailers │ │ ├── mailer.ts │ │ ├── report.mailer.ts │ │ └── templates │ │ │ └── report.template.ts │ ├── middlewares │ │ ├── asyncHandler.middlerware.ts │ │ └── errorHandler.middleware.ts │ ├── models │ │ ├── report-setting.model.ts │ │ ├── report.model.ts │ │ ├── transaction.model.ts │ │ └── user.model.ts │ ├── routes │ │ ├── analytics.route.ts │ │ ├── auth.route.ts │ │ ├── report.route.ts │ │ ├── transaction.route.ts │ │ └── user.route.ts │ ├── services │ │ ├── analytics.service.ts │ │ ├── auth.service.ts │ │ ├── report.service.ts │ │ ├── transaction.service.ts │ │ └── user.service.ts │ ├── utils │ │ ├── app-error.ts │ │ ├── bcrypt.ts │ │ ├── date.ts │ │ ├── format-currency.ts │ │ ├── get-env.ts │ │ ├── helper.ts │ │ ├── jwt.ts │ │ └── prompt.ts │ └── validators │ │ ├── auth.validator.ts │ │ ├── report.validator.ts │ │ ├── transaction.validator.ts │ │ └── user.validator.ts └── tsconfig.json └── client ├── .env ├── .gitignore ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── src ├── @types │ └── transaction.type.ts ├── App.tsx ├── app │ ├── api-client.ts │ ├── hook.ts │ └── store.ts ├── assets │ ├── images │ │ ├── dashboard.png │ │ ├── dashboard_.png │ │ └── dashboard_dark.png │ └── react.svg ├── components │ ├── app-alert.tsx │ ├── data-table │ │ ├── index.tsx │ │ ├── table-pagination.tsx │ │ └── table-skeleton-loader.tsx │ ├── date-range-picker │ │ └── index.tsx │ ├── date-range-select │ │ └── index.tsx │ ├── empty-state │ │ └── index.tsx │ ├── footer │ │ └── index.tsx │ ├── logo │ │ └── logo.tsx │ ├── navbar │ │ ├── index.tsx │ │ ├── logout-dialog.tsx │ │ └── user-nav.tsx │ ├── page-header.tsx │ ├── page-layout.tsx │ ├── transaction │ │ ├── add-transaction-drawer.tsx │ │ ├── edit-transaction-drawer.tsx │ │ ├── import-transaction-modal │ │ │ ├── column-mapping-step.tsx │ │ │ ├── confirmation-step.tsx │ │ │ ├── fileupload-step.tsx │ │ │ └── index.tsx │ │ ├── reciept-scanner.tsx │ │ ├── transaction-form.tsx │ │ └── transaction-table │ │ │ ├── column.tsx │ │ │ ├── data.ts │ │ │ └── index.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── currency-input.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── single-select.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ └── toggle.tsx ├── constant │ └── index.ts ├── context │ └── theme-provider.tsx ├── data │ ├── Transaction_File - sample_transactions.csv │ └── Updated_Transaction_FIle.csv ├── features │ ├── analytics │ │ ├── analyticsAPI.ts │ │ └── anayticsType.ts │ ├── auth │ │ ├── authAPI.ts │ │ ├── authSlice.ts │ │ └── authType.ts │ ├── report │ │ ├── reportAPI.ts │ │ └── reportType.ts │ ├── transaction │ │ ├── transactionAPI.ts │ │ └── transationType.ts │ └── user │ │ ├── userAPI.ts │ │ └── userType.ts ├── hooks │ ├── use-auth-expiration.ts │ ├── use-debounce-search.ts │ ├── use-edit-transaction-drawer.ts │ ├── use-mobile.ts │ └── use-progress-loader.ts ├── index.css ├── layouts │ ├── app-layout.tsx │ └── base-layout.tsx ├── lib │ ├── format-currency.ts │ ├── format-percentage.ts │ └── utils.ts ├── main.tsx ├── pages │ ├── auth │ │ ├── _component │ │ │ ├── signin-form.tsx │ │ │ └── signup-form.tsx │ │ ├── sign-in.tsx │ │ └── sign-up.tsx │ ├── dashboard │ │ ├── _component │ │ │ ├── dashboard-header.tsx │ │ │ ├── dashboard-stats.tsx │ │ │ └── summary-card.tsx │ │ ├── dashboard-data-chart.tsx │ │ ├── dashboard-recent-transactions.tsx │ │ ├── dashboard-summary.tsx │ │ ├── expense-pie-chart.tsx │ │ └── index.tsx │ ├── reports │ │ ├── _component │ │ │ ├── column.tsx │ │ │ ├── data.ts │ │ │ ├── report-table.tsx │ │ │ ├── schedule-report-drawer.tsx │ │ │ └── schedule-report-form.tsx │ │ └── index.tsx │ ├── settings │ │ ├── _components │ │ │ ├── account-form.tsx │ │ │ ├── appearance-theme.tsx │ │ │ └── billing-plan-card.tsx │ │ ├── account.tsx │ │ ├── appearance.tsx │ │ ├── billing.tsx │ │ └── index.tsx │ └── transactions │ │ └── index.tsx ├── routes │ ├── authRoute.tsx │ ├── common │ │ ├── routePath.tsx │ │ └── routes.tsx │ ├── index.tsx │ └── protectedRoute.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitIgnore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌟 Advanced MERN AI Finance SaaS Platform - Finora 2 | 3 | > This code, whether in parts or whole, is licensed for commercial use **only with a license**. It is **free for personal use**. 4 | > 👉 [Click here to obtain license](https://techwithemma.gumroad.com/l/huytmd) and 👉 [here to learn more](https://github.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/blob/main/TECHWITHEMMA-LICENSE.md) 5 | 6 | ## ❤️ Support the Channel 7 | 8 | Love this project? Here’s how you can support: 9 | 10 | * ☕ [Buy Me a Coffee](https://buymeacoffee.com/techwithemmaofficial) 11 | * 🌟 Star this repo 12 | * 🎥 [Subscribe on YouTube](https://tinyurl.com/subcribe-to-techwithEmma) 13 | 14 | --- 15 | 16 | ## 🗝️ Key Features: 👇 17 | 18 | * 🔐 Authentication (Email + Password with JWT) 19 | * 🏢 Create & Edit Transactions 20 | * 📤 Upload & Scan Receipt with AI 21 | * 📈 Beautiful Advanced Analytics (MongoDB Aggregate Pipeline) 22 | * 📊 Expenses Breakdown Pie Chart 23 | * 📈 Income & Expense Line Chart 24 | * 📅 Filter by Date Ranges — like Last 30 Days etc. 25 | * ♻️ Recurring Transactions with Cron Job 26 | * 📄 Auto-Generated Monthly Report (Emailed to User) 27 | * 📥 CSV transaction Import 28 | * 🔍 Filter & Search 29 | * 📅 Pagination 30 | * 🗑️ Bulk Delete 31 | * ➕ Duplicate Transactions 32 | * 🧑‍💼 Upload Profile Photo (Cloudinary) 33 | * 🌐 Built with MERN Stack (Node.js, MongoDB, React, TypeScript) 34 | 35 | - 💳 [Premium] Upgrades via Stripe — Free Trial, Monthly & Yearly Plans, Easy Plan Switching 👉 [Get It Here](https://techwithemma.gumroad.com/l/gasvc) 36 | 37 | 38 | 39 | ## 🔧 How to Use This Project 40 | 41 | ### 📺 Step 1: Watch the Complete Full Course on YouTube 42 | 43 | > Learn how it all works, including the folder structure, integration, AI config, and more. 44 | > 👉 [Watch the Course](https://www.youtube.com/watch?v=2S7Y2wewF6I) 45 | 46 | 47 | 48 | ### 💻 Step 2: Run It Locally, Setup Video, Live Preview 49 | 50 | > Want to run this project on your own machine? We've got you covered: 51 | 👉 [Setup & Live Preview Link](https://techwithemma.gumroad.com/l/nphhyz) 52 | 53 | 54 | 55 | ### 🚀 [Step 3]: Get the Extended Version — Stripe Payment Video (Free Trial), Full Source Code, Deployment & More. 56 | 57 | This is the missing piece — the Stripe payment that powers your SaaS. 58 | 👉 [Get the Extended Version](https://techwithemma.gumroad.com/l/gasvc) 59 | 60 | * Free Trial + Monthly & Yearly Plan 61 | * Switch between Monthly ↔️ Yearly Plan 62 | * Full Stripe Integration & Webhooks 63 | * Setup Video (Run locally) 64 | * Complete Full Source Code 65 | * Plus Support 66 | --- 67 | 68 | ## 📜 License Information 69 | 70 | A paid license is required for commercial use. To obtain a commercial license, please visit 👉 [Here](https://techwithemma.gumroad.com/l/huytmd) 71 | 72 | For more details about license, please refer to the [TECHWITHEMMA-LICENSE.md](https://github.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/blob/main/TECHWITHEMMA-LICENSE.md). 73 | 74 | --- 75 | 76 | # 📺 Subscribe for More Projects 77 | 78 | If you find this helpful, support by subscribing and sharing: 79 | 80 | 🔗 [https://tinyurl.com/subcribe-to-techwithEmma](https://tinyurl.com/subcribe-to-techwithEmma) 81 | -------------------------------------------------------------------------------- /TECHWITHEMMA-LICENSE.md: -------------------------------------------------------------------------------- 1 | # Techwithemma License Terms 2 | 3 | ## License Requirement 4 | 5 | If you intend to generate revenue using our intellectual property, you are required to obtain a valid license. A free license is included when you purchase the codebase. Below are instances where a license is required: 6 | 7 | - Operating as a business entity 8 | - Deploying the project as a Software as a Service (SaaS) 9 | - Generating any form of income using the codebase 10 | - Reselling or redistributing the code 11 | - Utilizing the project for content creation 12 | - Claiming ownership of the codebase 13 | 14 | ### When a License is NOT Required 15 | 16 | You do not need a license in the following cases: 17 | 18 | - Using the codebase for personal learning purposes 19 | - Incorporating it into a portfolio project 20 | - Enhancing your coding skills to secure employment opportunities 21 | 22 | We aim to keep our licensing policy straightforward and transparent. 23 | 24 | ## Intellectual Property Rights 25 | 26 | By purchasing a license, you are granted a **non-exclusive, non-transferable** right to use the code. However, you do not own any rights to the original intellectual property, including but not limited to the code, design, assets, and ideas. Redistribution or resale of the codebase, in part or whole, is strictly prohibited. 27 | 28 | ### Restrictions 29 | 30 | - You **may not** claim the code as your own. 31 | - You **may not** resell, distribute, or share the codebase with third parties. 32 | - You **may not** use the code to create tutorials, courses, or similar educational content based on Techwithemma’s projects without prior approval. 33 | 34 | ## Scope of License 35 | 36 | Each license is applicable **only** to the specific project you have purchased. Purchasing a single license **does not** grant you rights to other projects, past or future. 37 | 38 | ## Non-Compete Clause 39 | 40 | You are prohibited from creating educational content (such as tutorials, courses, or SaaS-related guides) based on our projects without explicit written permission. 41 | 42 | ## Liability Disclaimer 43 | 44 | By using our codebase, you acknowledge that it was originally created as an educational project. The code is provided **as is**, and Techwithemma assumes no responsibility for its suitability in production environments. It is your responsibility to ensure that the project meets your technical and business requirements. 45 | 46 | Techwithemma is not liable for any damages, financial losses, or legal issues that arise from the use of this codebase. 47 | 48 | ## Non-Exclusive License 49 | 50 | Your license is non-exclusive, meaning other users may also purchase and utilize the same codebase under similar terms. 51 | 52 | ## Refund Policy 53 | 54 | Due to the nature of digital products, **all sales are final, and refunds are not provided**. Please ensure you have reviewed the project and its suitability before making a purchase. 55 | 56 | ## Third-Party Tools & Integrations 57 | 58 | You acknowledge that the project may include dependencies, third-party integrations, external packages, or assets. Techwithemma does not provide support for external dependencies, and it is your responsibility to comply with their respective licensing terms. 59 | 60 | ## Agreement Acceptance 61 | 62 | By purchasing a license, you agree to all terms and conditions outlined in this agreement. These terms are legally binding and enforceable. -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "dev": "ts-node-dev --files src/index.ts", 7 | "build": "tsc && cp ./package.json ./dist", 8 | "start": "node dist/index.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "dependencies": { 15 | "@google/genai": "^0.13.0", 16 | "axios": "^1.9.0", 17 | "bcrypt": "^5.1.1", 18 | "cloudinary": "^1.41.3", 19 | "cookie-parser": "^1.4.7", 20 | "cors": "^2.8.5", 21 | "date-fns": "^4.1.0", 22 | "dotenv": "^16.5.0", 23 | "express": "^5.1.0", 24 | "helmet": "^8.1.0", 25 | "jsonwebtoken": "^9.0.2", 26 | "mongoose": "^8.15.1", 27 | "multer": "^1.4.5-lts.2", 28 | "multer-storage-cloudinary": "^4.0.0", 29 | "node-cron": "^3.0.3", 30 | "passport": "^0.7.0", 31 | "passport-jwt": "^4.0.1", 32 | "resend": "^4.5.1", 33 | "uuid": "^11.1.0", 34 | "zod": "^3.25.46" 35 | }, 36 | "devDependencies": { 37 | "@types/bcrypt": "^5.0.2", 38 | "@types/cookie-parser": "^1.4.8", 39 | "@types/cors": "^2.8.18", 40 | "@types/dotenv": "^6.1.1", 41 | "@types/express": "^5.0.2", 42 | "@types/mongoose": "^5.11.96", 43 | "@types/multer": "^1.4.12", 44 | "@types/node": "^22.15.29", 45 | "@types/node-cron": "^3.0.11", 46 | "@types/passport": "^1.0.17", 47 | "@types/passport-jwt": "^4.0.1", 48 | "ts-node-dev": "^2.0.0", 49 | "typescript": "^5.8.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/@types/analytics.type.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/8663cdde75ee93da1b7dfe2aa5ea5548abe7e046/backend/src/@types/analytics.type.ts -------------------------------------------------------------------------------- /backend/src/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { UserDocument } from "../models/user.model"; 2 | 3 | declare global { 4 | namespace Express { 5 | interface User extends UserDocument { 6 | _id?: any; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/@types/report.type.ts: -------------------------------------------------------------------------------- 1 | export type ReportType = { 2 | period: string; 3 | totalIncome: number; 4 | totalExpenses: number; 5 | availableBalance: number; 6 | savingsRate: number; 7 | topSpendingCategories: Array<{ name: string; percent: number }>; 8 | insights: string[]; 9 | }; 10 | -------------------------------------------------------------------------------- /backend/src/config/cloudinary.config.ts: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from "cloudinary"; 2 | import { CloudinaryStorage } from "multer-storage-cloudinary"; 3 | import { Env } from "./env.config"; 4 | import multer from "multer"; 5 | 6 | cloudinary.config({ 7 | cloud_name: Env.CLOUDINARY_CLOUD_NAME, 8 | api_key: Env.CLOUDINARY_API_KEY, 9 | api_secret: Env.CLOUDINARY_API_SECRET, 10 | }); 11 | 12 | const STORAGE_PARAMS = { 13 | folder: "images", 14 | allowed_formats: ["jpg", "png", "jpeg"], 15 | rescource_type: "image" as const, 16 | quality: "auto:good" as const, 17 | }; 18 | 19 | const storage = new CloudinaryStorage({ 20 | cloudinary, 21 | params: (req, file) => ({ 22 | ...STORAGE_PARAMS, 23 | }), 24 | }); 25 | 26 | export const upload = multer({ 27 | storage, 28 | limits: { fileSize: 2 * 1024 * 1024, files: 1 }, 29 | fileFilter: (_, file, cb) => { 30 | const isValid = /^image\/(jpe?g|png)$/.test(file.mimetype); 31 | if (!isValid) { 32 | return; 33 | } 34 | 35 | cb(null, true); 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /backend/src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { Env } from "./env.config"; 3 | 4 | const connctDatabase = async () => { 5 | try { 6 | await mongoose.connect(Env.MONGO_URI, { 7 | serverSelectionTimeoutMS: 8000, 8 | socketTimeoutMS: 45000, 9 | connectTimeoutMS: 30000, 10 | }); 11 | console.log("Connected to MongoDB database"); 12 | } catch (error) { 13 | console.error("Error connecting to MongoDB database:", error); 14 | process.exit(1); 15 | } 16 | }; 17 | 18 | export default connctDatabase; 19 | -------------------------------------------------------------------------------- /backend/src/config/env.config.ts: -------------------------------------------------------------------------------- 1 | import { getEnv } from "../utils/get-env"; 2 | 3 | const envConfig = () => ({ 4 | NODE_ENV: getEnv("NODE_ENV", "development"), 5 | 6 | PORT: getEnv("PORT", "8000"), 7 | BASE_PATH: getEnv("BASE_PATH", "/api"), 8 | MONGO_URI: getEnv("MONGO_URI"), 9 | 10 | JWT_SECRET: getEnv("JWT_SECRET", "secert_jwt"), 11 | JWT_EXPIRES_IN: getEnv("JWT_EXPIRES_IN", "15m") as string, 12 | 13 | JWT_REFRESH_SECRET: getEnv("JWT_REFRESH_SECRET", "secert_jwt_refresh"), 14 | JWT_REFRESH_EXPIRES_IN: getEnv("JWT_REFRESH_EXPIRES_IN", "7d") as string, 15 | 16 | GEMINI_API_KEY: getEnv("GEMINI_API_KEY"), 17 | 18 | CLOUDINARY_CLOUD_NAME: getEnv("CLOUDINARY_CLOUD_NAME"), 19 | CLOUDINARY_API_KEY: getEnv("CLOUDINARY_API_KEY"), 20 | CLOUDINARY_API_SECRET: getEnv("CLOUDINARY_API_SECRET"), 21 | 22 | RESEND_API_KEY: getEnv("RESEND_API_KEY"), 23 | RESEND_MAILER_SENDER: getEnv("RESEND_MAILER_SENDER", ""), 24 | 25 | FRONTEND_ORIGIN: getEnv("FRONTEND_ORIGIN", "localhost"), 26 | }); 27 | 28 | export const Env = envConfig(); 29 | -------------------------------------------------------------------------------- /backend/src/config/google-ai.config.ts: -------------------------------------------------------------------------------- 1 | import { GoogleGenAI } from "@google/genai"; 2 | import { Env } from "./env.config"; 3 | 4 | export const genAI = new GoogleGenAI({ apiKey: Env.GEMINI_API_KEY }); 5 | export const genAIModel = "gemini-2.0-flash"; 6 | -------------------------------------------------------------------------------- /backend/src/config/http.config.ts: -------------------------------------------------------------------------------- 1 | const httpConfig = () => ({ 2 | OK: 200, 3 | CREATED: 201, 4 | ACCEPTED: 202, 5 | NO_CONTENT: 204, 6 | // Client error responses 7 | BAD_REQUEST: 400, 8 | UNAUTHORIZED: 401, 9 | FORBIDDEN: 403, 10 | NOT_FOUND: 404, 11 | METHOD_NOT_ALLOWED: 405, 12 | CONFLICT: 409, 13 | UNPROCESSABLE_ENTITY: 422, 14 | TOO_MANY_REQUESTS: 429, 15 | 16 | // Server error responses 17 | INTERNAL_SERVER_ERROR: 500, 18 | NOT_IMPLEMENTED: 501, 19 | BAD_GATEWAY: 502, 20 | SERVICE_UNAVAILABLE: 503, 21 | GATEWAY_TIMEOUT: 504, 22 | }); 23 | 24 | export const HTTPSTATUS = httpConfig(); 25 | 26 | export type HttpStatusCodeType = (typeof HTTPSTATUS)[keyof typeof HTTPSTATUS]; 27 | -------------------------------------------------------------------------------- /backend/src/config/passport.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Strategy as JwtStrategy, 3 | ExtractJwt, 4 | StrategyOptions, 5 | } from "passport-jwt"; 6 | import passport from "passport"; 7 | import { Env } from "./env.config"; 8 | import { findByIdUserService } from "../services/user.service"; 9 | 10 | interface JwtPayload { 11 | userId: string; 12 | } 13 | 14 | const options: StrategyOptions = { 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | secretOrKey: Env.JWT_SECRET, 17 | audience: ["user"], 18 | algorithms: ["HS256"], 19 | }; 20 | 21 | passport.use( 22 | new JwtStrategy(options, async (payload: JwtPayload, done) => { 23 | try { 24 | if (!payload.userId) { 25 | return done(null, false, { message: "Invalid token payload" }); 26 | } 27 | 28 | const user = await findByIdUserService(payload.userId); 29 | if (!user) { 30 | return done(null, false); 31 | } 32 | 33 | return done(null, user); 34 | } catch (error) { 35 | return done(error, false); 36 | } 37 | }) 38 | ); 39 | 40 | passport.serializeUser((user: any, done) => done(null, user)); 41 | passport.deserializeUser((user: any, done) => done(null, user)); 42 | 43 | export const passportAuthenticateJwt = passport.authenticate("jwt", { 44 | session: false, 45 | }); 46 | -------------------------------------------------------------------------------- /backend/src/config/resend.config.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | import { Env } from "./env.config"; 3 | 4 | export const resend = new Resend(Env.RESEND_API_KEY); 5 | -------------------------------------------------------------------------------- /backend/src/controllers/analytics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { asyncHandler } from "../middlewares/asyncHandler.middlerware"; 3 | import { HTTPSTATUS } from "../config/http.config"; 4 | import { DateRangePreset } from "../enums/date-range.enum"; 5 | import { 6 | chartAnalyticsService, 7 | expensePieChartBreakdownService, 8 | summaryAnalyticsService, 9 | } from "../services/analytics.service"; 10 | 11 | export const summaryAnalyticsController = asyncHandler( 12 | async (req: Request, res: Response) => { 13 | const userId = req.user?._id; 14 | 15 | const { preset, from, to } = req.query; 16 | 17 | const filter = { 18 | dateRangePreset: preset as DateRangePreset, 19 | customFrom: from ? new Date(from as string) : undefined, 20 | customTo: to ? new Date(to as string) : undefined, 21 | }; 22 | const stats = await summaryAnalyticsService( 23 | userId, 24 | filter.dateRangePreset, 25 | filter.customFrom, 26 | filter.customTo 27 | ); 28 | 29 | return res.status(HTTPSTATUS.OK).json({ 30 | message: "Summary fetched successfully", 31 | data: stats, 32 | }); 33 | } 34 | ); 35 | 36 | export const chartAnalyticsController = asyncHandler( 37 | async (req: Request, res: Response) => { 38 | const userId = req.user?._id; 39 | const { preset, from, to } = req.query; 40 | 41 | const filter = { 42 | dateRangePreset: preset as DateRangePreset, 43 | customFrom: from ? new Date(from as string) : undefined, 44 | customTo: to ? new Date(to as string) : undefined, 45 | }; 46 | 47 | const chartData = await chartAnalyticsService( 48 | userId, 49 | filter.dateRangePreset, 50 | filter.customFrom, 51 | filter.customTo 52 | ); 53 | 54 | return res.status(HTTPSTATUS.OK).json({ 55 | message: "Chart fetched successfully", 56 | data: chartData, 57 | }); 58 | } 59 | ); 60 | 61 | export const expensePieChartBreakdownController = asyncHandler( 62 | async (req: Request, res: Response) => { 63 | const userId = req.user?._id; 64 | const { preset, from, to } = req.query; 65 | 66 | const filter = { 67 | dateRangePreset: preset as DateRangePreset, 68 | customFrom: from ? new Date(from as string) : undefined, 69 | customTo: to ? new Date(to as string) : undefined, 70 | }; 71 | const pieChartData = await expensePieChartBreakdownService( 72 | userId, 73 | filter.dateRangePreset, 74 | filter.customFrom, 75 | filter.customTo 76 | ); 77 | 78 | return res.status(HTTPSTATUS.OK).json({ 79 | message: "Expense breakdown fetched successfully", 80 | data: pieChartData, 81 | }); 82 | } 83 | ); 84 | -------------------------------------------------------------------------------- /backend/src/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { HTTPSTATUS } from "../config/http.config"; 3 | import { asyncHandler } from "../middlewares/asyncHandler.middlerware"; 4 | import { loginSchema, registerSchema } from "../validators/auth.validator"; 5 | import { loginService, registerService } from "../services/auth.service"; 6 | 7 | export const registerController = asyncHandler( 8 | async (req: Request, res: Response) => { 9 | const body = registerSchema.parse(req.body); 10 | 11 | const result = await registerService(body); 12 | 13 | return res.status(HTTPSTATUS.CREATED).json({ 14 | message: "User registered successfully", 15 | data: result, 16 | }); 17 | } 18 | ); 19 | 20 | export const loginController = asyncHandler( 21 | async (req: Request, res: Response) => { 22 | const body = loginSchema.parse({ 23 | ...req.body, 24 | }); 25 | const { user, accessToken, expiresAt, reportSetting } = 26 | await loginService(body); 27 | 28 | return res.status(HTTPSTATUS.OK).json({ 29 | message: "User logged in successfully", 30 | user, 31 | accessToken, 32 | expiresAt, 33 | reportSetting, 34 | }); 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /backend/src/controllers/report.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { asyncHandler } from "../middlewares/asyncHandler.middlerware"; 3 | import { HTTPSTATUS } from "../config/http.config"; 4 | import { 5 | generateReportService, 6 | getAllReportsService, 7 | updateReportSettingService, 8 | } from "../services/report.service"; 9 | import { updateReportSettingSchema } from "../validators/report.validator"; 10 | 11 | export const getAllReportsController = asyncHandler( 12 | async (req: Request, res: Response) => { 13 | const userId = req.user?._id; 14 | 15 | const pagination = { 16 | pageSize: parseInt(req.query.pageSize as string) || 20, 17 | pageNumber: parseInt(req.query.pageNumber as string) || 1, 18 | }; 19 | 20 | const result = await getAllReportsService(userId, pagination); 21 | 22 | return res.status(HTTPSTATUS.OK).json({ 23 | message: "Reports history fetched successfully", 24 | ...result, 25 | }); 26 | } 27 | ); 28 | 29 | export const updateReportSettingController = asyncHandler( 30 | async (req: Request, res: Response) => { 31 | const userId = req.user?._id; 32 | const body = updateReportSettingSchema.parse(req.body); 33 | 34 | await updateReportSettingService(userId, body); 35 | 36 | return res.status(HTTPSTATUS.OK).json({ 37 | message: "Reports setting updated successfully", 38 | }); 39 | } 40 | ); 41 | 42 | export const generateReportController = asyncHandler( 43 | async (req: Request, res: Response) => { 44 | const userId = req.user?._id; 45 | const { from, to } = req.query; 46 | const fromDate = new Date(from as string); 47 | const toDate = new Date(to as string); 48 | 49 | const result = await generateReportService(userId, fromDate, toDate); 50 | 51 | return res.status(HTTPSTATUS.OK).json({ 52 | message: "Report generated successfully", 53 | ...result, 54 | }); 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /backend/src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { asyncHandler } from "../middlewares/asyncHandler.middlerware"; 3 | import { 4 | findByIdUserService, 5 | updateUserService, 6 | } from "../services/user.service"; 7 | import { HTTPSTATUS } from "../config/http.config"; 8 | import { updateUserSchema } from "../validators/user.validator"; 9 | 10 | export const getCurrentUserController = asyncHandler( 11 | async (req: Request, res: Response) => { 12 | const userId = req.user?._id; 13 | 14 | const user = await findByIdUserService(userId); 15 | return res.status(HTTPSTATUS.OK).json({ 16 | message: "User fetched successfully", 17 | user, 18 | }); 19 | } 20 | ); 21 | 22 | export const updateUserController = asyncHandler( 23 | async (req: Request, res: Response) => { 24 | const body = updateUserSchema.parse(req.body); 25 | const userId = req.user?._id; 26 | const profilePic = req.file; 27 | 28 | const user = await updateUserService(userId, body, profilePic); 29 | 30 | return res.status(HTTPSTATUS.OK).json({ 31 | message: "User profile updated successfully", 32 | data: user, 33 | }); 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /backend/src/cron/index.ts: -------------------------------------------------------------------------------- 1 | import { startJobs } from "./scheduler"; 2 | 3 | export const initializeCrons = async () => { 4 | try { 5 | const jobs = startJobs(); 6 | console.log(`⏰ ${jobs.length} cron jobs intialized`); 7 | return jobs; 8 | } catch (error) { 9 | console.error("CRON INIT ERROR:", error); 10 | return []; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /backend/src/cron/jobs/transaction.job.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import TransactionModel from "../../models/transaction.model"; 3 | import { calculateNextOccurrence } from "../../utils/helper"; 4 | 5 | export const processRecurringTransactions = async () => { 6 | const now = new Date(); 7 | let processedCount = 0; 8 | let failedCount = 0; 9 | 10 | try { 11 | const transactionCursor = TransactionModel.find({ 12 | isRecurring: true, 13 | nextRecurringDate: { $lte: now }, 14 | }).cursor(); 15 | 16 | console.log("Starting recurring proccess"); 17 | 18 | for await (const tx of transactionCursor) { 19 | const nextDate = calculateNextOccurrence( 20 | tx.nextRecurringDate!, 21 | tx.recurringInterval! 22 | ); 23 | 24 | const session = await mongoose.startSession(); 25 | try { 26 | await session.withTransaction( 27 | async () => { 28 | // console.log(tx, "transaction"); 29 | await TransactionModel.create( 30 | [ 31 | { 32 | ...tx.toObject(), 33 | _id: new mongoose.Types.ObjectId(), 34 | title: `Recurring - ${tx.title}`, 35 | date: tx.nextRecurringDate, 36 | isRecurring: false, 37 | nextRecurringDate: null, 38 | recurringInterval: null, 39 | lastProcessed: null, 40 | createdAt: undefined, 41 | updatedAt: undefined, 42 | }, 43 | ], 44 | { session } 45 | ); 46 | 47 | await TransactionModel.updateOne( 48 | { _id: tx._id }, 49 | { 50 | $set: { 51 | nextRecurringDate: nextDate, 52 | lastProcessed: now, 53 | }, 54 | }, 55 | { session } 56 | ); 57 | }, 58 | { 59 | maxCommitTimeMS: 20000, 60 | } 61 | ); 62 | 63 | processedCount++; 64 | } catch (error: any) { 65 | failedCount++; 66 | console.log(`Failed reccurring tx: ${tx._id}`, error); 67 | } finally { 68 | await session.endSession(); 69 | } 70 | } 71 | 72 | console.log(`✅Processed: ${processedCount} transaction`); 73 | console.log(`❌ Failed: ${failedCount} transaction`); 74 | 75 | return { 76 | success: true, 77 | processedCount, 78 | failedCount, 79 | }; 80 | } catch (error: any) { 81 | console.error("Error occur processing transaction", error); 82 | 83 | return { 84 | success: false, 85 | error: error?.message, 86 | }; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /backend/src/cron/scheduler.ts: -------------------------------------------------------------------------------- 1 | import cron from "node-cron"; 2 | import { processRecurringTransactions } from "./jobs/transaction.job"; 3 | import { processReportJob } from "./jobs/report.job"; 4 | 5 | const scheduleJob = (name: string, time: string, job: Function) => { 6 | console.log(`Scheduling ${name} at ${time}`); 7 | 8 | return cron.schedule( 9 | time, 10 | async () => { 11 | try { 12 | await job(); 13 | console.log(`${name} completed`); 14 | } catch (error) { 15 | console.log(`${name} failed`, error); 16 | } 17 | }, 18 | { 19 | scheduled: true, 20 | timezone: "UTC", 21 | } 22 | ); 23 | }; 24 | 25 | export const startJobs = () => { 26 | return [ 27 | scheduleJob("Transactions", "5 0 * * *", processRecurringTransactions), 28 | 29 | //run 2:30am every first of the month 30 | scheduleJob("Reports", "30 2 1 * *", processReportJob), 31 | ]; 32 | }; 33 | -------------------------------------------------------------------------------- /backend/src/enums/date-range.enum.ts: -------------------------------------------------------------------------------- 1 | export enum DateRangeEnum { 2 | LAST_30_DAYS = "30days", 3 | LAST_MONTH = "lastMonth", 4 | LAST_3_MONTHS = "last3Months", 5 | LAST_YEAR = "lastYear", 6 | THIS_MONTH = "thisMonth", 7 | THIS_YEAR = "thisYear", 8 | ALL_TIME = "allTime", 9 | CUSTOM = "custom", 10 | } 11 | 12 | export type DateRangePreset = `${DateRangeEnum}`; 13 | -------------------------------------------------------------------------------- /backend/src/enums/error-code.enum.ts: -------------------------------------------------------------------------------- 1 | export const ErrorCodeEnum = { 2 | ACCESS_UNAUTHORIZED: "ACCESS_UNAUTHORIZED", 3 | 4 | AUTH_USER_NOT_FOUND: "AUTH_USER_NOT_FOUND", 5 | 6 | AUTH_EMAIL_ALREADY_EXISTS: "AUTH_EMAIL_ALREADY_EXISTS", 7 | AUTH_INVALID_TOKEN: "AUTH_INVALID_TOKEN", 8 | 9 | AUTH_NOT_FOUND: "AUTH_NOT_FOUND", 10 | AUTH_TOO_MANY_ATTEMPTS: "AUTH_TOO_MANY_ATTEMPTS", 11 | AUTH_UNAUTHORIZED_ACCESS: "AUTH_UNAUTHORIZED_ACCESS", 12 | AUTH_TOKEN_NOT_FOUND: "AUTH_TOKEN_NOT_FOUND", 13 | // Validation and Resource Errors 14 | VALIDATION_ERROR: "VALIDATION_ERROR", 15 | RESOURCE_NOT_FOUND: "RESOURCE_NOT_FOUND", 16 | FILE_UPLOAD_ERROR: "FILE_UPLOAD_ERROR", 17 | 18 | // System Errors 19 | INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR", 20 | } as const; 21 | 22 | export type ErrorCodeEnumType = keyof typeof ErrorCodeEnum; 23 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import "./config/passport.config"; 3 | import express, { NextFunction, Request, Response } from "express"; 4 | import cors from "cors"; 5 | import passport from "passport"; 6 | import { Env } from "./config/env.config"; 7 | import { HTTPSTATUS } from "./config/http.config"; 8 | import { errorHandler } from "./middlewares/errorHandler.middleware"; 9 | import { BadRequestException } from "./utils/app-error"; 10 | import { asyncHandler } from "./middlewares/asyncHandler.middlerware"; 11 | import connctDatabase from "./config/database.config"; 12 | import authRoutes from "./routes/auth.route"; 13 | import { passportAuthenticateJwt } from "./config/passport.config"; 14 | import userRoutes from "./routes/user.route"; 15 | import transactionRoutes from "./routes/transaction.route"; 16 | import { initializeCrons } from "./cron"; 17 | import reportRoutes from "./routes/report.route"; 18 | import { getDateRange } from "./utils/date"; 19 | import analyticsRoutes from "./routes/analytics.route"; 20 | 21 | const app = express(); 22 | const BASE_PATH = Env.BASE_PATH; 23 | 24 | app.use(express.json()); 25 | app.use(express.urlencoded({ extended: true })); 26 | 27 | app.use(passport.initialize()); 28 | 29 | app.use( 30 | cors({ 31 | origin: Env.FRONTEND_ORIGIN, 32 | credentials: true, 33 | }) 34 | ); 35 | 36 | app.get( 37 | "/", 38 | asyncHandler(async (req: Request, res: Response, next: NextFunction) => { 39 | throw new BadRequestException("This is a test error"); 40 | res.status(HTTPSTATUS.OK).json({ 41 | message: "Hello Subcribe to the channel", 42 | }); 43 | }) 44 | ); 45 | 46 | app.use(`${BASE_PATH}/auth`, authRoutes); 47 | app.use(`${BASE_PATH}/user`, passportAuthenticateJwt, userRoutes); 48 | app.use(`${BASE_PATH}/transaction`, passportAuthenticateJwt, transactionRoutes); 49 | app.use(`${BASE_PATH}/report`, passportAuthenticateJwt, reportRoutes); 50 | app.use(`${BASE_PATH}/analytics`, passportAuthenticateJwt, analyticsRoutes); 51 | 52 | app.use(errorHandler); 53 | 54 | app.listen(Env.PORT, async () => { 55 | await connctDatabase(); 56 | 57 | if (Env.NODE_ENV === "development") { 58 | await initializeCrons(); 59 | } 60 | 61 | console.log(`Server is running on port ${Env.PORT} in ${Env.NODE_ENV} mode`); 62 | }); 63 | -------------------------------------------------------------------------------- /backend/src/mailers/mailer.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../config/env.config"; 2 | import { resend } from "../config/resend.config"; 3 | 4 | type Params = { 5 | to: string | string[]; 6 | subject: string; 7 | text: string; 8 | html: string; 9 | from?: string; 10 | }; 11 | 12 | const mailer_sender = `Finora <${Env.RESEND_MAILER_SENDER}>`; 13 | 14 | export const sendEmail = async ({ 15 | to, 16 | from = mailer_sender, 17 | subject, 18 | text, 19 | html, 20 | }: Params) => { 21 | return await resend.emails.send({ 22 | from, 23 | to: Array.isArray(to) ? to : [to], 24 | text, 25 | subject, 26 | html, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /backend/src/mailers/report.mailer.ts: -------------------------------------------------------------------------------- 1 | import { formatCurrency } from "../utils/format-currency"; 2 | import { getReportEmailTemplate } from "./templates/report.template"; 3 | import { sendEmail } from "./mailer"; 4 | import { ReportType } from "../@types/report.type"; 5 | 6 | type ReportEmailParams = { 7 | email: string; 8 | username: string; 9 | report: ReportType; 10 | frequency: string; 11 | }; 12 | 13 | export const sendReportEmail = async (params: ReportEmailParams) => { 14 | const { email, username, report, frequency } = params; 15 | const html = getReportEmailTemplate( 16 | { 17 | username, 18 | ...report, 19 | }, 20 | frequency 21 | ); 22 | 23 | const text = `Your ${frequency} Financial Report (${report.period}) 24 | Income: ${formatCurrency(report.totalIncome)} 25 | Expenses: ${formatCurrency(report.totalExpenses)} 26 | Balance: ${formatCurrency(report.availableBalance)} 27 | Savings Rate: ${report.savingsRate.toFixed(2)}% 28 | 29 | ${report.insights.join("\n")} 30 | `; 31 | 32 | console.log(text, "text mail"); 33 | 34 | return sendEmail({ 35 | to: email, 36 | subject: `${frequency} Financial Report - ${report.period}`, 37 | text, 38 | html, 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /backend/src/middlewares/asyncHandler.middlerware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | 3 | type AsyncControllerType = ( 4 | req: Request, 5 | res: Response, 6 | next: NextFunction 7 | ) => Promise; 8 | 9 | export const asyncHandler = 10 | (controller: AsyncControllerType): AsyncControllerType => 11 | async (req, res, next) => { 12 | try { 13 | await controller(req, res, next); 14 | } catch (error) { 15 | next(error); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/middlewares/errorHandler.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { z, ZodError } from "zod"; 3 | import { ErrorRequestHandler } from "express"; 4 | import { HTTPSTATUS } from "../config/http.config"; 5 | import { AppError } from "../utils/app-error"; 6 | import { ErrorCodeEnum } from "../enums/error-code.enum"; 7 | import { MulterError } from "multer"; 8 | 9 | const formatZodError = (res: Response, error: z.ZodError) => { 10 | const errors = error?.issues?.map((err) => ({ 11 | field: err.path.join("."), 12 | message: err.message, 13 | })); 14 | return res.status(HTTPSTATUS.BAD_REQUEST).json({ 15 | message: "Validation failed", 16 | errors: errors, 17 | errorCode: ErrorCodeEnum.VALIDATION_ERROR, 18 | }); 19 | }; 20 | 21 | const handleMulterError = (error: MulterError) => { 22 | const messages = { 23 | LIMIT_UNEXPECTED_FILE: "Invalid file field name. Please use 'file'", 24 | LIMIT_FILE_SIZE: "File size exceeds the limit", 25 | LIMIT_FILE_COUNT: "Too many files uploaded", 26 | default: "File upload error", 27 | }; 28 | 29 | return { 30 | status: HTTPSTATUS.BAD_REQUEST, 31 | message: messages[error.code as keyof typeof messages] || messages.default, 32 | error: error.message, 33 | }; 34 | }; 35 | 36 | export const errorHandler: ErrorRequestHandler = ( 37 | error, 38 | req, 39 | res, 40 | next 41 | ): any => { 42 | console.log("Error occurred on PATH:", req.path, "Error:", error); 43 | 44 | if (error instanceof ZodError) { 45 | return formatZodError(res, error); 46 | } 47 | 48 | if (error instanceof MulterError) { 49 | const { status, message, error: err } = handleMulterError(error); 50 | return res.status(status).json({ 51 | message, 52 | error: err, 53 | errorCode: ErrorCodeEnum.FILE_UPLOAD_ERROR, 54 | }); 55 | } 56 | 57 | if (error instanceof AppError) { 58 | return res.status(error.statusCode).json({ 59 | message: error.message, 60 | errorCode: error.errorCode, 61 | }); 62 | } 63 | 64 | return res.status(HTTPSTATUS.INTERNAL_SERVER_ERROR).json({ 65 | message: "Internal Server Error", 66 | error: error?.message || "Unknow error occurred", 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /backend/src/models/report-setting.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export enum ReportFrequencyEnum { 4 | MONTHLY = "MONTHLY", 5 | } 6 | 7 | export interface ReportSettingDocument extends Document { 8 | userId: mongoose.Types.ObjectId; 9 | frequency: keyof typeof ReportFrequencyEnum; 10 | isEnabled: boolean; 11 | nextReportDate?: Date; 12 | lastSentDate?: Date; 13 | createdAt: Date; 14 | updatedAt: Date; 15 | } 16 | 17 | const reportSettingSchema = new mongoose.Schema( 18 | { 19 | userId: { 20 | type: mongoose.Schema.Types.ObjectId, 21 | required: true, 22 | ref: "User", 23 | }, 24 | frequency: { 25 | type: String, 26 | enum: Object.values(ReportFrequencyEnum), 27 | default: ReportFrequencyEnum.MONTHLY, 28 | }, 29 | isEnabled: { 30 | type: Boolean, 31 | default: false, 32 | }, 33 | nextReportDate: { 34 | type: Date, 35 | }, 36 | lastSentDate: { 37 | type: Date, 38 | }, 39 | }, 40 | { 41 | timestamps: true, 42 | } 43 | ); 44 | 45 | const ReportSettingModel = mongoose.model( 46 | "ReportSetting", 47 | reportSettingSchema 48 | ); 49 | 50 | export default ReportSettingModel; 51 | -------------------------------------------------------------------------------- /backend/src/models/report.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export enum ReportStatusEnum { 4 | SENT = "SENT", 5 | PENDING = "PENDING", 6 | FAILED = "FAILED", 7 | NO_ACTIVITY = "NO_ACTIVITY", 8 | } 9 | 10 | export interface ReportDocument extends Document { 11 | userId: mongoose.Types.ObjectId; 12 | period: string; 13 | sentDate: Date; 14 | status: keyof typeof ReportStatusEnum; 15 | createdAt: Date; 16 | updatedAt: Date; 17 | } 18 | 19 | const reportSchema = new mongoose.Schema( 20 | { 21 | userId: { 22 | type: mongoose.Schema.Types.ObjectId, 23 | required: true, 24 | ref: "User", 25 | }, 26 | period: { 27 | type: String, 28 | required: true, 29 | }, 30 | sentDate: { 31 | type: Date, 32 | required: true, 33 | }, 34 | status: { 35 | type: String, 36 | enum: Object.values(ReportStatusEnum), 37 | default: ReportStatusEnum.PENDING, 38 | }, 39 | }, 40 | { 41 | timestamps: true, 42 | } 43 | ); 44 | 45 | const ReportModel = mongoose.model("Report", reportSchema); 46 | export default ReportModel; 47 | -------------------------------------------------------------------------------- /backend/src/models/transaction.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | import { convertToCents, convertToDollarUnit } from "../utils/format-currency"; 3 | 4 | export enum TransactionStatusEnum { 5 | PENDING = "PENDING", 6 | COMPLETED = "COMPLETED", 7 | FAILED = "FAILED", 8 | } 9 | 10 | export enum RecurringIntervalEnum { 11 | DAILY = "DAILY", 12 | WEEKLY = "WEEKLY", 13 | MONTHLY = "MONTHLY", 14 | YEARLY = "YEARLY", 15 | } 16 | 17 | export enum TransactionTypeEnum { 18 | INCOME = "INCOME", 19 | EXPENSE = "EXPENSE", 20 | } 21 | 22 | export enum PaymentMethodEnum { 23 | CARD = "CARD", 24 | BANK_TRANSFER = "BANK_TRANSFER", 25 | MOBILE_PAYMENT = "MOBILE_PAYMENT", 26 | AUTO_DEBIT = "AUTO_DEBIT", 27 | CASH = "CASH", 28 | OTHER = "OTHER", 29 | } 30 | 31 | export interface TransactionDocument extends Document { 32 | userId: mongoose.Types.ObjectId; 33 | type: keyof typeof TransactionTypeEnum; 34 | title: string; 35 | amount: number; 36 | category: string; 37 | receiptUrl?: string; 38 | recurringInterval?: keyof typeof RecurringIntervalEnum; 39 | nextRecurringDate?: Date; 40 | lastProcessed?: Date; 41 | isRecurring: boolean; 42 | description?: string; 43 | date: Date; 44 | status: keyof typeof TransactionStatusEnum; 45 | paymentMethod: keyof typeof PaymentMethodEnum; 46 | createdAt: Date; 47 | updatedAt: Date; 48 | } 49 | 50 | const transactionSchema = new Schema( 51 | { 52 | userId: { 53 | type: Schema.Types.ObjectId, 54 | required: true, 55 | ref: "User", 56 | }, 57 | title: { 58 | type: String, 59 | required: true, 60 | }, 61 | type: { 62 | type: String, 63 | enum: Object.values(TransactionTypeEnum), 64 | required: true, 65 | }, 66 | amount: { 67 | type: Number, 68 | required: true, 69 | set: (value: number) => convertToCents(value), 70 | get: (value: number) => convertToDollarUnit(value), 71 | }, 72 | 73 | description: { 74 | type: String, 75 | }, 76 | category: { 77 | type: String, 78 | required: true, 79 | }, 80 | receiptUrl: { 81 | type: String, 82 | }, 83 | date: { 84 | type: Date, 85 | default: Date.now, 86 | }, 87 | isRecurring: { 88 | type: Boolean, 89 | default: false, 90 | }, 91 | recurringInterval: { 92 | type: String, 93 | enum: Object.values(RecurringIntervalEnum), 94 | default: null, 95 | }, 96 | nextRecurringDate: { 97 | type: Date, 98 | default: null, 99 | }, 100 | lastProcessed: { 101 | type: Date, 102 | default: null, 103 | }, 104 | status: { 105 | type: String, 106 | enum: Object.values(TransactionStatusEnum), 107 | default: TransactionStatusEnum.COMPLETED, 108 | }, 109 | paymentMethod: { 110 | type: String, 111 | enum: Object.values(PaymentMethodEnum), 112 | default: PaymentMethodEnum.CASH, 113 | }, 114 | }, 115 | { 116 | timestamps: true, 117 | toJSON: { virtuals: true, getters: true }, 118 | toObject: { virtuals: true, getters: true }, 119 | } 120 | ); 121 | 122 | const TransactionModel = mongoose.model( 123 | "Transaction", 124 | transactionSchema 125 | ); 126 | 127 | export default TransactionModel; 128 | -------------------------------------------------------------------------------- /backend/src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from "mongoose"; 2 | import { compareValue, hashValue } from "../utils/bcrypt"; 3 | 4 | export interface UserDocument extends Document { 5 | name: string; 6 | email: string; 7 | password: string; 8 | profilePicture: string | null; 9 | createdAt: Date; 10 | updatedAt: Date; 11 | comparePassword: (password: string) => Promise; 12 | omitPassword: () => Omit; 13 | } 14 | 15 | const userSchema = new Schema( 16 | { 17 | name: { 18 | type: String, 19 | required: true, 20 | trim: true, 21 | }, 22 | email: { 23 | type: String, 24 | required: true, 25 | unique: true, 26 | trim: true, 27 | lowercase: true, 28 | }, 29 | profilePicture: { 30 | type: String, 31 | default: null, 32 | }, 33 | password: { 34 | type: String, 35 | select: true, 36 | required: true, 37 | }, 38 | }, 39 | { 40 | timestamps: true, 41 | } 42 | ); 43 | 44 | userSchema.pre("save", async function (next) { 45 | if (this.isModified("password")) { 46 | if (this.password) { 47 | this.password = await hashValue(this.password); 48 | } 49 | } 50 | next(); 51 | }); 52 | 53 | userSchema.methods.omitPassword = function (): Omit { 54 | const userObject = this.toObject(); 55 | delete userObject.password; 56 | return userObject; 57 | }; 58 | 59 | userSchema.methods.comparePassword = async function (password: string) { 60 | return compareValue(password, this.password); 61 | }; 62 | 63 | const UserModel = mongoose.model("User", userSchema); 64 | export default UserModel; 65 | -------------------------------------------------------------------------------- /backend/src/routes/analytics.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | chartAnalyticsController, 4 | expensePieChartBreakdownController, 5 | summaryAnalyticsController, 6 | } from "../controllers/analytics.controller"; 7 | 8 | const analyticsRoutes = Router(); 9 | 10 | analyticsRoutes.get("/summary", summaryAnalyticsController); 11 | analyticsRoutes.get("/chart", chartAnalyticsController); 12 | analyticsRoutes.get("/expense-breakdown", expensePieChartBreakdownController); 13 | 14 | export default analyticsRoutes; 15 | -------------------------------------------------------------------------------- /backend/src/routes/auth.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | loginController, 4 | registerController, 5 | } from "../controllers/auth.controller"; 6 | 7 | const authRoutes = Router(); 8 | 9 | authRoutes.post("/register", registerController); 10 | authRoutes.post("/login", loginController); 11 | 12 | export default authRoutes; 13 | -------------------------------------------------------------------------------- /backend/src/routes/report.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | generateReportController, 4 | getAllReportsController, 5 | updateReportSettingController, 6 | } from "../controllers/report.controller"; 7 | 8 | const reportRoutes = Router(); 9 | 10 | reportRoutes.get("/all", getAllReportsController); 11 | reportRoutes.get("/generate", generateReportController); 12 | reportRoutes.put("/update-setting", updateReportSettingController); 13 | 14 | export default reportRoutes; 15 | -------------------------------------------------------------------------------- /backend/src/routes/transaction.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | bulkDeleteTransactionController, 4 | bulkTransactionController, 5 | createTransactionController, 6 | deleteTransactionController, 7 | duplicateTransactionController, 8 | getAllTransactionController, 9 | getTransactionByIdController, 10 | scanReceiptController, 11 | updateTransactionController, 12 | } from "../controllers/transaction.controller"; 13 | import { upload } from "../config/cloudinary.config"; 14 | 15 | const transactionRoutes = Router(); 16 | 17 | transactionRoutes.post("/create", createTransactionController); 18 | 19 | transactionRoutes.post( 20 | "/scan-receipt", 21 | upload.single("receipt"), 22 | scanReceiptController 23 | ); 24 | 25 | transactionRoutes.post("/bulk-transaction", bulkTransactionController); 26 | 27 | transactionRoutes.put("/duplicate/:id", duplicateTransactionController); 28 | transactionRoutes.put("/update/:id", updateTransactionController); 29 | 30 | transactionRoutes.get("/all", getAllTransactionController); 31 | transactionRoutes.get("/:id", getTransactionByIdController); 32 | transactionRoutes.delete("/delete/:id", deleteTransactionController); 33 | transactionRoutes.delete("/bulk-delete", bulkDeleteTransactionController); 34 | 35 | export default transactionRoutes; 36 | -------------------------------------------------------------------------------- /backend/src/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | getCurrentUserController, 4 | updateUserController, 5 | } from "../controllers/user.controller"; 6 | import { upload } from "../config/cloudinary.config"; 7 | 8 | const userRoutes = Router(); 9 | 10 | userRoutes.get("/current-user", getCurrentUserController); 11 | userRoutes.put( 12 | "/update", 13 | upload.single("profilePicture"), 14 | updateUserController 15 | ); 16 | 17 | export default userRoutes; 18 | -------------------------------------------------------------------------------- /backend/src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import UserModel from "../models/user.model"; 3 | import { NotFoundException, UnauthorizedException } from "../utils/app-error"; 4 | import { 5 | LoginSchemaType, 6 | RegisterSchemaType, 7 | } from "../validators/auth.validator"; 8 | import ReportSettingModel, { 9 | ReportFrequencyEnum, 10 | } from "../models/report-setting.model"; 11 | import { calulateNextReportDate } from "../utils/helper"; 12 | import { signJwtToken } from "../utils/jwt"; 13 | 14 | export const registerService = async (body: RegisterSchemaType) => { 15 | const { email } = body; 16 | 17 | const session = await mongoose.startSession(); 18 | 19 | try { 20 | await session.withTransaction(async () => { 21 | const existingUser = await UserModel.findOne({ email }).session(session); 22 | if (existingUser) throw new UnauthorizedException("User already exists"); 23 | 24 | const newUser = new UserModel({ 25 | ...body, 26 | }); 27 | 28 | await newUser.save({ session }); 29 | 30 | const reportSetting = new ReportSettingModel({ 31 | userId: newUser._id, 32 | frequency: ReportFrequencyEnum.MONTHLY, 33 | isEnabled: true, 34 | nextReportDate: calulateNextReportDate(), 35 | lastSentDate: null, 36 | }); 37 | await reportSetting.save({ session }); 38 | 39 | return { user: newUser.omitPassword() }; 40 | }); 41 | } catch (error) { 42 | throw error; 43 | } finally { 44 | await session.endSession(); 45 | } 46 | }; 47 | 48 | export const loginService = async (body: LoginSchemaType) => { 49 | const { email, password } = body; 50 | const user = await UserModel.findOne({ email }); 51 | if (!user) throw new NotFoundException("Email/password not found"); 52 | 53 | const isPasswordValid = await user.comparePassword(password); 54 | 55 | if (!isPasswordValid) 56 | throw new UnauthorizedException("Invalid email/password"); 57 | 58 | const { token, expiresAt } = signJwtToken({ userId: user.id }); 59 | 60 | const reportSetting = await ReportSettingModel.findOne( 61 | { 62 | userId: user.id, 63 | }, 64 | { _id: 1, frequency: 1, isEnabled: 1 } 65 | ).lean(); 66 | 67 | return { 68 | user: user.omitPassword(), 69 | accessToken: token, 70 | expiresAt, 71 | reportSetting, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /backend/src/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import UserModel from "../models/user.model"; 2 | import { NotFoundException } from "../utils/app-error"; 3 | import { UpdateUserType } from "../validators/user.validator"; 4 | 5 | export const findByIdUserService = async (userId: string) => { 6 | const user = await UserModel.findById(userId); 7 | return user?.omitPassword(); 8 | }; 9 | 10 | export const updateUserService = async ( 11 | userId: string, 12 | body: UpdateUserType, 13 | profilePic?: Express.Multer.File 14 | ) => { 15 | const user = await UserModel.findById(userId); 16 | if (!user) throw new NotFoundException("User not found"); 17 | 18 | if (profilePic) { 19 | user.profilePicture = profilePic.path; 20 | } 21 | 22 | user.set({ 23 | name: body.name, 24 | }); 25 | 26 | await user.save(); 27 | 28 | return user.omitPassword(); 29 | }; 30 | -------------------------------------------------------------------------------- /backend/src/utils/app-error.ts: -------------------------------------------------------------------------------- 1 | import { HTTPSTATUS, HttpStatusCodeType } from "../config/http.config"; 2 | import { ErrorCodeEnum, ErrorCodeEnumType } from "../enums/error-code.enum"; 3 | 4 | export class AppError extends Error { 5 | public statusCode: HttpStatusCodeType; 6 | public errorCode?: ErrorCodeEnumType; 7 | 8 | constructor( 9 | message: string, 10 | statusCode = HTTPSTATUS.INTERNAL_SERVER_ERROR, 11 | errorCode?: ErrorCodeEnumType 12 | ) { 13 | super(message); 14 | this.statusCode = statusCode; 15 | this.errorCode = errorCode; 16 | Error.captureStackTrace(this, this.constructor); 17 | } 18 | } 19 | 20 | export class HttpException extends AppError { 21 | constructor( 22 | message = "Http Exception Error", 23 | statusCode: HttpStatusCodeType, 24 | errorCode?: ErrorCodeEnumType 25 | ) { 26 | super(message, statusCode, errorCode); 27 | } 28 | } 29 | 30 | export class NotFoundException extends AppError { 31 | constructor(message = "Resource not found", errorCode?: ErrorCodeEnumType) { 32 | super( 33 | message, 34 | HTTPSTATUS.NOT_FOUND, 35 | errorCode || ErrorCodeEnum.RESOURCE_NOT_FOUND 36 | ); 37 | } 38 | } 39 | 40 | export class BadRequestException extends AppError { 41 | constructor(message = "Bad Request", errorCode?: ErrorCodeEnumType) { 42 | super( 43 | message, 44 | HTTPSTATUS.BAD_REQUEST, 45 | errorCode || ErrorCodeEnum.VALIDATION_ERROR 46 | ); 47 | } 48 | } 49 | 50 | export class UnauthorizedException extends AppError { 51 | constructor(message = "Unauthorized Access", errorCode?: ErrorCodeEnumType) { 52 | super( 53 | message, 54 | HTTPSTATUS.UNAUTHORIZED, 55 | errorCode || ErrorCodeEnum.ACCESS_UNAUTHORIZED 56 | ); 57 | } 58 | } 59 | 60 | export class InternalServerException extends AppError { 61 | constructor( 62 | message = "Internal Server Error", 63 | errorCode?: ErrorCodeEnumType 64 | ) { 65 | super( 66 | message, 67 | HTTPSTATUS.INTERNAL_SERVER_ERROR, 68 | errorCode || ErrorCodeEnum.INTERNAL_SERVER_ERROR 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /backend/src/utils/bcrypt.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | 3 | export const hashValue = async (value: string, saltRounds: number = 10) => 4 | await bcrypt.hash(value, saltRounds); 5 | 6 | export const compareValue = async (value: string, hashedValue: string) => 7 | await bcrypt.compare(value, hashedValue); 8 | -------------------------------------------------------------------------------- /backend/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { 2 | endOfDay, 3 | endOfMonth, 4 | endOfYear, 5 | startOfMonth, 6 | startOfYear, 7 | subDays, 8 | subMonths, 9 | subYears, 10 | } from "date-fns"; 11 | import { DateRangeEnum, DateRangePreset } from "../enums/date-range.enum"; 12 | 13 | export const getDateRange = ( 14 | preset?: DateRangePreset, 15 | customFrom?: Date, 16 | customTo?: Date 17 | ) => { 18 | if (customFrom && customTo) { 19 | return { 20 | from: customFrom, 21 | to: customTo, 22 | value: DateRangeEnum.CUSTOM, 23 | }; 24 | } 25 | 26 | const now = new Date(); 27 | 28 | // const yesterday = subDays(now.setHours(0, 0, 0, 0), 1); 29 | const today = endOfDay(now); 30 | const last30Days = { 31 | from: subDays(today, 29), 32 | to: today, 33 | value: DateRangeEnum.LAST_30_DAYS, 34 | label: "Last 30 Days", 35 | }; 36 | console.log(last30Days, "last30"); 37 | 38 | switch (preset) { 39 | case DateRangeEnum.ALL_TIME: 40 | return { 41 | from: null, 42 | to: null, 43 | value: DateRangeEnum.ALL_TIME, 44 | label: "All Time", 45 | }; 46 | case DateRangeEnum.LAST_30_DAYS: 47 | return last30Days; 48 | case DateRangeEnum.LAST_MONTH: 49 | return { 50 | from: startOfMonth(subMonths(now, 1)), 51 | to: endOfMonth(subMonths(now, 1)), 52 | value: DateRangeEnum.LAST_MONTH, 53 | label: "Last Month", 54 | }; 55 | case DateRangeEnum.LAST_3_MONTHS: 56 | return { 57 | from: startOfMonth(subMonths(now, 3)), 58 | to: endOfMonth(subMonths(now, 1)), 59 | value: DateRangeEnum.LAST_3_MONTHS, 60 | label: "Last 3 Months", 61 | }; 62 | case DateRangeEnum.LAST_YEAR: 63 | return { 64 | from: startOfYear(subYears(now, 1)), 65 | to: endOfYear(subYears(now, 1)), 66 | value: DateRangeEnum.LAST_YEAR, 67 | label: "Last Year", 68 | }; 69 | case DateRangeEnum.THIS_MONTH: 70 | return { 71 | from: startOfMonth(now), 72 | to: endOfDay(now), 73 | value: DateRangeEnum.THIS_MONTH, 74 | label: "This Month", 75 | }; 76 | case DateRangeEnum.THIS_YEAR: 77 | return { 78 | from: startOfYear(now), 79 | to: endOfDay(now), 80 | value: DateRangeEnum.THIS_YEAR, 81 | label: "This Year", 82 | }; 83 | default: 84 | return last30Days; 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /backend/src/utils/format-currency.ts: -------------------------------------------------------------------------------- 1 | // Convert dollars to cents when saving 2 | export function convertToCents(amount: number) { 3 | return Math.round(amount * 100); 4 | } 5 | 6 | // Convert cents to dollars when retrieving 7 | //convertFromCents 8 | export function convertToDollarUnit(amount: number) { 9 | return amount / 100; 10 | } 11 | 12 | export function formatCurrency(amount: number) { 13 | return new Intl.NumberFormat("en-US", { 14 | style: "currency", 15 | currency: "USD", 16 | }).format(amount); 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/utils/get-env.ts: -------------------------------------------------------------------------------- 1 | export const getEnv = (key: string, defaultValue?: string): string => { 2 | const value = process.env[key]; 3 | if (value === undefined) { 4 | if (defaultValue === undefined) { 5 | throw new Error(`Environment variable ${key} is not set`); 6 | } 7 | return defaultValue; 8 | } 9 | return value; 10 | }; 11 | -------------------------------------------------------------------------------- /backend/src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import { addDays, addMonths, addWeeks, addYears, startOfMonth } from "date-fns"; 2 | import { RecurringIntervalEnum } from "../models/transaction.model"; 3 | 4 | export function calulateNextReportDate(lastSentDate?: Date): Date { 5 | const now = new Date(); 6 | const lastSent = lastSentDate || now; 7 | 8 | const nextDate = startOfMonth(addMonths(lastSent, 1)); 9 | nextDate.setHours(0, 0, 0, 0); 10 | 11 | console.log(nextDate, "nextDate"); 12 | return nextDate; 13 | } 14 | 15 | export function calculateNextOccurrence( 16 | date: Date, 17 | recurringInterval: keyof typeof RecurringIntervalEnum 18 | ) { 19 | const base = new Date(date); 20 | base.setHours(0, 0, 0, 0); 21 | 22 | switch (recurringInterval) { 23 | case RecurringIntervalEnum.DAILY: 24 | return addDays(base, 1); 25 | case RecurringIntervalEnum.WEEKLY: 26 | return addWeeks(base, 1); 27 | case RecurringIntervalEnum.MONTHLY: 28 | return addMonths(base, 1); 29 | case RecurringIntervalEnum.YEARLY: 30 | return addYears(base, 1); 31 | default: 32 | return base; 33 | } 34 | } 35 | 36 | export function capitalizeFirstLetter(string: string) { 37 | return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt, { JwtPayload, SignOptions } from "jsonwebtoken"; 2 | import { Env } from "../config/env.config"; 3 | 4 | type TimeUnit = "s" | "m" | "h" | "d" | "w" | "y"; 5 | type TimeString = `${number}${TimeUnit}`; 6 | 7 | export type AccessTokenPayload = { 8 | userId: string; 9 | }; 10 | 11 | type SignOptsAndSecret = SignOptions & { 12 | secret: string; 13 | expiresIn?: TimeString | number; 14 | }; 15 | 16 | const defaults: SignOptions = { 17 | audience: ["user"], 18 | }; 19 | 20 | const accessTokenSignOptions: SignOptsAndSecret = { 21 | expiresIn: Env.JWT_EXPIRES_IN as TimeString, 22 | secret: Env.JWT_SECRET, 23 | }; 24 | 25 | export const signJwtToken = ( 26 | payload: AccessTokenPayload, 27 | options?: SignOptsAndSecret 28 | ) => { 29 | const isAccessToken = !options || options === accessTokenSignOptions; 30 | 31 | const { secret, ...opts } = options || accessTokenSignOptions; 32 | 33 | const token = jwt.sign(payload, secret, { 34 | ...defaults, 35 | ...opts, 36 | }); 37 | 38 | const expiresAt = isAccessToken 39 | ? (jwt.decode(token) as JwtPayload)?.exp! * 1000 40 | : undefined; 41 | 42 | return { 43 | token, 44 | expiresAt, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /backend/src/utils/prompt.ts: -------------------------------------------------------------------------------- 1 | import { PaymentMethodEnum } from "../models/transaction.model"; 2 | 3 | export const receiptPrompt = ` 4 | You are a financial assistant that helps users analyze and extract transaction details from receipt image (base64 encoded) 5 | Analyze this receipt image (base64 encoded) and extract transaction details matching this exact JSON format: 6 | { 7 | "title": "string", // Merchant/store name or brief description 8 | "amount": number, // Total amount (positive number) 9 | "date": "ISO date string", // Transaction date in YYYY-MM-DD format 10 | "description": "string", // Items purchased summary (max 50 words) 11 | "category": "string", // category of the transaction 12 | "type": "EXPENSE" // Always "EXPENSE" for receipts 13 | "paymentMethod": "string", // One of: ${Object.values(PaymentMethodEnum).join(",")} 14 | } 15 | 16 | Rules: 17 | 1. Amount must be positive 18 | 2. Date must be valid and in ISO format 19 | 3. Category must match our enum values 20 | 4. If uncertain about any field, omit it 21 | 5. If not a receipt, return {} 22 | 23 | Example valid response: 24 | { 25 | "title": "Walmart Groceries", 26 | "amount": 58.43, 27 | "date": "2025-05-08", 28 | "description": "Groceries: milk, eggs, bread", 29 | "category": "groceries", 30 | "paymentMethod": "CARD", 31 | "type": "EXPENSE" 32 | } 33 | `; 34 | 35 | export const reportInsightPrompt = ({ 36 | totalIncome, 37 | totalExpenses, 38 | availableBalance, 39 | savingsRate, 40 | categories, 41 | periodLabel, 42 | }: { 43 | totalIncome: number; 44 | totalExpenses: number; 45 | availableBalance: number; 46 | savingsRate: number; 47 | categories: Record; 48 | periodLabel: string; 49 | }) => { 50 | const categoryList = Object.entries(categories) 51 | .map( 52 | ([name, { amount, percentage }]) => 53 | `- ${name}: ${amount} (${percentage}%)` 54 | ) 55 | .join("\n"); 56 | 57 | console.log(categoryList, "category list"); 58 | 59 | return ` 60 | You are a friendly and smart financial coach, not a robot. 61 | 62 | Your job is to give **exactly 3 good short insights** to the user based on their data that feel like you're talking to them directly. 63 | 64 | Each insight should reflect the actual data and sound like something a smart money coach would say based on the data — short, clear, and practical. 65 | 66 | 🧾 Report for: ${periodLabel} 67 | - Total Income: $${totalIncome.toFixed(2)} 68 | - Total Expenses: $${totalExpenses.toFixed(2)} 69 | - Available Balance: $${availableBalance.toFixed(2)} 70 | - Savings Rate: ${savingsRate}% 71 | 72 | Top Expense Categories: 73 | ${categoryList} 74 | 75 | 📌 Guidelines: 76 | - Keep each insight to one short, realistic, personalized, natural sentence 77 | - Use conversational language, correct wordings & Avoid sounding robotic, or generic 78 | - Include specific data when helpful and comma to amount 79 | - Be encouraging if user spent less than they earned 80 | - Format your response **exactly** like this: 81 | 82 | ["Insight 1", "Insight 2", "Insight 3"] 83 | 84 | ✅ Example: 85 | [ 86 | "Nice! You kept $7,458 after expenses — that’s solid breathing room.", 87 | "You spent the most on 'Meals' this period — 32%. Maybe worth keeping an eye on.", 88 | "You stayed under budget this time. That's a win — keep the momentum" 89 | ] 90 | 91 | ⚠️ Output only a **JSON array of 3 strings**. Do not include any explanation, markdown, or notes. 92 | 93 | `.trim(); 94 | }; 95 | -------------------------------------------------------------------------------- /backend/src/validators/auth.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const emailSchema = z 4 | .string() 5 | .trim() 6 | .email("Invalid email address") 7 | .min(1) 8 | .max(255); 9 | 10 | export const passwordSchema = z.string().trim().min(4); 11 | 12 | export const registerSchema = z.object({ 13 | name: z.string().trim().min(1).max(255), 14 | email: emailSchema, 15 | password: passwordSchema, 16 | }); 17 | 18 | export const loginSchema = z.object({ 19 | email: emailSchema, 20 | password: passwordSchema, 21 | }); 22 | 23 | export type RegisterSchemaType = z.infer; 24 | export type LoginSchemaType = z.infer; 25 | -------------------------------------------------------------------------------- /backend/src/validators/report.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const reportSettingSchema = z.object({ 4 | isEnabled: z.boolean().default(true), 5 | }); 6 | 7 | export const updateReportSettingSchema = reportSettingSchema.partial(); 8 | 9 | export type UpdateReportSettingType = z.infer; 10 | -------------------------------------------------------------------------------- /backend/src/validators/transaction.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | PaymentMethodEnum, 4 | RecurringIntervalEnum, 5 | TransactionTypeEnum, 6 | } from "../models/transaction.model"; 7 | 8 | export const transactionIdSchema = z.string().trim().min(1); 9 | 10 | export const baseTransactionSchema = z.object({ 11 | title: z.string().min(1, "Title is required"), 12 | description: z.string().optional(), 13 | type: z.enum([TransactionTypeEnum.INCOME, TransactionTypeEnum.EXPENSE], { 14 | errorMap: () => ({ 15 | message: "Transaction type must either INCOME or EXPENSE", 16 | }), 17 | }), 18 | amount: z.number().positive("Amount must be postive").min(1), 19 | category: z.string().min(1, "Category is required"), 20 | date: z 21 | .union([z.string().datetime({ message: "Invalid date string" }), z.date()]) 22 | .transform((val) => new Date(val)), 23 | isRecurring: z.boolean().default(false), 24 | recurringInterval: z 25 | .enum([ 26 | RecurringIntervalEnum.DAILY, 27 | RecurringIntervalEnum.WEEKLY, 28 | RecurringIntervalEnum.MONTHLY, 29 | RecurringIntervalEnum.YEARLY, 30 | ]) 31 | .nullable() 32 | .optional(), 33 | 34 | receiptUrl: z.string().optional(), 35 | paymentMethod: z 36 | .enum([ 37 | PaymentMethodEnum.CARD, 38 | PaymentMethodEnum.BANK_TRANSFER, 39 | PaymentMethodEnum.MOBILE_PAYMENT, 40 | PaymentMethodEnum.AUTO_DEBIT, 41 | PaymentMethodEnum.CASH, 42 | PaymentMethodEnum.OTHER, 43 | ]) 44 | .default(PaymentMethodEnum.CASH), 45 | }); 46 | 47 | export const bulkDeleteTransactionSchema = z.object({ 48 | transactionIds: z 49 | .array(z.string().length(24, "Invalid transaction ID format")) 50 | .min(1, "At least one transaction ID must be provided"), 51 | }); 52 | 53 | export const bulkTransactionSchema = z.object({ 54 | transactions: z 55 | .array(baseTransactionSchema) 56 | .min(1, "At least one transaction is required") 57 | .max(300, "Must not be more than 300 transactions") 58 | .refine( 59 | (txs) => 60 | txs.every((tx) => { 61 | const amount = Number(tx.amount); 62 | return !isNaN(amount) && amount > 0 && amount <= 1_000_000_000; 63 | }), 64 | { 65 | message: "Amount must be a postive number", 66 | } 67 | ), 68 | }); 69 | 70 | export const createTransactionSchema = baseTransactionSchema; 71 | export const updateTransactionSchema = baseTransactionSchema.partial(); 72 | 73 | export type CreateTransactionType = z.infer; 74 | 75 | export type UpdateTransactionType = z.infer; 76 | 77 | export type BulkDelteTransactionType = z.infer< 78 | typeof bulkDeleteTransactionSchema 79 | >; 80 | -------------------------------------------------------------------------------- /backend/src/validators/user.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const updateUserSchema = z.object({ 4 | name: z.string().trim().min(1).max(255).optional(), 5 | }); 6 | 7 | export type UpdateUserType = z.infer; 8 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:8000/api 2 | VITE_REDUX_PERSIST_SECRET_KEY=redux-persist -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | import-transaction-dialog 15 | _billing.tsx 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }) 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from 'eslint-plugin-react-x' 39 | import reactDom from 'eslint-plugin-react-dom' 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | 'react-x': reactX, 45 | 'react-dom': reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs['recommended-typescript'].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /client/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Finora | Personnal Financial Platform 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^5.0.1", 14 | "@radix-ui/react-avatar": "^1.1.7", 15 | "@radix-ui/react-checkbox": "^1.2.3", 16 | "@radix-ui/react-dialog": "^1.1.13", 17 | "@radix-ui/react-dropdown-menu": "^2.1.12", 18 | "@radix-ui/react-label": "^2.1.4", 19 | "@radix-ui/react-navigation-menu": "^1.2.10", 20 | "@radix-ui/react-popover": "^1.1.11", 21 | "@radix-ui/react-progress": "^1.1.4", 22 | "@radix-ui/react-radio-group": "^1.3.4", 23 | "@radix-ui/react-select": "^2.2.2", 24 | "@radix-ui/react-separator": "^1.1.4", 25 | "@radix-ui/react-slot": "^1.2.0", 26 | "@radix-ui/react-switch": "^1.2.2", 27 | "@radix-ui/react-tabs": "^1.1.9", 28 | "@radix-ui/react-toggle": "^1.1.6", 29 | "@radix-ui/react-toggle-group": "^1.1.7", 30 | "@reduxjs/toolkit": "^2.8.1", 31 | "@tailwindcss/vite": "^4.1.4", 32 | "@tanstack/react-table": "^8.21.3", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "cmdk": "^1.1.1", 36 | "date-fns": "^3.6.0", 37 | "lucide-react": "^0.503.0", 38 | "next-themes": "^0.4.6", 39 | "nuqs": "^2.4.3", 40 | "react": "^19.0.0", 41 | "react-countup": "^6.5.3", 42 | "react-currency-input-field": "^3.10.0", 43 | "react-day-picker": "^8.10.1", 44 | "react-dom": "^19.0.0", 45 | "react-hook-form": "^7.56.1", 46 | "react-papaparse": "^4.4.0", 47 | "react-redux": "^9.2.0", 48 | "react-router-dom": "^7.5.2", 49 | "recharts": "^2.15.3", 50 | "redux-persist": "^6.0.0", 51 | "redux-persist-transform-encrypt": "^5.1.1", 52 | "sonner": "^2.0.3", 53 | "tailwind-merge": "^3.2.0", 54 | "tailwindcss": "^4.1.4", 55 | "tree": "^0.1.3", 56 | "vaul": "^1.1.2", 57 | "zod": "^3.24.3" 58 | }, 59 | "devDependencies": { 60 | "@eslint/js": "^9.22.0", 61 | "@types/node": "^22.15.2", 62 | "@types/react": "^19.0.10", 63 | "@types/react-dom": "^19.0.4", 64 | "@vitejs/plugin-react": "^4.3.4", 65 | "eslint": "^9.22.0", 66 | "eslint-plugin-react-hooks": "^5.2.0", 67 | "eslint-plugin-react-refresh": "^0.4.19", 68 | "globals": "^16.0.0", 69 | "tw-animate-css": "^1.2.8", 70 | "typescript": "~5.7.2", 71 | "typescript-eslint": "^8.26.1", 72 | "vite": "^6.3.1" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/@types/transaction.type.ts: -------------------------------------------------------------------------------- 1 | export type CsvColumn = { 2 | id: string; 3 | name: string; 4 | sampleData: string; 5 | hasError?: boolean; 6 | }; 7 | 8 | export type TransactionField = { 9 | fieldName: string; 10 | required: boolean; 11 | description?: string; 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import AppRoutes from "./routes"; 2 | import { ThemeProvider } from "./context/theme-provider"; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default App; -------------------------------------------------------------------------------- /client/src/app/api-client.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 2 | import { RootState } from "./store"; 3 | 4 | const baseQuery = fetchBaseQuery({ 5 | baseUrl: import.meta.env.VITE_API_URL, 6 | credentials: "include", 7 | prepareHeaders: (headers, { getState }) => { 8 | const auth = (getState() as RootState).auth; 9 | if (auth?.accessToken) { 10 | headers.set("Authorization", `Bearer ${auth.accessToken}`); 11 | } 12 | return headers; 13 | }, 14 | }); 15 | 16 | export const apiClient = createApi({ 17 | reducerPath: "api", // Add API client reducer to root reducer 18 | baseQuery: baseQuery, 19 | refetchOnMountOrArgChange: true, // Refetch on mount or arg change 20 | tagTypes: ["transactions", "analytics", "billingSubscription"], // Tag types for RTK Query 21 | endpoints: () => ({}), // Endpoints for RTK Query 22 | }); 23 | -------------------------------------------------------------------------------- /client/src/app/hook.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 2 | import { RootState, store } from "./store"; 3 | 4 | export type AppDispatch = typeof store.dispatch; // Type for dispatch function 5 | export const useAppDispatch: () => AppDispatch = useDispatch; 6 | export const useTypedSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /client/src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"; 2 | import authReducer from "../features/auth/authSlice"; 3 | import storage from "redux-persist/lib/storage"; 4 | import { 5 | persistReducer, 6 | persistStore, 7 | FLUSH, 8 | REHYDRATE, 9 | PAUSE, 10 | PERSIST, 11 | PURGE, 12 | REGISTER, 13 | } from "redux-persist"; 14 | import { apiClient } from "./api-client"; 15 | //import { encryptTransform } from 'redux-persist-transform-encrypt'; 16 | 17 | type RootReducerType = ReturnType; 18 | 19 | const persistConfig = { 20 | key: "root", // Key for the persisted data in storage 21 | storage, // Storage engine to use (localStorage) 22 | blacklist: [apiClient.reducerPath], // Specify which reducers not to persist (RTK Query cache) 23 | // transforms: [ 24 | // encryptTransform({ 25 | // secretKey: import.meta.env.VITE_REDUX_PERSIST_SECRET_KEY!, 26 | // onError: function (error) { 27 | // console.error('Encryption error:', error); 28 | // }, 29 | // }), 30 | // ], 31 | }; 32 | 33 | const rootReducer = combineReducers({ 34 | [apiClient.reducerPath]: apiClient.reducer, // Add API client reducer to root reducer 35 | auth: authReducer, // Add auth reducer to root reducer 36 | }); 37 | 38 | // Create a persisted version of the root reducer 39 | const persistedReducer = persistReducer( 40 | persistConfig, 41 | rootReducer 42 | ); 43 | 44 | const reduxPersistActions = [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]; 45 | 46 | export const store = configureStore({ 47 | reducer: persistedReducer, 48 | middleware: (getDefaultMiddleware) => 49 | getDefaultMiddleware({ 50 | serializableCheck: { 51 | ignoredActions: reduxPersistActions, /// Ignore specific actions in serializable checks 52 | }, 53 | }).concat(apiClient.middleware), 54 | }); 55 | 56 | export const persistor = persistStore(store); // Create a persistor linked to the store 57 | 58 | export type RootState = ReturnType; 59 | -------------------------------------------------------------------------------- /client/src/assets/images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/8663cdde75ee93da1b7dfe2aa5ea5548abe7e046/client/src/assets/images/dashboard.png -------------------------------------------------------------------------------- /client/src/assets/images/dashboard_.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/8663cdde75ee93da1b7dfe2aa5ea5548abe7e046/client/src/assets/images/dashboard_.png -------------------------------------------------------------------------------- /client/src/assets/images/dashboard_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TechWithEmmaYT/Advanced-MERN-AI-Financial-SaaS-Platform/8663cdde75ee93da1b7dfe2aa5ea5548abe7e046/client/src/assets/images/dashboard_dark.png -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/app-alert.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 3 | import { Check, Info, Terminal, X, AlertTriangle } from "lucide-react"; 4 | import { cn } from "@/lib/utils"; 5 | import { useEffect, useState } from "react"; 6 | 7 | type AlertVariant = "default" | "destructive" | "success" | "warning" | "info"; 8 | 9 | interface AppAlertProps { 10 | isError?: boolean; 11 | message: string; 12 | title?: string; 13 | variant?: AlertVariant; 14 | position?: 15 | | "top" 16 | | "top-right" 17 | | "top-left" 18 | | "bottom" 19 | | "bottom-right" 20 | | "bottom-left" 21 | | "center"; 22 | autoHideDuration?: number; 23 | onDismiss?: () => void; 24 | className?: string; 25 | showDismissButton?: boolean; 26 | } 27 | 28 | const variantClasses = { 29 | default: 30 | "bg-gray-100 text-gray-900 border border-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:border-gray-600", 31 | destructive: 32 | "bg-red-100 text-red-800 border border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600", 33 | success: 34 | "bg-green-100 text-green-800 border border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600", 35 | warning: 36 | "bg-yellow-100 text-yellow-800 border border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-600", 37 | info: 38 | "bg-blue-100 text-blue-800 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-600", 39 | }; 40 | 41 | const iconMap = { 42 | default: , 43 | destructive: , 44 | success: , 45 | warning: , 46 | info: , 47 | }; 48 | 49 | export const AppAlert = ({ 50 | isError = false, 51 | title = "Notice", 52 | message, 53 | variant = "destructive", 54 | autoHideDuration = 5000, 55 | onDismiss, 56 | className, 57 | showDismissButton = true, 58 | }: AppAlertProps) => { 59 | const [_, setShowError] = useState(isError); 60 | 61 | useEffect(() => { 62 | if (isError) { 63 | setShowError(true); 64 | if (autoHideDuration > 0) { 65 | const timer = setTimeout(() => { 66 | setShowError(false); 67 | onDismiss?.(); 68 | }, autoHideDuration); 69 | return () => clearTimeout(timer); 70 | } 71 | } 72 | }, [isError, autoHideDuration, onDismiss]); 73 | 74 | const handleDismiss = () => { 75 | setShowError(false); 76 | onDismiss?.(); 77 | }; 78 | 79 | return ( 80 |
81 | 87 |
88 |
89 | {iconMap[variant]} 90 | {title} 91 |
92 | {message} 93 |
94 | {showDismissButton && ( 95 | 102 | )} 103 |
104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /client/src/components/data-table/table-skeleton-loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Skeleton } from "@/components/ui/skeleton"; 3 | 4 | interface TableSkeletonProps { 5 | columns: number; 6 | rows?: number; 7 | } 8 | 9 | const TableSkeleton: React.FC = ({ 10 | columns, 11 | rows = 25, 12 | }) => { 13 | return ( 14 |
15 | {/* Table Header Skeleton */} 16 |
17 | {[...Array(columns)].map((_, index) => ( 18 |
19 | 20 |
21 | ))} 22 |
23 | 24 | {/* Table Body Skeleton */} 25 |
26 | {[...Array(rows)].map((_, rowIndex) => ( 27 |
28 | {[...Array(columns)].map((_, colIndex) => ( 29 |
33 | 34 |
35 | ))} 36 |
37 | ))} 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default TableSkeleton; -------------------------------------------------------------------------------- /client/src/components/date-range-picker/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { addDays, format } from "date-fns" 5 | import { CalendarIcon } from "lucide-react" 6 | import { DateRange } from "react-day-picker" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Button } from "../ui/button" 10 | import { Calendar } from "../ui/calendar" 11 | import { 12 | Popover, 13 | PopoverContent, 14 | PopoverTrigger, 15 | } from "../ui/popover" 16 | 17 | export function CalendarDateRangePicker({ 18 | className, 19 | }: React.HTMLAttributes) { 20 | const [date, setDate] = React.useState({ 21 | from: new Date(2023, 0, 20), 22 | to: addDays(new Date(2023, 0, 20), 20), 23 | }) 24 | 25 | return ( 26 |
27 | 28 | 29 | 52 | 53 | 54 | 62 | 63 | 64 |
65 | ) 66 | } -------------------------------------------------------------------------------- /client/src/components/empty-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { FileSearch, LucideIcon } from "lucide-react"; 2 | import * as React from "react"; 3 | 4 | interface EmptyStateProps { 5 | icon?: LucideIcon; 6 | title: string; 7 | description: string; 8 | className?: string; 9 | } 10 | 11 | export const EmptyState: React.FC = ({ 12 | icon, 13 | title, 14 | description, 15 | className = "", 16 | }) => { 17 | const Icon = icon || FileSearch 18 | return ( 19 |
20 | {Icon && ( 21 |
22 | 23 |
24 | )} 25 |

{title}

26 |

27 | {description} 28 |

29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/components/footer/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | const Footer = () => { 3 | return ( 4 |
5 |
6 |
7 | ) 8 | } 9 | 10 | export default Footer -------------------------------------------------------------------------------- /client/src/components/logo/logo.tsx: -------------------------------------------------------------------------------- 1 | import { PROTECTED_ROUTES } from "@/routes/common/routePath" 2 | import { GalleryVerticalEnd } from "lucide-react" 3 | import { Link } from "react-router-dom" 4 | 5 | const Logo = (props: { url?: string }) => { 6 | return ( 7 | 8 |
9 | 10 |
11 | Finora 12 | 13 | ) 14 | } 15 | 16 | export default Logo -------------------------------------------------------------------------------- /client/src/components/navbar/logout-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2 | import { DialogContent,DialogDescription } from "@/components/ui/dialog"; 3 | import { Loader } from "lucide-react"; 4 | import { Button } from "../ui/button"; 5 | import { useTransition } from "react"; 6 | import { useAppDispatch } from "@/app/hook"; 7 | import { logout } from "@/features/auth/authSlice"; 8 | import { useNavigate } from "react-router-dom"; 9 | import { AUTH_ROUTES } from "@/routes/common/routePath"; 10 | 11 | interface LogoutDialogProps { 12 | isOpen: boolean; 13 | setIsOpen: (value: boolean) => void; 14 | } 15 | 16 | const LogoutDialog = ({ isOpen, setIsOpen }: LogoutDialogProps) => { 17 | const [isPending, startTransition] = useTransition(); 18 | const dispatch = useAppDispatch(); 19 | const navigate = useNavigate(); 20 | 21 | const handleLogout = () => { 22 | startTransition(() => { 23 | setIsOpen(false); 24 | dispatch(logout()); 25 | navigate(AUTH_ROUTES.SIGN_IN); 26 | }); 27 | }; 28 | return ( 29 | 30 | 31 | 32 | Are you sure you want to log out? 33 | 34 | This will end your current session and you will need to log in 35 | again to access your account. 36 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | export default LogoutDialog -------------------------------------------------------------------------------- /client/src/components/navbar/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown, LogOut } from "lucide-react" 2 | import { 3 | Avatar, 4 | AvatarFallback, 5 | AvatarImage, 6 | } from "../ui/avatar" 7 | import { Button } from "../ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuGroup, 12 | DropdownMenuItem, 13 | DropdownMenuLabel, 14 | DropdownMenuSeparator, 15 | DropdownMenuTrigger, 16 | } from "../ui/dropdown-menu" 17 | 18 | export function UserNav({ 19 | userName, 20 | profilePicture, 21 | onLogout, 22 | }: { 23 | userName: string; 24 | profilePicture: string; 25 | onLogout: () => void; 26 | }) { 27 | return ( 28 | 29 | 30 | 48 | 49 | 56 | 57 | {userName} 58 | Free Trial (2 days left) 59 | 60 | 61 | 62 | 65 | 66 | Log out 67 | 68 | 69 | 70 | 71 | ) 72 | } -------------------------------------------------------------------------------- /client/src/components/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from "react"; 2 | 3 | interface PageHeaderProps { 4 | title?: string; 5 | subtitle?: string; 6 | rightAction?: ReactNode; 7 | renderPageHeader?: ReactNode 8 | } 9 | 10 | const PageHeader = ({ title, subtitle, rightAction,renderPageHeader }: PageHeaderProps) => { 11 | return ( 12 |
13 |
14 | {renderPageHeader 15 | ? {renderPageHeader} 16 | : ( 17 |
18 | {(title || subtitle) && ( 19 |
20 | {title &&

{title}

} 21 | {subtitle &&

{subtitle}

} 22 |
23 | )} 24 | {rightAction && rightAction} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default PageHeader -------------------------------------------------------------------------------- /client/src/components/page-layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import PageHeader from "./page-header"; 3 | 4 | interface PropsType { 5 | children: React.ReactNode; 6 | className?: string 7 | title?: string; 8 | subtitle?: string; 9 | rightAction?: React.ReactNode; 10 | showHeader?: boolean; 11 | addMarginTop?: boolean; 12 | renderPageHeader?: React.ReactNode 13 | } 14 | 15 | const PageLayout = ({ children, className, 16 | title, 17 | subtitle, 18 | rightAction, 19 | showHeader = true, 20 | addMarginTop = false, 21 | renderPageHeader, 22 | }: PropsType) => { 23 | return ( 24 |
25 | {showHeader && ( 26 | 32 | )} 33 |
36 | {children} 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default PageLayout; 43 | -------------------------------------------------------------------------------- /client/src/components/transaction/add-transaction-drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Drawer, 4 | DrawerClose, 5 | DrawerContent, 6 | DrawerDescription, 7 | DrawerHeader, 8 | DrawerTitle, 9 | DrawerTrigger, 10 | } from "@/components/ui/drawer"; 11 | import { PlusIcon, XIcon } from "lucide-react"; 12 | import { useState } from "react"; 13 | import TransactionForm from "./transaction-form"; 14 | 15 | const AddTransactionDrawer = () => { 16 | const [open, setOpen] = useState(false); 17 | 18 | const onCloseDrawer = () => { 19 | setOpen(false); 20 | }; 21 | 22 | 23 | return ( 24 | 25 | 26 | 30 | 31 | 32 | 33 |
34 | 35 | Add Transaction 36 | 37 | 38 | Add a new transaction to track your finances 39 | 40 |
41 | 42 | 43 | 44 |
45 | 48 |
49 |
50 | ); 51 | }; 52 | 53 | export default AddTransactionDrawer; 54 | -------------------------------------------------------------------------------- /client/src/components/transaction/edit-transaction-drawer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Drawer, 3 | DrawerContent, 4 | DrawerDescription, 5 | DrawerHeader, 6 | DrawerTitle, 7 | } from "@/components/ui/drawer"; 8 | import TransactionForm from "./transaction-form"; 9 | import useEditTransactionDrawer from "@/hooks/use-edit-transaction-drawer"; 10 | 11 | const EditTransactionDrawer = () => { 12 | const { open, transactionId, onCloseDrawer } = 13 | useEditTransactionDrawer(); 14 | return ( 15 | 16 | 17 | 18 | 19 | Edit Transaction 20 | 21 | 22 | Edit a transaction to track your finances 23 | 24 | 25 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default EditTransactionDrawer; 34 | -------------------------------------------------------------------------------- /client/src/components/transaction/import-transaction-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | } from "@/components/ui/dialog"; 6 | import { Button } from "@/components/ui/button"; 7 | import { ImportIcon } from "lucide-react"; 8 | import FileUploadStep from "./fileupload-step"; 9 | import ColumnMappingStep from "./column-mapping-step"; 10 | import { CsvColumn, TransactionField } from "@/@types/transaction.type"; 11 | import ConfirmationStep from "./confirmation-step"; 12 | 13 | 14 | const ImportTransactionModal = () => { 15 | const [step, setStep] = useState<1 | 2 | 3>(1); 16 | const [file, setFile] = useState(null); 17 | const [csvColumns, setCsvColumns] = useState([]); 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | const [csvData, setCsvData] = useState([]); 20 | const [mappings, setMappings] = useState>({}); 21 | const [open, setOpen] = useState(false); 22 | 23 | const transactionFields: TransactionField[] = [ 24 | { fieldName: 'title', required: true }, 25 | { fieldName: 'amount', required: true }, 26 | { fieldName: 'type', required: true }, 27 | { fieldName: 'date', required: true }, 28 | { fieldName: 'category', required: true }, 29 | { fieldName: 'paymentMethod', required: true }, 30 | { fieldName: 'description', required: false }, 31 | ]; 32 | 33 | // console.log(transactionFields, file, csvColumns, csvData, mappings); 34 | 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | const handleFileUpload = (file: File, columns: CsvColumn[], data: any[]) => { 38 | setFile(file); 39 | setCsvColumns(columns); 40 | setCsvData(data); 41 | setMappings({}); 42 | setStep(2); 43 | }; 44 | 45 | const resetImport = () => { 46 | setFile(null); 47 | setCsvColumns([]); 48 | setMappings({}); 49 | setStep(1); 50 | }; 51 | 52 | const handleClose = () => { 53 | setOpen(false); 54 | setTimeout(() => resetImport(), 300); 55 | }; 56 | 57 | const handleMappingComplete = (mappings: Record) => { 58 | setMappings(mappings); 59 | setStep(3); 60 | }; 61 | 62 | const handleBack = (step: 1 | 2 | 3 ) => { 63 | setStep(step); 64 | }; 65 | 66 | 67 | 68 | const renderStep = () => { 69 | switch(step) { 70 | case 1: 71 | return ; 72 | case 2: 73 | return ( 74 | handleBack(1)} 80 | /> 81 | ); 82 | case 3: 83 | return ( 84 | handleBack(2)} 89 | onComplete={() => handleClose()} 90 | /> 91 | ); 92 | default: 93 | return null; 94 | } 95 | }; 96 | 97 | return ( 98 | 99 | 108 | 109 | {renderStep()} 110 | 111 | 112 | ); 113 | }; 114 | 115 | export default ImportTransactionModal; 116 | -------------------------------------------------------------------------------- /client/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /client/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Avatar({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | function AvatarImage({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 32 | ) 33 | } 34 | 35 | function AvatarFallback({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ) 49 | } 50 | 51 | export { Avatar, AvatarImage, AvatarFallback } 52 | -------------------------------------------------------------------------------- /client/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /client/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground dark:text-white shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /client/src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronLeft, ChevronRight } from "lucide-react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | function Calendar({ 11 | className, 12 | classNames, 13 | showOutsideDays = true, 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 41 | : "[&:has([aria-selected])]:rounded-md" 42 | ), 43 | day: cn( 44 | buttonVariants({ variant: "ghost" }), 45 | "size-8 p-0 font-normal aria-selected:opacity-100" 46 | ), 47 | day_range_start: 48 | "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", 49 | day_range_end: 50 | "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", 51 | day_selected: 52 | "bg-primary text-primary-foreground hover:!bg-primary !text-white hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 53 | day_today: "bg-accent text-accent-foreground", 54 | day_outside: 55 | "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", 56 | day_disabled: "text-muted-foreground opacity-50 pointer-events-none", 57 | day_range_middle: 58 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 59 | day_hidden: "invisible", 60 | ...classNames, 61 | }} 62 | components={{ 63 | IconLeft: ({ className, ...props }) => ( 64 | 65 | ), 66 | IconRight: ({ className, ...props }) => ( 67 | 68 | ), 69 | }} 70 | {...props} 71 | /> 72 | ) 73 | } 74 | 75 | export { Calendar } 76 | -------------------------------------------------------------------------------- /client/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /client/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Checkbox({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export { Checkbox } 33 | -------------------------------------------------------------------------------- /client/src/components/ui/currency-input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react' 2 | import CurrencyInput from 'react-currency-input-field' 3 | import { cn } from '@/lib/utils' 4 | 5 | interface CurrencyInputFieldProps { 6 | name: string 7 | value?: string 8 | onValueChange?: (value?: string, name?: string) => void 9 | placeholder?: string 10 | className?: string 11 | prefix?: string 12 | disabled?: boolean 13 | } 14 | 15 | const CurrencyInputField = forwardRef( 16 | ({ name, value, onValueChange, placeholder, className, prefix = '$', disabled }, ref) => { 17 | return ( 18 | 36 | ) 37 | } 38 | ) 39 | 40 | CurrencyInputField.displayName = 'CurrencyInputField' 41 | 42 | export default CurrencyInputField 43 | -------------------------------------------------------------------------------- /client/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /client/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /client/src/components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | ChevronLeftIcon, 4 | ChevronRightIcon, 5 | MoreHorizontalIcon, 6 | } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Button, buttonVariants } from "@/components/ui/button" 10 | 11 | function Pagination({ className, ...props }: React.ComponentProps<"nav">) { 12 | return ( 13 |