Phase 6: Summary API and page

- 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 <noreply@anthropic.com>
This commit is contained in:
Spencer Twaddle
2026-04-25 07:58:54 -05:00
parent 38296bc22a
commit 69d6ac0bea
3 changed files with 210 additions and 1 deletions
@@ -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<IActionResult> 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, int>
{
[OutgoType.Need] = 50,
[OutgoType.Want] = 30,
[OutgoType.Save] = 20,
};
var breakdown = new List<SummaryBreakdownItem>();
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<IActionResult> 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();
}
}