e1f99158aa
Live verification revealed the JWT carries the user id in the 'sub' claim (NameClaimType=sub, MapInboundClaims=false), so ClaimTypes.NameIdentifier is null at runtime. This caused ExpensesController.GetMine/GetById to throw NullReferenceException (500) on the '!.Value', and made the services fall back to 'system' — silently defeating the self-ownership guard. Resolve via NameIdentifier (unit tests) then 'sub' (real tokens). Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
4.0 KiB
C#
97 lines
4.0 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ROLAC.API.Data;
|
|
using ROLAC.API.DTOs.Expense;
|
|
using ROLAC.API.Entities;
|
|
|
|
namespace ROLAC.API.Services;
|
|
|
|
public class MonthlyStatementService : IMonthlyStatementService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IHttpContextAccessor _http;
|
|
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; }
|
|
|
|
// See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests.
|
|
private string CurrentUserId =>
|
|
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? _http.HttpContext?.User.FindFirstValue("sub")
|
|
?? "system";
|
|
|
|
public async Task<List<MonthlyStatementDto>> GetAllAsync(int? year)
|
|
{
|
|
var query = _db.MonthlyStatements.AsNoTracking().AsQueryable();
|
|
if (year.HasValue) query = query.Where(s => s.Year == year.Value);
|
|
var list = await query.OrderByDescending(s => s.Year).ThenByDescending(s => s.Month).ToListAsync();
|
|
return list.Select(Map).ToList();
|
|
}
|
|
|
|
public async Task<MonthlyStatementDto?> GetByIdAsync(int id)
|
|
{
|
|
var s = await _db.MonthlyStatements.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
|
return s is null ? null : Map(s);
|
|
}
|
|
|
|
public async Task<int> CreateAsync(CreateMonthlyStatementRequest r)
|
|
{
|
|
if (await _db.MonthlyStatements.AnyAsync(s => s.Year == r.Year && s.Month == r.Month))
|
|
throw new InvalidOperationException($"A statement for {r.Year}-{r.Month:D2} already exists.");
|
|
|
|
var s = new MonthlyStatement
|
|
{
|
|
Year = r.Year, Month = r.Month,
|
|
OpeningBalance = r.OpeningBalance, TotalOtherIncome = r.TotalOtherIncome,
|
|
BankStatementBalance = r.BankStatementBalance, Notes = r.Notes,
|
|
};
|
|
await RecomputeAsync(s);
|
|
_db.MonthlyStatements.Add(s);
|
|
await _db.SaveChangesAsync();
|
|
return s.Id;
|
|
}
|
|
|
|
public async Task UpdateAsync(int id, UpdateMonthlyStatementRequest r)
|
|
{
|
|
var s = await _db.MonthlyStatements.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
|
|
if (s.IsFinalized) throw new InvalidOperationException("Statement is finalized and cannot be modified.");
|
|
s.OpeningBalance = r.OpeningBalance; s.TotalOtherIncome = r.TotalOtherIncome;
|
|
s.BankStatementBalance = r.BankStatementBalance; s.Notes = r.Notes;
|
|
await RecomputeAsync(s);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task FinalizeAsync(int id)
|
|
{
|
|
var s = await _db.MonthlyStatements.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
|
|
s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId;
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task RecomputeAsync(MonthlyStatement s)
|
|
{
|
|
var first = new DateOnly(s.Year, s.Month, 1);
|
|
var next = first.AddMonths(1);
|
|
|
|
s.TotalGiving = await _db.Givings
|
|
.Where(g => g.GivingDate >= first && g.GivingDate < next)
|
|
.SumAsync(g => (decimal?)g.Amount) ?? 0m;
|
|
|
|
s.TotalExpenses = await _db.Expenses
|
|
.Where(e => e.Status == "Paid" && e.ExpenseDate >= first && e.ExpenseDate < next)
|
|
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
|
|
|
s.CalculatedClosingBalance = s.OpeningBalance + s.TotalGiving + s.TotalOtherIncome - s.TotalExpenses;
|
|
s.Difference = s.CalculatedClosingBalance - s.BankStatementBalance;
|
|
}
|
|
|
|
private static MonthlyStatementDto Map(MonthlyStatement s) => new()
|
|
{
|
|
Id = s.Id, Year = s.Year, Month = s.Month,
|
|
OpeningBalance = s.OpeningBalance, TotalGiving = s.TotalGiving, TotalOtherIncome = s.TotalOtherIncome,
|
|
TotalExpenses = s.TotalExpenses, CalculatedClosingBalance = s.CalculatedClosingBalance,
|
|
BankStatementBalance = s.BankStatementBalance, Difference = s.Difference,
|
|
Notes = s.Notes, IsFinalized = s.IsFinalized,
|
|
};
|
|
}
|