From 9933c180b7d2ae3c56b97ad71738c9386bde5a2f Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Fri, 29 May 2026 18:37:25 -0700 Subject: [PATCH] feat(expense): add controllers + register services Adds ExpenseCategoriesController, ExpensesController, MonthlyStatementsController and registers IExpenseCategoryService, IExpenseService, IMonthlyStatementService in DI. Co-Authored-By: Claude Sonnet 4.6 --- .../ExpenseCategoriesController.cs | 43 ++++++ .../Controllers/ExpensesController.cs | 133 ++++++++++++++++++ .../MonthlyStatementsController.cs | 48 +++++++ API/ROLAC.API/Program.cs | 3 + 4 files changed, 227 insertions(+) create mode 100644 API/ROLAC.API/Controllers/ExpenseCategoriesController.cs create mode 100644 API/ROLAC.API/Controllers/ExpensesController.cs create mode 100644 API/ROLAC.API/Controllers/MonthlyStatementsController.cs diff --git a/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs new file mode 100644 index 0000000..7e9f327 --- /dev/null +++ b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/expense-categories")] +[Authorize(Roles = "finance,super_admin")] +public class ExpenseCategoriesController : ControllerBase +{ + private readonly IExpenseCategoryService _svc; + public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc; + + [HttpGet] + public async Task GetAll([FromQuery] bool includeInactive = false) + => Ok(await _svc.GetAllAsync(includeInactive)); + + [HttpPost("groups")] + public async Task CreateGroup([FromBody] CreateExpenseGroupRequest r) + => Ok(new { id = await _svc.CreateGroupAsync(r) }); + + [HttpPut("groups/{id:int}")] + public async Task UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r) + { try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } + + [HttpDelete("groups/{id:int}")] + public async Task DeactivateGroup(int id) + { try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } + + [HttpPost("subcategories")] + public async Task CreateSub([FromBody] CreateExpenseSubCategoryRequest r) + { try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } } + + [HttpPut("subcategories/{id:int}")] + public async Task UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r) + { try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } + + [HttpDelete("subcategories/{id:int}")] + public async Task DeactivateSub(int id) + { try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } +} diff --git a/API/ROLAC.API/Controllers/ExpensesController.cs b/API/ROLAC.API/Controllers/ExpensesController.cs new file mode 100644 index 0000000..93c7bc7 --- /dev/null +++ b/API/ROLAC.API/Controllers/ExpensesController.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/expenses")] +[Authorize] +public class ExpensesController : ControllerBase +{ + private readonly IExpenseService _svc; + public ExpensesController(IExpenseService svc) => _svc = svc; + + private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin"); + private bool CanViewAll() => IsFinance() || User.IsInRole("pastor"); + + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null, + [FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null, + [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + { + if (!CanViewAll()) return Forbid(); + return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to)); + } + + [HttpGet("mine")] + public async Task GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value; + return Ok(await _svc.GetMineAsync(uid, status, page, pageSize)); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + if (dto is null) return NotFound(); + var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value; + if (!CanViewAll() && dto.SubmittedBy != uid) return Forbid(); + return Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateExpenseRequest r) + { + try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateExpenseRequest r) + { + try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/submit")] + public async Task Submit(int id) + { + try { await _svc.SubmitAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/approve")] + [Authorize(Roles = "finance,super_admin")] + public async Task Approve(int id) + { + try { await _svc.ApproveAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/reject")] + [Authorize(Roles = "finance,super_admin")] + public async Task Reject(int id, [FromBody] RejectExpenseRequest r) + { + try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/pay")] + [Authorize(Roles = "finance,super_admin")] + public async Task Pay(int id, [FromBody] PayExpenseRequest r) + { + try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/receipt")] + [RequestSizeLimit(10_485_760)] + public async Task UploadReceipt(int id, IFormFile file) + { + if (file is null || file.Length == 0) return BadRequest(new { message = "No file." }); + var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" }; + if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." }); + try + { + await using var stream = file.OpenReadStream(); + await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance()); + return NoContent(); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpGet("{id:int}/receipt")] + public async Task GetReceipt(int id) + { + try + { + var result = await _svc.OpenReceiptAsync(id, IsFinance()); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException) { return Forbid(); } + } +} diff --git a/API/ROLAC.API/Controllers/MonthlyStatementsController.cs b/API/ROLAC.API/Controllers/MonthlyStatementsController.cs new file mode 100644 index 0000000..851c69d --- /dev/null +++ b/API/ROLAC.API/Controllers/MonthlyStatementsController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/monthly-statements")] +[Authorize(Roles = "finance,super_admin")] +public class MonthlyStatementsController : ControllerBase +{ + private readonly IMonthlyStatementService _svc; + public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc; + + [HttpGet] + public async Task GetAll([FromQuery] int? year = null) + => Ok(await _svc.GetAllAsync(year)); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateMonthlyStatementRequest r) + { + try { return Ok(new { id = await _svc.CreateAsync(r) }); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateMonthlyStatementRequest r) + { + try { await _svc.UpdateAsync(id, r); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/finalize")] + public async Task Finalize(int id) + { + try { await _svc.FinalizeAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 9a1148e..1daf205 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -124,6 +124,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC