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>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:37:28 -05:00
commit d788dfea03
46 changed files with 4885 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
# 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`.