- 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>
17 KiB
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:
- Stage 1 (node):
npm run buildinsideclient/→ outputs toclient/dist/ - Stage 2 (dotnet): Publishes the ASP.NET app, copies
client/distintowwwroot/
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 —subclaim 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, andMonthlyPercentageare computed — not stored. The API returns them as calculated fields in DTOs.
KnownUser (local cache of authenticated users)
Id(string — the OIDCsubclaim, 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 inSharedWithEmail(string) — the email used when the share was created; used to resolve pending shares on loginPermission(enum: View | Edit)IsPending(bool) — true whenSharedWithUserIdis nullCreatedAt
When a user authenticates, the app queries for pending shares where
SharedWithEmailmatches their token'sSharedWithUserIdand clearsIsPending. 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
{
"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 optionsMoneyDisplay— formatted currency displayAutocompleteInput— reusable input with suggestions dropdownBudgetNav— 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
/apiendpoints require[Authorize] - Extract
subclaim 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):
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)
# 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.ApiASP.NET Core Web API project (targeting .NET 9) - Create
src/Budget.ClientVite + React + TypeScript project - Add both to
Budget.sln - Configure ASP.NET to serve static files from
wwwrootwith SPA fallback - Configure Vite dev proxy to forward
/apirequests 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
KnownUserand resolve pending shares on each authenticated request - Add
oidc-client-tsto the React app - Create auth context / provider with login, logout, and token storage
- Add stub
/callbackroute 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
FrequencySelectandMoneyDisplayshared 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
AutocompleteInputcomponent 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;
EffectiveTaxRatefield 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
BudgetNavtab 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
SortOrderinteger 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.