Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/sections/Budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,12 @@ See `.claude/DESIGN_RULES.md` for the full rules.
### Current State

**Compliant.** No violations found. Controllers do not inject DbContext. BudgetService only queries its own tables. Cache ownership is correct.

### Authorization

Budget mutations are guarded at two layers (defense in depth):

1. **Controller layer** — `[Authorize(Policy = PolicyNames.FinanceAdminOrAdmin)]` on `FinanceController`, and resource-based `BudgetOperationRequirement.Edit` checks in `BudgetController` before calling line-item mutations.
2. **Service layer** — `BudgetService` mutation methods call `IAuthorizationService.AuthorizeAsync` internally, throwing `UnauthorizedAccessException` for unprivileged callers. Admin/group/category/year/projection mutations use `BudgetOperationRequirement.Manage` (FinanceAdmin / Admin / system-principal only). Line-item create/update/delete use `BudgetOperationRequirement.Edit` with the owning `BudgetCategory` resource — so department coordinators are scoped to their own categories, FinanceAdmin/Admin succeed on any category, and background jobs pass `SystemPrincipal.Instance`.

Service-layer enforcement means that future call paths (background jobs, alternate UI surfaces, future API endpoints) cannot bypass authorization by skipping controller attributes.
36 changes: 36 additions & 0 deletions src/Humans.Application/Authorization/BudgetOperationRequirement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;

namespace Humans.Application.Authorization;

/// <summary>
/// Resource-based authorization requirement for budget operations.
///
/// <list type="bullet">
/// <item>
/// <description>
/// <c>Edit</c> is resource-scoped — pair it with a <c>BudgetCategory</c> to
/// authorize line-item create/update/delete. FinanceAdmin/Admin can edit any
/// category; department coordinators can edit categories linked to their team.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>Manage</c> is global — used for budget-wide admin operations
/// (budget year lifecycle, group/category management, projection parameters,
/// background sync jobs). Only FinanceAdmin/Admin and the system principal succeed.
/// </description>
/// </item>
/// </list>
/// </summary>
public sealed class BudgetOperationRequirement : IAuthorizationRequirement
{
public static readonly BudgetOperationRequirement Edit = new(nameof(Edit));
public static readonly BudgetOperationRequirement Manage = new(nameof(Manage));

public string OperationName { get; }

private BudgetOperationRequirement(string operationName)
{
OperationName = operationName;
}
}
48 changes: 30 additions & 18 deletions src/Humans.Application/Interfaces/IBudgetService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Humans.Application.DTOs;
using Humans.Domain.Entities;
using Humans.Domain.Enums;
Expand All @@ -7,27 +8,34 @@ namespace Humans.Application.Interfaces;

/// <summary>
/// Service for managing budget years, groups, categories, and line items.
///
/// Mutation methods enforce authorization at the service boundary via
/// <see cref="Microsoft.AspNetCore.Authorization.IAuthorizationService"/> and throw
/// <see cref="UnauthorizedAccessException"/> for unprivileged callers. Callers must
/// pass the current <see cref="ClaimsPrincipal"/>; background jobs pass
/// <see cref="Humans.Application.Authorization.SystemPrincipal.Instance"/>.
/// </summary>
public interface IBudgetService
{
// Budget Years
Task<IReadOnlyList<BudgetYear>> GetAllYearsAsync(bool includeArchived = false);
Task<BudgetYear?> GetYearByIdAsync(Guid id);
Task<BudgetYear?> GetActiveYearAsync();
Task<BudgetYear> CreateYearAsync(string year, string name, Guid actorUserId);
Task UpdateYearStatusAsync(Guid yearId, BudgetYearStatus status, Guid actorUserId);
Task UpdateYearAsync(Guid yearId, string year, string name, Guid actorUserId);
Task DeleteYearAsync(Guid yearId, Guid actorUserId);
Task RestoreYearAsync(Guid yearId, Guid actorUserId);
Task<BudgetYear> CreateYearAsync(string year, string name, Guid actorUserId, ClaimsPrincipal principal);
Task UpdateYearStatusAsync(Guid yearId, BudgetYearStatus status, Guid actorUserId, ClaimsPrincipal principal);
Task UpdateYearAsync(Guid yearId, string year, string name, Guid actorUserId, ClaimsPrincipal principal);
Task DeleteYearAsync(Guid yearId, Guid actorUserId, ClaimsPrincipal principal);
Task RestoreYearAsync(Guid yearId, Guid actorUserId, ClaimsPrincipal principal);

Task<int> SyncDepartmentsAsync(Guid budgetYearId, Guid actorUserId);
Task<bool> EnsureTicketingGroupAsync(Guid budgetYearId, Guid actorUserId);
Task<int> SyncDepartmentsAsync(Guid budgetYearId, Guid actorUserId, ClaimsPrincipal principal);
Task<bool> EnsureTicketingGroupAsync(Guid budgetYearId, Guid actorUserId, ClaimsPrincipal principal);

// Ticketing Projection
Task<TicketingProjection?> GetTicketingProjectionAsync(Guid budgetGroupId);
Task UpdateTicketingProjectionAsync(Guid budgetGroupId, LocalDate? startDate, LocalDate? eventDate,
int initialSalesCount, decimal dailySalesRate, decimal averageTicketPrice, int vatRate,
decimal stripeFeePercent, decimal stripeFeeFixed, decimal ticketTailorFeePercent, Guid actorUserId);
decimal stripeFeePercent, decimal stripeFeeFixed, decimal ticketTailorFeePercent,
Guid actorUserId, ClaimsPrincipal principal);

/// <summary>
/// Sync ticket sales actuals (already aggregated per ISO week by the ticket side)
Expand All @@ -40,14 +48,18 @@ Task UpdateTicketingProjectionAsync(Guid budgetGroupId, LocalDate? startDate, Lo
Task<int> SyncTicketingActualsAsync(
Guid budgetYearId,
IReadOnlyList<TicketingWeeklyActuals> weeklyActuals,
ClaimsPrincipal principal,
CancellationToken ct = default);

/// <summary>
/// Re-materialize projected ticketing line items (no actuals sync). Called
/// after projection parameters change so the projected lines reflect the new inputs.
/// Returns the number of projected line items created.
/// </summary>
Task<int> RefreshTicketingProjectionsAsync(Guid budgetYearId, CancellationToken ct = default);
Task<int> RefreshTicketingProjectionsAsync(
Guid budgetYearId,
ClaimsPrincipal principal,
CancellationToken ct = default);

/// <summary>
/// Compute virtual (non-persisted) weekly ticket projections for future weeks.
Expand All @@ -63,21 +75,21 @@ Task<IReadOnlyList<TicketingWeekProjection>> GetTicketingProjectionEntriesAsync(
int GetActualTicketsSold(BudgetGroup ticketingGroup);

// Budget Groups
Task<BudgetGroup> CreateGroupAsync(Guid budgetYearId, string name, bool isRestricted, Guid actorUserId);
Task UpdateGroupAsync(Guid groupId, string name, int sortOrder, bool isRestricted, Guid actorUserId);
Task DeleteGroupAsync(Guid groupId, Guid actorUserId);
Task<BudgetGroup> CreateGroupAsync(Guid budgetYearId, string name, bool isRestricted, Guid actorUserId, ClaimsPrincipal principal);
Task UpdateGroupAsync(Guid groupId, string name, int sortOrder, bool isRestricted, Guid actorUserId, ClaimsPrincipal principal);
Task DeleteGroupAsync(Guid groupId, Guid actorUserId, ClaimsPrincipal principal);

// Budget Categories
Task<BudgetCategory?> GetCategoryByIdAsync(Guid id);
Task<BudgetCategory> CreateCategoryAsync(Guid budgetGroupId, string name, decimal allocatedAmount, ExpenditureType expenditureType, Guid? teamId, Guid actorUserId);
Task UpdateCategoryAsync(Guid categoryId, string name, decimal allocatedAmount, ExpenditureType expenditureType, Guid actorUserId);
Task DeleteCategoryAsync(Guid categoryId, Guid actorUserId);
Task<BudgetCategory> CreateCategoryAsync(Guid budgetGroupId, string name, decimal allocatedAmount, ExpenditureType expenditureType, Guid? teamId, Guid actorUserId, ClaimsPrincipal principal);
Task UpdateCategoryAsync(Guid categoryId, string name, decimal allocatedAmount, ExpenditureType expenditureType, Guid actorUserId, ClaimsPrincipal principal);
Task DeleteCategoryAsync(Guid categoryId, Guid actorUserId, ClaimsPrincipal principal);

// Budget Line Items
Task<BudgetLineItem?> GetLineItemByIdAsync(Guid id);
Task<BudgetLineItem> CreateLineItemAsync(Guid budgetCategoryId, string description, decimal amount, Guid? responsibleTeamId, string? notes, LocalDate? expectedDate, int vatRate, Guid actorUserId);
Task UpdateLineItemAsync(Guid lineItemId, string description, decimal amount, Guid? responsibleTeamId, string? notes, LocalDate? expectedDate, int vatRate, Guid actorUserId);
Task DeleteLineItemAsync(Guid lineItemId, Guid actorUserId);
Task<BudgetLineItem> CreateLineItemAsync(Guid budgetCategoryId, string description, decimal amount, Guid? responsibleTeamId, string? notes, LocalDate? expectedDate, int vatRate, Guid actorUserId, ClaimsPrincipal principal);
Task UpdateLineItemAsync(Guid lineItemId, string description, decimal amount, Guid? responsibleTeamId, string? notes, LocalDate? expectedDate, int vatRate, Guid actorUserId, ClaimsPrincipal principal);
Task DeleteLineItemAsync(Guid lineItemId, Guid actorUserId, ClaimsPrincipal principal);

// Coordinator
Task<HashSet<Guid>> GetEffectiveCoordinatorTeamIdsAsync(Guid userId);
Expand Down
10 changes: 8 additions & 2 deletions src/Humans.Application/Interfaces/ITicketingBudgetService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Humans.Application.DTOs;
using Humans.Domain.Entities;

Expand All @@ -11,13 +12,18 @@ public interface ITicketingBudgetService
/// <summary>
/// Sync completed weeks of ticket sales into budget line items from TicketTailor/Stripe data,
/// then refresh projections for future weeks.
/// The <paramref name="principal"/> is forwarded to <see cref="IBudgetService"/> for
/// service-boundary authorization. Background jobs pass
/// <see cref="Humans.Application.Authorization.SystemPrincipal.Instance"/>.
/// </summary>
Task<int> SyncActualsAsync(Guid budgetYearId, CancellationToken ct = default);
Task<int> SyncActualsAsync(Guid budgetYearId, ClaimsPrincipal principal, CancellationToken ct = default);

/// <summary>
/// Refresh projected line items only (no actuals sync). Called after saving projection parameters.
/// The <paramref name="principal"/> is forwarded to <see cref="IBudgetService"/> for
/// service-boundary authorization.
/// </summary>
Task<int> RefreshProjectionsAsync(Guid budgetYearId, CancellationToken ct = default);
Task<int> RefreshProjectionsAsync(Guid budgetYearId, ClaimsPrincipal principal, CancellationToken ct = default);

/// <summary>
/// Compute projected line items for future weeks based on ticketing projection parameters
Expand Down
3 changes: 2 additions & 1 deletion src/Humans.Infrastructure/Jobs/TicketingBudgetSyncJob.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Hangfire;
using Humans.Application.Authorization;
using Humans.Application.Interfaces;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -38,7 +39,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default)

try
{
var count = await _ticketingBudgetService.SyncActualsAsync(activeYear.Id);
var count = await _ticketingBudgetService.SyncActualsAsync(activeYear.Id, SystemPrincipal.Instance, cancellationToken);
_logger.LogInformation("Ticketing budget sync completed: {Count} line items synced", count);
}
catch (Exception ex)
Expand Down
Loading
Loading