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

17 KiB
Raw Blame History

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

{
  "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):

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