K5: add cashbook repositories

- IBookingCategoryRepository + implementation
- ICashBookEntryRepository + implementation
- GetBalanceAsync calculates income - expense + initial
- ExistsMembershipFeeForMonthAsync for duplicate check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beo3000 2026-01-03 14:34:57 +01:00
parent 2f2e93ffce
commit 9c4a6f2ab6
6 changed files with 272 additions and 1 deletions

View File

@ -1703,7 +1703,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
| ✓ | K2 | Infrastructure | EF Configurations | 4 |
| ✓ | K3 | Infrastructure | Migration | - |
| ✓ | K4 | Security | Kassenwart Role + Policy | 4 |
| | K5 | Infrastructure | Repositories | 5 |
| | K5 | Infrastructure | Repositories | 5 |
| ☐ | K6 | Application | DTOs | 3 |
| ☐ | K7 | Application | Service Interfaces | 2 |
| ☐ | K8 | Application | Service Implementations | 4 |

View File

@ -0,0 +1,34 @@
using Koogle.Domain.Entities;
namespace Koogle.Domain.Interfaces;
/// <summary>
/// Repository interface for booking category operations.
/// </summary>
public interface IBookingCategoryRepository
{
/// <summary>
/// Gets all booking categories for a club.
/// </summary>
Task<List<BookingCategory>> GetByClubIdAsync(Guid clubId, bool includeInactive = false, CancellationToken ct = default);
/// <summary>
/// Gets a booking category by its identifier.
/// </summary>
Task<BookingCategory?> GetByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Gets a system category by name for a club.
/// </summary>
Task<BookingCategory?> GetSystemCategoryAsync(Guid clubId, string name, CancellationToken ct = default);
/// <summary>
/// Adds a new booking category.
/// </summary>
Task<BookingCategory> AddAsync(BookingCategory entity, CancellationToken ct = default);
/// <summary>
/// Updates an existing booking category.
/// </summary>
Task<BookingCategory> UpdateAsync(BookingCategory entity, CancellationToken ct = default);
}

View File

@ -0,0 +1,39 @@
using Koogle.Domain.Entities;
namespace Koogle.Domain.Interfaces;
/// <summary>
/// Repository interface for cash book entry operations.
/// </summary>
public interface ICashBookEntryRepository
{
/// <summary>
/// Gets cash book entries for a club within optional date range.
/// </summary>
Task<List<CashBookEntry>> GetByClubIdAsync(Guid clubId, DateTime? from, DateTime? to, CancellationToken ct = default);
/// <summary>
/// Gets a cash book entry by its identifier.
/// </summary>
Task<CashBookEntry?> GetByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Calculates the balance for a club as of a specific date.
/// </summary>
Task<decimal> GetBalanceAsync(Guid clubId, DateTime? asOfDate = null, CancellationToken ct = default);
/// <summary>
/// Adds a new cash book entry.
/// </summary>
Task<CashBookEntry> AddAsync(CashBookEntry entity, CancellationToken ct = default);
/// <summary>
/// Gets cash book entries linked to a specific day.
/// </summary>
Task<List<CashBookEntry>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default);
/// <summary>
/// Checks if a membership fee entry exists for a specific month.
/// </summary>
Task<bool> ExistsMembershipFeeForMonthAsync(Guid clubId, int year, int month, CancellationToken ct = default);
}

View File

@ -92,6 +92,8 @@ public static class DependencyInjection
services.AddScoped<ITriggerRepository, TriggerRepository>();
services.AddScoped<IPlayerGameStatisticsRepository, PlayerGameStatisticsRepository>();
services.AddScoped<IClubGifRepository, ClubGifRepository>();
services.AddScoped<IBookingCategoryRepository, BookingCategoryRepository>();
services.AddScoped<ICashBookEntryRepository, CashBookEntryRepository>();
// Services
services.AddScoped<IEmailService, SmtpEmailService>();

View File

@ -0,0 +1,67 @@
using Koogle.Domain.Entities;
using Koogle.Domain.Interfaces;
using KoogleApp.Data;
using Microsoft.EntityFrameworkCore;
namespace Koogle.Infrastructure.Repositories;
/// <summary>
/// Repository implementation for managing booking category entities.
/// </summary>
/// <param name="contextFactory">The database context factory for creating scoped contexts.</param>
public class BookingCategoryRepository(IDbContextFactory<AppDbContext> contextFactory) : IBookingCategoryRepository
{
/// <inheritdoc />
public async Task<List<BookingCategory>> GetByClubIdAsync(Guid clubId, bool includeInactive = false, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
var query = context.BookingCategories
.Where(c => c.ClubId == clubId && !c.IsDeleted);
if (!includeInactive)
{
query = query.Where(c => c.IsActive);
}
return await query
.OrderBy(c => c.Name)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<BookingCategory?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
return await context.BookingCategories
.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, ct);
}
/// <inheritdoc />
public async Task<BookingCategory?> GetSystemCategoryAsync(Guid clubId, string name, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
return await context.BookingCategories
.FirstOrDefaultAsync(c => c.ClubId == clubId && c.IsSystemCategory && c.Name == name && !c.IsDeleted, ct);
}
/// <inheritdoc />
public async Task<BookingCategory> AddAsync(BookingCategory entity, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
entity.Id = Guid.NewGuid();
entity.CreatedAt = DateTime.UtcNow;
await context.BookingCategories.AddAsync(entity, ct);
await context.SaveChangesAsync(ct);
return entity;
}
/// <inheritdoc />
public async Task<BookingCategory> UpdateAsync(BookingCategory entity, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
entity.ModifiedAt = DateTime.UtcNow;
context.BookingCategories.Update(entity);
await context.SaveChangesAsync(ct);
return entity;
}
}

View File

@ -0,0 +1,129 @@
using Koogle.Domain.Entities;
using Koogle.Domain.Enums;
using Koogle.Domain.Interfaces;
using KoogleApp.Data;
using Microsoft.EntityFrameworkCore;
namespace Koogle.Infrastructure.Repositories;
/// <summary>
/// Repository implementation for managing cash book entry entities.
/// </summary>
/// <param name="contextFactory">The database context factory for creating scoped contexts.</param>
public class CashBookEntryRepository(IDbContextFactory<AppDbContext> contextFactory) : ICashBookEntryRepository
{
/// <inheritdoc />
public async Task<List<CashBookEntry>> GetByClubIdAsync(Guid clubId, DateTime? from, DateTime? to, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
IQueryable<CashBookEntry> query = context.CashBookEntries
.Where(e => e.ClubId == clubId && !e.IsDeleted);
if (from.HasValue)
{
query = query.Where(e => e.BookingDate >= from.Value);
}
if (to.HasValue)
{
query = query.Where(e => e.BookingDate <= to.Value);
}
return await query
.Include(e => e.Category)
.Include(e => e.Person)
.Include(e => e.Day)
.OrderByDescending(e => e.BookingDate)
.ThenByDescending(e => e.CreatedAt)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<CashBookEntry?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
return await context.CashBookEntries
.Include(e => e.Category)
.Include(e => e.Person)
.Include(e => e.Day)
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, ct);
}
/// <inheritdoc />
public async Task<decimal> GetBalanceAsync(Guid clubId, DateTime? asOfDate = null, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
// Get initial balance from club
var club = await context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId, ct);
var initialBalance = club?.InitialBalance ?? 0m;
var query = context.CashBookEntries
.Where(e => e.ClubId == clubId && !e.IsDeleted);
if (asOfDate.HasValue)
{
query = query.Where(e => e.BookingDate <= asOfDate.Value);
}
var entries = await query.ToListAsync(ct);
var income = entries
.Where(e => e.EntryType == CashBookEntryType.Income)
.Sum(e => e.Amount);
var expense = entries
.Where(e => e.EntryType == CashBookEntryType.Expense)
.Sum(e => e.Amount);
return initialBalance + income - expense;
}
/// <inheritdoc />
public async Task<CashBookEntry> AddAsync(CashBookEntry entity, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
entity.Id = Guid.NewGuid();
entity.CreatedAt = DateTime.UtcNow;
await context.CashBookEntries.AddAsync(entity, ct);
await context.SaveChangesAsync(ct);
return entity;
}
/// <inheritdoc />
public async Task<List<CashBookEntry>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
return await context.CashBookEntries
.Where(e => e.DayId == dayId && !e.IsDeleted)
.Include(e => e.Category)
.Include(e => e.Person)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<bool> ExistsMembershipFeeForMonthAsync(Guid clubId, int year, int month, CancellationToken ct = default)
{
await using var context = await contextFactory.CreateDbContextAsync(ct);
// Get the system category for membership fees
var membershipCategory = await context.BookingCategories
.FirstOrDefaultAsync(c => c.ClubId == clubId && c.IsSystemCategory && c.Name == "Mitgliedsbeitrag" && !c.IsDeleted, ct);
if (membershipCategory == null)
{
return false;
}
var startOfMonth = new DateTime(year, month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
return await context.CashBookEntries
.AnyAsync(e => e.ClubId == clubId
&& e.CategoryId == membershipCategory.Id
&& e.BookingDate >= startOfMonth
&& e.BookingDate <= endOfMonth
&& !e.IsDeleted, ct);
}
}