Build a personal finance tracker in Replit in 1-2 hours. Track income and expenses across multiple accounts, set monthly budgets per category, and visualize spending trends with charts. Account balances update automatically via a PostgreSQL trigger on every transaction. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.
What you're building
Personal finance tools like Mint charge for premium features, share your data with advertisers, and can disappear overnight (Mint shut down in 2024). Building your own finance tracker takes a few hours and gives you a tool that works exactly the way you think about your money — with no subscription fees or data sharing.
Replit Agent generates the full backend: accounts, categories, transactions, and budget tables with Drizzle ORM. The most important architectural decision is maintaining account balances via a PostgreSQL trigger rather than in application code. The trigger fires on every insert, update, and delete on the transactions table, recalculating the account balance atomically. This prevents balance drift from race conditions or failed API calls.
The app runs entirely on Replit's built-in PostgreSQL with no external services. User data is isolated via Replit Auth — every query includes a WHERE user_id condition. Deploy on Autoscale, which scales to zero between daily usage sessions, keeping costs at zero on the free tier.
Final result
A personal finance tracker where you log income and expenses, set monthly budgets, and see your net worth and spending trends — all running in your Replit account with zero monthly cost.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient)
- Basic understanding of what income, expenses, and accounts are (no coding needed)
- No external API keys required — everything runs on Replit's built-in PostgreSQL
Build steps
Generate the schema and project with Replit Agent
The transaction-to-balance relationship is the most important design decision. By maintaining balances in the accounts table via a trigger, you never need to SUM all transactions to show a balance — it's always pre-calculated and accurate.
1// Prompt to type into Replit Agent:2// Build a personal finance tracker with Express and PostgreSQL using Drizzle ORM.3// Create these tables in shared/schema.ts:4// - accounts: id serial pk, user_id text not null, name text not null,5// type text not null (checking/savings/credit_card/cash/investment),6// balance integer not null default 0 (in cents),7// currency text default 'USD', is_active boolean default true, created_at timestamp8// - categories: id serial pk, user_id text not null, name text not null,9// type text not null (income/expense), icon text, color text,10// position integer default 011// - transactions: id serial pk, user_id text not null,12// account_id integer references accounts not null,13// category_id integer references categories,14// type text not null (income/expense/transfer),15// amount integer not null (always positive, in cents),16// description text, date date not null,17// is_recurring boolean default false,18// recurrence_rule text (none/daily/weekly/biweekly/monthly),19// notes text, created_at timestamp20// - budgets: id serial pk, user_id text not null,21// category_id integer references categories not null,22// amount integer not null (monthly limit in cents),23// month date not null (first day of month),24// UNIQUE on (user_id, category_id, month)25// Create a PostgreSQL trigger on transactions that updates accounts.balance:26// On INSERT: if type='expense' subtract amount, if type='income' add amount27// On DELETE: reverse the above28// On UPDATE: reverse the old amount, apply the new amount29// Set up Replit Auth. Bind server to 0.0.0.0.Pro tip: Ask Agent to create the trigger in the initial migration. A trigger-based balance is more reliable than application-level balance updates because it fires even for direct database edits done through Drizzle Studio.
Expected result: Agent creates shared/schema.ts with all four tables and a migration that includes the PostgreSQL trigger function. Verify the trigger exists by opening Drizzle Studio and creating a test transaction.
Build the transaction CRUD routes
Transactions are the core of the app. The balance trigger means you only need to insert, update, or delete transaction rows — the balance updates automatically. No manual account balance management.
1const { db } = require('../db');2const { transactions, accounts, categories } = require('../../shared/schema');3const { eq, and, gte, lte, desc, sql } = require('drizzle-orm');45router.get('/api/transactions', async (req, res) => {6 const { startDate, endDate, accountId, categoryId, type, q, page = 1, limit = 50 } = req.query;7 const offset = (Number(page) - 1) * Number(limit);89 const conditions = [eq(transactions.userId, req.user.id)];10 if (startDate) conditions.push(gte(transactions.date, startDate));11 if (endDate) conditions.push(lte(transactions.date, endDate));12 if (accountId) conditions.push(eq(transactions.accountId, Number(accountId)));13 if (categoryId) conditions.push(eq(transactions.categoryId, Number(categoryId)));14 if (type) conditions.push(eq(transactions.type, type));15 if (q) conditions.push(sql`transactions.description ILIKE ${'%' + q + '%'}`);1617 const [result, countResult] = await Promise.all([18 db.select({19 id: transactions.id,20 type: transactions.type,21 amount: transactions.amount,22 description: transactions.description,23 date: transactions.date,24 accountName: accounts.name,25 categoryName: categories.name,26 categoryColor: categories.color,27 categoryIcon: categories.icon28 }).from(transactions)29 .leftJoin(accounts, eq(transactions.accountId, accounts.id))30 .leftJoin(categories, eq(transactions.categoryId, categories.id))31 .where(and(...conditions))32 .orderBy(desc(transactions.date))33 .limit(Number(limit))34 .offset(offset),3536 db.select({ count: sql`COUNT(*)` }).from(transactions).where(and(...conditions))37 ]);3839 res.json({ transactions: result, total: Number(countResult[0].count) });40});4142router.post('/api/transactions', async (req, res) => {43 const { accountId, categoryId, type, amount, description, date, isRecurring, recurrenceRule, notes } = req.body;4445 // Validate account belongs to user46 const account = await db.query.accounts.findFirst({47 where: and(eq(accounts.id, Number(accountId)), eq(accounts.userId, req.user.id))48 });49 if (!account) return res.status(404).json({ error: 'Account not found' });5051 const [tx] = await db.insert(transactions).values({52 userId: req.user.id, accountId: Number(accountId), categoryId: categoryId ? Number(categoryId) : null,53 type, amount: Math.abs(Number(amount)), description, date, isRecurring, recurrenceRule, notes54 }).returning();5556 // The trigger automatically updates accounts.balance — no manual update needed57 res.json(tx);58});5960router.delete('/api/transactions/:id', async (req, res) => {61 const tx = await db.query.transactions.findFirst({62 where: and(eq(transactions.id, Number(req.params.id)), eq(transactions.userId, req.user.id))63 });64 if (!tx) return res.status(404).json({ error: 'Transaction not found' });6566 await db.delete(transactions).where(eq(transactions.id, tx.id));67 // Trigger reverses the balance automatically68 res.json({ success: true });69});Pro tip: After inserting a transaction, verify the trigger worked by calling GET /api/accounts and checking the balance changed. If it didn't, check the trigger exists in Drizzle Studio under Database → Functions.
Expected result: Adding a $50 expense transaction automatically reduces the account balance by $50. Deleting the transaction restores the balance. This confirms the trigger is working.
Build the budget and reports routes
The budget comparison query is the most valuable feature — it shows how much you've spent against your budget for the current month, per category. The monthly summary report powers the trend charts.
1// Prompt to type into Replit Agent:2// Add budget and reporting routes to server/routes/reports.js:3//4// GET /api/budgets/:month — budget comparison for a month (format: YYYY-MM-01)5// Returns per-category: budget_amount, actual_spent, percentage_used6// Query:7// SELECT c.id, c.name, c.color, c.icon,8// b.amount AS budget_amount,9// COALESCE(SUM(t.amount), 0) AS actual_spent,10// CASE WHEN b.amount > 0 THEN ROUND(COALESCE(SUM(t.amount), 0) * 100.0 / b.amount)11// ELSE 0 END AS percentage_used12// FROM budgets b13// JOIN categories c ON c.id = b.category_id14// LEFT JOIN transactions t ON t.category_id = c.id15// AND t.type = 'expense'16// AND t.date >= :month AND t.date < :month + interval '1 month'17// WHERE b.user_id = req.user.id AND b.month = :month18// GROUP BY c.id, c.name, c.color, c.icon, b.amount19//20// PUT /api/budgets/:month — set budgets for a month21// Body: array of {categoryId, amount}22// Use INSERT ... ON CONFLICT (user_id, category_id, month) DO UPDATE SET amount23//24// GET /api/reports/monthly — income vs expense for last 12 months25// Returns array of {month, income, expenses, net}26// Use DATE_TRUNC('month', date) to group by month27//28// GET /api/reports/categories — spending by category for a date range29// Returns array of {category_name, category_color, total_amount, transaction_count}30//31// GET /api/dashboard — summary endpoint:32// Returns: accounts (list with balances), net_worth (sum of all account balances),33// current_month_income, current_month_expenses, budget_alerts (categories over 90%),34// recent_transactions (last 10)Expected result: GET /api/budgets/2026-05-01 returns each budget category with actual spending and percentage used. GET /api/dashboard returns all summary data needed for the main dashboard view.
Build the React dashboard and transaction form
The dashboard is where users spend most of their time. Show accounts, net worth, budget progress bars, and recent transactions on a single page. The add-transaction modal should be fast to open and submit.
1// Prompt to type into Replit Agent:2// Build these React components:3//4// 1. Dashboard at client/src/pages/Dashboard.jsx:5// - On mount: fetch GET /api/dashboard6// - Net worth card: large number showing sum of all account balances (in dollars)7// - Account cards: one per account showing name, type badge, current balance8// Color: positive balance = green text, negative = red (for credit cards: invert)9// - Income vs Expenses section: two numbers for current month10// 'Saved this month' = income - expenses in green or red11// - Budget progress bars:12// Each category: name, progress bar (width = percentage_used%),13// actual/budget amounts in dollars14// Colors: green if < 80%, yellow if 80-100%, red if > 100%15// - Recent transactions list: last 10, each showing:16// category icon, description, account name, date, amount (red for expense, green for income)17//18// 2. Add Transaction modal:19// - Amount input with $ prefix (number, required)20// - Type selector: Income / Expense / Transfer (button group)21// - Account selector (dropdown from GET /api/accounts)22// - Category selector (dropdown filtered by selected type)23// - Description input24// - Date picker (default today)25// - Notes textarea (optional)26// - Submit → POST /api/transactions, close modal, refresh dashboard27//28// 3. Transactions page at client/src/pages/Transactions.jsx:29// - Data table with filters: date range picker, account dropdown, category dropdown, search bar30// - Rows: date, description, category (with colored icon), account, amount (colored)31// - Click row to edit, delete button per rowExpected result: The dashboard loads with account balances, net worth, budget progress bars (green/yellow/red), and recent transactions. Adding a transaction closes the modal and updates all numbers without a full page reload.
Add seed categories and deploy
Seed the categories table with common income and expense categories before sharing the app. Then deploy on Autoscale — personal finance tools are used daily but briefly, making Autoscale ideal.
1// Prompt to type into Replit Agent:2// Create scripts/seed.js that inserts default categories for a new user.3// Income categories:4// [{name: 'Salary', type: 'income', icon: '💼', color: '#22c55e'},5// {name: 'Freelance', type: 'income', icon: '💻', color: '#16a34a'},6// {name: 'Investments', type: 'income', icon: '📈', color: '#15803d'},7// {name: 'Other Income', type: 'income', icon: '➕', color: '#166534'}]8// Expense categories:9// [{name: 'Food & Dining', type: 'expense', icon: '🍔', color: '#ef4444'},10// {name: 'Transportation', type: 'expense', icon: '🚗', color: '#f97316'},11// {name: 'Shopping', type: 'expense', icon: '🛍️', color: '#a855f7'},12// {name: 'Bills & Utilities', type: 'expense', icon: '📄', color: '#6366f1'},13// {name: 'Entertainment', type: 'expense', icon: '🎬', color: '#ec4899'},14// {name: 'Health', type: 'expense', icon: '🏥', color: '#14b8a6'},15// {name: 'Housing', type: 'expense', icon: '🏠', color: '#8b5cf6'},16// {name: 'Other', type: 'expense', icon: '📦', color: '#6b7280'}]17// Accept userId as command line arg: node scripts/seed.js USER_ID18// INSERT with ON CONFLICT DO NOTHING so re-running is safe19//20// Then add SESSION_SECRET to Replit Secrets (lock icon)21// Ensure server binds to 0.0.0.022// Deploy: Deploy → AutoscalePro tip: Run the seed script from the Replit Shell tab after getting your Replit user ID from the /api/me route. This gives you sensible default categories on first login without needing to set them up manually.
Expected result: The app deploys on Autoscale. After logging in with Replit Auth, the category dropdowns are pre-populated with income and expense categories. The first account and transaction can be created immediately.
Complete code
1const { Router } = require('express');2const { db } = require('../db');3const { transactions, accounts, categories } = require('../../shared/schema');4const { eq, and, gte, lte, desc, sql } = require('drizzle-orm');56const router = Router();78router.get('/api/transactions', async (req, res) => {9 if (!req.user) return res.status(401).json({ error: 'Auth required' });10 const { startDate, endDate, accountId, categoryId, type, q, page = 1, limit = 50 } = req.query;11 const offset = (Number(page) - 1) * Number(limit);1213 const conditions = [eq(transactions.userId, req.user.id)];14 if (startDate) conditions.push(gte(transactions.date, startDate));15 if (endDate) conditions.push(lte(transactions.date, endDate));16 if (accountId) conditions.push(eq(transactions.accountId, Number(accountId)));17 if (type) conditions.push(eq(transactions.type, type));18 if (q) conditions.push(sql`transactions.description ILIKE ${'%' + q + '%'}`);1920 const rows = await db.select({21 id: transactions.id, type: transactions.type, amount: transactions.amount,22 description: transactions.description, date: transactions.date,23 accountName: accounts.name, categoryName: categories.name,24 categoryColor: categories.color, categoryIcon: categories.icon25 }).from(transactions)26 .leftJoin(accounts, eq(transactions.accountId, accounts.id))27 .leftJoin(categories, eq(transactions.categoryId, categories.id))28 .where(and(...conditions)).orderBy(desc(transactions.date))29 .limit(Number(limit)).offset(offset);3031 res.json(rows);32});3334router.post('/api/transactions', async (req, res) => {35 if (!req.user) return res.status(401).json({ error: 'Auth required' });36 const { accountId, categoryId, type, amount, description, date, notes } = req.body;3738 const account = await db.query.accounts.findFirst({39 where: and(eq(accounts.id, Number(accountId)), eq(accounts.userId, req.user.id))40 });41 if (!account) return res.status(404).json({ error: 'Account not found' });4243 const [tx] = await db.insert(transactions).values({44 userId: req.user.id, accountId: Number(accountId),45 categoryId: categoryId ? Number(categoryId) : null,46 type, amount: Math.abs(Number(amount)), description, date, notes47 }).returning();4849 // PostgreSQL trigger updates accounts.balance automatically50 const [updated] = await db.select({ balance: accounts.balance })51 .from(accounts).where(eq(accounts.id, Number(accountId)));5253 res.json({ transaction: tx, newBalance: updated.balance });54});5556router.delete('/api/transactions/:id', async (req, res) => {57 if (!req.user) return res.status(401).json({ error: 'Auth required' });58 const tx = await db.query.transactions.findFirst({59 where: and(eq(transactions.id, Number(req.params.id)), eq(transactions.userId, req.user.id))60 });Customization ideas
CSV import from bank exports
Add a POST /api/transactions/import route that accepts a CSV file using multer. Parse common bank export formats (date, description, amount columns), match descriptions to categories using keyword rules, and bulk-insert the transactions. The trigger handles balance updates for all imported rows.
Savings goals
Add a goals table (name, target_amount, current_amount, target_date, account_id). A goal can link to a specific savings account. The dashboard shows progress bars per goal alongside budget bars, with projected completion date based on current monthly savings rate.
Net worth history chart
Add a net_worth_snapshots table. A Scheduled Deployment runs on the first of each month, sums all account balances for the user, and inserts a snapshot row. The reports page shows a line chart of net worth over time.
Common pitfalls
Pitfall: Manually updating account balances in the route handler instead of using a trigger
How to avoid: Use a PostgreSQL trigger that fires AFTER INSERT, UPDATE, DELETE on transactions. The trigger and the originating transaction execute atomically — either both succeed or both fail.
Pitfall: Storing amounts as floats (1.50) instead of integers in cents (150)
How to avoid: Store all amounts as integers in cents. Convert to dollars only in the frontend for display: (cents / 100).toFixed(2). Never store 1.50 — store 150.
Pitfall: Querying all transactions to calculate account balance on every page load
How to avoid: The trigger-maintained balance column in the accounts table is the answer. Always read the pre-calculated balance, never SUM transactions. The trigger keeps it current.
Best practices
- Store all monetary amounts as integers in cents (100 = $1.00) to avoid floating-point rounding errors in financial calculations.
- Use a PostgreSQL trigger to maintain account balances — it runs atomically with the transaction insert and handles rollbacks correctly if anything fails.
- Scope every query to the authenticated user's user_id. Never return transactions or account balances for other users even if called with a valid account ID.
- Use Drizzle Studio (database icon in sidebar) to verify the trigger works after setup: insert a transaction manually in Drizzle Studio and confirm the account balance updates.
- Default the transaction date to today's date in the frontend form but allow editing — users often log transactions from receipts at the end of the day.
- Deploy on Autoscale — personal finance apps have brief daily usage sessions. The scale-to-zero behavior keeps costs at zero on the free tier.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a personal finance tracker with Express and PostgreSQL. I have an accounts table with a balance integer column (in cents) and a transactions table with type (income/expense/transfer) and amount columns. I need a PostgreSQL trigger function that fires AFTER INSERT, UPDATE, DELETE on transactions and keeps accounts.balance accurate. For INSERT: if type='expense', subtract amount from accounts.balance; if type='income', add amount. For DELETE: reverse the above. For UPDATE: reverse the old row's effect and apply the new row's effect. Help me write the trigger function in PL/pgSQL.
Add recurring transaction auto-generation to the finance tracker. A Scheduled Deployment (scripts/generateRecurring.js) runs daily. It queries transactions where is_recurring=true and recurrence_rule is set. For each recurring transaction, check if a new instance should be generated based on the rule (daily/weekly/biweekly/monthly) and last transaction date. If yes, insert a new transaction row with today's date. The PostgreSQL trigger handles the balance update automatically.
Frequently asked questions
How do I handle credit card transactions?
Credit card accounts work like any other account. Expenses increase the amount owed (balance goes more negative) and payments decrease it (balance goes less negative). In the UI, show credit card balances in red and display the absolute value as 'owed' to make it intuitive.
What is a transfer transaction?
A transfer moves money between two of your accounts — for example, paying your credit card bill from your checking account. Create two transactions: an expense on the checking account and an income on the credit card account, both for the same amount. The trigger handles both balance updates.
Can multiple family members use the same tracker?
In the current design, each Replit Auth user gets their own isolated data. For shared tracking, add a household_id column to accounts and transactions, and a household_members table. Queries would filter by household_id instead of user_id for shared accounts.
Do I need a paid Replit plan for this?
No. The free plan includes PostgreSQL, Autoscale deployment, and Replit Auth. The only limitation is the database sleeping after 5 minutes of inactivity, which adds a brief cold-start delay on the first daily login.
How do I import my bank transactions?
Most banks let you export transactions as CSV. Build a POST /api/transactions/import endpoint using multer for file upload and csv-parse for parsing. Map the CSV columns (date, description, amount) to your schema, detect income vs expense from the amount sign, and bulk-insert the rows. The trigger handles all balance updates.
Why store amounts in cents instead of dollars?
Floating-point numbers cannot represent all decimal values precisely. 0.1 + 0.2 evaluates to 0.30000000000000004 in JavaScript. Storing amounts as integers (cents) eliminates all rounding errors in financial arithmetic. Only convert to dollars when displaying to the user.
Can RapidDev help build a custom finance tool for my business?
Yes. RapidDev has built 600+ apps and can add features like multi-currency support, bank API integration (Plaid), tax category mapping, and accountant export formats. Book a free consultation at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation