Split expenses. Stay friends.
Live app: https://tally-frontend-xi.vercel.app/
Tally is a mobile-first group expense app built for trips with friends. Create a trip, invite people, log shared costs, see who owes whom, and mark settlements when money changes hands offline. Tally does not move money — it keeps a clear shared record so nobody has to dig through chat threads for “who paid for dinner.”
The product differentiator is AI receipt scanning with line-item assignment: scan a restaurant bill, let Claude extract each item, then tag who ordered what in under a minute — instead of typing every amount by hand.
Most expense splitters stop at “enter a total and split equally.” That works for an Uber. It fails at a shared meal where Ada had pepper soup, Bola had jollof, and Chidi took both shawarmas.
Tally’s answer:
- AI extracts line items from the receipt (name, quantity, price).
- You assign each line to one or more trip members (or everyone).
- Balances update from a derived split map — same debt math as manual expenses.
Equal-split paths (transport, lodging) still work in one tap. Line-item assignment is additive, not a replacement.
- Create trips with destination, dates, and a base currency
- Invite members via shareable join links
- Organizer and member roles on the trip detail screen
- Manual entry with equal or custom splits
- Categories (food, transport, lodging, activities, other)
- Multi-currency amounts with FX conversion into the trip base currency
- Optional receipt photo attachment
- Camera capture or gallery upload
- Claude OCR returns merchant, total, currency, confidence, and line items
- Split Mode Decision sheet after a successful itemized scan:
- Assign by item → tag members per line (progress bar + unassigned counter; save blocked until every unit is assigned)
- Split equally → jump to the existing Add Expense form, prefilled
- Total-only / unreadable receipts fall back to the manual form
- Per-trip and aggregate balance views
- Debt simplification (fewer payments, same outcome)
- Confirm offline settlements; history stays on the trip
- Google OAuth and email magic-link sign-in
- Onboarding (display name + home currency)
- Profile preferences and sign-out
- In-app notifications (joins, expenses, settlements)
- Public Terms of Service and Privacy Policy
| Layer | Choice |
|---|---|
| Framework | Next.js 14 (App Router), TypeScript |
| UI | Tailwind CSS, Lucide icons, Inter, mobile shell (≈375–430px) |
| Forms | React Hook Form + Zod |
| State | Zustand (store/) with typed hooks |
| Auth | Supabase Auth (Google + magic link) |
| Data | Supabase Postgres + RLS; Storage for receipt images |
| Legacy / optional | AWS DynamoDB for some user fields |
| AI | Anthropic Claude (POST /api/expenses/scan) |
| FX | exchangerate.host (optional key) with open.er-api.com fallback |
- Node.js 18+
- A Supabase project
- Anthropic API key (for receipt scan)
- Optional: AWS credentials if you use DynamoDB user sync
npm install
cp .env.local.example .env.local
# Fill in the variables below, then:
npm run devOpen https://tally-frontend-xi.vercel.app/ for the live app. For local development, the server runs on your machine after npm run dev.
| Command | Description |
|---|---|
npm run dev |
Development server |
npm run build |
Production build |
npm start |
Serve production build |
npm run lint |
ESLint |
npm test |
Debt-simplification unit tests |
Copy from .env.local.example:
NEXT_PUBLIC_APP_URL=https://tally-frontend-xi.vercel.app
# Supabase (required for auth + primary data)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Optional DynamoDB user storage
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
DYNAMODB_TABLE_NAME=
# Receipt OCR (required for AI scan)
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL= # optional; defaults to claude-sonnet-4-5
# FX (optional)
FX_API_KEY=For local development, set NEXT_PUBLIC_APP_URL to your local origin and match Supabase Auth redirect URLs accordingly.
Run the SQL in the Supabase SQL editor (order suggested below). Scripts are written to be safe to re-run.
| File | Purpose |
|---|---|
supabase/schema.sql |
Core trips, members, invites, expenses, RLS |
supabase/expenses.sql |
Expenses table (if not covered by schema) |
supabase/settlements.sql |
Settlement records |
supabase/fx.sql |
Cached FX rates |
supabase/notifications.sql |
In-app notifications |
supabase/receipt-scan.sql |
Receipt URL column + receipts storage bucket |
supabase/expense-line-items.sql |
line_items jsonb for AI assign flow |
supabase/fix-invite-rpc.sql |
Invite lookup RPC fix if join links fail |
Also configure Supabase Auth providers (Google + email) and redirect URLs to match NEXT_PUBLIC_APP_URL (e.g. /auth/callback).
app/(auth)/ Landing + onboarding
app/(app)/ Authenticated shell (bottom nav)
app/(legal)/ Terms + Privacy
app/join/ Invite join flow
app/auth/ OAuth / magic-link callbacks
app/api/ scan, fx, notifications, user
components/ui/ Primitives (BottomSheet, Avatar, …)
components/layout/ MobileShell, BottomNav, StickyHeader
features/ auth, trips, expenses, balances, settlements,
notifications, profile
store/ Zustand slices + selectors
lib/ currency, FX, debt math, DB helpers, storage
supabase/ SQL migrations / policies
types/ Shared TypeScript models
| Route | What it does |
|---|---|
/ |
Landing + sign-in |
/onboarding |
Display name + home currency |
/dashboard |
Active / past trips |
/trips/new |
Create trip |
/trips/[tripId] |
Trip detail, members, expenses |
/trips/[tripId]/invite |
Invite link |
/join/[token] |
Accept invite |
/trips/[tripId]/expenses/new |
Manual / prefilled add expense |
/trips/[tripId]/expenses/scan |
Camera + OCR |
/trips/[tripId]/expenses/assign |
Line-item member assignment |
/balances |
Aggregate balances |
/trips/[tripId]/balances |
Trip balances |
/trips/[tripId]/settlements |
Settlement history / confirm |
/profile |
Account preferences |
/notifications |
Activity feed |
/terms, /privacy |
Legal |
Scan receipt
→ Claude extracts line items (+ total, merchant, currency)
→ If line items exist: Split Mode sheet
├─ Assign by item → /expenses/assign → derive splits → save
└─ Split equally → /expenses/new (prefilled)
→ If total only / OCR failed: /expenses/new (existing fallback)
Per-line splitMap values are summed into the expense-level splitMap before save. Balance calculation, debt simplification, and settlements read that top-level map — they do not care whether the expense came from OCR or manual entry.
Import typed hooks from @/store — avoid reaching for the raw store unless necessary:
import {
useTrips,
useExpenses,
useBalances,
useUnassignedTotal,
useAddToast,
} from "@/store";Unstyled page / chunk 404s (layout.css or main-app.js 404):
# Stop the dev server (Ctrl+C), then:
rm -rf .next
npm run devHard-refresh the browser (Ctrl+Shift+R).
Receipt scan always fails: confirm ANTHROPIC_API_KEY is set and the server was restarted after editing .env.local.
Save after assign fails on line_items: run supabase/expense-line-items.sql.
Invite links broken: run supabase/fix-invite-rpc.sql and verify Auth redirect URLs.
- Dark canvas (
#0A0A0F), surfaces#13131A/#1C1C27 - Accent gradient
#7C3AED → #2563EB - Status: green
#10B981(assigned / settled), rose#F43F5E(owed / left) - Built mobile-first; desktop centers a phone-width frame
Private / hackathon project — all rights reserved unless otherwise noted.