# 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 ` 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`.