Collaborative monthly budget app (Next.js 16, Prisma + SQLite, English/Greek).
- Landing page – logged-out visitors (Normal auth mode) get a public landing page on
/(src/components/landing/) with coded product mockups, features, FAQ and sign-up / log-in calls to action. Logged-in users get the dashboard on/; logout returns to the landing page. Route rules live insrc/lib/navigation.ts(tested byscripts/test-navigation.ts). - SEO – the landing is prerendered per language by
src/app/(marketing)/[lang]/: Greek at/(served from/elby a cookie-less rewrite innext.config.ts), English at/en, withhreflang, canonical URLs, Open Graph/Twitter cards, generated share images and icons, andWebApplication+FAQPageJSON-LD. Everything undersrc/app/(app)/(the signed-in app, auth pages, invites) isnoindex;robots.txt,sitemap.xmland the manifest come fromsrc/lib/seo.ts. - Selected month – one global month bar (
src/components/month-context-bar.tsx) sits at the top of the month-scoped pages (Overview, Alerts, Expenses, Savings, Calendar; list insrc/lib/navigation.ts) and is persisted inlocalStorage['aura_selected_month'].GET /api/wallets/[id]?month=YYYY-MMreturns that month's expenses, planned expenses and metrics. - Planned expenses – expenses you know will happen later in a month. They never count as spent until you press "Mark as spent" (creates a real
Expense, exactly once). - "If everything is paid" projection (
src/lib/month-projection.ts) –budget + extra from General − spent − saved into savings − savings contributions still due − pending planned − bills due − subscriptions due − carry-over. A bill is unpaid as of month end until it has a linked expense (or legacyPAIDstatus) dated on/before that month end, so an unpaid bill from an earlier month is carried into every later month until it is paid. Paying it from its own month (the UI passespaidDateinside that month) removes the extra from all later months. Subscriptions due in the month always count (they never create expenses). The result is not clamped. - Savings (
/savings, math insrc/lib/savings.ts, ledger inSavingsTransaction) – each wallet has a General savings pool plus sub-buckets. A sub-bucket exists only while it has ≥1 pending planned expense linked to it ("Save for this" on a planned expense in a future month). Its monthly contribution = per expense(amount − saved so far) ÷ months left(months before the due month; saved money fills the earliest expense first; rounded up to the cent), shown as "Savings this month" on Overview and Expenses. Save records a deposit that counts against that month's budget (not anExpense); extra deposits lower future contributions. Mark as spent on a linked expense pays from the bucket first — theExpensekeeps its full amount withsavingsFundedAmount, and only the shortfall counts in the month. A bucket with no pending expense left closes; any leftover must go to another bucket or General (never straight to the budget). General money can be moved into buckets, used as extra budget for a month (explicit action only), or adjusted manually (money from/to outside the app; budget-neutral). Moving a linked expense's date into the current or a past month unlinks it. - Bills, subscriptions and recurrence – paying a recurring row creates the next occurrence (
src/lib/recurrence.ts, month-end clamping). Expenses created by paying a bill are linked viaExpense.invoiceId. Subscriptions never createExpenserows. Subscriptions and recurring bills are managed on Recurring payments (/recurring): one card per series with its next unpaid occurrence and monthly-equivalent cost, independent of the selected month. - Roles – only the wallet
OWNERedits wallet settings, category names and monthly limits;MEMBERcan add/edit expenses, bills, planned expenses and savings;VIEWERis read-only. - Categories & limits – manage them on Wallet & Team; deleting a category keeps its expenses/bills (uncategorised).
- Export / import (
/api/wallets/[id]/{export,import}, format2.1, additive over2.0/1.0) – includes expenses (withsavingsFundedAmount), bills/subscriptions, planned expenses (withsavingsBucketRef), savings buckets, the savings ledger and activity history; links are rebuilt from file-localrefs. Import is additive and atomic; an imported General merges into the target's General, and an active bucket without a pending expense is closed with its money moved to General. Members and invites are not exported. - Alerts & reminders (
/alerts) – dedicated alerts route for urgent invoices (due in ≤3 days or overdue) and unpaid bills/subscriptions for the selected month with carry-over. Accessible via the bell badge in the sidebar navigation, mobile topbar and drawer; the count badge turns red if any alert is overdue, amber otherwise, and hides at 0.
All UI text lives in src/lib/i18n/dictionaries/en.ts and el.ts. pnpm i18n:check (also part of pnpm test) fails on hardcoded user-visible strings, missing Greek keys and mismatched {placeholders}. Server error messages shown to users are mapped in src/lib/i18n/api-errors.ts. Known limitation: activity-log detail sentences are stored in English when written (titles are translated).
pnpm test # e2e data checks + DB-free unit tests + i18n gate
pnpm test:api # HTTP API tests; needs `pnpm dev` running (creates and removes temporary rows)
pnpm test:seo # crawler's-eye HTML checks; needs NEXT_PUBLIC_AUTH_MODE=normal pnpm build && pnpm start -p 3300
npx prisma validateDatabase changes are additive migrations only (prisma migrate deploy); never reset prisma/dev.db. Every schema change needs a committed migration. CI builds its test database from migrations alone, so a db push-only change fails the deploy.
Local dev needs a .env file (gitignored) containing DATABASE_URL="file:./dev.db". prisma/*.db files are gitignored too: databases and their backups stay local. A fresh clone runs pnpm exec prisma migrate deploy && pnpm exec tsx prisma/seed.ts.
For local email and password reset testing, configure the same variables in .env:
PUBLIC_BASE_URL=http://localhost:3000EMAIL_DELIVERY_ENABLED=false(default outside production: emails and reset links are printed to the server console instead of sending over the network)- Optional
EMAIL_PROVIDER=gmail,GMAIL_USER,GMAIL_APP_PASSWORD,EMAIL_FROM,EMAIL_REPLY_TO(orSMTP_*alternatives). To test real delivery locally, setEMAIL_DELIVERY_ENABLED=trueand runnpx tsx scripts/send-test-email.ts <to>.
Pushing to main runs .github/workflows/deploy-production.yml. All of it happens on the GitHub runner except the final step:
- Lint, typecheck, then
pnpm testagainst a fresh SQLite file built from migrations +prisma/seed.ts. next buildwithoutput: "standalone"andNEXT_PUBLIC_AUTH_MODE=normal.- Assemble the release: standalone server,
.next/static,public,prisma/anddeploy/, plus a pinned Prisma CLI inmigrator/. - Smoke test: boot the release in production mode and check that register/session work, and that
/api/usersandx-user-idaccess are rejected. - Ship it to the droplet.
deploy/deploy-release.shbacks up the database, runsprisma migrate deploy, swaps thecurrentsymlink, reloads PM2 (budget-app), and checks the health URL.
Production runs with mock auth disabled. When NODE_ENV=production, the server ignores x-user-id, never falls back to a default user, returns 404 for /api/users, and refuses to sign sessions without AUTH_SECRET.
Calendar feeds (/api/wallets/[id]/calendar.ics) need a per-member secret in ?token=, because calendar apps can't send cookies. Members get their token from GET /api/wallets/[id]/calendar-token (session-authenticated, created on first use) and replace it with POST. The Sync .ICS modal has a "Reset link" button for this. A missing or wrong token returns 404. Removing a member deletes their token.
Droplet layout (/root/budget-app):
| Path | Purpose |
|---|---|
current |
Symlink to the live release, which PM2 runs |
releases/<time>-<sha> |
Last 3 releases. Roll back with ln -sfn to an older one, then pm2 reload budget-app |
shared/.env.production |
Runtime environment configuration (see table below) |
shared/data/budget.db |
Production SQLite database |
shared/backups/ |
Pre-migration DB copies (last 10) |
Production environment variables come from two files. Both deploy/deploy-release.sh and the PM2 ecosystem load them in this order, so the later file wins:
/root/shared/.env.production: droplet-wide mail settings shared by all apps. These are Brevo SMTP relay settings for lnf.gr (EMAIL_PROVIDER=smtp,SMTP_HOST=smtp-relay.brevo.com,SMTP_PORT=587,SMTP_SECURE=false,SMTP_USER,SMTP_PASS, plus a defaultEMAIL_FROM). The path can be overridden withSHARED_ENV_FILE./root/budget-app/shared/.env.production: this app's values. It overridesEMAIL_FROMwithAura Budget <no-reply@lnf.gr>. There is no Reply-To, because the sender is no-reply.
| Variable | Required / Default | Example / Description |
|---|---|---|
NODE_ENV |
Required (production) |
Production mode (disables mock auth) |
PORT |
Required (3200) |
Port PM2 binds the Next server to |
HOSTNAME |
Required (127.0.0.1) |
Localhost interface behind nginx |
DATABASE_URL |
Required | file:/root/budget-app/shared/data/budget.db (absolute file: URL) |
AUTH_SECRET |
Required | Session cookie signing secret (min 32 characters) |
PUBLIC_BASE_URL |
Required | https://budget.lnf.gr (root URL for emails/password resets; must start with https://) |
EMAIL_PROVIDER |
Optional (default: smtp) |
gmail or smtp |
GMAIL_USER |
Required if provider is gmail |
e.g. example@gmail.com |
GMAIL_APP_PASSWORD |
Required if provider is gmail |
16-character Google App Password (e.g. abcd efgh ijkl mnop), unless OAuth2 credentials are configured |
EMAIL_FROM |
Required | Sender header, e.g. Aura Budget <example@gmail.com> |
EMAIL_REPLY_TO |
Optional | Reply-To address, e.g. support@example.com |
EMAIL_DELIVERY_ENABLED |
Optional (default: true in prod) |
Set false / 0 / no to suppress sending and log emails to console |
SMTP_HOST |
Required if provider is smtp |
SMTP server host, e.g. smtp.example.com |
SMTP_PORT |
Required if provider is smtp |
SMTP server port, e.g. 587 |
SMTP_SECURE |
Optional (default: false) |
true for SSL/TLS (port 465), false for STARTTLS |
SMTP_USER |
Optional | SMTP username |
SMTP_PASS |
Optional | SMTP password |
WEBAUTHN_ORIGIN |
Optional (default: PUBLIC_BASE_URL) |
Public origin (e.g. https://budget.lnf.gr); only set if it differs from PUBLIC_BASE_URL |
WEBAUTHN_RP_ID |
Optional (default: derived from origin) | Relying party ID (e.g. budget.lnf.gr); defaults to hostname of WEBAUTHN_ORIGIN / PUBLIC_BASE_URL |
Gmail app passwords: A Google Account with 2-Step Verification (2FA) enabled is required to generate an App Password (Google Account → Security → 2-Step Verification → App passwords). A
535 BadCredentialserror means the app password was revoked, expired, or entered incorrectly.
Passkeys: Passkeys work on HTTPS origins and on
http://localhostin dev. For local browser testing, use Chrome DevTools → More tools → WebAuthn to set up a virtual authenticator.
nginx (deploy/nginx/, live copy in /etc/nginx/conf.d/budget.lnf.gr.conf) terminates TLS (Let's Encrypt, renewed by certbot via webroot) and rate-limits /api/auth/login|register to 10 requests/min per IP.
GitHub secrets: DROPLET_HOST, DROPLET_USER, DROPLET_SSH_KEY, DROPLET_HOST_KEY, DEPLOY_PATH (/root/budget-app), and optionally DROPLET_PORT.
This is a Next.js project bootstrapped with create-next-app.
First, run the development server:
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun devOpen http://localhost:3000 with your browser to see the result.
You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.
This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
You can check out the Next.js GitHub repository - your feedback and contributions are welcome!