K8: add cashbook service implementations

- BookingCategoryService: CRUD + EnsureSystemCategoriesAsync
- CashBookService: entries, balance, reports, membership fees
- CashBookMappingProfile: entity to DTO mappings
- DI registration for both services
This commit is contained in:
beo3000 2026-01-03 14:53:48 +01:00
parent 1a7ba8837d
commit 2c09cfa991
5 changed files with 414 additions and 1 deletions

View File

@ -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 |

View File

@ -40,6 +40,8 @@ namespace Koogle.Application
services.AddScoped<IPlayerStatisticsService, PlayerStatisticsService>();
services.AddScoped<IClubGifService, ClubGifService>();
services.AddScoped<IClubRequestService, ClubRequestService>();
services.AddScoped<IBookingCategoryService, BookingCategoryService>();
services.AddScoped<ICashBookService, CashBookService>();
// Note: Game types are registered in Koogle.Web where Blazor components are defined
// Use services.AddGameType<TDefinition, TLogicService>() to register game types

View File

@ -0,0 +1,25 @@
using AutoMapper;
using Koogle.Application.DTOs;
using Koogle.Domain.Entities;
namespace Koogle.Application.Mapping;
/// <summary>
/// AutoMapper profile for cash book entity mappings.
/// </summary>
public class CashBookMappingProfile : Profile
{
/// <summary>
/// Initializes cash book entity to DTO mappings.
/// </summary>
public CashBookMappingProfile()
{
// BookingCategory -> BookingCategoryDto
CreateMap<BookingCategory, BookingCategoryDto>();
// CashBookEntry -> CashBookEntryDto
CreateMap<CashBookEntry, CashBookEntryDto>()
.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));
}
}

View File

@ -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;
/// <summary>
/// Service for managing booking category operations within the current club context.
/// </summary>
public class BookingCategoryService : IBookingCategoryService
{
private readonly IBookingCategoryRepository _repository;
private readonly ICurrentClubContext _clubContext;
private readonly IMapper _mapper;
/// <summary>
/// Initializes a new instance of the <see cref="BookingCategoryService"/> class.
/// </summary>
public BookingCategoryService(
IBookingCategoryRepository repository,
ICurrentClubContext clubContext,
IMapper mapper)
{
_repository = repository;
_clubContext = clubContext;
_mapper = mapper;
}
/// <inheritdoc />
public async Task<List<BookingCategoryDto>> GetAllAsync(bool includeInactive = false, CancellationToken ct = default)
{
var categories = await _repository.GetByClubIdAsync(_clubContext.ClubId, includeInactive, ct);
return _mapper.Map<List<BookingCategoryDto>>(categories);
}
/// <inheritdoc />
public async Task<BookingCategoryDto?> 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<BookingCategoryDto>(category);
}
/// <inheritdoc />
public async Task<BookingCategoryDto> 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<BookingCategoryDto>(created);
}
/// <inheritdoc />
public async Task<BookingCategoryDto> 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<BookingCategoryDto>(updated);
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
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);
}
}
}
}

View File

@ -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;
/// <summary>
/// Service for managing cash book operations within the current club context.
/// </summary>
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<AppDbContext> _contextFactory;
private readonly IMapper _mapper;
/// <summary>
/// Initializes a new instance of the <see cref="CashBookService"/> class.
/// </summary>
public CashBookService(
ICashBookEntryRepository entryRepository,
IBookingCategoryRepository categoryRepository,
IPersonExpenseRepository personExpenseRepository,
IPersonRepository personRepository,
IClubRepository clubRepository,
ICurrentClubContext clubContext,
IDbContextFactory<AppDbContext> contextFactory,
IMapper mapper)
{
_entryRepository = entryRepository;
_categoryRepository = categoryRepository;
_personExpenseRepository = personExpenseRepository;
_personRepository = personRepository;
_clubRepository = clubRepository;
_clubContext = clubContext;
_contextFactory = contextFactory;
_mapper = mapper;
}
/// <inheritdoc />
public async Task<List<CashBookEntryDto>> GetEntriesAsync(DateTime? from = null, DateTime? to = null, CancellationToken ct = default)
{
var entries = await _entryRepository.GetByClubIdAsync(_clubContext.ClubId, from, to, ct);
return _mapper.Map<List<CashBookEntryDto>>(entries);
}
/// <inheritdoc />
public async Task<CashBookEntryDto?> 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<CashBookEntryDto>(entry);
}
/// <inheritdoc />
public async Task<CashBookEntryDto> 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<CashBookEntryDto>(reloaded);
}
/// <inheritdoc />
public async Task<CashBookEntryDto> 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<CashBookEntryDto>(reloaded);
}
/// <inheritdoc />
public async Task<bool> 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;
}
/// <inheritdoc />
public async Task<decimal> GetBalanceAsync(DateTime? asOfDate = null, CancellationToken ct = default)
{
return await _entryRepository.GetBalanceAsync(_clubContext.ClubId, asOfDate, ct);
}
/// <inheritdoc />
public async Task<CashBookReportDto> 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<List<CashBookEntryDto>>(entries)
};
}
/// <inheritdoc />
public async Task<List<CashBookEntryDto>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default)
{
var entries = await _entryRepository.GetByDayIdAsync(dayId, ct);
return _mapper.Map<List<CashBookEntryDto>>(entries);
}
/// <inheritdoc />
public async Task<List<CashBookEntryDto>> 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<CashBookEntry>();
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<List<CashBookEntryDto>>(createdEntries);
}
/// <inheritdoc />
public async Task<bool> MembershipFeesExistAsync(int year, int month, CancellationToken ct = default)
{
return await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, year, month, ct);
}
}