A self-hosted, invite-only personal expense tracker. Connects to your Gmail account and automatically imports UPI transaction emails, so you can label, filter, and visualise your spending — all on infrastructure you control.
Self-hosted first. Your financial data never leaves your own MongoDB instance. There is no cloud service, no third-party analytics, and no shared database. You run it, you own it.
- Privacy — transaction data stays in your own MongoDB Atlas cluster (or any MongoDB instance).
- Control — invite exactly who you want; revoke access at any time from the database.
- No vendor lock-in — standard Next.js app; deploy anywhere (Vercel, Railway, Fly.io, VPS).
- Extensible — add support for more banks by writing a single parser file; no other changes needed.
- Stat cards — spent this month, transaction count, largest transaction, top payee
- Linked bank accounts shown alongside the dashboard heading
- Area chart with selectable time ranges: Monthly · Quarterly · 6 Months · Year
- Category breakdown bar chart (proportional, per selected range)
- Auto-imported from Gmail via the Sync button (last 30 days)
- Filter by date range, label, or free-text search (payee / UTR / account)
- Assign a single label per transaction inline
- Delete transactions with confirmation dialog
- Download as XLSX — pick a date range, get a two-sheet report (Overview + Transactions)
- Create, rename, and delete custom labels with emoji support
- Renaming cascades to all transactions that use the label
- Invite-only — only pre-approved email addresses can sign in
- PKCE + state + nonce enforced on the Google OAuth flow
- Full CSP, HSTS, X-Frame-Options, Referrer-Policy, and Permissions-Policy headers
- All GraphQL inputs validated server-side; introspection disabled in production
- Rate limiting on the GraphQL endpoint; MongoDB query timeouts
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| UI | shadcn/ui · Tailwind CSS v3 |
| API | GraphQL — Apollo Server 4 + Apollo Client 3 |
| Auth | NextAuth v5 (Auth.js) · Google OAuth only |
| Database | MongoDB Atlas |
| Gmail API · modular parser registry | |
| Charts | Recharts v3 |
| Export | SheetJS (xlsx) |
git clone https://github.com/okpalindrome/xpens.git
cd xpens
npm installCreate .env.local in the project root:
MONGODB_URI=mongodb+srv://...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
AUTH_SECRET= # openssl rand -base64 32
AUTH_URL=http://localhost:3000
ADMIN_EMAIL=you@gmail.com| Variable | How to get it |
|---|---|
MONGODB_URI |
MongoDB Atlas → Connect → Drivers |
GOOGLE_CLIENT_ID |
Google Cloud Console → APIs & Services → Credentials |
GOOGLE_CLIENT_SECRET |
Same credential above |
AUTH_SECRET |
Run openssl rand -base64 32 |
AUTH_URL |
http://localhost:3000 for local dev |
ADMIN_EMAIL |
Your Gmail address — auto-invited on first startup |
- Create an OAuth 2.0 Client ID (Web application)
- Add authorized redirect URI:
http://localhost:3000/api/auth/callback/google - Enable the Gmail API for the same project
- Add your Gmail address as a test user (OAuth consent screen → Test users)
npm run devOn first startup, ADMIN_EMAIL is automatically added to the invites collection if it is empty. Open http://localhost:3000 and sign in with Google.
Access is by invitation only — there is no self-serve sign-up.
Invite a user (MongoDB Atlas Data Explorer or mongosh):
db.invites.insertOne({
email: "newuser@gmail.com",
invitedAt: new Date(),
invitedBy: "admin"
})Revoke access:
db.invites.deleteOne({ email: "user@gmail.com" })Existing sessions expire naturally based on the JWT lifetime.
- Sync fetches Gmail messages in the selected date range
- Each message's
FromandSubjectheaders are matched against the parser registry - The matching parser extracts: amount, date, receiver VPA, account suffix, UTR
- Transactions are upserted with the Gmail message ID as the unique key — re-syncing never creates duplicates
| Bank | Sender | Subject pattern |
|---|---|---|
| HDFC Bank | alerts@hdfcbank.bank.in |
You have done a UPI txn |
- Create
lib/email/parsers/<bank>.tsimplementing theEmailParserinterface:
import type { EmailHeaders, EmailParser, ParsedTransaction } from "@/lib/email/types";
export const MyBankParser: EmailParser = {
sender: "alerts@mybank.com",
subjectPattern: /debit alert/i,
subjectQuery: "Debit Alert", // used in Gmail search query
bankName: "My Bank",
parse(html, headers): ParsedTransaction | null {
const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const amount = text.match(/INR ([\d.]+)/i);
if (!amount) return null;
return {
messageId: headers.messageId,
amount: parseFloat(amount[1]),
date: headers.date,
source: "unknown",
receiver: "unknown",
utr: "unknown",
};
},
};- Register it in
lib/email/registry.ts:
import { MyBankParser } from "@/lib/email/parsers/mybank";
const parsers: EmailParser[] = [HDFCParser, MyBankParser];No other files need to change — the Gmail search query is built dynamically from the registry.
app/
(auth)/
login/ # Sign-in page
no-access/ # Shown for uninvited accounts
(dashboard)/
page.tsx # Dashboard — stats, chart, category totals
transactions/ # Transaction table with filters + XLSX export
labels/ # Label CRUD with emoji picker
api/
auth/[...nextauth]/ # NextAuth v5 handlers
graphql/ # Apollo Server 4 endpoint
lib/
auth.ts # Full NextAuth config (MongoDB adapter, PKCE, nonce)
auth.config.ts # Edge-compatible config (middleware — no MongoDB)
validation.ts # Server-side input validation for all GraphQL inputs
excel.ts # XLSX export (SheetJS)
db/
mongodb.ts # MongoClient singleton
models/ # user, transaction, label model functions
email/
types.ts # EmailParser interface
registry.ts # Parser registry
parsers/hdfc.ts # HDFC UPI email parser
sync.ts # Gmail API fetch → parse → upsert
graphql/
schema.ts # GraphQL SDL
context.ts # Auth context (userId from session)
resolvers/ # transaction, label, sync resolvers
operations.ts # Client-side GQL operation documents
apollo-client.ts # Apollo Client singleton
middleware.ts # Auth guard (edge-compatible config)
| Collection | Description |
|---|---|
invites |
Allow-list. Fields: email, invitedAt, invitedBy |
user_tokens |
Gmail OAuth tokens. Fields: userId, accessToken, refreshToken, updatedAt |
transactions |
UPI transactions. _id = Gmail message ID (dedup key) |
labels |
User-defined labels. Fields: userId, name, createdAt |
users |
Managed by NextAuth |
accounts |
Managed by NextAuth |
npm run dev # Start dev server (http://localhost:3000)
npm test # Run test suite
npm run build # Production build + type check
npm run lint # ESLintDeploys as a standard Next.js application (Vercel, Railway, Fly.io, etc.).
Example: Vercel:
- Push to GitHub
- Import the project in Vercel
- Set all environment variables from the table above
- Add the production callback URI in Google Cloud Console:
https://yourdomain.com/api/auth/callback/google - Set
AUTH_URL=https://yourdomain.comin Vercel environment variables