A personal finance tracker built with Svelte 5, TypeScript, Express, and MongoDB. Includes an AI finance assistant powered by Groq, real-time sync via Pusher, recurring transactions, receipt OCR, and multi-currency support. Deployed on Vercel.
- Transactions — Add, edit, and delete income and expense entries with category, date, currency, family member, and notes
- Recurring transactions — Set up daily / weekly / biweekly / monthly / yearly templates; entries are auto-generated by a server-side scheduler
- Dashboard — Summary cards, category pie chart, trend chart, budget progress bars, upcoming recurring transactions, and recent entries
- AI assistant — Chat with your financial data, parse expenses from natural language ("spent 450 on lunch"), and OCR receipts (single or bulk up to 10 images)
- Budget goals — Per-category spending limits with green → amber → red progress bars
- Multi-currency — 150+ currencies, live exchange rates, per-transaction currency, unified display currency
- Family members — Tag transactions by household member
- Import / Export — CSV import (up to 500 rows) and CSV export
- Real-time sync — Pusher WebSocket events with 5-minute polling fallback
- Dark mode — System-aware toggle persisted to
localStorage - Mobile — Responsive layout with FAB quick-add button
- Google OAuth — Sign in with Google in addition to email/password
SpendsWise/
├── index.html # Vite frontend entry
├── src/ # Svelte 5 SPA
│ ├── app.ts # Client entry point
│ ├── App.svelte # Root component — router, modals, layout
│ ├── app.css # Tailwind v4 via @import "tailwindcss"
│ ├── types.ts # Shared TypeScript interfaces
│ ├── lib/
│ │ ├── state.svelte.ts # Reactive state (Svelte 5 runes)
│ │ ├── api.ts # API wrapper + all endpoint functions
│ │ ├── currency.ts # Currency formatting/conversion + live rates
│ │ ├── calculations.svelte.ts # Async summary calculations
│ │ ├── utils.ts # Date helpers, trend data, image compression
│ │ ├── charts.ts # Canvas pie chart + trend chart renderers
│ │ └── constants.ts # Currencies, colors, category icons
│ ├── components/ # UI components (modals, forms, cards, charts)
│ └── views/ # Dashboard, IncomeView, ExpenseView, AccountView
├── api/
│ └── index.ts # Vercel serverless entry (re-exports Express app)
├── server/ # Express + TypeScript API
│ └── src/
│ ├── app.ts # Express app setup — middleware, routes, scheduler
│ ├── index.ts # Local dev entry (listens on PORT)
│ ├── config.ts # Environment variable loading
│ ├── db.ts # MongoDB connection
│ ├── lib/
│ │ ├── pusher.ts # Pusher real-time notifications
│ │ └── recurringScheduler.ts # 60s scheduler for recurring entries
│ ├── middleware/ # auth, csrf, asyncHandler, groqRateLimiter, passport
│ ├── models/ # Expense, User Mongoose schemas
│ └── routes/ # auth, expenses, familyMembers, ai, currency
├── vercel.json
├── package.json # Frontend deps + scripts
└── server/package.json # Backend deps + scripts
- Node.js 18+
- A MongoDB instance (local or MongoDB Atlas)
npm install
npm run dev # Vite dev server with HMR (proxies /api → localhost:4000)
npm run build # production build → dist/
npm run preview # preview production build locallycd server
npm install
cp .env.example .env
# Fill in the required values (see Environment variables below)
npm run dev # ts-node-dev with auto-reloadGenerate secrets:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"Health check: GET http://localhost:4000/health
Set these in server/.env for local development, or in your Vercel dashboard for production.
| Variable | Required | Description |
|---|---|---|
MONGODB_URI |
yes | MongoDB connection string |
JWT_SECRET |
yes | Long random string for signing JWTs |
CSRF_SECRET |
yes | Long random string for signing CSRF tokens (separate from JWT_SECRET) |
GOOGLE_CLIENT_ID |
no | Google OAuth — from Google Cloud Console → APIs & Services → Credentials |
GOOGLE_CLIENT_SECRET |
no | Google OAuth client secret |
GOOGLE_CALLBACK_URL |
no | Default: http://localhost:5173/api/auth/google/callback |
FRONTEND_URL |
no | Default: http://localhost:5173 (must match Vite dev server or Vercel URL) |
GROQ_API_KEY |
no | Enables AI features — get one free at console.groq.com |
GROQ_MODEL |
no | Groq text model (default: llama-3.3-70b-versatile) |
GROQ_VISION_API_KEY |
no | Separate Groq key for receipt OCR — falls back to GROQ_API_KEY |
GROQ_VISION_MODEL |
no | Groq vision model (default: meta-llama/llama-4-scout-17b-16e-instruct) |
AI_DAILY_LIMIT |
no | Max AI requests per user per day (default: 50) |
AI_MONTHLY_LIMIT |
no | Max AI requests per user per month (default: 500) |
PUSHER_APP_ID |
no | Pusher app ID — enables real-time sync |
PUSHER_KEY |
no | Pusher public key (used by server and client) |
PUSHER_SECRET |
no | Pusher secret (server only) |
PUSHER_CLUSTER |
no | Pusher cluster (e.g. us2, eu, ap1) |
CURRENCY_API_KEY |
no | exchangerate-api.com key — uses demo key if not set |
Session is managed via an HttpOnly cookie (sw_session). All state-changing requests must include a valid CSRF token in the x-csrf-token header — fetch one from GET /api/auth/csrf first.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
no | Health check |
| GET | /api/auth/csrf |
no | Get CSRF token; sets sw_csrf cookie |
| POST | /api/auth/register |
no | { email, password } |
| POST | /api/auth/login |
no | { email, password } |
| POST | /api/auth/logout |
no | Clears session cookie |
| GET | /api/auth/me |
yes | Current user profile |
| PUT | /api/auth/password |
yes | { currentPassword, newPassword } |
| GET | /api/auth/google |
no | Start Google OAuth flow |
| GET | /api/auth/google/callback |
no | Google OAuth callback |
| GET | /api/expenses |
yes | List all expenses (sorted by date desc) |
| POST | /api/expenses |
yes | { amount, category, type, note?, date?, currency?, familyMember?, recurrence? } |
| PUT | /api/expenses/:id |
yes | Update any subset of expense fields |
| DELETE | /api/expenses/:id |
yes | Delete single expense |
| DELETE | /api/expenses |
yes | { confirm: true } — delete all expenses |
| GET | /api/expenses/recurring |
yes | List active recurring templates |
| PUT | /api/expenses/:id/recurring |
yes | { frequency, endDate, isActive, nextDueDate } |
| POST | /api/expenses/bulk |
yes | { rows: [...] } — bulk create up to 500 rows |
| GET | /api/family-members |
yes | List family members |
| POST | /api/family-members |
yes | { name } |
| DELETE | /api/family-members |
yes | { name } |
| GET | /api/currency/rates |
yes | ?base={currency} — live conversion rates |
| GET | /api/ai/quota |
yes | Remaining daily/monthly AI quota |
| POST | /api/ai/chat |
yes | { message, history? } — chat with expense context |
| POST | /api/ai/parse |
yes | { text } — natural language → structured expense |
| POST | /api/ai/parse-receipt |
yes | { imageData } — base64 receipt image → expense fields |
| POST | /api/ai/parse-receipts-bulk |
yes | { images: [...] } — batch OCR, up to 10 images |
Expense
{
userId: ObjectId,
type: "income" | "expense",
amount: Number (0–1e12),
category: String,
currency: String (default "USD"),
familyMember: String,
note: String,
date: Date,
recurrence: { // null for non-recurring
frequency: "daily" | "weekly" | "biweekly" | "monthly" | "yearly",
nextDueDate: Date,
endDate: Date | null,
isActive: Boolean
},
timestamps: true
}
Recurring templates carry the recurrence subdocument. Auto-generated instances are plain expenses with no recurrence field.
User
{
email: String (unique),
passwordHash: String,
googleId: String (optional),
familyMembers: [String],
aiUsage: { dailyCount, monthlyCount, dailyDate, monthlyDate },
tokenVersion: Number,
timestamps: true
}
| Control | Detail |
|---|---|
| CSRF protection | Double-submit cookie pattern. GET /api/auth/csrf issues a token; all POST/PUT/DELETE must send it as x-csrf-token. |
| Session cookie | HttpOnly, SameSite=Strict, Secure (production). |
| Token revocation | tokenVersion on the User document. Password change increments it, invalidating all live sessions instantly. |
| Rate limiting | Auth: 10 req / 15 min / IP. Expenses: 200 req / 15 min / IP. |
| Content-Type enforcement | Non-JSON bodies on state-changing requests rejected with 415. |
| Password rules | 12–72 characters, requires uppercase, lowercase, digit, and special character. bcrypt cost 12. |
| NoSQL injection | express-mongo-sanitize strips $ and . operator keys from all request bodies. |
| Security headers | Helmet (HSTS, X-Content-Type-Options, etc.) on every response. |
| ObjectId validation | All :id params validated with ObjectId.isValid() before DB queries. |
| Google OAuth isolation | Email and Google ID lookups run separately to prevent account takeover. |
The project deploys as a monorepo on Vercel:
- Frontend — Vite builds the Svelte SPA to
dist/; static files served by Vercel CDN. - Backend —
api/index.tsis the serverless entry; all/api/*and/healthrequests route there.
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"routes": [
{ "src": "/api/(.*)", "dest": "api/index.ts" },
{ "src": "/health", "dest": "api/index.ts" },
{ "handle": "filesystem" },
{ "src": "/(.*)", "dest": "/index.html" }
]
}MongoDB Atlas note: Add
0.0.0.0/0to Network Access so Vercel's dynamic IPs can connect.
MIT