diff --git a/src/Koogle.Application/DTOs/TriggerDto.cs b/src/Koogle.Application/DTOs/TriggerDto.cs
new file mode 100644
index 0000000..a13bf69
--- /dev/null
+++ b/src/Koogle.Application/DTOs/TriggerDto.cs
@@ -0,0 +1,76 @@
+using Koogle.Domain.Enums;
+
+namespace Koogle.Application.DTOs;
+
+///
+/// Data transfer object representing a trigger.
+///
+public record TriggerDto
+{
+ ///
+ /// Unique identifier of the trigger.
+ ///
+ public Guid Id { get; init; }
+
+ ///
+ /// Name of the trigger.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// The type of expense trigger.
+ ///
+ public ExpenseTriggerType ExpenseTriggerType { get; init; }
+
+ ///
+ /// Expenses linked to this trigger for the current club.
+ ///
+ public List LinkedExpenses { get; init; } = [];
+}
+
+///
+/// Data transfer object for linking/unlinking an expense to a trigger.
+///
+public record ExpenseTriggerLinkDto
+{
+ ///
+ /// The expense identifier.
+ ///
+ public Guid ExpenseId { get; init; }
+
+ ///
+ /// The trigger identifier.
+ ///
+ public Guid TriggerId { get; init; }
+}
+
+///
+/// Data transfer object for firing a trigger.
+///
+public record FireTriggerDto
+{
+ ///
+ /// The trigger type to fire.
+ ///
+ public ExpenseTriggerType TriggerType { get; init; }
+
+ ///
+ /// The person receiving the expense(s).
+ ///
+ public Guid PersonId { get; init; }
+
+ ///
+ /// The day context for the expense.
+ ///
+ public Guid DayId { get; init; }
+
+ ///
+ /// Optional game context for the expense.
+ ///
+ public Guid? GameId { get; init; }
+
+ ///
+ /// Multiplier for expense price (e.g., remaining points). Default is 1.
+ ///
+ public int Multiplier { get; init; } = 1;
+}
diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs
index c9f7b99..1ff724a 100644
--- a/src/Koogle.Application/DependencyInjection.cs
+++ b/src/Koogle.Application/DependencyInjection.cs
@@ -31,6 +31,7 @@ namespace Koogle.Application
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/src/Koogle.Application/Interfaces/ITriggerService.cs b/src/Koogle.Application/Interfaces/ITriggerService.cs
new file mode 100644
index 0000000..6240bfd
--- /dev/null
+++ b/src/Koogle.Application/Interfaces/ITriggerService.cs
@@ -0,0 +1,68 @@
+using Koogle.Application.DTOs;
+using Koogle.Domain.Enums;
+
+namespace Koogle.Application.Interfaces;
+
+///
+/// Service interface for managing triggers and firing automatic expense assignments.
+///
+public interface ITriggerService
+{
+ ///
+ /// Gets all available trigger types with their associated expenses for the current club.
+ ///
+ /// Cancellation token.
+ /// A list of trigger DTOs with their linked expenses.
+ Task> GetAllTriggersAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets all expenses linked to a specific trigger type for the current club.
+ ///
+ /// The expense trigger type.
+ /// Cancellation token.
+ /// A list of expenses linked to the trigger type.
+ Task> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default);
+
+ ///
+ /// Checks if a trigger type has any linked expenses for the current club.
+ ///
+ /// The expense trigger type.
+ /// Cancellation token.
+ /// True if the trigger has linked expenses; otherwise, false.
+ Task HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default);
+
+ ///
+ /// Fires a trigger, creating PersonExpenses for all linked expenses.
+ ///
+ /// The trigger type (e.g., ExpensePoint, Circle, NinePins).
+ /// The person receiving the expense(s).
+ /// The day context for the expense.
+ /// Optional game context for the expense.
+ /// Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1.
+ /// Cancellation token.
+ /// A list of created PersonExpense DTOs.
+ Task> FireTriggerAsync(
+ ExpenseTriggerType type,
+ Guid personId,
+ Guid dayId,
+ Guid? gameId = null,
+ int multiplier = 1,
+ CancellationToken ct = default);
+
+ ///
+ /// Links an expense to a trigger for the current club.
+ ///
+ /// The expense to link.
+ /// The trigger to link to.
+ /// Cancellation token.
+ Task LinkExpenseToTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default);
+
+ ///
+ /// Unlinks an expense from a trigger for the current club.
+ ///
+ /// The expense to unlink.
+ /// The trigger to unlink from.
+ /// Cancellation token.
+ /// True if unlinked successfully; otherwise, false.
+ Task UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default);
+}
diff --git a/src/Koogle.Application/Services/TriggerService.cs b/src/Koogle.Application/Services/TriggerService.cs
new file mode 100644
index 0000000..a8d94ee
--- /dev/null
+++ b/src/Koogle.Application/Services/TriggerService.cs
@@ -0,0 +1,228 @@
+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 triggers and firing automatic expense assignments.
+///
+public class TriggerService : ITriggerService
+{
+ private readonly IDbContextFactory _contextFactory;
+ private readonly ITriggerRepository _triggerRepository;
+ private readonly ICurrentClubContext _clubContext;
+ private readonly IMapper _mapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The database context factory.
+ /// The trigger repository.
+ /// The current club context.
+ /// The AutoMapper instance.
+ public TriggerService(
+ IDbContextFactory contextFactory,
+ ITriggerRepository triggerRepository,
+ ICurrentClubContext clubContext,
+ IMapper mapper)
+ {
+ _contextFactory = contextFactory;
+ _triggerRepository = triggerRepository;
+ _clubContext = clubContext;
+ _mapper = mapper;
+ }
+
+ ///
+ public async Task> GetAllTriggersAsync(CancellationToken ct = default)
+ {
+ var triggers = await _triggerRepository.GetAllAsync(ct);
+ var expenseTriggers = await _triggerRepository.GetExpenseTriggersByClubIdAsync(_clubContext.ClubId, ct);
+
+ var result = triggers.Select(t => new TriggerDto
+ {
+ Id = t.Id,
+ Name = t.Name,
+ ExpenseTriggerType = t.ExpenseTriggerType,
+ LinkedExpenses = expenseTriggers
+ .Where(et => et.TriggerId == t.Id && !et.Expense.IsDeleted)
+ .Select(et => new ExpenseSummaryDto
+ {
+ Id = et.Expense.Id,
+ Name = et.Expense.Name,
+ Price = et.Expense.Price,
+ IsOneClick = et.Expense.IsOneClick
+ })
+ .ToList()
+ }).ToList();
+
+ return result;
+ }
+
+ ///
+ public async Task> GetExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default)
+ {
+ var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct);
+ return _mapper.Map>(expenses);
+ }
+
+ ///
+ public async Task HasExpensesForTriggerAsync(ExpenseTriggerType type, CancellationToken ct = default)
+ {
+ var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct);
+ return expenses.Count > 0;
+ }
+
+ ///
+ public async Task> FireTriggerAsync(
+ ExpenseTriggerType type,
+ Guid personId,
+ Guid dayId,
+ Guid? gameId = null,
+ int multiplier = 1,
+ CancellationToken ct = default)
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync(ct);
+
+ // Get all expenses linked to this trigger type for the current club
+ var expenses = await _triggerRepository.GetExpensesByTriggerTypeAsync(_clubContext.ClubId, type, ct);
+
+ if (expenses.Count == 0)
+ return [];
+
+ // Validate day exists and is not closed
+ var day = await context.Days
+ .Where(d => d.Id == dayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
+ .FirstOrDefaultAsync(ct)
+ ?? throw new InvalidOperationException($"Day with id {dayId} not found.");
+
+ if (day.Status == DayStatus.Closed)
+ throw new InvalidOperationException("Cannot add expenses to a closed day.");
+
+ // Validate person exists
+ var person = await context.Persons
+ .Where(p => p.Id == personId && p.ClubId == _clubContext.ClubId && !p.IsDeleted)
+ .FirstOrDefaultAsync(ct)
+ ?? throw new InvalidOperationException($"Person with id {personId} not found.");
+
+ var createdExpenses = new List();
+
+ foreach (var expense in expenses)
+ {
+ // Calculate price with multiplier
+ var price = expense.Price * multiplier;
+
+ var personExpense = new PersonExpense
+ {
+ Id = Guid.NewGuid(),
+ PersonId = personId,
+ ExpenseId = expense.Id,
+ DayId = dayId,
+ GameId = gameId,
+ ClubId = _clubContext.ClubId,
+ Name = expense.Name,
+ Price = price,
+ ExpenseType = expense.ExpenseType,
+ PersonExpenseStatus = PersonExpenseStatus.Open,
+ AssignedById = Guid.Empty, // TODO: Get from ICurrentUserService when available
+ CreatedAt = DateTime.UtcNow,
+ IsDeleted = false
+ };
+
+ context.PersonExpenses.Add(personExpense);
+ createdExpenses.Add(personExpense);
+ }
+
+ await context.SaveChangesAsync(ct);
+
+ // Reload with navigation properties
+ var result = new List();
+ foreach (var pe in createdExpenses)
+ {
+ var loaded = await context.PersonExpenses
+ .Where(p => p.Id == pe.Id)
+ .Include(p => p.Person)
+ .Include(p => p.Day)
+ .FirstOrDefaultAsync(ct);
+
+ if (loaded is not null)
+ {
+ result.Add(MapToDto(loaded));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ public async Task LinkExpenseToTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default)
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync(ct);
+
+ // Validate expense exists for current club
+ var expense = await context.Expenses
+ .Where(e => e.Id == expenseId && e.ClubId == _clubContext.ClubId && !e.IsDeleted)
+ .FirstOrDefaultAsync(ct)
+ ?? throw new InvalidOperationException($"Expense with id {expenseId} not found.");
+
+ // Validate trigger exists
+ var trigger = await _triggerRepository.GetByIdAsync(triggerId, ct)
+ ?? throw new InvalidOperationException($"Trigger with id {triggerId} not found.");
+
+ // Check if mapping already exists
+ var existing = await context.ExpenseTriggers
+ .FirstOrDefaultAsync(et =>
+ et.ClubId == _clubContext.ClubId &&
+ et.ExpenseId == expenseId &&
+ et.TriggerId == triggerId, ct);
+
+ if (existing is not null)
+ return; // Already linked
+
+ var expenseTrigger = new ExpenseTrigger
+ {
+ ClubId = _clubContext.ClubId,
+ ExpenseId = expenseId,
+ TriggerId = triggerId,
+ AssignedAt = DateTime.UtcNow,
+ AssignedById = Guid.Empty // TODO: Get from ICurrentUserService when available
+ };
+
+ await _triggerRepository.AddExpenseTriggerAsync(expenseTrigger, ct);
+ }
+
+ ///
+ public async Task UnlinkExpenseFromTriggerAsync(Guid expenseId, Guid triggerId, CancellationToken ct = default)
+ {
+ return await _triggerRepository.RemoveExpenseTriggerAsync(_clubContext.ClubId, expenseId, triggerId, ct);
+ }
+
+ ///
+ /// Maps a PersonExpense entity to a PersonExpenseDto.
+ ///
+ private static PersonExpenseDto MapToDto(PersonExpense entity)
+ {
+ return new PersonExpenseDto
+ {
+ Id = entity.Id,
+ PersonId = entity.PersonId,
+ PersonName = entity.Person?.Name ?? string.Empty,
+ ExpenseId = entity.ExpenseId,
+ DayId = entity.DayId,
+ DayPostDate = entity.Day?.PostDate ?? DateTime.MinValue,
+ GameId = entity.GameId,
+ ClubId = entity.ClubId,
+ Name = entity.Name,
+ Price = entity.Price,
+ ExpenseType = entity.ExpenseType,
+ PersonExpenseStatus = entity.PersonExpenseStatus,
+ AssignedById = entity.AssignedById,
+ CreatedAt = entity.CreatedAt
+ };
+ }
+}
diff --git a/src/Koogle.Domain/Interfaces/ITriggerRepository.cs b/src/Koogle.Domain/Interfaces/ITriggerRepository.cs
new file mode 100644
index 0000000..7c6b8c4
--- /dev/null
+++ b/src/Koogle.Domain/Interfaces/ITriggerRepository.cs
@@ -0,0 +1,67 @@
+using Koogle.Domain.Entities;
+using Koogle.Domain.Enums;
+
+namespace Koogle.Domain.Interfaces;
+
+///
+/// Repository interface for managing trigger entities and expense-trigger relationships.
+///
+public interface ITriggerRepository
+{
+ ///
+ /// Retrieves all triggers.
+ ///
+ /// Cancellation token.
+ /// A list of all triggers.
+ Task> GetAllAsync(CancellationToken ct = default);
+
+ ///
+ /// Retrieves a trigger by its unique identifier.
+ ///
+ /// The unique identifier of the trigger.
+ /// Cancellation token.
+ /// The trigger if found; otherwise, null.
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+
+ ///
+ /// Retrieves a trigger by its type.
+ ///
+ /// The expense trigger type.
+ /// Cancellation token.
+ /// The trigger if found; otherwise, null.
+ Task GetByTypeAsync(ExpenseTriggerType type, CancellationToken ct = default);
+
+ ///
+ /// Retrieves all expense-trigger mappings for a specific club.
+ ///
+ /// The unique identifier of the club.
+ /// Cancellation token.
+ /// A list of expense-trigger mappings for the club.
+ Task> GetExpenseTriggersByClubIdAsync(Guid clubId, CancellationToken ct = default);
+
+ ///
+ /// Retrieves all expenses linked to a specific trigger type for a club.
+ ///
+ /// The unique identifier of the club.
+ /// The expense trigger type.
+ /// Cancellation token.
+ /// A list of expenses linked to the trigger type.
+ Task> GetExpensesByTriggerTypeAsync(Guid clubId, ExpenseTriggerType type, CancellationToken ct = default);
+
+ ///
+ /// Adds an expense-trigger mapping.
+ ///
+ /// The expense-trigger mapping to add.
+ /// Cancellation token.
+ Task AddExpenseTriggerAsync(ExpenseTrigger expenseTrigger, CancellationToken ct = default);
+
+ ///
+ /// Removes an expense-trigger mapping.
+ ///
+ /// The club identifier.
+ /// The expense identifier.
+ /// The trigger identifier.
+ /// Cancellation token.
+ /// True if removed successfully; otherwise, false.
+ Task RemoveExpenseTriggerAsync(Guid clubId, Guid expenseId, Guid triggerId, CancellationToken ct = default);
+}
diff --git a/src/Koogle.Infrastructure/DependencyInjection.cs b/src/Koogle.Infrastructure/DependencyInjection.cs
index 08dde99..5f7eae5 100644
--- a/src/Koogle.Infrastructure/DependencyInjection.cs
+++ b/src/Koogle.Infrastructure/DependencyInjection.cs
@@ -79,6 +79,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// Services
//services.AddScoped();
diff --git a/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs b/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs
new file mode 100644
index 0000000..d65cf62
--- /dev/null
+++ b/src/Koogle.Infrastructure/Repositories/TriggerRepository.cs
@@ -0,0 +1,86 @@
+using Koogle.Domain.Entities;
+using Koogle.Domain.Enums;
+using Koogle.Domain.Interfaces;
+using KoogleApp.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Koogle.Infrastructure.Repositories;
+
+///
+/// Repository implementation for managing trigger entities and expense-trigger relationships.
+///
+/// The database context factory for creating scoped contexts.
+public class TriggerRepository(IDbContextFactory contextFactory) : ITriggerRepository
+{
+ ///
+ public async Task> GetAllAsync(CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ return await context.Triggers
+ .Where(t => !t.IsDeleted)
+ .OrderBy(t => t.Name)
+ .ToListAsync(ct);
+ }
+
+ ///
+ public async Task GetByIdAsync(Guid id, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ return await context.Triggers
+ .FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted, ct);
+ }
+
+ ///
+ public async Task GetByTypeAsync(ExpenseTriggerType type, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ return await context.Triggers
+ .FirstOrDefaultAsync(t => t.ExpenseTriggerType == type && !t.IsDeleted, ct);
+ }
+
+ ///
+ public async Task> GetExpenseTriggersByClubIdAsync(Guid clubId, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ return await context.ExpenseTriggers
+ .Where(et => et.ClubId == clubId)
+ .Include(et => et.Expense)
+ .Include(et => et.Trigger)
+ .ToListAsync(ct);
+ }
+
+ ///
+ public async Task> GetExpensesByTriggerTypeAsync(Guid clubId, ExpenseTriggerType type, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ return await context.ExpenseTriggers
+ .Where(et => et.ClubId == clubId && et.Trigger.ExpenseTriggerType == type)
+ .Select(et => et.Expense)
+ .Where(e => !e.IsDeleted)
+ .ToListAsync(ct);
+ }
+
+ ///
+ public async Task AddExpenseTriggerAsync(ExpenseTrigger expenseTrigger, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ expenseTrigger.AssignedAt = DateTime.UtcNow;
+ await context.ExpenseTriggers.AddAsync(expenseTrigger, ct);
+ await context.SaveChangesAsync(ct);
+ }
+
+ ///
+ public async Task RemoveExpenseTriggerAsync(Guid clubId, Guid expenseId, Guid triggerId, CancellationToken ct = default)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(ct);
+ var entity = await context.ExpenseTriggers
+ .FirstOrDefaultAsync(et => et.ClubId == clubId && et.ExpenseId == expenseId && et.TriggerId == triggerId, ct);
+
+ if (entity is null)
+ return false;
+
+ context.ExpenseTriggers.Remove(entity);
+ await context.SaveChangesAsync(ct);
+ return true;
+ }
+}
diff --git a/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs b/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs
index b389ac7..ae3643b 100644
--- a/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs
+++ b/test/Koogle.Tests/Integration/ClubServiceIntegrationTests.cs
@@ -408,4 +408,15 @@ internal class ClubRepository : Koogle.Domain.Interfaces.IClubRepository
await _context.SaveChangesAsync(ct);
return true;
}
+
+ public async Task GetByNameAsync(string clubName, Guid? excludeGuid, CancellationToken ct = default)
+ {
+ var query = _context.Clubs
+ .Where(c => !c.IsDeleted && c.Name.ToLower() == clubName.ToLower());
+ if (excludeGuid.HasValue)
+ {
+ query = query.Where(c => c.Id != excludeGuid.Value);
+ }
+ return await query.FirstOrDefaultAsync(ct);
+ }
}