Local-first personal finance tracker for understanding monthly budget, recurring commitments, and cash flow.
This repository has an initial Electron/Vite/React scaffold.
pnpm install
pnpm dev
pnpm buildCurrent direction:
- App: local desktop app
- Shell: Electron + Vite
- Frontend: React + TypeScript
- UI: shadcn/ui + Base UI + Tailwind
- Data: SQLite with JSONL export
- Primary MVP workflow: monthly budget overview
- App icon:
build/icon.png
Next implementation step:
- Add the Drizzle SQLite schema and migrations.
- Build typed persistence/services for accounts, categories, transactions, and dashboard totals.
- Build the first vertical slice: accounts, categories, manual transactions, and dashboard totals.
Fina should help answer one core MVP question:
How much money is available this month after income, spending, and recurring commitments?
The first prototype focuses on monthly budget clarity, not complete accounting.
A user can:
- Add income, expenses, and transfers manually.
- Import transactions from a bank or credit card CSV statement.
- Review imported transactions before accepting them.
- Categorize transactions into budget categories.
- Mark recurring income and expenses.
- See a current-month overview of:
- total income
- total expenses
- net cash flow
- recurring commitments
- remaining available budget
- spending by category
- recent transactions
Core record for income, expenses, and transfers.
Fields:
- date
- amount
- type: income, expense, or transfer
- description
- account
- category
- optional notes
- source: manual or imported
Small set of accounts used to group transactions and make imports understandable:
- checking account
- savings account
- credit card
- cash
Full reconciliation can come later.
Editable budget categories with sensible defaults, for example:
- salary
- rent
- groceries
- utilities
- subscriptions
- restaurants
- transportation
- other
Recurring income or expenses help the monthly overview account for predictable commitments before transactions appear in imports.
Examples:
- salary
- rent
- utilities
- phone bill
- insurance
- subscriptions
Imported transactions go through review before saving.
The review step should allow the user to:
- inspect parsed rows
- choose or confirm the target account
- map CSV columns if needed
- accept, skip, or edit transactions
- avoid obvious duplicates
Data should stay portable. The app should support JSONL export for durable records:
- accounts
- categories
- transactions
- recurring items
- import batches
A likely record shape:
{"recordType":"transaction","schemaVersion":1,"data":{}}Important later, but not part of the first prototype:
- tax calculation
- tax filing exports
- investment performance tracking
- multi-user support
- cloud sync
- automatic bank API integrations
- advanced forecasting
- full double-entry accounting
- mobile app support
Optimize for long-term maintainability and high-quality React/UI architecture while keeping the first prototype small.
- Desktop shell: Electron + Vite
- Language: TypeScript
- Frontend: React
- Styling/UI: Tailwind + shadcn/ui + Base UI
- Routing: TanStack Router
- Database: SQLite
- Database layer: Drizzle ORM
- Database state: TanStack Query
- Local UI state: React state first, Zustand only if cross-route UI state becomes painful
- Forms: React Hook Form
- Validation: Zod
- Tables: TanStack Table
- Charts: Recharts, used lightly
- CSV parsing: Papa Parse
- Tests: Vitest + React Testing Library, Playwright later
- Electron gives mature desktop capabilities, filesystem access, and SQLite integration.
- Vite keeps local React development fast.
- Drizzle keeps SQLite schema and queries typed without hiding SQL too much.
- TanStack Query separates database-backed state from UI state.
- React Hook Form and Zod fit shadcn form patterns and provide reusable boundary validation.
- TanStack Router and Table are headless, typed, and compatible with app-owned UI.
AccountTransactionCategoryRecurringItemImportBatchImportedTransactionCandidate
Accepted import candidates become real transactions.
Manual transaction flow:
- User opens transaction form.
- User enters transaction details.
- UI validates required fields and amount/date shape.
- App saves transaction to SQLite.
- Dashboard recalculates monthly aggregates.
Import flow:
- User selects a CSV file.
- App parses the file locally.
- User maps columns if the format is unknown.
- App creates import candidates.
- User reviews candidates.
- Accepted candidates are saved as transactions.
- Import batch metadata is stored for traceability.
Dashboard flow:
- UI requests transactions and recurring items for the selected month.
- App computes monthly totals.
- UI renders summary cards, category breakdown, and recent transactions.
Validate where user or imported input becomes application data:
- transaction date must be valid
- amount must be numeric and non-zero
- transaction type must be known
- account must exist
- category should exist for income and expenses
- imported rows must be parsed into a predictable candidate shape before review
src/
app/ # React app setup, router, providers
components/ # shared UI and app-specific components
features/
dashboard/
transactions/
imports/
recurring/
settings/
domain/ # pure finance logic and types
persistence/ # Drizzle schema, migrations, repository functions
services/ # typed app operations used by UI
validation/ # shared Zod schemas
export/ # JSONL export/import helpers
electron/ # main/preload process code
Keep domain logic framework-independent. React should render and coordinate workflows, not own finance calculations.
/dashboard/transactions/imports/recurring/settings
Use SQLite as the source of truth. Store money as integer minor units, for example cents, to avoid floating point errors. Store dates as ISO strings unless a later implementation reason pushes us toward integer timestamps.
Represents a place where money moves in or out.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
name |
text not null | User-visible account name |
type |
text not null | checking, savings, credit_card, cash, other |
currency |
text not null | Default USD or user-selected later |
is_archived |
integer not null | Boolean 0/1 |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Represents budget/reporting categories.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
name |
text not null | User-visible category name |
kind |
text not null | income, expense, or transfer |
color |
text | Optional UI color token |
is_system |
integer not null | Marks seeded defaults |
is_archived |
integer not null | Boolean 0/1 |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Recommended unique index: categories(name, kind).
Represents accepted financial activity.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
date |
text not null | ISO date, e.g. 2026-05-10 |
amount_minor |
integer not null | Positive integer amount in minor units |
type |
text not null | income, expense, or transfer |
description |
text not null | User/import description |
account_id |
text not null | References accounts.id |
category_id |
text | References categories.id |
transfer_account_id |
text | Optional destination/source account for transfers |
notes |
text | User notes |
source |
text not null | manual or imported |
import_batch_id |
text | References import_batches.id when imported |
external_id |
text | Stable imported ID if available |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Use positive amount_minor plus type instead of signed amounts. This keeps UI and reports explicit. Transfer-specific behavior can be refined later.
Suggested indexes:
transactions(date)transactions(account_id, date)transactions(category_id, date)transactions(import_batch_id)
Represents expected future income or expenses.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
name |
text not null | User-visible name |
type |
text not null | income or expense for MVP |
amount_minor |
integer not null | Positive integer amount in minor units |
account_id |
text | References accounts.id |
category_id |
text | References categories.id |
frequency |
text not null | weekly, biweekly, monthly, yearly |
start_date |
text not null | ISO date |
end_date |
text | Optional ISO date |
next_due_date |
text | Optional cached ISO date |
is_active |
integer not null | Boolean 0/1 |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Represents one imported file and its processing status.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
account_id |
text | Target account if selected |
file_name |
text not null | Original file name |
file_hash |
text | Helps detect re-imports |
status |
text not null | reviewing, accepted, partial, discarded |
raw_row_count |
integer not null | Number of parsed rows |
accepted_count |
integer not null | Number accepted as transactions |
skipped_count |
integer not null | Number skipped |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Persist import review state so a partially reviewed import can be resumed.
| Column | Type | Notes |
|---|---|---|
id |
text primary key | UUID/CUID generated by the app |
import_batch_id |
text not null | References import_batches.id |
raw_row |
text not null | JSON string of original parsed CSV row |
date |
text | Parsed ISO date |
amount_minor |
integer | Parsed amount |
type |
text | Parsed or inferred transaction type |
description |
text | Parsed description |
category_id |
text | User-selected category |
status |
text not null | pending, accepted, skipped, duplicate, invalid |
duplicate_transaction_id |
text | References likely duplicate transaction |
created_transaction_id |
text | References accepted transaction |
created_at |
text not null | ISO timestamp |
updated_at |
text not null | ISO timestamp |
Create default categories during initial database setup:
- income: Salary, Interest, Other Income
- expense: Rent, Groceries, Utilities, Subscriptions, Restaurants, Transportation, Insurance, Healthcare, Other Expense
- transfer: Transfer
Create no default accounts until the user adds one. The first-run flow should prompt for an account before manual transaction entry or import.
Each line should be one durable record with explicit type and schema version:
{"recordType":"account","schemaVersion":1,"data":{"id":"..."}}
{"recordType":"category","schemaVersion":1,"data":{"id":"..."}}
{"recordType":"transaction","schemaVersion":1,"data":{"id":"..."}}
{"recordType":"recurringItem","schemaVersion":1,"data":{"id":"..."}}
{"recordType":"importBatch","schemaVersion":1,"data":{"id":"..."}}Export accepted records only by default. Import candidates are review state, not essential durable finance data, and can be excluded unless a debug/full export mode is added later.
Prioritize end-to-end usefulness over completeness:
- manual transactions before polished imports
- CSV import before bank API integration
- monthly dashboard before advanced reports
- local SQLite before sync
- simple categories before rules/automation
- explicit user review before smart categorization
- Whether SQLite access should be synchronous or async from the renderer perspective.
- Whether Electron main process owns all persistence calls, or whether a local service layer is shared differently.
- Whether import candidates are persisted before review or kept in memory until accepted.
- How much category automation belongs in the MVP.
- Whether to include charts in the first prototype or start with text/table summaries only.