Implement backup and restore functionality; add IBackupService and BackupService; refactor services to use DbContextFactory

This commit is contained in:
2026-04-03 10:53:53 +02:00
parent 0923c037eb
commit 387c18e834
15 changed files with 411 additions and 71 deletions

35
.vscode/tasks.json vendored
View File

@@ -22,6 +22,37 @@
} }
} }
}, },
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj",
"-c",
"Release",
"-r",
"win-x64",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:EnableCompressionInSingleFile=true",
"/p:IncludeNativeLibrariesForSelfExtract=true",
"/p:IncludeAllContentForSelfExtract=true",
"/p:DebugType=None",
"/p:DebugSymbols=false",
"-o",
"${workspaceFolder}/Publish"
],
"problemMatcher": "$msCompile",
"options": {
"statusbar": {
"label": "$(package) Publish",
"color": "#22c55e",
"detail": "Publish desktop app into Publish folder"
}
}
},
{ {
"label": "watch", "label": "watch",
"command": "dotnet", "command": "dotnet",
@@ -34,6 +65,10 @@
], ],
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"isBackground": true, "isBackground": true,
"presentation": {
"reveal": "never",
"panel": "dedicated"
},
"options": { "options": {
"statusbar": { "statusbar": {
"label": "$(eye) Watch", "label": "$(eye) Watch",

View File

@@ -1,4 +1,5 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using System.Reflection
<div class="d-flex flex-column vh-100"> <div class="d-flex flex-column vh-100">
<nav class="app-navbar d-flex justify-content-between align-items-center"> <nav class="app-navbar d-flex justify-content-between align-items-center">
@@ -43,10 +44,17 @@
{ {
get get
{ {
var now = DateTime.Now; var entryAssembly = Assembly.GetEntryAssembly();
var yearShort = now.Year % 100; var infoVersion = entryAssembly?
var dayOfYear = now.DayOfYear; .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
return $"{yearShort}.{dayOfYear}"; .InformationalVersion;
if (!string.IsNullOrWhiteSpace(infoVersion))
{
return infoVersion.Split('+')[0];
}
return entryAssembly?.GetName().Version?.ToString(2) ?? "1.0";
} }
} }
} }

View File

@@ -28,6 +28,7 @@
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack"> <button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
<i class="bi bi-arrow-left"></i> Zurück <i class="bi bi-arrow-left"></i> Zurück
</button> </button>
|
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true"> <button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
<i class="bi bi-plus-lg"></i> Buchung <i class="bi bi-plus-lg"></i> Buchung
</button> </button>
@@ -37,6 +38,7 @@
<button class="btn btn-dark btn-nav" @onclick="HandleExport"> <button class="btn btn-dark btn-nav" @onclick="HandleExport">
<i class="bi bi-file-earmark-pdf"></i> PDF <i class="bi bi-file-earmark-pdf"></i> PDF
</button> </button>
|
<button class="btn btn-nav @(showCurrentYearOnly ? "btn-primary" : "btn-outline-secondary")" @onclick="ToggleYearFilter"> <button class="btn btn-nav @(showCurrentYearOnly ? "btn-primary" : "btn-outline-secondary")" @onclick="ToggleYearFilter">
<i class="bi bi-funnel"></i> @(showCurrentYearOnly ? $"Nur {DateTime.Now.Year}" : "Alle Buchungen") <i class="bi bi-funnel"></i> @(showCurrentYearOnly ? $"Nur {DateTime.Now.Year}" : "Alle Buchungen")
</button> </button>

View File

@@ -1,6 +1,9 @@
@page "/" @page "/"
@inject IAccountService AccountService @inject IAccountService AccountService
@inject IBackupService BackupService
@inject ISettingsService SettingsService @inject ISettingsService SettingsService
@inject IJSRuntime JsRuntime
@inject NavigationManager NavigationManager
<div class="container-fluid"> <div class="container-fluid">
@@ -19,8 +22,22 @@
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true"> <button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
<i class="bi bi-plus-lg"></i> Neues Konto <i class="bi bi-plus-lg"></i> Neues Konto
</button> </button>
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
<i class="bi bi-save"></i> Backup
</button>
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
</div> </div>
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@operationMessage
</div>
}
@if (accounts == null) @if (accounts == null)
{ {
<div class="text-center py-5"> <div class="text-center py-5">
@@ -56,6 +73,8 @@
private bool showAddAccount; private bool showAddAccount;
private bool showEditClubName; private bool showEditClubName;
private string clubName = string.Empty; private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName; private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -87,4 +106,39 @@
showEditClubName = false; showEditClubName = false;
await LoadClubName(); await LoadClubName();
} }
private async Task HandleBackupAsync()
{
var message = await BackupService.CreateBackupAsync();
if(message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase)) {
SetOperationMessage(message, false);
}
}
private async Task HandleRestoreAsync()
{
var confirmed = await JsRuntime.InvokeAsync<bool>("confirm",
"Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?");
if (!confirmed) return;
var message = await BackupService.RestoreBackupAsync();
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
if (isSuccess)
{
// Reload the Blazor app so all components/services re-query from restored DB.
NavigationManager.NavigateTo("/", forceLoad: true);
} else {
SetOperationMessage(message, isSuccess);
}
}
private void SetOperationMessage(string message, bool success)
{
operationMessage = message;
operationMessageClass = success ? "alert-success" : "alert-danger";
}
} }

View File

@@ -0,0 +1,7 @@
namespace Duempelkas.App.Services;
public interface IBackupService
{
Task<string> CreateBackupAsync();
Task<string> RestoreBackupAsync();
}

View File

@@ -2,8 +2,21 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
<PublishedExeFileName>Duempelkas-v$(Version).exe</PublishedExeFileName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>None</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -24,4 +37,11 @@
<ProjectReference Include="..\Duempelkas.App\Duempelkas.App.csproj" /> <ProjectReference Include="..\Duempelkas.App\Duempelkas.App.csproj" />
<ProjectReference Include="..\Duempelkas.Infrastructure\Duempelkas.Infrastructure.csproj" /> <ProjectReference Include="..\Duempelkas.Infrastructure\Duempelkas.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="FinalizePublishOutput" AfterTargets="Publish">
<Move SourceFiles="$(PublishDir)$(AssemblyName).exe"
DestinationFiles="$(PublishDir)$(PublishedExeFileName)"
Condition="Exists('$(PublishDir)$(AssemblyName).exe') and '$(AssemblyName).exe' != '$(PublishedExeFileName)'" />
<Touch Files="$(PublishDir)duempelkas.db" AlwaysCreate="true" Condition="!Exists('$(PublishDir)duempelkas.db')" />
</Target>
</Project> </Project>

View File

@@ -13,7 +13,8 @@ class Program
{ {
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args); var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
var dbPath = Path.Combine(AppContext.BaseDirectory, "duempelkas.db"); var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}"); appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app"); appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");

View File

@@ -12,12 +12,15 @@ public static class DependencyInjection
{ {
services.AddDbContext<FinanceDbContext>(options => services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString)); options.UseSqlite(connectionString));
services.AddDbContextFactory<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<IAccountService, AccountService>(); services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEntryService, EntryService>(); services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IBalanceQueryService, BalanceQueryService>(); services.AddScoped<IBalanceQueryService, BalanceQueryService>();
services.AddScoped<IPdfStatementService, PdfStatementService>(); services.AddScoped<IPdfStatementService, PdfStatementService>();
services.AddScoped<IFileSaveService, FileSaveService>(); services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
return services; return services;

View File

@@ -9,13 +9,15 @@ namespace Duempelkas.Infrastructure.Services;
public class AccountService : IAccountService public class AccountService : IAccountService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public AccountService(FinanceDbContext db) => _db = db; public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync() public async Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{ {
var accounts = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.OrderBy(a => a.Name) .OrderBy(a => a.Name)
.ToListAsync(); .ToListAsync();
@@ -25,7 +27,9 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> GetAccountAsync(int accountId) public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
@@ -35,51 +39,59 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> CreateAccountAsync(string name) public async Task<AccountSummaryDto> CreateAccountAsync(string name)
{ {
await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name }; var account = new Account { Name = name };
_db.Accounts.Add(account); db.Accounts.Add(account);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc); return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc);
} }
public async Task RenameAccountAsync(int accountId, string newName) public async Task RenameAccountAsync(int accountId, string newName)
{ {
var account = await _db.Accounts.FindAsync(accountId) await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
account.Name = newName; account.Name = newName;
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance) public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
{ {
var account = await _db.Accounts.FindAsync(accountId) await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
account.CarryoverBalance = carryoverBalance; account.CarryoverBalance = carryoverBalance;
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task DeleteAccountAsync(int accountId) public async Task DeleteAccountAsync(int accountId)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
var entryIds = account.Entries.Select(e => e.Id).ToList(); var entryIds = account.Entries.Select(e => e.Id).ToList();
var transferLinks = await _db.TransferLinks var transferLinks = await db.TransferLinks
.Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId))
.ToListAsync(); .ToListAsync();
foreach (var link in transferLinks) foreach (var link in transferLinks)
{ {
var otherEntryId = entryIds.Contains(link.SourceEntryId) ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = entryIds.Contains(link.SourceEntryId) ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) if (otherEntry != null)
_db.Entries.Remove(otherEntry); db.Entries.Remove(otherEntry);
} }
_db.TransferLinks.RemoveRange(transferLinks); db.TransferLinks.RemoveRange(transferLinks);
_db.Accounts.Remove(account); db.Accounts.Remove(account);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private static AccountSummaryDto MapToSummary(Account account) private static AccountSummaryDto MapToSummary(Account account)

View File

@@ -0,0 +1,146 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private static readonly string DbPath = Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
"duempelkas.db");
public async Task<string> CreateBackupAsync()
{
if (!File.Exists(DbPath))
{
return "Keine Datenbankdatei gefunden.";
}
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Backup wurde abgebrochen.";
}
backupPath = Path.ChangeExtension(backupPath, ".bak");
try
{
File.Copy(DbPath, backupPath, overwrite: true);
return $"Backup gespeichert: {backupPath}";
}
catch (Exception ex)
{
return $"Backup fehlgeschlagen: {ex.Message}";
}
}
public async Task<string> RestoreBackupAsync()
{
var backupPath = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Wiederherstellung wurde abgebrochen.";
}
if (!File.Exists(backupPath))
{
return "Die gewählte Backup-Datei wurde nicht gefunden.";
}
try
{
// Ensure no pooled connection keeps stale handles to the previous DB file.
SqliteConnection.ClearAllPools();
var targetDirectory = Path.GetDirectoryName(DbPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
File.Copy(backupPath, DbPath, overwrite: true);
SqliteConnection.ClearAllPools();
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
}
catch (Exception ex)
{
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
}
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.DefaultExt = 'bak'
$dialog.AddExtension = $true
$dialog.OverwritePrompt = $true
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Backup speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
return RunPowerShellDialog(script);
});
}
private static Task<string?> ShowOpenDialogAsync()
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}
var script = @"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.Multiselect = $false
$dialog.Title = 'Backup auswählen'
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
";
return RunPowerShellDialog(script);
});
}
private static string? RunPowerShellDialog(string script)
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return null;
}
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
}
}

View File

@@ -8,13 +8,15 @@ namespace Duempelkas.Infrastructure.Services;
public class BalanceQueryService : IBalanceQueryService public class BalanceQueryService : IBalanceQueryService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public BalanceQueryService(FinanceDbContext db) => _db = db; public BalanceQueryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId) public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");

View File

@@ -9,13 +9,15 @@ namespace Duempelkas.Infrastructure.Services;
public class EntryService : IEntryService public class EntryService : IEntryService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public EntryService(FinanceDbContext db) => _db = db; public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) public async Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{ {
var query = _db.Entries.Where(e => e.AccountId == accountId); await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
if (currentYearOnly) if (currentYearOnly)
query = query.Where(e => e.Date.Year == DateTime.Now.Year); query = query.Where(e => e.Date.Year == DateTime.Now.Year);
@@ -27,7 +29,7 @@ public class EntryService : IEntryService
var entryIds = entries.Select(e => e.Id).ToList(); var entryIds = entries.Select(e => e.Id).ToList();
var transferLinks = await _db.TransferLinks var transferLinks = await db.TransferLinks
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account) .Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account) .Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
.Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId))
@@ -59,7 +61,9 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{ {
var displayId = await GenerateDisplayIdAsync(accountId, date.Year); await using var db = await _dbFactory.CreateDbContextAsync();
var displayId = await GenerateDisplayIdAsync(db, accountId, date.Year);
var entry = new Entry var entry = new Entry
{ {
@@ -70,8 +74,8 @@ public class EntryService : IEntryService
Title = title, Title = title,
Amount = amount Amount = amount
}; };
_db.Entries.Add(entry); db.Entries.Add(entry);
await _db.SaveChangesAsync(); 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); return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
} }
@@ -81,9 +85,10 @@ public class EntryService : IEntryService
if (sourceAccountId == targetAccountId) if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich."); throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
await using var transaction = await _db.Database.BeginTransactionAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var sourceDisplayId = await GenerateDisplayIdAsync(sourceAccountId, date.Year); var sourceDisplayId = await GenerateDisplayIdAsync(db, sourceAccountId, date.Year);
var sourceEntry = new Entry var sourceEntry = new Entry
{ {
@@ -95,10 +100,10 @@ public class EntryService : IEntryService
Amount = amount Amount = amount
}; };
_db.Entries.Add(sourceEntry); db.Entries.Add(sourceEntry);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
var targetDisplayId = await GenerateDisplayIdAsync(targetAccountId, date.Year); var targetDisplayId = await GenerateDisplayIdAsync(db, targetAccountId, date.Year);
var targetEntry = new Entry var targetEntry = new Entry
{ {
@@ -110,8 +115,8 @@ public class EntryService : IEntryService
Amount = amount Amount = amount
}; };
_db.Entries.Add(targetEntry); db.Entries.Add(targetEntry);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
var link = new TransferLink var link = new TransferLink
{ {
@@ -119,62 +124,68 @@ public class EntryService : IEntryService
TargetEntryId = targetEntry.Id, TargetEntryId = targetEntry.Id,
Note = $"Umbuchung: {title}" Note = $"Umbuchung: {title}"
}; };
_db.TransferLinks.Add(link); db.TransferLinks.Add(link);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
sourceEntry.TransferLinkId = link.Id; sourceEntry.TransferLinkId = link.Id;
targetEntry.TransferLinkId = link.Id; targetEntry.TransferLinkId = link.Id;
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
public async Task DeleteEntryAsync(int entryId) public async Task DeleteEntryAsync(int entryId)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = true; entry.IsDeleted = true;
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = true; if (otherEntry != null) otherEntry.IsDeleted = true;
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task RestoreEntryAsync(int entryId) public async Task RestoreEntryAsync(int entryId)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = false; entry.IsDeleted = false;
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = false; if (otherEntry != null) otherEntry.IsDeleted = false;
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date; entry.Date = date;
@@ -184,7 +195,7 @@ public class EntryService : IEntryService
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) if (otherEntry != null)
{ {
otherEntry.Date = date; otherEntry.Date = date;
@@ -193,20 +204,22 @@ public class EntryService : IEntryService
} }
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId) .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId)
?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden."); ?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden.");
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId) var otherEntry = await db.Entries.FindAsync(otherEntryId)
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden."); ?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
// Update date, title, amount on both sides // Update date, title, amount on both sides
@@ -221,16 +234,16 @@ public class EntryService : IEntryService
if (otherEntry.AccountId != newLinkedAccountId) if (otherEntry.AccountId != newLinkedAccountId)
{ {
otherEntry.AccountId = newLinkedAccountId; otherEntry.AccountId = newLinkedAccountId;
otherEntry.DisplayId = await GenerateDisplayIdAsync(newLinkedAccountId, date.Year); otherEntry.DisplayId = await GenerateDisplayIdAsync(db, newLinkedAccountId, date.Year);
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private async Task<string> GenerateDisplayIdAsync(int accountId, int year) private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int accountId, int year)
{ {
var prefix = $"{year}-"; var prefix = $"{year}-";
var maxDisplayId = await _db.Entries var maxDisplayId = await db.Entries
.Where(e => e.DisplayId.StartsWith(prefix)) .Where(e => e.DisplayId.StartsWith(prefix))
.Select(e => e.DisplayId) .Select(e => e.DisplayId)
.MaxAsync(id => (string?)id); .MaxAsync(id => (string?)id);

View File

@@ -12,15 +12,15 @@ namespace Duempelkas.Infrastructure.Services;
public class PdfStatementService : IPdfStatementService public class PdfStatementService : IPdfStatementService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
private readonly IEntryService _entryService; private readonly IEntryService _entryService;
private readonly IBalanceQueryService _balanceQueryService; private readonly IBalanceQueryService _balanceQueryService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private static readonly CultureInfo DeLocale = new("de-DE"); private static readonly CultureInfo DeLocale = new("de-DE");
public PdfStatementService(FinanceDbContext db, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService) public PdfStatementService(IDbContextFactory<FinanceDbContext> dbFactory, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService)
{ {
_db = db; _dbFactory = dbFactory;
_entryService = entryService; _entryService = entryService;
_balanceQueryService = balanceQueryService; _balanceQueryService = balanceQueryService;
_settingsService = settingsService; _settingsService = settingsService;
@@ -28,7 +28,9 @@ public class PdfStatementService : IPdfStatementService
public async Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly) public async Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
{ {
var account = await _db.Accounts.FindAsync(accountId) await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly); var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly);

View File

@@ -17,15 +17,17 @@ public class TransferServiceTests : IDisposable
public TransferServiceTests() public TransferServiceTests()
{ {
var options = new DbContextOptionsBuilder<FinanceDbContext>() var options = new DbContextOptionsBuilder<FinanceDbContext>()
.UseSqlite("Data Source=:memory:") .UseSqlite("Data Source=duempelkas-transfer-tests;Mode=Memory;Cache=Shared")
.Options; .Options;
_db = new FinanceDbContext(options); _db = new FinanceDbContext(options);
_db.Database.OpenConnection(); _db.Database.OpenConnection();
_db.Database.EnsureCreated(); _db.Database.EnsureCreated();
_entryService = new EntryService(_db); var dbFactory = new TestDbContextFactory(options);
_balanceQueryService = new BalanceQueryService(_db);
_entryService = new EntryService(dbFactory);
_balanceQueryService = new BalanceQueryService(dbFactory);
} }
[Fact] [Fact]
@@ -77,6 +79,7 @@ public class TransferServiceTests : IDisposable
await _entryService.DeleteEntryAsync(sourceEntry.Id); await _entryService.DeleteEntryAsync(sourceEntry.Id);
_db.ChangeTracker.Clear();
var entries = await _db.Entries.ToListAsync(); var entries = await _db.Entries.ToListAsync();
entries.Should().HaveCount(2); entries.Should().HaveCount(2);
entries.Should().AllSatisfy(e => e.IsDeleted.Should().BeTrue()); entries.Should().AllSatisfy(e => e.IsDeleted.Should().BeTrue());
@@ -107,4 +110,19 @@ public class TransferServiceTests : IDisposable
_db.Database.CloseConnection(); _db.Database.CloseConnection();
_db.Dispose(); _db.Dispose();
} }
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
{
private readonly DbContextOptions<FinanceDbContext> _options;
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
{
_options = options;
}
public FinanceDbContext CreateDbContext() => new(_options);
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new FinanceDbContext(_options));
}
} }

View File

@@ -17,15 +17,17 @@ public class BalanceCalculationTests : IDisposable
public BalanceCalculationTests() public BalanceCalculationTests()
{ {
var options = new DbContextOptionsBuilder<FinanceDbContext>() var options = new DbContextOptionsBuilder<FinanceDbContext>()
.UseSqlite("Data Source=:memory:") .UseSqlite("Data Source=duempelkas-balance-tests;Mode=Memory;Cache=Shared")
.Options; .Options;
_db = new FinanceDbContext(options); _db = new FinanceDbContext(options);
_db.Database.OpenConnection(); _db.Database.OpenConnection();
_db.Database.EnsureCreated(); _db.Database.EnsureCreated();
_balanceQueryService = new BalanceQueryService(_db); var dbFactory = new TestDbContextFactory(options);
_entryService = new EntryService(_db);
_balanceQueryService = new BalanceQueryService(dbFactory);
_entryService = new EntryService(dbFactory);
} }
[Fact] [Fact]
@@ -106,4 +108,19 @@ public class BalanceCalculationTests : IDisposable
_db.Database.CloseConnection(); _db.Database.CloseConnection();
_db.Dispose(); _db.Dispose();
} }
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
{
private readonly DbContextOptions<FinanceDbContext> _options;
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
{
_options = options;
}
public FinanceDbContext CreateDbContext() => new(_options);
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new FinanceDbContext(_options));
}
} }