A full-stack budgeting app for iOS and Android built with Expo and Supabase — track accounts and transactions, set a monthly budget, and log expenses by typing, scanning a receipt, or just speaking to it.
- Accounts — multiple accounts (cash, bank, credit card, savings) with a default account and running balances
- Transactions — income/expense tracking with categories, search, filters, and daily income vs. expense charts
- AI receipt scanning — snap or pick a photo of a receipt and let Gemini extract the amount, category, and description
- AI voice entry — describe a transaction out loud ("I spent 400 on groceries yesterday") and have it transcribed and parsed automatically
- Monthly budget — set a budget and track spend against it on the dashboard
- CSV export — export recent transactions to CSV and share them from the Transactions screen
- AI assistant — ask questions about your spending
- Onboarding — first-run currency and starting balance setup
- Expo (SDK 54) + Expo Router
- Clerk for authentication
- Supabase (Postgres + Row Level Security) as the backend, authenticated via Clerk's native third-party auth integration (no Supabase JWT template needed)
- Google Gemini for receipt/voice extraction
- NativeWind (Tailwind for React Native)
- Zustand
- TanStack Query
- React Hook Form + ZOD
-
Install dependencies
npm install
-
Add a
.envfile in the project root with:EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_KEY= EXPO_PUBLIC_GEMINI_API_KEY= -
In your Supabase project, add Clerk as a Third-Party Auth provider (Authentication → Sign In / Providers → Clerk) so
auth.jwt()->>'sub'resolves to the Clerk user id — this app does not use the legacy Supabase JWT template approach. -
Run the Supabase queries below to set up the schema.
-
Start the app
npx expo start -c
In the output, you'll find options to open the app in a
- development build
- Android emulator
- iOS simulator
- Expo Go, a limited sandbox for trying out app development with Expo
create table users (
clerk_id text primary key,
email text not null,
name text,
image_url text,
currency text, -- null until the user completes onboarding
created_at timestamp with time zone default now()
);alter table users enable row level security;
create policy "Users can insert own row"
on users for insert
with check (clerk_id = auth.jwt()->>'sub');
create policy "Users can read own row"
on users for select
using (clerk_id = auth.jwt()->>'sub');
create policy "Users can update own row"
on users for update
using (clerk_id = auth.jwt()->>'sub');create table accounts (
id uuid default gen_random_uuid() primary key,
user_id text not null references users(clerk_id) on delete cascade,
name text not null,
type text not null, -- 'CASH' | 'BANK' | 'CREDIT_CARD' | 'SAVINGS'
balance numeric not null default 0,
is_default boolean not null default false,
created_at timestamp with time zone default now()
);alter table accounts enable row level security;
create policy "Users can manage own accounts"
on accounts for all
using (user_id = auth.jwt()->>'sub')
with check (user_id = auth.jwt()->>'sub');create table transactions (
id uuid default gen_random_uuid() primary key,
user_id text not null references users(clerk_id) on delete cascade,
account_id uuid not null references accounts(id) on delete cascade,
type text not null, -- 'INCOME' | 'EXPENSE'
amount numeric not null,
category text not null,
description text,
date timestamp with time zone not null default now(),
status text not null default 'COMPLETED',
input_method text not null default 'MANUAL', -- 'MANUAL' | 'RECEIPT_SCAN' | 'VOICE'
voice_transcript text,
is_flagged boolean not null default false,
flag_reason text,
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now()
);alter table transactions enable row level security;
create policy "Users can manage own transactions"
on transactions for all
using (user_id = auth.jwt()->>'sub')
with check (user_id = auth.jwt()->>'sub');-- One budget per user (simple monthly budget, no per-category breakdown)
create table budgets (
id uuid default gen_random_uuid() primary key,
user_id text not null unique references users(clerk_id) on delete cascade,
amount numeric not null,
last_alert_sent timestamp with time zone,
last_alert_threshold numeric, -- last budget-usage % (80 or 100) emailed for, resets each calendar month
created_at timestamp with time zone default now(),
updated_at timestamp with time zone default now()
);alter table budgets enable row level security;
create policy "Users can manage own budget"
on budgets for all
using (user_id = auth.jwt()->>'sub')
with check (user_id = auth.jwt()->>'sub');Two Supabase Edge Functions in supabase/functions/ run on a schedule and email the user via Resend:
check-budget-alerts— runs daily, emails a user the first time they cross 80% and again the first time they cross 100% of their monthly budget (tracked vialast_alert_sent/last_alert_threshold, resets each calendar month).weekly-tips— runs every Monday, asks Gemini for a few short, personalized tips based on each user's last 7 days of transactions and emails them.
Neither is called from the app — they're standalone, timer-driven backend jobs that live entirely on Supabase.
-
Link the project (one-time; skip if using the dashboard only)
npx supabase login npx supabase init npx supabase link --project-ref <your-project-ref>
-
Apply the DB schema — Supabase dashboard → SQL Editor → run
scripts/schema.sql. -
Deploy the two edge functions — either:
npx supabase functions deploy check-budget-alerts npx supabase functions deploy weekly-tips
or from the dashboard: Edge Functions → Deploy a new function, paste in the contents of the corresponding
index.ts(and the files under_shared/), and give it the same name as the folder — no CLI required. -
Set secrets (project-wide, covers both functions) — either
npx supabase secrets set KEY=valueper line below, or dashboard → Edge Functions → Secrets:RESEND_API_KEY= RESEND_FROM_EMAIL= # "Welth <alerts@yourdomain.com>" once you verify a domain in Resend; # until then, use "Welth <onboarding@resend.dev>" — the address must # stay onboarding@resend.dev and only delivers to the email you signed # up with, but the display name (the "Welth" part) is yours to set GEMINI_API_KEY= # same Gemini key as EXPO_PUBLIC_GEMINI_API_KEY, without the EXPO_PUBLIC_ prefix EMAIL_LOGO_URL= # any public URL to the Welth logo, used in the email header; # omit this secret entirely to fall back to a text "Welth" wordmarkSUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare provided automatically in the edge function runtime — Supabase reserves theSUPABASE_prefix, so you can't (and don't need to) set these yourself. -
Schedule them — SQL Editor → run
scripts/cron_jobs.sql, after filling in your real project URL and service-role key in the twovault.create_secret(...)calls at the top.
Once done: check-budget-alerts runs daily at 9am UTC, weekly-tips runs Mondays at 9am UTC.
Easiest way: dashboard → Edge Functions → select the function → use the built-in test/invoke panel to trigger it on demand and see the response and logs right there.
CLI alternative: curl the deployed URL directly (this CLI version has no functions invoke subcommand):
curl -i --location --request POST 'https://<project-ref>.supabase.co/functions/v1/check-budget-alerts' \
--header 'Authorization: Bearer <anon-or-service-role-key>'A response of {"sent": 0} just means no user currently qualifies — for check-budget-alerts you need a test user whose spend is ≥80% of their budget, for weekly-tips a test user with a transaction in the last 7 days. last_alert_sent/last_alert_threshold block repeat sends within the same month, so reset them on your test budget row between runs if you want to re-trigger the same threshold.