From 69d6ac0bea848059e4dbee916b2e571227a42f53 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:58:54 -0500 Subject: [PATCH] Phase 6: Summary API and page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SummaryController: computes monthly income, breakdown by type (Need/Want/Save/Unspent), and pre-tax income - Need/Want/Save get target% (50/30/20), maxAmount, and remaining; Unspent shows totals only - PUT /summary/tax-rate updates EffectiveTaxRate on the budget (no new migration needed) - Add SummaryDto, SummaryBreakdownItem, PreTaxIncomeDto DTOs - Add Summary page: income header cards, type breakdown table with ⓘ tooltip for target%, pre-tax section with editable tax rate field Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/SummaryController.cs | 81 +++++++++++++ src/Budget.Api/DTOs/SummaryDtos.cs | 20 ++++ src/Budget.Client/src/pages/SummaryPage.tsx | 110 +++++++++++++++++- 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/Budget.Api/Controllers/SummaryController.cs create mode 100644 src/Budget.Api/DTOs/SummaryDtos.cs diff --git a/src/Budget.Api/Controllers/SummaryController.cs b/src/Budget.Api/Controllers/SummaryController.cs new file mode 100644 index 0000000..0442ff3 --- /dev/null +++ b/src/Budget.Api/Controllers/SummaryController.cs @@ -0,0 +1,81 @@ +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}/summary")] +[Authorize] +public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase +{ + private string UserId => User.FindFirst("sub")!.Value; + + [HttpGet] + public async Task Get(Guid budgetId) + { + if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + + var budget = await db.Budgets.FindAsync(budgetId); + if (budget is null) return NotFound(); + + var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync(); + var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync(); + + var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency)); + var annualIncome = monthlyIncome * 12m; + + var typeTargets = new Dictionary + { + [OutgoType.Need] = 50, + [OutgoType.Want] = 30, + [OutgoType.Save] = 20, + }; + + var breakdown = new List(); + decimal totalMonthlyOutgo = 0m; + + foreach (var (type, target) in typeTargets) + { + var typeOutgos = outgos.Where(o => o.Type == type).ToList(); + var monthlyTotal = typeOutgos.Sum(o => FrequencyCalculator.ToMonthly(o.Amount, o.Frequency)); + var annualTotal = monthlyTotal * 12m; + totalMonthlyOutgo += monthlyTotal; + var percent = monthlyIncome > 0 ? Math.Round(monthlyTotal / monthlyIncome * 100, 2) : 0m; + var maxAmount = monthlyIncome * target / 100m; + var remaining = maxAmount - monthlyTotal; + breakdown.Add(new SummaryBreakdownItem(type.ToString(), target, monthlyTotal, annualTotal, percent, maxAmount, remaining)); + } + + var unspent = monthlyIncome - totalMonthlyOutgo; + var unspentPercent = monthlyIncome > 0 ? Math.Round(unspent / monthlyIncome * 100, 2) : 0m; + breakdown.Add(new SummaryBreakdownItem("Unspent", null, unspent, unspent * 12m, unspentPercent, null, null)); + + var annualOutgoTotal = totalMonthlyOutgo * 12m; + var minimumAnnualGross = budget.EffectiveTaxRate < 1m + ? annualOutgoTotal / (1m - budget.EffectiveTaxRate) + : 0m; + + return Ok(new SummaryDto( + monthlyIncome, + annualIncome, + breakdown, + new PreTaxIncomeDto(budget.EffectiveTaxRate, minimumAnnualGross, minimumAnnualGross / 12m))); + } + + [HttpPut("tax-rate")] + public async Task UpdateTaxRate(Guid budgetId, [FromBody] UpdateTaxRateRequest req) + { + if (!await authz.CanWriteAsync(budgetId, UserId)) return Forbid(); + var budget = await db.Budgets.FindAsync(budgetId); + if (budget is null) return NotFound(); + budget.EffectiveTaxRate = req.EffectiveTaxRate; + budget.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/src/Budget.Api/DTOs/SummaryDtos.cs b/src/Budget.Api/DTOs/SummaryDtos.cs new file mode 100644 index 0000000..dfb1e9d --- /dev/null +++ b/src/Budget.Api/DTOs/SummaryDtos.cs @@ -0,0 +1,20 @@ +namespace Budget.Api.DTOs; + +public record SummaryBreakdownItem( + string Type, + int? TargetPercent, + decimal Total, + decimal Annually, + decimal Percent, + decimal? MaxAmount, + decimal? Remaining); + +public record PreTaxIncomeDto(decimal EffectiveTaxRate, decimal MinimumAnnualGross, decimal MinimumMonthlyGross); + +public record SummaryDto( + decimal MonthlyIncome, + decimal AnnualIncome, + List Breakdown, + PreTaxIncomeDto PreTaxIncome); + +public record UpdateTaxRateRequest(decimal EffectiveTaxRate); diff --git a/src/Budget.Client/src/pages/SummaryPage.tsx b/src/Budget.Client/src/pages/SummaryPage.tsx index 4d1b2ed..8d78abd 100644 --- a/src/Budget.Client/src/pages/SummaryPage.tsx +++ b/src/Budget.Client/src/pages/SummaryPage.tsx @@ -1,3 +1,111 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import type { SummaryDto } from '../types'; +import { api } from '../api/client'; +import { MoneyDisplay } from '../components/MoneyDisplay'; +import { BudgetNav } from '../components/BudgetNav'; + +const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + export function SummaryPage() { - return
Summary — coming soon
; + const { id: budgetId } = useParams<{ id: string }>(); + const [summary, setSummary] = useState(null); + const [taxRateInput, setTaxRateInput] = useState(''); + const [savingTax, setSavingTax] = useState(false); + + useEffect(() => { + if (!budgetId) return; + api.get(`/api/budgets/${budgetId}/summary`).then(s => { + setSummary(s); + setTaxRateInput(String(Math.round(s.preTaxIncome.effectiveTaxRate * 100))); + }); + }, [budgetId]); + + const saveTaxRate = async () => { + if (!budgetId || !summary) return; + setSavingTax(true); + const rate = parseFloat(taxRateInput) / 100; + await api.put(`/api/budgets/${budgetId}/summary/tax-rate`, { effectiveTaxRate: rate }); + const updated = await api.get(`/api/budgets/${budgetId}/summary`); + setSummary(updated); + setSavingTax(false); + }; + + if (!summary) return
Loading...
; + + return ( +
+ +

Summary

+ +
+
+
Monthly Income
+
+ +
+
+
+
Annual Income
+
+ +
+
+
+ + + + + + + + + + + + + + {summary.breakdown.map(row => ( + + + + + + + + + ))} + +
TypeMonthly TotalAnnual Total% of IncomeMax Amount (target%)Remaining
+ {row.type} + {row.targetPercent != null && ( + + )} + {row.percent.toFixed(1)}%{row.maxAmount != null ? fmt.format(row.maxAmount) : '—'}{row.remaining != null ? fmt.format(row.remaining) : '—'}
+ +
+

Pre-Tax Income

+
+ +
+
+
Minimum Annual Gross:
+
Minimum Monthly Gross:
+
+
+
+ ); }