Files
budget/.plans/budget-site.md
T
Spencer Twaddle d788dfea03 Phase 1: Project scaffolding and infrastructure
- Scaffold Budget.Api (ASP.NET Core Web API, net10.0) with EF Core + Npgsql
- Scaffold Budget.Client (Vite + React + TypeScript) with /api proxy to localhost:5000
- Define all entity models: Budget, Income, Outgo, KnownUser, BudgetShare
- Configure AppDbContext with EF mappings and cascade deletes
- Add InitialCreate migration
- Configure SPA static file serving + fallback in Program.cs
- Add Dockerfile (multi-stage: node + dotnet sdk + aspnet runtime)
- Add .env.example with all required environment variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 07:37:28 -05:00

472 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Budget Site — Implementation Plan
## Overview
Replace a Google Docs spreadsheet with a multi-user budget web app. Users get Income, Outgo, and Summary views organized around the 50/30/20 framework. Budgets can be shared with other users in edit or view-only mode.
**Stack:**
- Backend: ASP.NET Core (serves API + hosts compiled React assets)
- Frontend: Vite + React (TypeScript)
- Database: PostgreSQL via EF Core
- Auth: OIDC against `auth.stwaddle.com` (OpenIddict-based)
- Deployment: Single Docker container (no nginx needed)
---
## Frequency Reference
All monetary amounts are normalized to monthly and annual values using these multipliers:
| Frequency | Monthly Multiplier | Notes |
|---------------|---------------------------|---------------------------------|
| Biennial | amount / 24 | Once every 2 years |
| Annually | amount / 12 | Once per year |
| Biannually | amount × 2 / 12 | Twice per year |
| Quarterly | amount × 4 / 12 | 4× per year |
| Every 2 Months| amount × 6 / 12 | Every other month (6× per year) |
| Monthly | amount × 1 | |
| Semi-monthly | amount × 2 | Twice per month |
| Biweekly | amount × 26 / 12 | Every 2 weeks |
| Weekly | amount × 52 / 12 | Every week |
---
## Architecture
### Single-Container Strategy
The ASP.NET Core app serves the Vite production build from its `wwwroot` directory. The Dockerfile is a two-stage build:
1. **Stage 1 (node):** `npm run build` inside `client/` → outputs to `client/dist/`
2. **Stage 2 (dotnet):** Publishes the ASP.NET app, copies `client/dist` into `wwwroot/`
At runtime, ASP.NET uses `UseStaticFiles()` + a catch-all fallback route that returns `index.html` for any non-API path (SPA fallback). API routes are prefixed `/api`.
### Development Mode
In development the Vite dev server runs separately (e.g., `localhost:5173`). ASP.NET proxies unknown routes to it via `Microsoft.AspNetCore.SpaProxy` or a simple reverse proxy middleware. A `launchSettings.json` profile wires this up so `dotnet run` and `npm run dev` together give hot-reload on both sides.
### Project Layout
```
Budget.sln
├── src/
│ ├── Budget.Api/ # ASP.NET Core project
│ │ ├── Controllers/
│ │ ├── Data/ # EF Core DbContext, migrations
│ │ ├── Models/ # EF Core entities
│ │ ├── DTOs/ # Request/response shapes
│ │ ├── Services/ # Business logic
│ │ ├── wwwroot/ # Populated by Docker build stage
│ │ └── Program.cs
│ └── Budget.Client/ # Vite + React project
│ ├── src/
│ │ ├── pages/ # Income, Outgo, Summary
│ │ ├── components/
│ │ ├── api/ # API client (fetch wrappers)
│ │ ├── hooks/
│ │ └── types/
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
├── Dockerfile
├── docker-compose.yml # User maintains this separately
├── .env.example
└── .plans/
```
---
## Data Model
### Entities
**Budget**
- `Id` (Guid)
- `Name` (string)
- `OwnerUserId` (string — `sub` claim from OIDC token)
- `CreatedAt`, `UpdatedAt`
**Income**
- `Id` (Guid)
- `BudgetId` (Guid, FK)
- `Name` (string)
- `Frequency` (enum)
- `Amount` (decimal)
- `SortOrder` (int) — preserves user-defined row ordering
**Outgo**
- `Id` (Guid)
- `BudgetId` (Guid, FK)
- `Name` (string)
- `Category` (string, nullable)
- `Type` (enum: Need | Want | Save)
- `Frequency` (enum)
- `Amount` (decimal)
- `PaymentSource` (string, nullable)
- `Notes` (string, nullable)
- `SortOrder` (int)
> `Monthly`, `Annually`, and `MonthlyPercentage` are computed — not stored. The API returns them as calculated fields in DTOs.
**KnownUser** *(local cache of authenticated users)*
- `Id` (string — the OIDC `sub` claim, PK)
- `Email` (string)
- `Name` (string)
- `LastSeenAt` (DateTimeOffset)
> Populated/updated each time a user authenticates with the Budget app. Used for the share-by-email lookup flow without requiring any changes to the Auth server.
**BudgetShare**
- `Id` (Guid)
- `BudgetId` (Guid, FK)
- `SharedWithUserId` (string, nullable) — null until the target user has logged in
- `SharedWithEmail` (string) — the email used when the share was created; used to resolve pending shares on login
- `Permission` (enum: View | Edit)
- `IsPending` (bool) — true when `SharedWithUserId` is null
- `CreatedAt`
> When a user authenticates, the app queries for pending shares where `SharedWithEmail` matches their token's `email` claim, sets `SharedWithUserId` and clears `IsPending`. This allows pre-sharing with users who haven't logged in yet without any Auth server changes or enumeration risk.
### Authorization Rules
- Only the budget owner or a user with Edit permission may mutate data.
- A user with View permission may read all budget data.
- A user with no share record (resolved or pending) may not access the budget.
- The owner may not be removed from their own budget.
- Pending shares are not counted for access until resolved.
---
## API Surface
Base path: `/api`
### Budgets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/budgets` | List budgets accessible to the current user |
| POST | `/api/budgets` | Create a new budget |
| GET | `/api/budgets/{id}` | Get budget metadata |
| PUT | `/api/budgets/{id}` | Rename budget |
| DELETE | `/api/budgets/{id}` | Delete budget (owner only) |
### Sharing
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/budgets/{id}/shares` | List shares for a budget |
| POST | `/api/budgets/{id}/shares` | Add a share |
| PUT | `/api/budgets/{id}/shares/{shareId}` | Update permission |
| DELETE | `/api/budgets/{id}/shares/{shareId}` | Revoke share |
### Income
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/budgets/{id}/incomes` | List all incomes (with computed monthly/annually) |
| POST | `/api/budgets/{id}/incomes` | Add income |
| PUT | `/api/budgets/{id}/incomes/{incomeId}` | Update income |
| DELETE | `/api/budgets/{id}/incomes/{incomeId}` | Delete income |
| PUT | `/api/budgets/{id}/incomes/order` | Reorder incomes |
### Outgo
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/budgets/{id}/outgos` | List all outgos (with computed fields) |
| POST | `/api/budgets/{id}/outgos` | Add outgo |
| PUT | `/api/budgets/{id}/outgos/{outgoId}` | Update outgo |
| DELETE | `/api/budgets/{id}/outgos/{outgoId}` | Delete outgo |
| PUT | `/api/budgets/{id}/outgos/order` | Reorder outgos |
| GET | `/api/budgets/{id}/outgos/categories` | Distinct category values (autocomplete) |
| GET | `/api/budgets/{id}/outgos/payment-sources` | Distinct payment source values (autocomplete) |
### Summary
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/budgets/{id}/summary` | Full summary (income total, type breakdowns, pre-tax calc) |
### Summary Response Shape
```json
{
"monthlyIncome": 0.00,
"annualIncome": 0.00,
"breakdown": [
{
"type": "Need",
"targetPercent": 50,
"total": 0.00,
"annually": 0.00,
"percent": 0.00,
"maxAmount": 0.00,
"remaining": 0.00
},
{ "type": "Want", "targetPercent": 30, ... },
{ "type": "Save", "targetPercent": 20, ... },
{ "type": "Unspent", "targetPercent": null, "total": 0.00, "annually": 0.00, "percent": 0.00 }
],
"preTaxIncome": {
"effectiveTaxRate": 0.25,
"minimumAnnualGross": 0.00,
"minimumMonthlyGross": 0.00
}
}
```
---
## Pre-Tax Income Calculator
The calculator takes a configurable effective tax rate (stored per-budget, defaulting to 25%) and computes:
```
minimumAnnualGross = annualOutgoTotal / (1 - effectiveTaxRate)
```
The effective tax rate is stored on the Budget entity and editable from the Summary page via a settings panel.
---
## Frontend Pages & Components
### Routing
```
/ → redirect to /budgets
/budgets → budget list / selector
/budgets/:id/income → Income page
/budgets/:id/outgo → Outgo page
/budgets/:id/summary → Summary page
/budgets/:id/settings → Budget settings (sharing, tax rate)
```
### Income Page
Editable table with columns: Name, Frequency, Amount, Monthly, Annually.
- Inline editing (click a cell to edit)
- Add row button at bottom
- Delete row button per row
- Drag-to-reorder rows
- Monthly and Annually columns are read-only computed displays
### Outgo Page
Editable table with columns: Name, Category, Type, Frequency, Amount, Monthly, Annually, Payment Source, Monthly%, Notes.
- Same inline editing / add / delete / reorder as Income
- **Category**: free-text input with dropdown suggestions sourced from `/outgos/categories`
- **Payment Source**: same autocomplete pattern via `/outgos/payment-sources`
- **Type**: dropdown (Need / Want / Save)
- **Monthly%**: read-only computed
- Monthly and Annually: read-only computed
### Summary Page
- Header card: Monthly Income, Annual Income
- Table of types (Need, Want, Save, Unspent) with columns: Type, Monthly Total, Annual Total, % of Income, Target %, Max Amount, Remaining
- Target % shown as a tooltip/info icon rather than a data column to reduce visual noise
- Unspent row only shows Total, Annually, %
- Pre-Tax Income section: shows minimum annual gross with an editable tax rate field
### Budget Settings Page
- Rename budget
- Effective tax rate input
- Shares table: list current shares, add by user email/ID, change permission, revoke
### Shared Components
- `FrequencySelect` — dropdown for the 9 frequency options
- `MoneyDisplay` — formatted currency display
- `AutocompleteInput` — reusable input with suggestions dropdown
- `BudgetNav` — tabs: Income | Outgo | Summary | Settings
- Auth guard wrapping all budget routes
---
## Authentication
Auth is handled by the external OIDC provider at `auth.stwaddle.com`.
### Backend
- Add `Microsoft.AspNetCore.Authentication.JwtBearer`
- Configure authority and audience from environment variables:
```
AUTH__AUTHORITY=https://auth.stwaddle.com
AUTH__AUDIENCE=budget-api
```
- All `/api` endpoints require `[Authorize]`
- Extract `sub` claim as the user identity key
### Frontend
- Use `oidc-client-ts` (or `@axa-fr/react-oidc`) for the OIDC flow
- Store the access token and attach it as `Authorization: Bearer <token>` on all API calls
- Auth config sourced from env vars baked in at build time:
```
VITE_AUTH_AUTHORITY=https://auth.stwaddle.com
VITE_AUTH_CLIENT_ID=budget-client
VITE_AUTH_REDIRECT_URI=https://budget.stwaddle.com/callback
```
Auth integration is scaffolded in Phase 2 but login/callback pages are left as stubs; full OIDC flow requires a client registration on `auth.stwaddle.com` (done via the Auth admin UI — create a Public client with Authorization Code + PKCE, add the budget app's redirect URI, and assign the `email`, `profile`, and `roles` scopes).
### User Identity Cache & Pending Share Resolution
On every authenticated request, the Budget API upserts a `KnownUser` record using the `sub`, `email`, and `name` claims from the token. After upserting, it resolves any pending shares by matching `SharedWithEmail` to the user's email claim, setting `SharedWithUserId` and clearing `IsPending`.
---
## Configuration & Environment
`.env.example`:
```
# Database
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=budget
POSTGRES_USER=budget
POSTGRES_PASSWORD=changeme
# Auth
AUTH__AUTHORITY=https://auth.stwaddle.com
AUTH__AUDIENCE=budget-api
# Client (baked into Vite build)
VITE_AUTH_AUTHORITY=https://auth.stwaddle.com
VITE_AUTH_CLIENT_ID=budget-client
VITE_AUTH_REDIRECT_URI=https://budget.stwaddle.com/callback
```
`docker-compose.yml` (user-maintained, referenced here for context):
```yaml
services:
app:
build: .
ports:
- "8080:8080"
env_file: .env
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
```
---
## Dockerfile (Multi-Stage)
```dockerfile
# Stage 1: Build React client
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY src/Budget.Client/package*.json ./
RUN npm ci
COPY src/Budget.Client/ ./
RUN npm run build
# Stage 2: Build and publish ASP.NET app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api-build
WORKDIR /app
COPY Budget.sln ./
COPY src/Budget.Api/ ./src/Budget.Api/
RUN dotnet publish src/Budget.Api/Budget.Api.csproj -c Release -o /publish
# Stage 3: Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=api-build /publish ./
COPY --from=client-build /app/client/dist ./wwwroot
EXPOSE 8080
ENTRYPOINT ["dotnet", "Budget.Api.dll"]
```
---
## Implementation Phases
### Phase 1 — Project Scaffolding & Infrastructure
- [ ] Create `src/Budget.Api` ASP.NET Core Web API project (targeting .NET 9)
- [ ] Create `src/Budget.Client` Vite + React + TypeScript project
- [ ] Add both to `Budget.sln`
- [ ] Configure ASP.NET to serve static files from `wwwroot` with SPA fallback
- [ ] Configure Vite dev proxy to forward `/api` requests to the ASP.NET dev port
- [ ] Add EF Core + Npgsql packages; scaffold `AppDbContext`
- [ ] Define all entity models and configure EF mappings
- [ ] Add initial migration
- [ ] Write `Dockerfile` (multi-stage as above)
- [ ] Write `.env.example`
- [ ] Verify clean build: `dotnet build` + `npm run build`
### Phase 2 — Authentication Scaffolding
- [ ] Add JWT bearer auth to ASP.NET; read authority/audience from config
- [ ] Add `[Authorize]` to a test endpoint and verify token validation works
- [ ] Add middleware to upsert `KnownUser` and resolve pending shares on each authenticated request
- [ ] Add `oidc-client-ts` to the React app
- [ ] Create auth context / provider with login, logout, and token storage
- [ ] Add stub `/callback` route for OIDC redirect
- [ ] Protect all budget routes with an auth guard
- [ ] Verify clean build
### Phase 3 — Budget & Sharing API
- [ ] `BudgetsController`: CRUD + list
- [ ] Authorization service: checks owner vs. share permissions
- [ ] `SharesController`: list / add / update / delete
- [ ] Apply authorization service to all budget-scoped controllers
- [ ] Integration-test the happy paths manually
- [ ] Verify clean build
### Phase 4 — Income API & Page
- [ ] `IncomesController`: list, add, update, delete, reorder
- [ ] Frequency calculation service (shared utility used by all computed fields)
- [ ] Income list DTO including computed Monthly and Annually
- [ ] React Income page: table, inline edit, add/delete row, drag reorder
- [ ] `FrequencySelect` and `MoneyDisplay` shared components
- [ ] Verify clean build
### Phase 5 — Outgo API & Page
- [ ] `OutgosController`: list, add, update, delete, reorder, categories, payment-sources
- [ ] Outgo list DTO including Monthly, Annually, MonthlyPercentage
- [ ] React Outgo page: table with all columns, inline edit, add/delete, reorder
- [ ] `AutocompleteInput` component wired to categories and payment-sources endpoints
- [ ] Verify clean build
### Phase 6 — Summary API & Page
- [ ] `SummaryController`: compute and return full summary shape
- [ ] Pre-tax income calculation; `EffectiveTaxRate` field on Budget entity + migration
- [ ] React Summary page: income header cards, type breakdown table, pre-tax section
- [ ] Editable tax rate on Summary page (PATCH to budget settings)
- [ ] Verify clean build
### Phase 7 — Budget Settings & Sharing UI
- [ ] Budget list / selector page (landing page after login)
- [ ] Budget Settings page: rename, tax rate, shares table
- [ ] Share add flow: input email address, choose permission, submit; show "pending" badge for users not yet seen
- [ ] `BudgetNav` tab component used across all budget pages
- [ ] Verify clean build
### Phase 8 — Polish & Production Readiness
- [ ] Global error boundary and API error toasts in React
- [ ] Loading skeletons for tables
- [ ] Confirm-on-delete dialogs
- [ ] EF Core migration applied automatically on startup (`context.Database.MigrateAsync()`)
- [ ] Health check endpoint (`/healthz`)
- [ ] Final Dockerfile review and end-to-end smoke test inside Docker
- [ ] Verify clean build
---
## Open Questions / Deferred Decisions
- **Optimistic vs. server-side updates**: The React tables can either update locally then sync, or round-trip to the server. Phase 45 will use server-round-trip for simplicity; can add optimistic updates in Phase 8 if the UX feels slow.
- **Row reordering persistence**: Drag-to-reorder uses a `SortOrder` integer column. The reorder endpoint accepts an ordered list of IDs and bulk-updates SortOrder. This is straightforward but may need debouncing if the user drags frequently.
- **Auth client registration**: Before Phase 2 can be fully tested end-to-end, a client must be registered in the Auth admin UI for the Budget app. Required fields: client type = Public, grant = Authorization Code + PKCE, redirect URI = `http://localhost:5173/callback` (dev) + production URL, scopes = `email profile roles`.