Skip to content
Open
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
81 changes: 81 additions & 0 deletions .agent/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Architecture

## Data Model (Prisma)

```
Group (id, name, currency, currencyCode)
└── Participant (id, name)
└── Expense (id, title, amount, expenseDate, splitMode, isReimbursement)
├── paidBy → Participant
├── paidFor → ExpensePaidFor[] (participantId, shares)
├── Category (id, grouping, name)
├── ExpenseDocument[] (url, width, height)
└── RecurringExpenseLink (nextExpenseCreatedAt)
└── Activity (time, activityType, data) - audit log
```

### Split Modes

- `EVENLY`: Divide equally, `shares` = 1 per participant
- `BY_SHARES`: Proportional, e.g., shares 2:1:1 = 50%:25%:25%
- `BY_PERCENTAGE`: Basis points (10000 = 100%), e.g., 2500 = 25%
- `BY_AMOUNT`: Direct cents, `shares` = exact amount owed

### Calculations (src/lib/balances.ts)

```typescript
// BY_PERCENTAGE: (expense.amount * shares) / 10000
// BY_SHARES: (expense.amount * shares) / totalShares
// BY_AMOUNT: shares directly
// Rounding: Math.round() at the end
```

## Directory Details

### src/app/

Next.js App Router. Pages, layouts, Server Actions. Group pages under `groups/[groupId]/`.

### src/components/

Reusable components. shadcn/UI primitives in `ui/`. Feature components at root.

### src/trpc/

- `init.ts` - tRPC config, SuperJSON transformer
- `routers/_app.ts` - Root router composition
- `routers/groups/` - Group domain (expenses, balances, stats, activities)
- `routers/categories/` - Category CRUD

### src/lib/

- `api.ts` - Database operations (createExpense, updateExpense, etc.)
- `balances.ts` - Balance calculation logic
- `totals.ts` - Expense total calculations
- `schemas.ts` - Zod validation schemas
- `prisma.ts` - Prisma client singleton
- `featureFlags.ts` - Feature toggles (S3 docs, receipt scanning)

## tRPC Router Hierarchy

```
appRouter
├── groups
│ ├── get, getDetails, list, create, update
│ ├── expenses (list, get, create, update, delete)
│ ├── balances (list)
│ ├── stats (get)
│ └── activities (list)
└── categories
└── list
```

API calls: `trpc.groups.expenses.create()`, `trpc.groups.balances.list()`, etc.

## Feature Flags

Env vars for optional features:

- `NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS` - S3 image uploads
- `NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT` - GPT-4V receipt scanning
- `NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT` - AI category suggestions
119 changes: 119 additions & 0 deletions .agent/database.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Database

## Setup

```bash
./scripts/start-local-db.sh # Start PostgreSQL container
npx prisma migrate dev # Run migrations
npx prisma studio # GUI for database
npx prisma generate # Regenerate client after schema changes
```

## Prisma Client Singleton

```typescript
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma = globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
```

Dev mode uses global singleton to survive hot reload.

## Schema Changes

1. Edit `prisma/schema.prisma`
2. Run `npx prisma migrate dev --name descriptive_name`
3. Commit migration file + schema changes together

## Query Patterns

### Create with Relations

```typescript
// src/lib/api.ts - createExpense
await prisma.expense.create({
data: {
groupId,
title,
amount,
paidById: paidBy,
splitMode,
expenseDate,
paidFor: {
createMany: {
data: paidFor.map(({ participant, shares }) => ({
participantId: participant,
shares,
})),
},
},
},
})
```

### Query with Includes

```typescript
// Expenses with payer and split details
await prisma.expense.findMany({
where: { groupId },
include: {
paidBy: true,
paidFor: { include: { participant: true } },
category: true,
},
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
})
```

### Update with Nested Operations

```typescript
// Update expense and replace paidFor entries
await prisma.expense.update({
where: { id: expenseId },
data: {
title,
amount,
paidFor: {
deleteMany: {}, // Remove all existing
createMany: { data: newPaidFor },
},
},
})
```

## Transactions

Used for atomic operations:

```typescript
// src/lib/api.ts - createRecurringExpenses
await prisma.$transaction(async (tx) => {
const expense = await tx.expense.create({ data: expenseData })
await tx.recurringExpenseLink.update({
where: { id: linkId },
data: { nextExpenseCreatedAt: nextDate },
})
return expense
})
```

## Amount Storage

All monetary values stored as **integers in cents**:

- `100` = $1.00
- `15050` = $150.50

Split shares vary by mode:

- `EVENLY`: 1 per participant
- `BY_SHARES`: Weight integers (1, 2, 3...)
- `BY_PERCENTAGE`: Basis points (2500 = 25%)
- `BY_AMOUNT`: Cents directly
110 changes: 110 additions & 0 deletions .agent/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Testing

## Jest Unit Tests

```bash
npm test # Run all tests
npm test -- --watch # Watch mode
npm test -- path/to/file.test.ts # Specific file
```

Tests in `src/**/*.test.ts` alongside implementation.

### Test Data Factory Pattern

```typescript
// src/lib/balances.test.ts
const makeExpense = (overrides: Partial<BalancesExpense>): BalancesExpense =>
({
id: 'e1',
expenseDate: new Date('2025-01-01T00:00:00.000Z'),
title: 'Dinner',
amount: 0,
isReimbursement: false,
splitMode: 'EVENLY',
paidBy: { id: 'p0', name: 'P0' },
paidFor: [{ participant: { id: 'p0', name: 'P0' }, shares: 1 }],
...overrides,
}) as BalancesExpense

// Usage
const expenses = [
makeExpense({
amount: 100,
paidBy: { id: 'p0', name: 'P0' },
paidFor: [
{ participant: { id: 'p0', name: 'P0' }, shares: 1 },
{ participant: { id: 'p1', name: 'P1' }, shares: 1 },
],
}),
]
```

### Focus Areas

- `balances.test.ts` - Balance calculations, split modes, edge cases
- `totals.test.ts` - Expense totals, user shares
- `currency.test.ts` - Currency formatting

## Playwright E2E Tests

```bash
npm run test-e2e # Runs against local dev server
```

Tests in `tests/e2e/*.spec.ts` and `tests/*.spec.ts`.

### Test Helpers (`tests/helpers/`)

| Helper | Purpose |
| ----------------------------------------------- | ------------------------ |
| `createGroupViaAPI(page, name, participants)` | Fast group setup via API |
| `createExpense(page, { title, amount, payer })` | Fill expense form |
| `navigateToExpenseCreate(page, groupId)` | Go to expense creation |
| `fillParticipants(page, names)` | Add participants to form |
| `selectComboboxOption(page, label, value)` | Select dropdown value |

### Stability Patterns

```typescript
// Wait after navigation
await page.goto(`/groups/${groupId}`)
await page.waitForLoadState()

// Wait for URL after form submission
await page.getByRole('button', { name: 'Create' }).click()
await page.waitForURL(/\/groups\/[^/]+\/expenses/)

// Use API for fast setup
const groupId = await createGroupViaAPI(page, 'Test Group', ['Alice', 'Bob'])
```

### Example Test

```typescript
import { createExpense } from '../helpers'
import { createGroupViaAPI } from '../helpers/batch-api'

test('creates expense with correct values', async ({ page }) => {
const groupId = await createGroupViaAPI(page, `Test ${randomId(4)}`, [
'Alice',
'Bob',
])
await page.goto(`/groups/${groupId}/expenses`)

await createExpense(page, {
title: 'Dinner',
amount: '150.00',
payer: 'Alice',
})

await expect(page.getByText('Dinner')).toBeVisible()
await expect(page.getByText('$150.00')).toBeVisible()
})
```

### Config Notes

- `fullyParallel: false` in playwright.config.ts prevents DB conflicts
- Runs Chromium, Firefox, WebKit
- `json` reporter when `CLAUDE_CODE` env var detected
Loading