A personal finance web app built around the once-weekly desktop session, not daily engagement.
Live: verdant-finance.com · Demo: click "Try the demo" on the landing page (no signup, read-only sandbox).
Most finance apps assume you'll check them every day. Verdant assumes you won't. The whole app is built around sitting down once a week on payday: log last week's transactions, plan next week's envelopes, move on.
The paycheck cycle is the unit of time everywhere — net worth, spending, budget allocation. Past weeks are read-only by design. Future weeks don't exist until you log the paycheck that starts them.
It's invite-only (about ten users projected). I built it as a portfolio piece and as a tool I actually use.
Captures from the live app. Demo path lets you click through without signing up.
| Dashboard | Budget |
|---|---|
![]() |
![]() |
| Investments | Spending Log |
|---|---|
![]() |
![]() |
Frontend: React 18 · Vite · bespoke CSS (no Tailwind for layout) · motion for orchestrated UI sequences · hand-rolled SVG charts (no Recharts)
Backend: Supabase (PostgreSQL + Row-Level Security + Edge Functions) · pgcrypto for bcrypt PIN hashing
Auth: Clerk (Google OAuth) bridged to Supabase via third-party auth · custom-minted Supabase JWT for the demo path · bcrypt 4-digit PIN as a second factor
Infra: Vercel (auto-deploy from main) · Cloudflare DNS + Email Routing · custom domain
Other: papaparse for CSV transaction imports · Python tools for seeding the demo user
Total operating cost: ~$12/year (domain only — everything else on free tiers).
A few things that took real thought, lifted from the architectural notes:
-
Row-Level Security on every table, with Clerk as the IdP. Policies filter by
(select auth.jwt() ->> 'sub') = user_id.users.idisTEXT(Clerk user ID directly) instead ofUUID, so every policy is a direct equality check instead of a JOIN. -
Custom-minted JWT for the public demo. "Try the demo" hits an Edge Function that signs a Supabase JWT with
sub='demo'. The frontend treats it like any other session; RLS isolates demo data automatically. No service-role key ever touches the browser. -
Restrictive RLS policies for the read-only demo. Permissive policies pass for demo SELECTs, but restrictive policies block all INSERT/UPDATE/DELETE when
auth.jwt() ->> 'sub' = 'demo'. Read-only enforcement lives in the database, not the UI. -
Hand-rolled SVG charts. Interactive Catmull-Rom spline for net worth (pointer drag, range tabs reseed the series).
stroke-dasharraydonut for investment allocation. CSS-flex daily bars for spending. No chart library — smaller bundle, exactly the interactions I wanted. -
Week-as-period model derived from data. Period boundaries are computed from the
paycheckstable at query time. No separateperiodstable, so no sync bugs and biweekly/weekly schedule changes just work. -
Per-IP rate limiting without a separate rate-limit table. The waitlist Edge Function counts existing rows in the waitlist table itself, filtered by
ip = <cf-connecting-ip>andcreated_at > NOW() - INTERVAL '1 hour'. Reuses data that's already being written. -
Email-existence oracle defense.
users.emailis deliberately not UNIQUE — that would let an attacker probe registration by reading the unique-violation error. Email uniqueness is owned by Clerk, which has proper abuse handling.
git clone https://github.com/nhat-phamdev/Verdant.git
cd Verdant
npm install
cp .env.example .env # fill in the keys below
npm run dev # http://localhost:5173.env needs:
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
VITE_CLERK_PUBLISHABLE_KEY=
VITE_ALLOWLIST=you@gmail.com # comma-separated Google emails
Supabase Edge Functions live in supabase/functions/ and deploy via the Supabase CLI. The schema (supabase/schema.sql) is annotated with the threat model and inline rationale — reading it teaches you the security model alongside the data model.
Shipped to production and in personal use. Scope is intentionally bounded:
- Desktop-only (1440px). The marketing surface is responsive; the app itself is not.
- Invite-only via app-side allowlist.
- No tests yet — manual smoke testing on the production URL. Tests deferred.
- No CI beyond Vercel auto-deploy from
main.
This isn't a SaaS — it's a personal-use app I happen to be proud of. If that framing is useful for context: I'm a junior CS major, this is my first real shipped project, and getting it to production solo at this scale was the point.
MIT — see LICENSE.



