Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 52 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# Tabby

A no-auth expense splitting PWA. Create a group via shareable link, add expenses with flexible splits, and get a simplified breakdown of who owes who — minimizing total settlement transactions.
An expense splitting PWA with Google OAuth. Sign in, create groups, invite friends via shareable links, add expenses with flexible splits, and get a simplified breakdown of who owes who — minimizing total settlement transactions.

Built as a portfolio project demonstrating full-stack TypeScript, AWS cloud-native deployment, and thoughtful API/data modeling.

## What it does

1. Alice creates a group ("Ski Trip 2026") and gets a shareable link
2. Friends open the link, enter their name, and join — no signup required
3. Anyone adds expenses with equal, exact, or percentage-based splits
4. The app computes a simplified settlement plan: the minimum number of transactions to zero out all balances
1. Alice signs in with her Google account
2. She creates a group ("Ski Trip 2026") and gets a shareable invite link
3. Friends sign in, open the link, and join the group
4. Anyone adds expenses with equal, exact, or percentage-based splits
5. The app computes a simplified settlement plan: the minimum number of transactions to zero out all balances
6. Each user sees all their groups on the home page

## Tech Stack

Expand All @@ -18,48 +20,53 @@ Built as a portfolio project demonstrating full-stack TypeScript, AWS cloud-nati
| Frontend | React 18 + TypeScript + Tailwind CSS + TanStack Query (PWA) |
| Backend | Fastify 5 + TypeScript |
| Database | PostgreSQL via Neon (serverless) + Drizzle ORM |
| Auth | Google OAuth 2.0 (authorization code flow) |
| Hosting | AWS Lambda + API Gateway (backend) + Cloudflare Pages (frontend) |
| IaC | Terraform + Terraform Cloud |
| CI/CD | GitHub Actions |
| Monitoring | CloudWatch (backend) + Sentry (frontend) |

## Key Design Decisions

- **No auth** — identity is a name + httpOnly session cookie per group. No accounts, no friction.
- **Google OAuth** — persistent identity across devices. Users sign in with Google, sessions stored in the database with hashed tokens in httpOnly cookies. Supports multi-group membership from a single account.
- **Dual-path session resolution** — backward-compatible with pre-auth anonymous sessions. New sessions resolve through the `sessions` table; legacy anonymous sessions fall back to `members.sessionToken`.
- **Debt simplification** — a greedy algorithm computes net balances and pairs max creditors against max debtors to minimize settlement transactions (O(n log n), optimal for small groups).
- **Integer cents** — all arithmetic is done in integer cents internally; stored as `DECIMAL(10,2)` in Postgres. Never `FLOAT`.
- **On-the-fly balance computation** — balances are recomputed from raw `ExpenseSplit` records on each request. No cache to go stale.
- **Neon HTTP driver** — Drizzle connects to Neon via `@neondatabase/serverless`. Stateless per Lambda invocation — no connection pool config, no VPC required.
- **TanStack Query** — declarative server-state management on the frontend. Handles caching, background polling (12s interval), and online/offline coordination via `onlineManager`.
- **SSM Parameter Store** — secrets fetched at Lambda cold start, never in environment variables or source code.
- **SSM Parameter Store** — secrets (including Google OAuth credentials) fetched at Lambda cold start, never in environment variables or source code.
- **Activity-based expiry** — groups expire 90 days after the last expense. Expired groups reject all write operations with `410 Gone`; read endpoints (balances, expenses) continue to work. Ghost members (those who left) remain in balance calculations to keep the ledger accurate.
- **Rate limiting** — global 100 requests/minute per IP; tighter limits on sensitive endpoints: 30 expense submissions/hour per member, 10 group joins/minute per IP.

## Architecture

```
Browser (React PWA)
serves via
| serves via
Cloudflare Pages
└─ /api/v1/* API Gateway (HTTP API)
|
+- /api/v1/* -> API Gateway (HTTP API)
|
Lambda (Fastify)
|
Neon PostgreSQL (Drizzle, HTTP driver)
|
Google OAuth (token exchange)
```

## Monorepo Structure

```
split-expenses/
├── packages/
│ ├── api/ Fastify backend (Lambda-compatible)
│ ├── web/ React PWA (Vite + Tailwind)
│ └── shared/ Shared TypeScript types
├── infra/ Terraform (AWS infrastructure)
├── .github/
│ └── workflows/ GitHub Actions CI/CD
+-- packages/
| +-- api/ Fastify backend (Lambda-compatible)
| +-- web/ React PWA (Vite + Tailwind)
| +-- shared/ Shared TypeScript types
+-- infra/ Terraform (AWS infrastructure)
+-- .github/
| +-- workflows/ GitHub Actions CI/CD
+-- docs/ Design spec, TODO, future features
```

## Local Development
Expand All @@ -68,6 +75,7 @@ split-expenses/

- Node.js 22+
- A [Neon](https://neon.tech) account with two databases: `tabby_prod` (production) and `tabby_test` (dev/test)
- A [Google Cloud](https://console.cloud.google.com) project with OAuth 2.0 credentials

### First-Time Setup

Expand All @@ -78,10 +86,17 @@ npm install
# Build shared types
npm run build --workspace=packages/shared

# Copy env files and fill in your Neon connection strings
# Copy env files and fill in your values
cp packages/api/.env.example packages/api/.env
cp packages/web/.env.example packages/web/.env

# Required env vars in packages/api/.env:
# DATABASE_URL - Neon connection string
# COOKIE_SECRET - random string (openssl rand -base64 32)
# GOOGLE_CLIENT_ID - from Google Cloud Console
# GOOGLE_CLIENT_SECRET - from Google Cloud Console
# GOOGLE_REDIRECT_URI - http://localhost:3000/api/v1/auth/google/callback

# Push schema to your Neon dev database
cd packages/api && npx drizzle-kit push
```
Expand Down Expand Up @@ -117,17 +132,23 @@ All routes prefixed `/api/v1`. OpenAPI docs at `/docs` when running locally.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/groups` | None | Create group + owner session |
| `GET` | `/auth/google` | None | Redirect to Google OAuth |
| `GET` | `/auth/google/callback` | None | OAuth callback |
| `GET` | `/auth/me` | Session | Current user |
| `POST` | `/auth/logout` | Session | End session |
| `GET` | `/me/groups` | Session | List user's groups |
| `POST` | `/groups` | Session | Create group |
| `GET` | `/groups/invite/:inviteCode` | None | Group metadata for join preview |
| `POST` | `/groups/invite/:inviteCode/join` | None | Join group, receive session |
| `POST` | `/groups/invite/:inviteCode/join` | Session | Join group |
| `GET` | `/groups/:id` | Session | Fetch group by stable ID |
| `GET` | `/groups/:id/me` | Session | Current user's member record |
| `GET` | `/groups/:id/members` | Session | List active members |
| `DELETE` | `/groups/:id/members/:memberId` | Admin+ | Remove member |
| `POST` | `/groups/:id/expenses` | Session | Add expense with splits |
| `GET` | `/groups/:id/expenses` | Session | List all expenses |
| `PATCH` | `/groups/:id/expenses/:expenseId` | Session | Edit expense |
| `DELETE` | `/groups/:id/expenses/:expenseId` | Session | Delete expense |
| `GET` | `/groups/:id/balances` | Session | Net balances + settlement plan. `Balance.netCents` and `Settlement.amount` are **integer cents** (divide by 100 for dollars). Includes ghost members (those who left) to keep the ledger accurate. |
| `GET` | `/groups/:id/balances` | Session | Net balances + settlement plan (integer cents) |
| `PATCH` | `/groups/:id/settings` | Owner | Update name / regenerate invite |
| `GET` | `/groups/:id/activity` | Session | Activity log (most recent 50 entries) |

Expand All @@ -145,26 +166,26 @@ All routes prefixed `/api/v1`. OpenAPI docs at `/docs` when running locally.

| Action | Member | Admin | Owner |
|--------|--------|-------|-------|
| Add expenses | | | |
| Edit/delete own expense | | | |
| Edit/delete any expense | | | |
| Remove members | | | |
| Manage group settings | | | |
| Add expenses | yes | yes | yes |
| Edit/delete own expense | yes | yes | yes |
| Edit/delete any expense | no | yes | yes |
| Remove members | no | yes | yes |
| Manage group settings | no | no | yes |

**Ownership recovery:** If the owner's session is lost, ownership transfers lazily to an admin (or oldest active member) on the next owner-level action.
**Ownership recovery:** With Google OAuth, ownership is tied to the user account rather than a browser session. If ownership needs to transfer, it happens lazily to an admin (or oldest active member) on the next owner-level action.

## AWS Architecture Notes

- **Lambda cold starts:** Neon HTTP driver (`@neondatabase/serverless`) is stateless — no connection pool config, no VPC required. Each Lambda invocation connects to Neon over HTTPS.
- **Secrets:** Neon connection string and cookie secret stored in SSM Parameter Store as `SecureString`, fetched once at cold start and cached in memory.
- **Secrets:** Neon connection string, cookie secret, and Google OAuth credentials stored in SSM Parameter Store as `SecureString`, fetched once at cold start and cached in memory.
- **CDN strategy:** Cloudflare Pages handles CDN, HTTPS, and SPA routing automatically — no cache invalidation step needed.

## CI/CD Pipeline

| Trigger | Jobs |
|---------|------|
| PR | Lint, TypeScript, Unit tests, Integration tests, Build check (parallel) |
| `v*` tag | Full test suite Terraform plan + apply (manual approval gate) Lambda deploy Cloudflare Pages deploy |
| `v*` tag | Full test suite -> Terraform plan + apply (manual approval gate) -> Lambda deploy -> Cloudflare Pages deploy |

## License

Expand Down
40 changes: 40 additions & 0 deletions docs/FUTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Tabby — Future Features

---

### v2 Features

- [ ] **Admin role management** — no endpoint or UI to promote members to admin or demote admins; needs a new API route (`PATCH /groups/:id/members/:memberId/role`) and SettingsPage UI (owner capability per design spec)
- [ ] **Push notifications** for new expenses (service worker integration, Notification API)
- [ ] **WebSocket real-time updates** (API Gateway WebSocket APIs, connection management)
- [ ] **Offline expense queue** (IndexedDB, sync on reconnect)
- [ ] **Expense categories with spending breakdowns** (data viz, charting)
- [ ] **Settle-up flow with Venmo/Zelle deep links** (mobile deep linking)
- [ ] **Export ledger to CSV**
- [ ] **Scheduled data cleanup** — soft-delete expired group data 30 days after expiration via EventBridge + Lambda

---

### Testing

- [ ] **Rate limit test** — 31st expense within an hour should return 429 (currently untested)
- [ ] **Expense edit test** — add integration test for `PATCH /groups/:id/expenses/:expenseId` (endpoint exists but has no test)
- [ ] **Group expiration test** — verify that posting to an expired group returns 410 (logic exists, untested)
- [x] **Permission enforcement tests** — member cannot remove another member (should 403); member cannot delete someone else's expense (should 403) *(covered in `api.test.ts`)*

---

### Developer Experience

- [ ] **Pre-push typecheck hook** — add `.git/hooks/pre-push` to run `npm run typecheck` before each push
- [ ] **Mobile QA** — test layout on iPhone Safari and Android Chrome
- [ ] **PWA install** — verify install prompt on Android Chrome and iOS "Add to Home Screen"
- [ ] **Load test** — 50-member group, 200 expenses, verify `/balances` responds in <500ms
- [ ] **SNS alarms** — wire CloudWatch alarms to an SNS topic + email subscription

---

### Infrastructure

- [x] **Terraform state locking** — handled automatically by Terraform Cloud (migrated from S3 backend)
- [ ] **Lambda provisioned concurrency** — optional; eliminates cold-start latency on the first request after inactivity; set to 1 on the production alias once traffic justifies it
40 changes: 12 additions & 28 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,19 @@ Go to **Settings → Secrets and variables → Actions** and add:
- [x] `CLOUDFLARE_ACCOUNT_ID` — from Cloudflare dashboard
- [x] `TF_API_TOKEN` — from Terraform Cloud → User Settings → Tokens
- [x] `PROD_CORS_ORIGIN` — Cloudflare Pages URL (e.g. `https://tabby.pages.dev`) or custom domain
- [x] `GOOGLE_CLIENT_ID` — from Google Cloud Console OAuth credentials
- [x] `GOOGLE_CLIENT_SECRET` — from Google Cloud Console OAuth credentials
- [ ] `SENTRY_DSN` — from Sentry.io project settings (optional)

#### Google Cloud OAuth

- [x] Create a Google Cloud project and OAuth consent screen
- [x] Create OAuth 2.0 client credentials (Web application type)
- [x] Add authorized redirect URIs:
- `http://localhost:3000/api/v1/auth/google/callback` (local dev)
- `https://<api-gateway-url>/api/v1/auth/google/callback` (production)
- [x] Add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` to `packages/api/.env`

#### GitHub Environments

- [x] Create `production` environment with required reviewers to gate the Terraform apply
Expand All @@ -53,31 +64,4 @@ Go to **Settings → Secrets and variables → Actions** and add:

---

### Remaining Code Work

#### v2 Features

- [ ] **Edit expense UI** — backend (`PATCH /groups/:id/expenses/:expenseId`) and `api.updateExpense()` are fully implemented; only the DashboardPage edit button + modal is missing
- [ ] **Admin role management** — no endpoint or UI to promote members to admin or demote admins; needs a new API route (`PATCH /groups/:id/members/:memberId/role`) and SettingsPage UI (owner capability per design spec)
- [ ] **`GET /groups/:id/me` endpoint** — would let the API identify the current member directly, removing reliance on `member_hint_${groupId}` localStorage
- [ ] **Custom confirmation dialogs** — replace browser `confirm()` popups (delete expense, remove member, regenerate link) with styled in-app modals; currently works but looks jarring

#### Testing

- [ ] **Rate limit test** — 31st expense within an hour should return 429 (currently untested)
- [ ] **Expense edit test** — add integration test for `PATCH /groups/:id/expenses/:expenseId` (endpoint exists but has no test)
- [ ] **Group expiration test** — verify that posting to an expired group returns 410 (logic exists, untested)
- [x] **Permission enforcement tests** — member cannot remove another member (should 403); member cannot delete someone else's expense (should 403) *(covered in `api.test.ts`)*

#### Developer Experience

- [ ] **Pre-push typecheck hook** — add `.git/hooks/pre-push` to run `npm run typecheck` before each push
- [ ] **Mobile QA** — test layout on iPhone Safari and Android Chrome
- [ ] **PWA install** — verify install prompt on Android Chrome and iOS "Add to Home Screen"
- [ ] **Load test** — 50-member group, 200 expenses, verify `/balances` responds in <500ms
- [ ] **SNS alarms** — wire CloudWatch alarms to an SNS topic + email subscription

#### Infrastructure

- [x] **Terraform state locking** — handled automatically by Terraform Cloud (migrated from S3 backend)
- [ ] **Lambda provisioned concurrency** — optional; eliminates cold-start latency on the first request after inactivity; set to 1 on the production alias once traffic justifies it
See [FUTURE.md](FUTURE.md) for planned features and remaining code work.
Loading