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:
@@ -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 4–5 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`.
|
||||
Reference in New Issue
Block a user