Add infrastructure layer with persistence and services

- EF Core DbContext and entity configurations
- Design-time factory for migrations
- Initial and soft-delete migrations
- Service implementations: Account, AccountYear, Entry,
  BalanceQuery, FileSave, PdfStatement
- Dependency injection registration
This commit is contained in:
2026-03-31 17:12:57 +02:00
parent 8fbb8b8ea2
commit c3d68020d5
19 changed files with 1450 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Entities;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Duempelkas.Infrastructure.Services;
public class EntryService : IEntryService
{
private readonly FinanceDbContext _db;
public EntryService(FinanceDbContext db) => _db = db;
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{
var query = _db.Entries.Where(e => e.AccountId == accountId);
if (currentYearOnly)
query = query.Where(e => e.Date.Year == DateTime.Now.Year);
var entries = await query
.OrderBy(e => e.Date)
.ThenBy(e => e.CreatedUtc)
.ToListAsync();
var entryIds = entries.Select(e => e.Id).ToList();
var transferLinks = await _db.TransferLinks
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
.Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId))
.ToListAsync();
return entries.Select(e =>
{
var link = transferLinks.FirstOrDefault(tl => tl.SourceEntryId == e.Id || tl.TargetEntryId == e.Id);
string? linkedAccountName = null;
int? linkedAccountId = null;
if (link != null)
{
if (link.SourceEntryId == e.Id)
{
linkedAccountName = link.TargetEntry.Account.Name;
linkedAccountId = link.TargetEntry.AccountId;
}
else
{
linkedAccountName = link.SourceEntry.Account.Name;
linkedAccountId = link.SourceEntry.AccountId;
}
}
return new EntryDto(
e.Id, e.AccountId, e.DisplayId, e.Type, e.Date, e.Title, e.Amount,
e.IsDeleted, link != null, link?.Id, linkedAccountName, linkedAccountId);
}).ToList();
}
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
var displayId = await GenerateDisplayIdAsync(accountId, date.Year);
var entry = new Entry
{
AccountId = accountId,
DisplayId = displayId,
Type = type,
Date = date,
Title = title,
Amount = amount
};
_db.Entries.Add(entry);
await _db.SaveChangesAsync();
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
}
public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount)
{
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
await using var transaction = await _db.Database.BeginTransactionAsync();
var sourceDisplayId = await GenerateDisplayIdAsync(sourceAccountId, date.Year);
var sourceEntry = new Entry
{
AccountId = sourceAccountId,
DisplayId = sourceDisplayId,
Type = EntryType.Expense,
Date = date,
Title = title,
Amount = amount
};
_db.Entries.Add(sourceEntry);
await _db.SaveChangesAsync();
var targetDisplayId = await GenerateDisplayIdAsync(targetAccountId, date.Year);
var targetEntry = new Entry
{
AccountId = targetAccountId,
DisplayId = targetDisplayId,
Type = EntryType.Income,
Date = date,
Title = title,
Amount = amount
};
_db.Entries.Add(targetEntry);
await _db.SaveChangesAsync();
var link = new TransferLink
{
SourceEntryId = sourceEntry.Id,
TargetEntryId = targetEntry.Id,
Note = $"Umbuchung: {title}"
};
_db.TransferLinks.Add(link);
await _db.SaveChangesAsync();
sourceEntry.TransferLinkId = link.Id;
targetEntry.TransferLinkId = link.Id;
await _db.SaveChangesAsync();
await transaction.CommitAsync();
}
public async Task DeleteEntryAsync(int entryId)
{
var entry = await _db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = true;
var link = await _db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null)
{
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = true;
}
await _db.SaveChangesAsync();
}
public async Task RestoreEntryAsync(int entryId)
{
var entry = await _db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = false;
var link = await _db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null)
{
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = false;
}
await _db.SaveChangesAsync();
}
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
var entry = await _db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date;
entry.Title = title;
entry.Amount = amount;
if (link != null)
{
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId);
if (otherEntry != null)
{
otherEntry.Date = date;
otherEntry.Title = title;
otherEntry.Amount = amount;
}
}
await _db.SaveChangesAsync();
}
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
var entry = await _db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId)
?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden.");
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId)
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
// Update date, title, amount on both sides
entry.Date = date;
entry.Title = title;
entry.Amount = amount;
otherEntry.Date = date;
otherEntry.Title = title;
otherEntry.Amount = amount;
// If the linked account has changed, move the other entry to the new account
if (otherEntry.AccountId != newLinkedAccountId)
{
otherEntry.AccountId = newLinkedAccountId;
otherEntry.DisplayId = await GenerateDisplayIdAsync(newLinkedAccountId, date.Year);
}
await _db.SaveChangesAsync();
}
private async Task<string> GenerateDisplayIdAsync(int accountId, int year)
{
var prefix = $"{year}-";
var maxDisplayId = await _db.Entries
.Where(e => e.DisplayId.StartsWith(prefix))
.Select(e => e.DisplayId)
.MaxAsync(id => (string?)id);
int nextNumber = 1;
if (maxDisplayId != null)
{
var parts = maxDisplayId.Split('-');
if (parts.Length == 2 && int.TryParse(parts[1], out var current))
nextNumber = current + 1;
}
return $"{year}-{nextNumber:D3}";
}
}