Phase 4: Income API and page

- Add FrequencyCalculator static utility (all 9 frequency multipliers)
- Add IncomesController: list, create, update, delete, reorder
- Add Income DTOs with computed Monthly/Annually fields
- Add shared TypeScript types (IncomeDto, OutgoDto, BudgetDto, ShareDto, SummaryDto)
- Add API client with Bearer token injection via setTokenProvider
- Add FrequencySelect, MoneyDisplay, BudgetNav shared components
- Add Income page: sortable table with inline editing, add/delete rows, drag-to-reorder via dnd-kit
- Wire TokenWirer in App.tsx to keep API client in sync with auth state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:56:42 -05:00
parent 963e511287
commit f429a747d8
13 changed files with 543 additions and 31 deletions
@@ -0,0 +1,92 @@
using Budget.Api.Data;
using Budget.Api.DTOs;
using Budget.Api.Models;
using Budget.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Budget.Api.Controllers;
[ApiController]
[Route("api/budgets/{budgetId:guid}/incomes")]
[Authorize]
public class IncomesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
{
private string UserId => User.FindFirst("sub")!.Value;
private static IncomeDto ToDto(Income i) => new(
i.Id, i.BudgetId, i.Name, i.Frequency, i.Amount,
FrequencyCalculator.ToMonthly(i.Amount, i.Frequency),
FrequencyCalculator.ToAnnually(i.Amount, i.Frequency),
i.SortOrder);
[HttpGet]
public async Task<IActionResult> List(Guid budgetId)
{
if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid();
var incomes = await db.Incomes
.Where(i => i.BudgetId == budgetId)
.OrderBy(i => i.SortOrder)
.ToListAsync();
return Ok(incomes.Select(ToDto));
}
[HttpPost]
public async Task<IActionResult> Create(Guid budgetId, [FromBody] CreateIncomeRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var maxOrder = await db.Incomes.Where(i => i.BudgetId == budgetId).MaxAsync(i => (int?)i.SortOrder) ?? -1;
var income = new Income
{
Id = Guid.NewGuid(),
BudgetId = budgetId,
Name = req.Name,
Frequency = req.Frequency,
Amount = req.Amount,
SortOrder = maxOrder + 1,
};
db.Incomes.Add(income);
await db.SaveChangesAsync();
return Ok(ToDto(income));
}
[HttpPut("{incomeId:guid}")]
public async Task<IActionResult> Update(Guid budgetId, Guid incomeId, [FromBody] UpdateIncomeRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound();
income.Name = req.Name;
income.Frequency = req.Frequency;
income.Amount = req.Amount;
await db.SaveChangesAsync();
return Ok(ToDto(income));
}
[HttpDelete("{incomeId:guid}")]
public async Task<IActionResult> Delete(Guid budgetId, Guid incomeId)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var income = await db.Incomes.FirstOrDefaultAsync(i => i.Id == incomeId && i.BudgetId == budgetId);
if (income is null) return NotFound();
db.Incomes.Remove(income);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPut("order")]
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
{
if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid();
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
var lookup = incomes.ToDictionary(i => i.Id);
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
{
if (lookup.TryGetValue(req.OrderedIds[idx], out var income))
income.SortOrder = idx;
}
await db.SaveChangesAsync();
return NoContent();
}
}
+19
View File
@@ -0,0 +1,19 @@
using Budget.Api.Models;
namespace Budget.Api.DTOs;
public record IncomeDto(
Guid Id,
Guid BudgetId,
string Name,
Frequency Frequency,
decimal Amount,
decimal Monthly,
decimal Annually,
int SortOrder);
public record CreateIncomeRequest(string Name, Frequency Frequency, decimal Amount);
public record UpdateIncomeRequest(string Name, Frequency Frequency, decimal Amount);
public record ReorderIncomesRequest(List<Guid> OrderedIds);
@@ -0,0 +1,23 @@
using Budget.Api.Models;
namespace Budget.Api.Services;
public static class FrequencyCalculator
{
public static decimal ToMonthly(decimal amount, Frequency frequency) => frequency switch
{
Frequency.Biennial => amount / 24m,
Frequency.Annually => amount / 12m,
Frequency.Biannually => amount * 2m / 12m,
Frequency.Quarterly => amount * 4m / 12m,
Frequency.EveryTwoMonths => amount * 6m / 12m,
Frequency.Monthly => amount,
Frequency.SemiMonthly => amount * 2m,
Frequency.Biweekly => amount * 26m / 12m,
Frequency.Weekly => amount * 52m / 12m,
_ => throw new ArgumentOutOfRangeException(nameof(frequency)),
};
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
ToMonthly(amount, frequency) * 12m;
}
+57 -3
View File
@@ -8,6 +8,9 @@
"name": "budget-client",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
@@ -268,6 +271,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2568,9 +2624,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
+3
View File
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+15 -21
View File
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './auth/AuthContext';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { AuthGuard } from './auth/AuthGuard';
import { setTokenProvider } from './api/client';
import { CallbackPage } from './pages/CallbackPage';
import { BudgetsPage } from './pages/BudgetsPage';
import { IncomePage } from './pages/IncomePage';
@@ -8,33 +10,25 @@ import { OutgoPage } from './pages/OutgoPage';
import { SummaryPage } from './pages/SummaryPage';
import { SettingsPage } from './pages/SettingsPage';
function TokenWirer() {
const { getToken } = useAuth();
useEffect(() => { setTokenProvider(getToken); }, [getToken]);
return null;
}
function App() {
return (
<AuthProvider>
<TokenWirer />
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/budgets" replace />} />
<Route path="/callback" element={<CallbackPage />} />
<Route
path="/budgets"
element={<AuthGuard><BudgetsPage /></AuthGuard>}
/>
<Route
path="/budgets/:id/income"
element={<AuthGuard><IncomePage /></AuthGuard>}
/>
<Route
path="/budgets/:id/outgo"
element={<AuthGuard><OutgoPage /></AuthGuard>}
/>
<Route
path="/budgets/:id/summary"
element={<AuthGuard><SummaryPage /></AuthGuard>}
/>
<Route
path="/budgets/:id/settings"
element={<AuthGuard><SettingsPage /></AuthGuard>}
/>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>} />
<Route path="/budgets/:id/income" element={<AuthGuard><IncomePage /></AuthGuard>} />
<Route path="/budgets/:id/outgo" element={<AuthGuard><OutgoPage /></AuthGuard>} />
<Route path="/budgets/:id/summary" element={<AuthGuard><SummaryPage /></AuthGuard>} />
<Route path="/budgets/:id/settings" element={<AuthGuard><SettingsPage /></AuthGuard>} />
</Routes>
</BrowserRouter>
</AuthProvider>
+30
View File
@@ -0,0 +1,30 @@
let getToken: (() => string | null) | null = null;
export function setTokenProvider(fn: () => string | null) {
getToken = fn;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const token = getToken?.();
const res = await fetch(path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path}${res.status}: ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
delete: (path: string) => request<void>('DELETE', path),
};
@@ -0,0 +1,14 @@
import { NavLink, useParams } from 'react-router-dom';
export function BudgetNav() {
const { id } = useParams<{ id: string }>();
const base = `/budgets/${id}`;
return (
<nav style={{ display: 'flex', gap: '1rem', marginBottom: '1rem' }}>
<NavLink to={`${base}/income`}>Income</NavLink>
<NavLink to={`${base}/outgo`}>Outgo</NavLink>
<NavLink to={`${base}/summary`}>Summary</NavLink>
<NavLink to={`${base}/settings`}>Settings</NavLink>
</nav>
);
}
@@ -0,0 +1,31 @@
import type { Frequency } from '../types';
const FREQUENCY_LABELS: Record<Frequency, string> = {
Biennial: 'Biennial',
Annually: 'Annually',
Biannually: 'Biannually',
Quarterly: 'Quarterly',
EveryTwoMonths: 'Every 2 Months',
Monthly: 'Monthly',
SemiMonthly: 'Semi-Monthly',
Biweekly: 'Biweekly',
Weekly: 'Weekly',
};
const FREQUENCIES = Object.keys(FREQUENCY_LABELS) as Frequency[];
interface Props {
value: Frequency;
onChange: (v: Frequency) => void;
disabled?: boolean;
}
export function FrequencySelect({ value, onChange, disabled }: Props) {
return (
<select value={value} onChange={e => onChange(e.target.value as Frequency)} disabled={disabled}>
{FREQUENCIES.map(f => (
<option key={f} value={f}>{FREQUENCY_LABELS[f]}</option>
))}
</select>
);
}
@@ -0,0 +1,10 @@
interface Props {
value: number;
className?: string;
}
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export function MoneyDisplay({ value, className }: Props) {
return <span className={className}>{fmt.format(value)}</span>;
}
+5 -5
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
);
+164 -2
View File
@@ -1,3 +1,165 @@
export function IncomePage() {
return <div>Income coming soon</div>;
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { IncomeDto, Frequency } from '../types';
import { api } from '../api/client';
import { FrequencySelect } from '../components/FrequencySelect';
import { MoneyDisplay } from '../components/MoneyDisplay';
import { BudgetNav } from '../components/BudgetNav';
interface EditState {
name: string;
frequency: Frequency;
amount: string;
}
function SortableRow({
income,
onSave,
onDelete,
}: {
income: IncomeDto;
onSave: (id: string, edit: EditState) => void;
onDelete: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: income.id });
const style = { transform: CSS.Transform.toString(transform), transition };
const [editing, setEditing] = useState<EditState | null>(null);
const startEdit = () =>
setEditing({ name: income.name, frequency: income.frequency, amount: String(income.amount) });
const save = () => {
if (editing) { onSave(income.id, editing); setEditing(null); }
};
const cancel = () => setEditing(null);
if (editing) {
return (
<tr ref={setNodeRef} style={style}>
<td></td>
<td><input value={editing.name} onChange={e => setEditing(p => p && ({ ...p, name: e.target.value }))} /></td>
<td>
<FrequencySelect
value={editing.frequency}
onChange={v => setEditing(p => p && ({ ...p, frequency: v }))}
/>
</td>
<td><input type="number" value={editing.amount} onChange={e => setEditing(p => p && ({ ...p, amount: e.target.value }))} /></td>
<td></td>
<td></td>
<td>
<button onClick={save}>Save</button>
<button onClick={cancel}>Cancel</button>
</td>
</tr>
);
}
return (
<tr ref={setNodeRef} style={style}>
<td {...attributes} {...listeners} style={{ cursor: 'grab' }}></td>
<td onClick={startEdit}>{income.name}</td>
<td onClick={startEdit}>{income.frequency}</td>
<td onClick={startEdit}><MoneyDisplay value={income.amount} /></td>
<td><MoneyDisplay value={income.monthly} /></td>
<td><MoneyDisplay value={income.annually} /></td>
<td><button onClick={() => onDelete(income.id)}>Delete</button></td>
</tr>
);
}
export function IncomePage() {
const { id: budgetId } = useParams<{ id: string }>();
const [incomes, setIncomes] = useState<IncomeDto[]>([]);
const sensors = useSensors(useSensor(PointerSensor));
useEffect(() => {
if (budgetId) api.get<IncomeDto[]>(`/api/budgets/${budgetId}/incomes`).then(setIncomes);
}, [budgetId]);
const handleSave = async (id: string, edit: EditState) => {
const updated = await api.put<IncomeDto>(`/api/budgets/${budgetId}/incomes/${id}`, {
name: edit.name,
frequency: edit.frequency,
amount: parseFloat(edit.amount),
});
setIncomes(prev => prev.map(i => i.id === id ? updated : i));
};
const handleDelete = async (id: string) => {
await api.delete(`/api/budgets/${budgetId}/incomes/${id}`);
setIncomes(prev => prev.filter(i => i.id !== id));
};
const handleAdd = async () => {
const created = await api.post<IncomeDto>(`/api/budgets/${budgetId}/incomes`, {
name: 'New Income',
frequency: 'Monthly',
amount: 0,
});
setIncomes(prev => [...prev, created]);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = incomes.findIndex(i => i.id === active.id);
const newIdx = incomes.findIndex(i => i.id === over.id);
const reordered = arrayMove(incomes, oldIdx, newIdx);
setIncomes(reordered);
await api.put(`/api/budgets/${budgetId}/incomes/order`, {
orderedIds: reordered.map(i => i.id),
});
};
return (
<div>
<BudgetNav />
<h1>Income</h1>
<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Frequency</th>
<th>Amount</th>
<th>Monthly</th>
<th>Annually</th>
<th></th>
</tr>
</thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={incomes.map(i => i.id)} strategy={verticalListSortingStrategy}>
<tbody>
{incomes.map(income => (
<SortableRow
key={income.id}
income={income}
onSave={handleSave}
onDelete={handleDelete}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
<button onClick={handleAdd}>+ Add Row</button>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
export type Frequency =
| 'Biennial'
| 'Annually'
| 'Biannually'
| 'Quarterly'
| 'EveryTwoMonths'
| 'Monthly'
| 'SemiMonthly'
| 'Biweekly'
| 'Weekly';
export type OutgoType = 'Need' | 'Want' | 'Save';
export type SharePermission = 'View' | 'Edit';
export interface IncomeDto {
id: string;
budgetId: string;
name: string;
frequency: Frequency;
amount: number;
monthly: number;
annually: number;
sortOrder: number;
}
export interface OutgoDto {
id: string;
budgetId: string;
name: string;
category: string | null;
type: OutgoType;
frequency: Frequency;
amount: number;
paymentSource: string | null;
notes: string | null;
monthly: number;
annually: number;
monthlyPercent: number;
sortOrder: number;
}
export interface BudgetDto {
id: string;
name: string;
ownerUserId: string;
effectiveTaxRate: number;
createdAt: string;
updatedAt: string;
}
export interface ShareDto {
id: string;
sharedWithUserId: string | null;
sharedWithEmail: string;
permission: SharePermission;
isPending: boolean;
createdAt: string;
}
export interface SummaryBreakdownItem {
type: string;
targetPercent: number | null;
total: number;
annually: number;
percent: number;
maxAmount?: number;
remaining?: number;
}
export interface SummaryDto {
monthlyIncome: number;
annualIncome: number;
breakdown: SummaryBreakdownItem[];
preTaxIncome: {
effectiveTaxRate: number;
minimumAnnualGross: number;
minimumMonthlyGross: number;
};
}