diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 57d9418..56a3c3e 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1706,7 +1706,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 } | ✓ | K5 | Infrastructure | Repositories | 5 | | ✓ | K6 | Application | DTOs | 3 | | ✓ | K7 | Application | Service Interfaces | 2 | -| ☐ | K8 | Application | Service Implementations | 4 | +| ✓ | K8 | Application | Service Implementations | 4 | | ☐ | K9 | Application | Category Seeder | 1 | | ☐ | K10 | Application | Day Close Integration | 1 | | ☐ | K11 | Web | Fluxor CategoryState | 4 | diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index 3f1d44e..80eb552 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -40,6 +40,8 @@ namespace Koogle.Application services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Note: Game types are registered in Koogle.Web where Blazor components are defined // Use services.AddGameType() to register game types diff --git a/src/Koogle.Application/Mapping/CashBookMappingProfile.cs b/src/Koogle.Application/Mapping/CashBookMappingProfile.cs new file mode 100644 index 0000000..e8cc251 --- /dev/null +++ b/src/Koogle.Application/Mapping/CashBookMappingProfile.cs @@ -0,0 +1,25 @@ +using AutoMapper; +using Koogle.Application.DTOs; +using Koogle.Domain.Entities; + +namespace Koogle.Application.Mapping; + +/// +/// AutoMapper profile for cash book entity mappings. +/// +public class CashBookMappingProfile : Profile +{ + /// + /// Initializes cash book entity to DTO mappings. + /// + public CashBookMappingProfile() + { + // BookingCategory -> BookingCategoryDto + CreateMap(); + + // CashBookEntry -> CashBookEntryDto + CreateMap() + .ForMember(d => d.CategoryName, o => o.MapFrom(s => s.Category != null ? s.Category.Name : string.Empty)) + .ForMember(d => d.PersonName, o => o.MapFrom(s => s.Person != null ? s.Person.Name : null)); + } +} diff --git a/src/Koogle.Application/Services/BookingCategoryService.cs b/src/Koogle.Application/Services/BookingCategoryService.cs new file mode 100644 index 0000000..6619855 --- /dev/null +++ b/src/Koogle.Application/Services/BookingCategoryService.cs @@ -0,0 +1,137 @@ +using AutoMapper; +using Koogle.Application.DTOs; +using Koogle.Application.Interfaces; +using Koogle.Domain.Entities; +using Koogle.Domain.Enums; +using Koogle.Domain.Interfaces; + +namespace Koogle.Application.Services; + +/// +/// Service for managing booking category operations within the current club context. +/// +public class BookingCategoryService : IBookingCategoryService +{ + private readonly IBookingCategoryRepository _repository; + private readonly ICurrentClubContext _clubContext; + private readonly IMapper _mapper; + + /// + /// Initializes a new instance of the class. + /// + public BookingCategoryService( + IBookingCategoryRepository repository, + ICurrentClubContext clubContext, + IMapper mapper) + { + _repository = repository; + _clubContext = clubContext; + _mapper = mapper; + } + + /// + public async Task> GetAllAsync(bool includeInactive = false, CancellationToken ct = default) + { + var categories = await _repository.GetByClubIdAsync(_clubContext.ClubId, includeInactive, ct); + return _mapper.Map>(categories); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var category = await _repository.GetByIdAsync(id, ct); + if (category is null || category.ClubId != _clubContext.ClubId) + return null; + + return _mapper.Map(category); + } + + /// + public async Task CreateAsync(CreateBookingCategoryDto dto, CancellationToken ct = default) + { + var entity = new BookingCategory + { + Id = Guid.NewGuid(), + ClubId = _clubContext.ClubId, + Name = dto.Name, + Description = dto.Description, + CategoryType = dto.CategoryType, + IsSystemCategory = false, + Color = dto.Color, + Icon = dto.Icon, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + var created = await _repository.AddAsync(entity, ct); + return _mapper.Map(created); + } + + /// + public async Task UpdateAsync(UpdateBookingCategoryDto dto, CancellationToken ct = default) + { + var existing = await _repository.GetByIdAsync(dto.Id, ct) + ?? throw new InvalidOperationException($"Category with id {dto.Id} not found."); + + if (existing.ClubId != _clubContext.ClubId) + throw new InvalidOperationException("Category does not belong to current club."); + + existing.Name = dto.Name; + existing.Description = dto.Description; + existing.Color = dto.Color; + existing.Icon = dto.Icon; + existing.IsActive = dto.IsActive; + existing.ModifiedAt = DateTime.UtcNow; + + var updated = await _repository.UpdateAsync(existing, ct); + return _mapper.Map(updated); + } + + /// + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var existing = await _repository.GetByIdAsync(id, ct); + if (existing is null || existing.ClubId != _clubContext.ClubId) + return false; + + if (existing.IsSystemCategory) + throw new InvalidOperationException("System categories cannot be deleted."); + + existing.IsDeleted = true; + existing.ModifiedAt = DateTime.UtcNow; + await _repository.UpdateAsync(existing, ct); + return true; + } + + /// + public async Task EnsureSystemCategoriesAsync(CancellationToken ct = default) + { + var systemCategories = new[] + { + new { Name = "Spielstrafe", Type = BookingCategoryType.Income, Color = "#4CAF50", Icon = "Gavel" }, + new { Name = "Mitgliedsbeitrag", Type = BookingCategoryType.Income, Color = "#2196F3", Icon = "CardMembership" }, + new { Name = "Korrekturbuchung", Type = BookingCategoryType.Income, Color = "#FF9800", Icon = "Build" }, + new { Name = "Saldoanpassung", Type = BookingCategoryType.Income, Color = "#9E9E9E", Icon = "Balance" } + }; + + foreach (var cat in systemCategories) + { + var existing = await _repository.GetSystemCategoryAsync(_clubContext.ClubId, cat.Name, ct); + if (existing is null) + { + await _repository.AddAsync(new BookingCategory + { + Id = Guid.NewGuid(), + ClubId = _clubContext.ClubId, + Name = cat.Name, + CategoryType = cat.Type, + IsSystemCategory = true, + Color = cat.Color, + Icon = cat.Icon, + IsActive = true, + CreatedAt = DateTime.UtcNow + }, ct); + } + } + } +} diff --git a/src/Koogle.Application/Services/CashBookService.cs b/src/Koogle.Application/Services/CashBookService.cs new file mode 100644 index 0000000..a781c95 --- /dev/null +++ b/src/Koogle.Application/Services/CashBookService.cs @@ -0,0 +1,249 @@ +using AutoMapper; +using Koogle.Application.DTOs; +using Koogle.Application.Interfaces; +using Koogle.Domain.Entities; +using Koogle.Domain.Enums; +using Koogle.Domain.Interfaces; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; + +namespace Koogle.Application.Services; + +/// +/// Service for managing cash book operations within the current club context. +/// +public class CashBookService : ICashBookService +{ + private readonly ICashBookEntryRepository _entryRepository; + private readonly IBookingCategoryRepository _categoryRepository; + private readonly IPersonExpenseRepository _personExpenseRepository; + private readonly IPersonRepository _personRepository; + private readonly IClubRepository _clubRepository; + private readonly ICurrentClubContext _clubContext; + private readonly IDbContextFactory _contextFactory; + private readonly IMapper _mapper; + + /// + /// Initializes a new instance of the class. + /// + public CashBookService( + ICashBookEntryRepository entryRepository, + IBookingCategoryRepository categoryRepository, + IPersonExpenseRepository personExpenseRepository, + IPersonRepository personRepository, + IClubRepository clubRepository, + ICurrentClubContext clubContext, + IDbContextFactory contextFactory, + IMapper mapper) + { + _entryRepository = entryRepository; + _categoryRepository = categoryRepository; + _personExpenseRepository = personExpenseRepository; + _personRepository = personRepository; + _clubRepository = clubRepository; + _clubContext = clubContext; + _contextFactory = contextFactory; + _mapper = mapper; + } + + /// + public async Task> GetEntriesAsync(DateTime? from = null, DateTime? to = null, CancellationToken ct = default) + { + var entries = await _entryRepository.GetByClubIdAsync(_clubContext.ClubId, from, to, ct); + return _mapper.Map>(entries); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + var entry = await _entryRepository.GetByIdAsync(id, ct); + if (entry is null || entry.ClubId != _clubContext.ClubId) + return null; + + return _mapper.Map(entry); + } + + /// + public async Task CreateAsync(CreateCashBookEntryDto dto, CancellationToken ct = default) + { + var entity = new CashBookEntry + { + Id = Guid.NewGuid(), + ClubId = _clubContext.ClubId, + CategoryId = dto.CategoryId, + EntryType = dto.EntryType, + Amount = dto.Amount, + BookingDate = dto.BookingDate, + Comment = dto.Comment, + ReceiptReference = dto.ReceiptReference, + PersonId = dto.PersonId, + CreatedAt = DateTime.UtcNow + }; + + var created = await _entryRepository.AddAsync(entity, ct); + + // Reload with navigation properties + var reloaded = await _entryRepository.GetByIdAsync(created.Id, ct); + return _mapper.Map(reloaded); + } + + /// + public async Task UpdateAsync(UpdateCashBookEntryDto dto, CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + var existing = await context.CashBookEntries + .FirstOrDefaultAsync(e => e.Id == dto.Id && !e.IsDeleted, ct) + ?? throw new InvalidOperationException($"Entry with id {dto.Id} not found."); + + if (existing.ClubId != _clubContext.ClubId) + throw new InvalidOperationException("Entry does not belong to current club."); + + existing.CategoryId = dto.CategoryId; + existing.Amount = dto.Amount; + existing.BookingDate = dto.BookingDate; + existing.Comment = dto.Comment; + existing.ReceiptReference = dto.ReceiptReference; + existing.ModifiedAt = DateTime.UtcNow; + + await context.SaveChangesAsync(ct); + + var reloaded = await _entryRepository.GetByIdAsync(dto.Id, ct); + return _mapper.Map(reloaded); + } + + /// + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + var existing = await context.CashBookEntries + .FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, ct); + + if (existing is null || existing.ClubId != _clubContext.ClubId) + return false; + + existing.IsDeleted = true; + existing.ModifiedAt = DateTime.UtcNow; + await context.SaveChangesAsync(ct); + return true; + } + + /// + public async Task GetBalanceAsync(DateTime? asOfDate = null, CancellationToken ct = default) + { + return await _entryRepository.GetBalanceAsync(_clubContext.ClubId, asOfDate, ct); + } + + /// + public async Task GetReportAsync(DateTime from, DateTime to, CancellationToken ct = default) + { + var entries = await _entryRepository.GetByClubIdAsync(_clubContext.ClubId, from, to, ct); + var openingBalance = await _entryRepository.GetBalanceAsync(_clubContext.ClubId, from.AddDays(-1), ct); + + var totalIncome = entries.Where(e => e.EntryType == CashBookEntryType.Income).Sum(e => e.Amount); + var totalExpense = entries.Where(e => e.EntryType == CashBookEntryType.Expense).Sum(e => e.Amount); + + var incomeByCategory = entries + .Where(e => e.EntryType == CashBookEntryType.Income) + .GroupBy(e => new { e.CategoryId, e.Category.Name }) + .Select(g => new CategorySummaryDto + { + CategoryId = g.Key.CategoryId, + CategoryName = g.Key.Name, + Total = g.Sum(e => e.Amount), + Count = g.Count() + }) + .ToList(); + + var expenseByCategory = entries + .Where(e => e.EntryType == CashBookEntryType.Expense) + .GroupBy(e => new { e.CategoryId, e.Category.Name }) + .Select(g => new CategorySummaryDto + { + CategoryId = g.Key.CategoryId, + CategoryName = g.Key.Name, + Total = g.Sum(e => e.Amount), + Count = g.Count() + }) + .ToList(); + + return new CashBookReportDto + { + ReportStart = from, + ReportEnd = to, + OpeningBalance = openingBalance, + ClosingBalance = openingBalance + totalIncome - totalExpense, + TotalIncome = totalIncome, + TotalExpense = totalExpense, + IncomeByCategory = incomeByCategory, + ExpenseByCategory = expenseByCategory, + Entries = _mapper.Map>(entries) + }; + } + + /// + public async Task> GetByDayIdAsync(Guid dayId, CancellationToken ct = default) + { + var entries = await _entryRepository.GetByDayIdAsync(dayId, ct); + return _mapper.Map>(entries); + } + + /// + public async Task> CreateMembershipFeesAsync(CreateMembershipFeesDto dto, CancellationToken ct = default) + { + // Check if fees already exist + if (await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, dto.Year, dto.Month, ct)) + { + throw new InvalidOperationException($"Membership fees for {dto.Month}/{dto.Year} already exist."); + } + + // Get club to read fee amount + var club = await _clubRepository.GetByIdAsync(_clubContext.ClubId, ct) + ?? throw new InvalidOperationException("Club not found."); + + var feeAmount = dto.OverrideAmount ?? club.MonthlyMembershipFee; + if (feeAmount <= 0) + { + throw new InvalidOperationException("Membership fee amount must be greater than zero."); + } + + // Get membership category + var category = await _categoryRepository.GetSystemCategoryAsync(_clubContext.ClubId, "Mitgliedsbeitrag", ct) + ?? throw new InvalidOperationException("Membership fee category not found. Please ensure system categories are created."); + + // Get active members + var members = await _personRepository.GetByClubIdAsync(_clubContext.ClubId, ct); + var activeMembers = members.Where(p => p.PersonStatus == PersonStatus.Member).ToList(); + + var bookingDate = new DateTime(dto.Year, dto.Month, 1); + var createdEntries = new List(); + + foreach (var member in activeMembers) + { + var entry = new CashBookEntry + { + Id = Guid.NewGuid(), + ClubId = _clubContext.ClubId, + CategoryId = category.Id, + EntryType = CashBookEntryType.Income, + Amount = feeAmount, + BookingDate = bookingDate, + Comment = $"Mitgliedsbeitrag {dto.Month:D2}/{dto.Year} - {member.Name}", + PersonId = member.Id, + CreatedAt = DateTime.UtcNow + }; + + var created = await _entryRepository.AddAsync(entry, ct); + createdEntries.Add(created); + } + + return _mapper.Map>(createdEntries); + } + + /// + public async Task MembershipFeesExistAsync(int year, int month, CancellationToken ct = default) + { + return await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, year, month, ct); + } +}