Skip to main content
RapidDev - Software Development Agency

How to Build a Finance Tracker with Replit

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'll build

  • Multi-account management for checking, savings, credit card, and cash accounts
  • Transaction logging with categories, types (income/expense/transfer), and recurring rules
  • PostgreSQL trigger that maintains account balances automatically on every insert/update/delete
  • Monthly budget system with actual-vs-budget comparison per category
  • Reports API returning monthly income vs expense trends and category spending breakdowns
  • Dashboard with net worth calculation, budget progress bars, and recent transaction list
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

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

1

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.

prompt.txt
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 timestamp
8// - 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 0
11// - 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 timestamp
20// - 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 amount
27// On DELETE: reverse the above
28// On UPDATE: reverse the old amount, apply the new amount
29// 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.

2

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.

server/routes/transactions.js
1const { db } = require('../db');
2const { transactions, accounts, categories } = require('../../shared/schema');
3const { eq, and, gte, lte, desc, sql } = require('drizzle-orm');
4
5router.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);
8
9 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 + '%'}`);
16
17 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.icon
28 }).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),
35
36 db.select({ count: sql`COUNT(*)` }).from(transactions).where(and(...conditions))
37 ]);
38
39 res.json({ transactions: result, total: Number(countResult[0].count) });
40});
41
42router.post('/api/transactions', async (req, res) => {
43 const { accountId, categoryId, type, amount, description, date, isRecurring, recurrenceRule, notes } = req.body;
44
45 // Validate account belongs to user
46 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' });
50
51 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, notes
54 }).returning();
55
56 // The trigger automatically updates accounts.balance — no manual update needed
57 res.json(tx);
58});
59
60router.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' });
65
66 await db.delete(transactions).where(eq(transactions.id, tx.id));
67 // Trigger reverses the balance automatically
68 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.

3

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.

prompt.txt
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_used
6// 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_used
12// FROM budgets b
13// JOIN categories c ON c.id = b.category_id
14// LEFT JOIN transactions t ON t.category_id = c.id
15// 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 = :month
18// GROUP BY c.id, c.name, c.color, c.icon, b.amount
19//
20// PUT /api/budgets/:month — set budgets for a month
21// Body: array of {categoryId, amount}
22// Use INSERT ... ON CONFLICT (user_id, category_id, month) DO UPDATE SET amount
23//
24// GET /api/reports/monthly — income vs expense for last 12 months
25// Returns array of {month, income, expenses, net}
26// Use DATE_TRUNC('month', date) to group by month
27//
28// GET /api/reports/categories — spending by category for a date range
29// 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.

4

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.

prompt.txt
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/dashboard
6// - Net worth card: large number showing sum of all account balances (in dollars)
7// - Account cards: one per account showing name, type badge, current balance
8// Color: positive balance = green text, negative = red (for credit cards: invert)
9// - Income vs Expenses section: two numbers for current month
10// 'Saved this month' = income - expenses in green or red
11// - Budget progress bars:
12// Each category: name, progress bar (width = percentage_used%),
13// actual/budget amounts in dollars
14// 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 input
24// - Date picker (default today)
25// - Notes textarea (optional)
26// - Submit → POST /api/transactions, close modal, refresh dashboard
27//
28// 3. Transactions page at client/src/pages/Transactions.jsx:
29// - Data table with filters: date range picker, account dropdown, category dropdown, search bar
30// - Rows: date, description, category (with colored icon), account, amount (colored)
31// - Click row to edit, delete button per row

Expected 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.

5

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.

prompt.txt
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_ID
18// INSERT with ON CONFLICT DO NOTHING so re-running is safe
19//
20// Then add SESSION_SECRET to Replit Secrets (lock icon)
21// Ensure server binds to 0.0.0.0
22// Deploy: Deploy → Autoscale

Pro 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

server/routes/transactions.js
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');
5
6const router = Router();
7
8router.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);
12
13 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 + '%'}`);
19
20 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.icon
25 }).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);
30
31 res.json(rows);
32});
33
34router.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;
37
38 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' });
42
43 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, notes
47 }).returning();
48
49 // PostgreSQL trigger updates accounts.balance automatically
50 const [updated] = await db.select({ balance: accounts.balance })
51 .from(accounts).where(eq(accounts.id, Number(accountId)));
52
53 res.json({ transaction: tx, newBalance: updated.balance });
54});
55
56router.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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.