diff --git a/docs/superpowers/plans/2026-03-19-finance-tracker.md b/docs/superpowers/plans/2026-03-19-finance-tracker.md new file mode 100644 index 0000000..64fe0c7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-finance-tracker.md @@ -0,0 +1,2860 @@ +# Finance Tracker — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a personal finance web app that imports ČSOB bank CSV exports and visualises spending through a two-column year/month dashboard. + +**Architecture:** .NET 8 REST API backed by MySQL 8, consumed by a Vue 3 + Vuetify 3 SPA. NSwag generates a TypeScript API client from the .NET OpenAPI spec at build time. Three Docker containers (api, web, db) sit behind the user's existing Nginx reverse proxy. + +**Tech Stack:** .NET 8, EF Core 8 + Pomelo MySQL provider, BCrypt.Net-Next, Polly, NSwag.MSBuild, NSwag.AspNetCore · Vue 3, Vuetify 3, Pinia, Vue Router 4, ApexCharts, vue3-apexcharts · MySQL 8 · Docker / docker-compose + +--- + +## File Map + +``` +account-tracking/ +├── AccountTracking.sln +├── docker-compose.yml +├── .env.example +├── src/ +│ ├── AccountTracking.Api/ +│ │ ├── AccountTracking.Api.csproj +│ │ ├── Program.cs +│ │ ├── nswag.json ← generates openapi.json at dotnet build +│ │ ├── openapi.json ← NSwag output (committed) +│ │ ├── Controllers/ +│ │ │ ├── AuthController.cs +│ │ │ ├── TransactionsController.cs +│ │ │ └── DashboardController.cs +│ │ ├── Services/ +│ │ │ ├── CsvImportService.cs +│ │ │ └── DashboardService.cs +│ │ ├── Models/ +│ │ │ ├── Transaction.cs +│ │ │ ├── ImportLog.cs +│ │ │ └── Dtos/ +│ │ │ ├── LoginRequest.cs +│ │ │ ├── LoginResponse.cs +│ │ │ ├── ImportResult.cs +│ │ │ ├── TransactionDto.cs +│ │ │ ├── TransactionListResponse.cs +│ │ │ ├── SummaryDto.cs +│ │ │ ├── CategorySpendingDto.cs +│ │ │ ├── MonthlyBalanceDto.cs +│ │ │ └── CumulativeSpendingDto.cs +│ │ ├── Data/ +│ │ │ └── AppDbContext.cs +│ │ └── Dockerfile +│ ├── AccountTracking.Api.Tests/ +│ │ ├── AccountTracking.Api.Tests.csproj +│ │ ├── CsvImportServiceTests.cs +│ │ └── DashboardServiceTests.cs +│ └── AccountTracking.Web/ +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ ├── nswag.json ← reads openapi.json, generates src/api/ +│ ├── index.html +│ ├── Dockerfile +│ └── src/ +│ ├── main.ts +│ ├── App.vue +│ ├── api/ +│ │ ├── apiClient.ts ← NSwag-generated (do not edit) +│ │ └── index.ts ← manually written: authenticated clients +│ ├── router/ +│ │ └── index.ts +│ ├── stores/ +│ │ ├── auth.ts +│ │ ├── dashPeriod.ts +│ │ └── txPeriod.ts +│ ├── views/ +│ │ ├── LoginView.vue +│ │ ├── DashboardView.vue +│ │ └── TransactionsView.vue +│ └── components/ +│ ├── YearSummary.vue +│ ├── MonthSummary.vue +│ ├── DonutChart.vue +│ ├── MonthlyBalancesChart.vue +│ ├── CumulativeSpendingChart.vue +│ └── UploadCsvButton.vue +``` + +--- + +## Task 1: Solution & Project Scaffold + +**Files:** +- Create: `AccountTracking.sln` +- Create: `src/AccountTracking.Api/AccountTracking.Api.csproj` +- Create: `src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj` + +- [ ] **Step 1: Create the .NET solution** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +dotnet new sln -n AccountTracking +dotnet new webapi -n AccountTracking.Api -o src/AccountTracking.Api --no-openapi +dotnet new xunit -n AccountTracking.Api.Tests -o src/AccountTracking.Api.Tests +dotnet sln add src/AccountTracking.Api/AccountTracking.Api.csproj +dotnet sln add src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj +``` + +- [ ] **Step 2: Add NuGet packages to API project** + +```bash +cd src/AccountTracking.Api +dotnet add package Pomelo.EntityFrameworkCore.MySql --version 8.0.2 +dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.10 +dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10 +dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.10 +dotnet add package NSwag.AspNetCore --version 14.1.0 +dotnet add package NSwag.MSBuild --version 14.1.0 +dotnet add package BCrypt.Net-Next --version 4.0.3 +dotnet add package Polly --version 8.4.1 +``` + +- [ ] **Step 3: Add NuGet packages to test project** + +```bash +cd ../AccountTracking.Api.Tests +dotnet add reference ../AccountTracking.Api/AccountTracking.Api.csproj +dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10 +``` + +- [ ] **Step 4: Configure the API `.csproj` for NSwag MSBuild generation** + +Replace the contents of `src/AccountTracking.Api/AccountTracking.Api.csproj`: + +```xml + + + + net8.0 + enable + enable + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +``` + +Note: The NSwag target is gated by `GenerateOpenApiSpec=true` so it only runs when explicitly requested (not on every build, which would slow the inner loop). + +- [ ] **Step 5: Delete the template boilerplate files** + +```bash +cd /mnt/c/Repos/Personal/account-tracking/src/AccountTracking.Api +rm -f WeatherForecast.cs Controllers/WeatherForecastController.cs +``` + +- [ ] **Step 6: Verify solution builds** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +dotnet build +``` + +Expected: `Build succeeded.` + +- [ ] **Step 7: Commit** + +```bash +git add AccountTracking.sln src/AccountTracking.Api/ src/AccountTracking.Api.Tests/ +git commit -m "feat: scaffold .NET solution with API and test projects" +``` + +--- + +## Task 2: Data Models, EF Core Context & Migrations + +**Files:** +- Create: `src/AccountTracking.Api/Models/Transaction.cs` +- Create: `src/AccountTracking.Api/Models/ImportLog.cs` +- Create: `src/AccountTracking.Api/Models/Dtos/` (all DTO files) +- Create: `src/AccountTracking.Api/Data/AppDbContext.cs` + +- [ ] **Step 1: Create the Transaction entity** + +`src/AccountTracking.Api/Models/Transaction.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AccountTracking.Api.Models; + +[Table("transactions")] +public class Transaction +{ + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("account_number")] + [MaxLength(30)] + public string AccountNumber { get; set; } = ""; + + [Column("booking_date")] + public DateOnly BookingDate { get; set; } + + [Column("amount")] + [Precision(15, 2)] + public decimal Amount { get; set; } + + [Column("currency")] + [MaxLength(3)] + public string Currency { get; set; } = "CZK"; + + [Column("balance")] + [Precision(15, 2)] + public decimal Balance { get; set; } + + [Column("counter_party_name")] + [MaxLength(255)] + public string? CounterPartyName { get; set; } + + [Column("operation_description")] + [MaxLength(255)] + public string? OperationDescription { get; set; } + + [Column("message")] + public string? Message { get; set; } + + [Column("category")] + [MaxLength(100)] + public string? Category { get; set; } + + [Column("variable_symbol")] + [MaxLength(30)] + public string? VariableSymbol { get; set; } + + [Column("bank_note")] + [MaxLength(255)] + public string? BankNote { get; set; } + + [Column("transaction_id")] + [MaxLength(512)] + public string TransactionId { get; set; } = ""; + + // Extra CSV columns stored but not used in UI + [Column("counter_bank_code")] [MaxLength(255)] public string? CounterBankCode { get; set; } + [Column("constant_symbol")] [MaxLength(255)] public string? ConstantSymbol { get; set; } + [Column("specific_symbol")] [MaxLength(255)] public string? SpecificSymbol { get; set; } + [Column("order_name")] [MaxLength(255)] public string? OrderName { get; set; } + [Column("exchange_rate")] [MaxLength(255)] public string? ExchangeRate { get; set; } + [Column("e2e_id")] [MaxLength(255)] public string? E2EId { get; set; } + [Column("payer_reference")] [MaxLength(255)] public string? PayerReference { get; set; } + [Column("original_payer")] [MaxLength(255)] public string? OriginalPayer { get; set; } + [Column("final_recipient")] [MaxLength(255)] public string? FinalRecipient { get; set; } + [Column("original_transaction")] [MaxLength(255)] public string? OriginalTransaction { get; set; } +} +``` + +- [ ] **Step 2: Create the ImportLog entity** + +`src/AccountTracking.Api/Models/ImportLog.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AccountTracking.Api.Models; + +[Table("import_logs")] +public class ImportLog +{ + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("imported_at")] + public DateTime ImportedAt { get; set; } = DateTime.UtcNow; + + [Column("filename")] + [MaxLength(255)] + public string Filename { get; set; } = ""; + + [Column("records_imported")] + public int RecordsImported { get; set; } + + [Column("records_skipped")] + public int RecordsSkipped { get; set; } +} +``` + +- [ ] **Step 3: Create all DTOs** + +`src/AccountTracking.Api/Models/Dtos/LoginRequest.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record LoginRequest(string Username, string Password); +``` + +`src/AccountTracking.Api/Models/Dtos/LoginResponse.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record LoginResponse(string Token, string ExpiresAt); +``` + +`src/AccountTracking.Api/Models/Dtos/ImportResult.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record ImportResult(int RecordsImported, int RecordsSkipped); +public record ErrorResult(string Error); +``` + +`src/AccountTracking.Api/Models/Dtos/TransactionDto.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record TransactionDto( + int Id, + DateOnly BookingDate, + string? CounterPartyName, + string? Category, + decimal Amount, + decimal Balance, + string? Message +); +``` + +`src/AccountTracking.Api/Models/Dtos/TransactionListResponse.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record TransactionListResponse( + IEnumerable Items, + int TotalCount, + int Page, + int PageSize +); +``` + +`src/AccountTracking.Api/Models/Dtos/SummaryDto.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record SummaryDto(decimal TotalSpent, decimal TotalIncome); +``` + +`src/AccountTracking.Api/Models/Dtos/CategorySpendingDto.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record CategorySpendingDto(string Category, decimal Total); +``` + +`src/AccountTracking.Api/Models/Dtos/MonthlyBalanceDto.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record MonthlyBalanceDto(int Month, decimal ClosingBalance); +``` + +`src/AccountTracking.Api/Models/Dtos/CumulativeSpendingDto.cs`: +```csharp +namespace AccountTracking.Api.Models.Dtos; +public record CumulativeSpendingDto(int Day, decimal CumulativeSpent); +``` + +- [ ] **Step 4: Create AppDbContext** + +`src/AccountTracking.Api/Data/AppDbContext.cs`: +```csharp +using AccountTracking.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Transactions => Set(); + public DbSet ImportLogs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // transaction_id: case-sensitive unique index using utf8mb4_bin + modelBuilder.Entity() + .HasIndex(t => t.TransactionId) + .IsUnique(); + + modelBuilder.Entity() + .Property(t => t.TransactionId) + .UseCollation("utf8mb4_bin"); + + // ImportedAt stored as UTC + modelBuilder.Entity() + .Property(l => l.ImportedAt) + .HasConversion( + v => DateTime.SpecifyKind(v, DateTimeKind.Utc), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + } +} +``` + +- [ ] **Step 5: Verify it compiles** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +dotnet build +``` + +Expected: `Build succeeded.` + +- [ ] **Step 6: Commit** + +```bash +git add src/AccountTracking.Api/Models/ src/AccountTracking.Api/Data/ +git commit -m "feat: add EF Core models, DTOs and AppDbContext" +``` + +--- + +## Task 3: Application Startup (Program.cs) + +**Files:** +- Create/replace: `src/AccountTracking.Api/Program.cs` + +This task wires up all middleware: CORS, JWT auth, NSwag/Swagger, database registration with InMemory fallback for NSwag generation, and Polly migration retry. + +- [ ] **Step 1: Write Program.cs** + +`src/AccountTracking.Api/Program.cs`: +```csharp +using System.Text; +using AccountTracking.Api.Data; +using AccountTracking.Api.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Polly; + +var builder = WebApplication.CreateBuilder(args); + +// ── Configuration ────────────────────────────────────────────────────────── +var connectionString = builder.Configuration["DB_CONNECTION"]; +var jwtSecret = builder.Configuration["JWT_SECRET"] + ?? throw new InvalidOperationException("JWT_SECRET is required"); +var appUsername = builder.Configuration["APP_USERNAME"] + ?? throw new InvalidOperationException("APP_USERNAME is required"); +var appPassword = builder.Configuration["APP_PASSWORD"] + ?? throw new InvalidOperationException("APP_PASSWORD is required"); +var allowedOrigin = builder.Configuration["ALLOWED_ORIGIN"] ?? "http://localhost:3000"; + +// ── Database ──────────────────────────────────────────────────────────────── +if (!string.IsNullOrEmpty(connectionString)) +{ + builder.Services.AddDbContext(opts => + opts.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))); +} +else +{ + // NSwag build-time spec generation: no real DB available + builder.Services.AddDbContext(opts => + opts.UseInMemoryDatabase("nswag_gen")); +} + +// ── Services ──────────────────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// ── Auth ──────────────────────────────────────────────────────────────────── +var keyBytes = Convert.FromBase64String(jwtSecret); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opts => + { + opts.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(keyBytes), + ValidateIssuer = false, + ValidateAudience = false, + ClockSkew = TimeSpan.Zero + }; + }); +builder.Services.AddAuthorization(); + +// Store credentials for use by AuthController +builder.Services.AddSingleton(new AppCredentials(appUsername, appPassword, keyBytes)); + +// ── CORS ──────────────────────────────────────────────────────────────────── +builder.Services.AddCors(opts => +{ + opts.AddDefaultPolicy(policy => + policy.WithOrigins(allowedOrigin, "http://localhost:3000") + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +// ── Controllers & OpenAPI ─────────────────────────────────────────────────── +builder.Services.AddControllers(); +builder.Services.AddOpenApiDocument(config => +{ + config.Title = "AccountTracking API"; + config.Version = "v1"; + config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme + { + Type = NSwag.OpenApiSecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }); +}); + +// ── Kestrel: limit upload to 10 MB ───────────────────────────────────────── +builder.WebHost.ConfigureKestrel(opts => + opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024); + +var app = builder.Build(); + +// ── Migrate DB (skip when running without a real DB) ─────────────────────── +if (!string.IsNullOrEmpty(connectionString)) +{ + var retryPolicy = Policy + .Handle() + .WaitAndRetry( + 5, + attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), + (ex, delay, attempt, _) => + Console.WriteLine($"DB migration attempt {attempt} failed: {ex.Message}. Retrying in {delay.TotalSeconds}s...")); + + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + retryPolicy.Execute(() => db.Database.Migrate()); +} + +// ── Middleware pipeline ───────────────────────────────────────────────────── +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseOpenApi(); +app.UseSwaggerUi(); +app.MapControllers(); + +app.Run(); + +// Accessible by tests +public partial class Program { } + +// Credential container registered in DI +public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes); +``` + +- [ ] **Step 2: Verify it builds** + +```bash +dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj +``` + +Expected: `Build succeeded.` + +- [ ] **Step 3: Commit** + +```bash +git add src/AccountTracking.Api/Program.cs +git commit -m "feat: configure startup — CORS, JWT, NSwag, Polly migration retry" +``` + +--- + +## Task 4: Auth Controller + +**Files:** +- Create: `src/AccountTracking.Api/Controllers/AuthController.cs` + +- [ ] **Step 1: Create AuthController** + +`src/AccountTracking.Api/Controllers/AuthController.cs`: +```csharp +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using AccountTracking.Api.Models.Dtos; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace AccountTracking.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly AppCredentials _credentials; + + public AuthController(AppCredentials credentials) + => _credentials = credentials; + + [HttpPost("login")] + public IActionResult Login([FromBody] LoginRequest request) + { + if (request.Username != _credentials.Username + || !BCrypt.Net.BCrypt.Verify(request.Password, _credentials.PasswordHash)) + return Unauthorized(); + + var expiry = DateTime.UtcNow.AddDays(30); + var token = GenerateToken(expiry); + + return Ok(new LoginResponse(token, expiry.ToString("O"))); + } + + private string GenerateToken(DateTime expiry) + { + var key = new SymmetricSecurityKey(_credentials.JwtKeyBytes); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var jwt = new JwtSecurityToken( + claims: [new Claim(ClaimTypes.Name, _credentials.Username)], + expires: expiry, + signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(jwt); + } +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj +``` + +Expected: `Build succeeded.` + +- [ ] **Step 3: Commit** + +```bash +git add src/AccountTracking.Api/Controllers/AuthController.cs +git commit -m "feat: add JWT auth endpoint" +``` + +--- + +## Task 5: CSV Import Service (TDD) + +**Files:** +- Create: `src/AccountTracking.Api/Services/CsvImportService.cs` +- Create: `src/AccountTracking.Api.Tests/CsvImportServiceTests.cs` + +The service parses ČSOB semicolon-delimited UTF-8 CSV files. Header row is found by scanning for a line starting with "číslo účtu". Czech-locale amounts ("1 740,80") are normalised before parsing. + +- [ ] **Step 1: Write the failing tests** + +`src/AccountTracking.Api.Tests/CsvImportServiceTests.cs`: +```csharp +using AccountTracking.Api.Data; +using AccountTracking.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Tests; + +public class CsvImportServiceTests +{ + private static AppDbContext CreateDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new AppDbContext(opts); + } + + private static CsvImportService CreateService(AppDbContext db) + => new(db); + + // Minimal valid CSV with two data rows + private const string ValidCsv = """ + Pohyby na účtu 216868554/0300 dne 19.03.2026 + + číslo účtu;datum zaúčtování;částka;měna;zůstatek;číslo protiúčtu;kód banky protiúčtu;jméno protistrany;adresa protistrany;konstantní symbol;variabilní symbol;specifický symbol;označení operace;název trálého příkazu;vlastní poznámka;zpráva;kategorie;původní transakce;kurz;E2E identifikace;reference plátce;původní plátce;konečný příjemce;ID transakce + 216868554/0300;15.03.2026;-1 740,80;CZK;93 346,57;;;;;;205000001;;Transakce platební kartou;;;Zpráva 1;Potraviny;;;;;tx-001 + 216868554/0300;14.03.2026;-800,00;CZK;95 087,37;;;;;;205000002;;Transakce platební kartou;;;Zpráva 2;Restaurace;;;;;tx-002 + """; + + private const string CsvMissingHeader = """ + Pohyby na účtu + Nějaky obsah bez správné hlavičky + datum;částka + """; + + private const string CsvMissingRequiredColumn = """ + číslo účtu;datum zaúčtování;měna;zůstatek;ID transakce + 216868554/0300;15.03.2026;CZK;93346,57;tx-001 + """; + + [Fact] + public async Task Import_ParsesRowsCorrectly() + { + using var db = CreateDb(); + var svc = CreateService(db); + + var result = await svc.ImportAsync(ValidCsv, "test.csv"); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.RecordsImported); + Assert.Equal(0, result.Value.RecordsSkipped); + + var transactions = await db.Transactions.ToListAsync(); + Assert.Equal(2, transactions.Count); + + var tx = transactions.First(t => t.TransactionId == "tx-001"); + Assert.Equal(-1740.80m, tx.Amount); + Assert.Equal(93346.57m, tx.Balance); + Assert.Equal("Potraviny", tx.Category); + Assert.Equal(new DateOnly(2026, 3, 15), tx.BookingDate); + } + + [Fact] + public async Task Import_SkipsDuplicateTransactionIds() + { + using var db = CreateDb(); + var svc = CreateService(db); + + await svc.ImportAsync(ValidCsv, "first.csv"); + var result = await svc.ImportAsync(ValidCsv, "second.csv"); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value!.RecordsImported); + Assert.Equal(2, result.Value.RecordsSkipped); + Assert.Equal(2, await db.Transactions.CountAsync()); + } + + [Fact] + public async Task Import_FindsHeaderByScanning_NotByLineNumber() + { + // Header is on line 3 in ValidCsv — but we scan, not hardcode + using var db = CreateDb(); + var svc = CreateService(db); + + var result = await svc.ImportAsync(ValidCsv, "test.csv"); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Import_ReturnsError_WhenHeaderNotFound() + { + using var db = CreateDb(); + var svc = CreateService(db); + + var result = await svc.ImportAsync(CsvMissingHeader, "bad.csv"); + + Assert.False(result.IsSuccess); + Assert.Contains("hlavičku", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Import_ReturnsError_WhenRequiredColumnMissing() + { + using var db = CreateDb(); + var svc = CreateService(db); + + var result = await svc.ImportAsync(CsvMissingRequiredColumn, "bad.csv"); + + Assert.False(result.IsSuccess); + Assert.Contains("částka", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("1 740,80", 1740.80)] + [InlineData("-4 263,15", -4263.15)] + [InlineData("-800,00", -800.00)] + [InlineData("93 346,57", 93346.57)] + public void ParseAmount_HandlesChechLocaleFormat(string input, decimal expected) + { + var result = CsvImportService.ParseCzechDecimal(input); + Assert.Equal(expected, result); + } +} +``` + +- [ ] **Step 2: Run tests — verify they all fail** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +dotnet test src/AccountTracking.Api.Tests/ --no-build 2>&1 | head -30 +``` + +Expected: build errors because `CsvImportService` does not exist yet. + +- [ ] **Step 3: Implement CsvImportService** + +`src/AccountTracking.Api/Services/CsvImportService.cs`: +```csharp +using AccountTracking.Api.Data; +using AccountTracking.Api.Models; +using AccountTracking.Api.Models.Dtos; + +namespace AccountTracking.Api.Services; + +public class CsvImportService(AppDbContext db) +{ + private static readonly string[] RequiredColumns = ["částka", "ID transakce", "datum zaúčtování"]; + + public async Task ImportAsync(string csvContent, string filename) + { + var lines = csvContent + .Split('\n', StringSplitOptions.None) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + + // Find header row by scanning for line starting with "číslo účtu" + int headerIndex = -1; + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith("číslo účtu", StringComparison.OrdinalIgnoreCase)) + { + headerIndex = i; + break; + } + } + + if (headerIndex < 0) + return ImportServiceResult.Failure("Soubor neobsahuje správnou hlavičku (řádek začínající 'číslo účtu')."); + + var headers = lines[headerIndex].Split(';'); + var columnIndex = BuildColumnIndex(headers); + + // Validate required columns + foreach (var col in RequiredColumns) + { + if (!columnIndex.ContainsKey(col)) + return ImportServiceResult.Failure($"Souboru chybí povinný sloupec: '{col}'."); + } + + // Load existing transaction IDs to detect duplicates + var existingIds = db.Transactions + .Select(t => t.TransactionId) + .ToHashSet(); + + int imported = 0, skipped = 0; + var toInsert = new List(); + + for (int i = headerIndex + 1; i < lines.Length; i++) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + + var fields = line.Split(';'); + if (fields.Length < headers.Length) continue; + + var txId = GetField(fields, columnIndex, "ID transakce"); + if (string.IsNullOrEmpty(txId)) continue; + + if (existingIds.Contains(txId)) + { + skipped++; + continue; + } + + existingIds.Add(txId); // prevent duplicates within the same file + + toInsert.Add(new Transaction + { + AccountNumber = GetField(fields, columnIndex, "číslo účtu"), + BookingDate = DateOnly.ParseExact(GetField(fields, columnIndex, "datum zaúčtování"), "d.M.yyyy"), + Amount = ParseCzechDecimal(GetField(fields, columnIndex, "částka")), + Currency = GetField(fields, columnIndex, "měna"), + Balance = ParseCzechDecimal(GetField(fields, columnIndex, "zůstatek")), + CounterPartyName = GetField(fields, columnIndex, "jméno protistrany"), + OperationDescription = GetField(fields, columnIndex, "označení operace"), + Message = GetField(fields, columnIndex, "zpráva"), + Category = GetField(fields, columnIndex, "kategorie"), + VariableSymbol = GetField(fields, columnIndex, "variabilní symbol"), + BankNote = GetField(fields, columnIndex, "vlastní poznámka"), + TransactionId = txId, + CounterBankCode = GetField(fields, columnIndex, "kód banky protiúčtu"), + ConstantSymbol = GetField(fields, columnIndex, "konstantní symbol"), + SpecificSymbol = GetField(fields, columnIndex, "specifický symbol"), + OrderName = GetField(fields, columnIndex, "název trvalého příkazu"), + ExchangeRate = GetField(fields, columnIndex, "kurz"), + E2EId = GetField(fields, columnIndex, "E2E identifikace"), + PayerReference = GetField(fields, columnIndex, "reference plátce"), + OriginalPayer = GetField(fields, columnIndex, "původní plátce"), + FinalRecipient = GetField(fields, columnIndex, "konečný příjemce"), + OriginalTransaction = GetField(fields, columnIndex, "původní transakce"), + }); + imported++; + } + + if (toInsert.Count > 0) + { + db.Transactions.AddRange(toInsert); + db.ImportLogs.Add(new ImportLog + { + Filename = filename, + RecordsImported = imported, + RecordsSkipped = skipped, + ImportedAt = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + } + + return ImportServiceResult.Success(new ImportResult(imported, skipped)); + } + + public static decimal ParseCzechDecimal(string value) + { + // Czech format: "1 740,80" → remove spaces, replace comma with dot + var normalised = value.Replace(" ", "").Replace(",", "."); + return decimal.Parse(normalised, System.Globalization.CultureInfo.InvariantCulture); + } + + private static Dictionary BuildColumnIndex(string[] headers) + => headers + .Select((h, i) => (Header: h.Trim(), Index: i)) + .Where(x => !string.IsNullOrEmpty(x.Header)) + .ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase); + + private static string GetField(string[] fields, Dictionary index, string column) + { + if (!index.TryGetValue(column, out int i)) return ""; + return i < fields.Length ? fields[i].Trim() : ""; + } +} + +public class ImportServiceResult +{ + public bool IsSuccess { get; private init; } + public ImportResult? Value { get; private init; } + public string? Error { get; private init; } + + public static ImportServiceResult Success(ImportResult value) + => new() { IsSuccess = true, Value = value }; + + public static ImportServiceResult Failure(string error) + => new() { IsSuccess = false, Error = error }; +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +dotnet test src/AccountTracking.Api.Tests/ -v normal +``` + +Expected: all 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AccountTracking.Api/Services/CsvImportService.cs \ + src/AccountTracking.Api.Tests/CsvImportServiceTests.cs +git commit -m "feat: implement CSV import service with tests (TDD)" +``` + +--- + +## Task 6: Transactions API Controller + +**Files:** +- Create: `src/AccountTracking.Api/Controllers/TransactionsController.cs` + +- [ ] **Step 1: Create TransactionsController** + +`src/AccountTracking.Api/Controllers/TransactionsController.cs`: +```csharp +using AccountTracking.Api.Data; +using AccountTracking.Api.Models.Dtos; +using AccountTracking.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Controllers; + +[ApiController] +[Route("api/transactions")] +[Authorize] +public class TransactionsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly CsvImportService _importService; + + public TransactionsController(AppDbContext db, CsvImportService importService) + { + _db = db; + _importService = importService; + } + + [HttpPost("import")] + public async Task Import(IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new ErrorResult("Nebyl vybrán žádný soubor.")); + + string content; + using (var reader = new StreamReader(file.OpenReadStream(), System.Text.Encoding.UTF8)) + content = await reader.ReadToEndAsync(); + + var result = await _importService.ImportAsync(content, file.FileName); + + if (!result.IsSuccess) + return BadRequest(new ErrorResult(result.Error!)); + + return Ok(result.Value); + } + + [HttpGet] + public async Task List( + [FromQuery] int year, + [FromQuery] int? month, + [FromQuery] string? category, + [FromQuery] string? search, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + pageSize = Math.Clamp(pageSize, 1, 200); + page = Math.Max(1, page); + + var query = _db.Transactions.AsQueryable(); + + query = query.Where(t => t.BookingDate.Year == year); + if (month.HasValue) + query = query.Where(t => t.BookingDate.Month == month.Value); + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(t => t.Category == category); + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(t => + (t.CounterPartyName != null && EF.Functions.Like(t.CounterPartyName, $"%{search}%")) || + (t.Message != null && EF.Functions.Like(t.Message, $"%{search}%"))); + + var total = await query.CountAsync(); + var items = await query + .OrderByDescending(t => t.BookingDate) + .ThenByDescending(t => t.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(t => new TransactionDto( + t.Id, t.BookingDate, t.CounterPartyName, + t.Category, t.Amount, t.Balance, t.Message)) + .ToListAsync(); + + return Ok(new TransactionListResponse(items, total, page, pageSize)); + } + + [HttpGet("categories")] + public async Task Categories() + { + var cats = await _db.Transactions + .Where(t => t.Category != null) + .Select(t => t.Category!) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + + return Ok(cats); + } +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj +``` + +Expected: `Build succeeded.` + +- [ ] **Step 3: Commit** + +```bash +git add src/AccountTracking.Api/Controllers/TransactionsController.cs +git commit -m "feat: add transactions import, list and categories endpoints" +``` + +--- + +## Task 7: Dashboard Service & Controller (TDD) + +**Files:** +- Create: `src/AccountTracking.Api/Services/DashboardService.cs` +- Create: `src/AccountTracking.Api/Controllers/DashboardController.cs` +- Create: `src/AccountTracking.Api.Tests/DashboardServiceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +`src/AccountTracking.Api.Tests/DashboardServiceTests.cs`: +```csharp +using AccountTracking.Api.Data; +using AccountTracking.Api.Models; +using AccountTracking.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Tests; + +public class DashboardServiceTests +{ + private static AppDbContext CreateDb(params Transaction[] seed) + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new AppDbContext(opts); + db.Transactions.AddRange(seed); + db.SaveChanges(); + return db; + } + + private static Transaction Tx(string date, decimal amount, decimal balance, + string category = "Test", string txId = "") => + new() + { + BookingDate = DateOnly.Parse(date), + Amount = amount, + Balance = balance, + Category = category, + TransactionId = txId == "" ? Guid.NewGuid().ToString() : txId, + Currency = "CZK", + AccountNumber = "test" + }; + + // ── Summary ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Summary_YearOnly_ReturnsTotalsForWholeYear() + { + using var db = CreateDb( + Tx("2025-01-10", -1000, 9000), + Tx("2025-06-15", -2000, 7000), + Tx("2025-03-01", 5000, 12000), + Tx("2024-12-31", -500, 9500) // different year — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSummaryAsync(2025, null); + + Assert.Equal(3000m, result.TotalSpent); // 1000 + 2000 + Assert.Equal(5000m, result.TotalIncome); + } + + [Fact] + public async Task Summary_YearAndMonth_ReturnsTotalsForMonth() + { + using var db = CreateDb( + Tx("2025-03-10", -1000, 9000), + Tx("2025-03-20", -500, 8500), + Tx("2025-04-01", -800, 7700) // different month — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSummaryAsync(2025, 3); + + Assert.Equal(1500m, result.TotalSpent); + Assert.Equal(0m, result.TotalIncome); + } + + // ── SpendingByCategory ─────────────────────────────────────────────────── + + [Fact] + public async Task SpendingByCategory_ReturnsAbsoluteValuesSortedDescending() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900, "A"), + Tx("2025-03-02", -300, 600, "B"), + Tx("2025-03-03", -200, 400, "A") + ); + var svc = new DashboardService(db); + + var result = await svc.GetSpendingByCategoryAsync(2025, 3); + + Assert.Equal(2, result.Count); + Assert.Equal("A", result[0].Category); + Assert.Equal(300m, result[0].Total); // 100 + 200 + Assert.Equal("B", result[1].Category); + Assert.Equal(300m, result[1].Total); + } + + [Fact] + public async Task SpendingByCategory_ExcludesPositiveAmounts() + { + using var db = CreateDb( + Tx("2025-03-01", -500, 9500, "Potraviny"), + Tx("2025-03-02", 200, 9700, "Potraviny") // refund — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSpendingByCategoryAsync(2025, 3); + + Assert.Single(result); + Assert.Equal(500m, result[0].Total); + } + + // ── MonthlyBalances ────────────────────────────────────────────────────── + + [Fact] + public async Task MonthlyBalances_ReturnsLastBalancePerMonth() + { + using var db = CreateDb( + Tx("2025-01-10", -100, 900), + Tx("2025-01-20", -200, 700), // last in Jan — use this balance + Tx("2025-03-05", -150, 550) // March — Feb omitted (no transactions) + ); + var svc = new DashboardService(db); + + var result = await svc.GetMonthlyBalancesAsync(2025); + + Assert.Equal(2, result.Count); + Assert.Equal(1, result[0].Month); + Assert.Equal(700m, result[0].ClosingBalance); + Assert.Equal(3, result[1].Month); + Assert.Equal(550m, result[1].ClosingBalance); + } + + // ── CumulativeSpending ─────────────────────────────────────────────────── + + [Fact] + public async Task CumulativeSpending_FillsGapsAndAccumulates() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900), + Tx("2025-03-03", -200, 700), // gap on day 2 + Tx("2025-03-03", -50, 650) // two transactions on same day + ); + var svc = new DashboardService(db); + + var result = await svc.GetCumulativeSpendingAsync(2025, 3); + + // Day 1: 100, Day 2: filled = 100, Day 3: 100 + 250 = 350 + Assert.Equal(3, result.Count); + Assert.Equal(1, result[0].Day); Assert.Equal(100m, result[0].CumulativeSpent); + Assert.Equal(2, result[1].Day); Assert.Equal(100m, result[1].CumulativeSpent); + Assert.Equal(3, result[2].Day); Assert.Equal(350m, result[2].CumulativeSpent); + } + + [Fact] + public async Task CumulativeSpending_ExcludesPositiveAmounts() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900), + Tx("2025-03-01", 500, 1400) // income — not counted + ); + var svc = new DashboardService(db); + + var result = await svc.GetCumulativeSpendingAsync(2025, 3); + + Assert.Single(result); + Assert.Equal(100m, result[0].CumulativeSpent); + } +} +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +dotnet test src/AccountTracking.Api.Tests/ 2>&1 | head -20 +``` + +Expected: build error — `DashboardService` not found. + +- [ ] **Step 3: Implement DashboardService** + +`src/AccountTracking.Api/Services/DashboardService.cs`: +```csharp +using AccountTracking.Api.Data; +using AccountTracking.Api.Models.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Services; + +public class DashboardService(AppDbContext db) +{ + public async Task GetSummaryAsync(int year, int? month) + { + var query = db.Transactions.Where(t => t.BookingDate.Year == year); + if (month.HasValue) + query = query.Where(t => t.BookingDate.Month == month.Value); + + var spent = await query + .Where(t => t.Amount < 0) + .SumAsync(t => (decimal?)t.Amount) ?? 0; + var income = await query + .Where(t => t.Amount > 0) + .SumAsync(t => (decimal?)t.Amount) ?? 0; + + return new SummaryDto(Math.Abs(spent), income); + } + + public async Task> GetSpendingByCategoryAsync(int year, int? month) + { + var query = db.Transactions + .Where(t => t.BookingDate.Year == year && t.Amount < 0); + + if (month.HasValue) + query = query.Where(t => t.BookingDate.Month == month.Value); + + return await query + .GroupBy(t => t.Category ?? "Nezatříděno") + .Select(g => new CategorySpendingDto(g.Key, Math.Abs(g.Sum(t => t.Amount)))) + .OrderByDescending(c => c.Total) + .ToListAsync(); + } + + public async Task> GetMonthlyBalancesAsync(int year) + { + // For each month: get the balance of the transaction with the latest booking_date + // (highest id as tiebreaker for same-date rows) + var rows = await db.Transactions + .Where(t => t.BookingDate.Year == year) + .GroupBy(t => t.BookingDate.Month) + .Select(g => new MonthlyBalanceDto( + g.Key, + g.OrderByDescending(t => t.BookingDate) + .ThenByDescending(t => t.Id) + .First().Balance)) + .OrderBy(r => r.Month) + .ToListAsync(); + + return rows; + } + + public async Task> GetCumulativeSpendingAsync(int year, int month) + { + // Get daily spending totals (absolute values, negative amounts only) + var dailySpending = await db.Transactions + .Where(t => t.BookingDate.Year == year + && t.BookingDate.Month == month + && t.Amount < 0) + .GroupBy(t => t.BookingDate.Day) + .Select(g => new { Day = g.Key, Spent = Math.Abs(g.Sum(t => t.Amount)) }) + .OrderBy(g => g.Day) + .ToListAsync(); + + if (dailySpending.Count == 0) + return []; + + // Fill gaps: for each day from day 1 to last day with spending, + // carry forward the previous cumulative value + var result = new List(); + decimal running = 0; + int lastDay = dailySpending.Max(d => d.Day); + var spendingByDay = dailySpending.ToDictionary(d => d.Day, d => d.Spent); + + for (int day = 1; day <= lastDay; day++) + { + if (spendingByDay.TryGetValue(day, out var spent)) + running += spent; + + // Only emit days up to and including last spending day + // but fill gaps so chart is continuous + if (day <= lastDay) + result.Add(new CumulativeSpendingDto(day, running)); + } + + return result; + } +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +dotnet test src/AccountTracking.Api.Tests/ -v normal +``` + +Expected: all tests pass (both CsvImportService and DashboardService suites). + +- [ ] **Step 5: Create DashboardController** + +`src/AccountTracking.Api/Controllers/DashboardController.cs`: +```csharp +using AccountTracking.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AccountTracking.Api.Controllers; + +[ApiController] +[Route("api/dashboard")] +[Authorize] +public class DashboardController : ControllerBase +{ + private readonly DashboardService _svc; + public DashboardController(DashboardService svc) => _svc = svc; + + [HttpGet("summary")] + public async Task Summary([FromQuery] int year, [FromQuery] int? month) + => Ok(await _svc.GetSummaryAsync(year, month)); + + [HttpGet("spending-by-category")] + public async Task SpendingByCategory([FromQuery] int year, [FromQuery] int? month) + => Ok(await _svc.GetSpendingByCategoryAsync(year, month)); + + [HttpGet("monthly-balances")] + public async Task MonthlyBalances([FromQuery] int year) + => Ok(await _svc.GetMonthlyBalancesAsync(year)); + + [HttpGet("cumulative-spending")] + public async Task CumulativeSpending([FromQuery] int year, [FromQuery] int month) + => Ok(await _svc.GetCumulativeSpendingAsync(year, month)); +} +``` + +- [ ] **Step 6: Build and verify** + +```bash +dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj +``` + +Expected: `Build succeeded.` + +- [ ] **Step 7: Commit** + +```bash +git add src/AccountTracking.Api/Services/DashboardService.cs \ + src/AccountTracking.Api/Controllers/DashboardController.cs \ + src/AccountTracking.Api.Tests/DashboardServiceTests.cs +git commit -m "feat: implement dashboard service and controller with tests (TDD)" +``` + +--- + +## Task 8: EF Core Migrations + +**Files:** +- Create: `src/AccountTracking.Api/Data/Migrations/` (generated) + +Migrations require a real MySQL connection. Use a local dev DB for this step. + +- [ ] **Step 1: Set up a local MySQL instance (if not running)** + +```bash +docker run -d --name finance-dev-db \ + -e MYSQL_ROOT_PASSWORD=devpass \ + -e MYSQL_DATABASE=accounttracking \ + -p 3306:3306 \ + mysql:8 +``` + +Wait ~15 seconds for MySQL to start. + +- [ ] **Step 2: Create the initial migration** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \ + dotnet ef migrations add InitialCreate \ + --project src/AccountTracking.Api \ + --output-dir Data/Migrations +``` + +Expected: `Done. To undo this action, use 'ef migrations remove'` + +- [ ] **Step 3: Verify the migration applies** + +```bash +DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \ + dotnet ef database update \ + --project src/AccountTracking.Api +``` + +Expected: `Done.` + +- [ ] **Step 4: Commit** + +```bash +git add src/AccountTracking.Api/Data/Migrations/ +git commit -m "feat: add initial EF Core database migration" +``` + +--- + +## Task 9: NSwag OpenAPI Spec Generation + +**Files:** +- Create: `src/AccountTracking.Api/nswag.json` +- Create (generated): `src/AccountTracking.Api/openapi.json` + +- [ ] **Step 1: Create nswag.json in the API project** + +`src/AccountTracking.Api/nswag.json`: +```json +{ + "runtime": "Net80", + "documentGenerator": { + "aspNetCoreToOpenApi": { + "project": "AccountTracking.Api.csproj", + "msBuildProjectExtensionsPath": null, + "configuration": "Debug", + "noBuild": true, + "verbose": false, + "outputType": "OpenApi3", + "output": "openapi.json" + } + }, + "codeGenerators": {} +} +``` + +- [ ] **Step 2: Build and generate the spec** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj \ + /p:GenerateOpenApiSpec=true +``` + +If the NSwag target fails due to DB (no DB connection in build env), run manually: + +```bash +cd src/AccountTracking.Api +dotnet build -c Debug +$(dotnet tool list --global | grep nswag | awk '{print $1}') run nswag.json +``` + +Or use the simpler approach — run the API and curl the swagger endpoint: + +```bash +# Terminal 1: start the API with InMemory DB (no DB_CONNECTION set) +cd src/AccountTracking.Api +dotnet run & +API_PID=$! +sleep 3 + +# Fetch and save the OpenAPI spec +curl -s http://localhost:5000/swagger/v1/swagger.json -o openapi.json + +kill $API_PID +``` + +- [ ] **Step 3: Verify openapi.json contains expected routes** + +```bash +cat src/AccountTracking.Api/openapi.json | grep -E '"\/api\/(auth|transactions|dashboard)' +``` + +Expected: lines containing `/api/auth/login`, `/api/transactions`, `/api/dashboard/summary` etc. + +- [ ] **Step 4: Add openapi.json to .gitignore or commit it** + +The spec should be committed so the frontend Dockerfile can use it without running the API: + +```bash +git add src/AccountTracking.Api/openapi.json src/AccountTracking.Api/nswag.json +git commit -m "feat: add NSwag config and commit generated openapi.json" +``` + +--- + +## Task 10: Frontend Scaffold + +**Files:** +- Create: `src/AccountTracking.Web/package.json` +- Create: `src/AccountTracking.Web/vite.config.ts` +- Create: `src/AccountTracking.Web/tsconfig.json` +- Create: `src/AccountTracking.Web/index.html` +- Create: `src/AccountTracking.Web/src/main.ts` +- Create: `src/AccountTracking.Web/src/App.vue` + +- [ ] **Step 1: Scaffold the Vue project** + +```bash +cd /mnt/c/Repos/Personal/account-tracking/src +npm create vue@latest AccountTracking.Web -- \ + --typescript --router --pinia --no-jsx --no-vitest --no-playwright --no-eslint +cd AccountTracking.Web +npm install +``` + +- [ ] **Step 2: Add Vuetify, ApexCharts and NSwag dependencies** + +```bash +npm install vuetify@^3.6.0 @mdi/font apexcharts vue3-apexcharts +npm install --save-dev nswag vite-plugin-vuetify +``` + +- [ ] **Step 3: Configure Vite with Vuetify plugin and API proxy** + +Replace `src/AccountTracking.Web/vite.config.ts`: +```ts +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vuetify from 'vite-plugin-vuetify' + +export default defineConfig({ + plugins: [ + vue(), + vuetify({ autoImport: true }), + ], + server: { + proxy: { + '/api': { + target: 'http://localhost:5000', + changeOrigin: true, + }, + }, + }, +}) +``` + +- [ ] **Step 4: Configure main.ts with Vuetify and Pinia** + +`src/AccountTracking.Web/src/main.ts`: +```ts +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { createVuetify } from 'vuetify' +import { aliases, mdi } from 'vuetify/iconsets/mdi' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import VueApexCharts from 'vue3-apexcharts' +import router from './router' +import App from './App.vue' + +import 'vuetify/styles' +import '@mdi/font/css/materialdesignicons.css' + +const vuetify = createVuetify({ + components, + directives, + icons: { defaultSet: 'mdi', aliases, sets: { mdi } }, + theme: { + defaultTheme: 'dark', + }, +}) + +createApp(App) + .use(createPinia()) + .use(router) + .use(vuetify) + .use(VueApexCharts) + .mount('#app') +``` + +- [ ] **Step 5: Create a minimal App.vue** + +`src/AccountTracking.Web/src/App.vue`: +```vue + +``` + +- [ ] **Step 6: Verify the dev server starts** + +```bash +cd src/AccountTracking.Web +npm run dev +``` + +Expected: `VITE v5.x ready` with a local URL. Open it — should show a blank dark page. Stop with Ctrl+C. + +- [ ] **Step 7: Commit** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +git add src/AccountTracking.Web/ +git commit -m "feat: scaffold Vue 3 + Vuetify + Pinia frontend" +``` + +--- + +## Task 11: NSwag TypeScript Client + +**Files:** +- Create: `src/AccountTracking.Web/nswag.json` +- Create (generated): `src/AccountTracking.Web/src/api/apiClient.ts` +- Create: `src/AccountTracking.Web/src/api/index.ts` + +- [ ] **Step 1: Copy openapi.json to the web project** + +```bash +cp src/AccountTracking.Api/openapi.json src/AccountTracking.Web/openapi.json +``` + +- [ ] **Step 2: Create nswag.json for TypeScript client generation** + +`src/AccountTracking.Web/nswag.json`: +```json +{ + "runtime": "Default", + "documentGenerator": { + "fromDocument": { + "url": "openapi.json" + } + }, + "codeGenerators": { + "openApiToTypeScriptClient": { + "className": "{controller}Client", + "template": "Fetch", + "typeScriptVersion": 5.0, + "dateTimeType": "String", + "nullValue": "Undefined", + "generateClientClasses": true, + "generateDtoTypes": true, + "operationGenerationMode": "MultipleClientsFromOperationId", + "markOptionalProperties": true, + "typeStyle": "Interface", + "enumStyle": "Enum", + "output": "src/api/apiClient.ts" + } + } +} +``` + +- [ ] **Step 3: Generate the TypeScript client** + +```bash +cd src/AccountTracking.Web +npx nswag run nswag.json +``` + +Expected: `src/api/apiClient.ts` created. + +- [ ] **Step 4: Create the authenticated API wrapper** + +`src/AccountTracking.Web/src/api/index.ts`: +```ts +import { useRouter } from 'vue-router' +import { AuthClient, TransactionsClient, DashboardClient } from './apiClient' + +// Custom fetch that injects the JWT and handles 401 +class AuthFetch { + fetch(url: RequestInfo, init?: RequestInit): Promise { + const token = localStorage.getItem('token') + const headers = new Headers(init?.headers) + if (token) headers.set('Authorization', `Bearer ${token}`) + + return fetch(url, { ...init, headers }).then((res) => { + if (res.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('expiresAt') + // Navigate to login — import router lazily to avoid circular dep + import('../router').then(({ default: router }) => router.push('/login')) + } + return res + }) + } +} + +const httpClient = new AuthFetch() + +// Base URL is empty: NSwag generates full paths like /api/auth/login from the OpenAPI spec. +// Verify after generation — if routes come out as /auth/login, change '' to '/api'. +export const authApi = new AuthClient('', httpClient) +export const transactionsApi = new TransactionsClient('', httpClient) +export const dashboardApi = new DashboardClient('', httpClient) +``` + +- [ ] **Step 5: Add generated file to .gitignore or commit** + +The generated `apiClient.ts` should be committed (it's the contract between front and back). Add `openapi.json` to `.gitignore` in the web dir (it's a copy): + +```bash +echo "openapi.json" >> src/AccountTracking.Web/.gitignore +``` + +- [ ] **Step 6: Verify TypeScript compiles** + +```bash +cd src/AccountTracking.Web +npm run build +``` + +Expected: build succeeds (or type errors from placeholder views — fix those in the next tasks). + +- [ ] **Step 7: Commit** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +git add src/AccountTracking.Web/nswag.json \ + src/AccountTracking.Web/src/api/ \ + src/AccountTracking.Web/.gitignore +git commit -m "feat: generate NSwag TypeScript client and authenticated API wrapper" +``` + +--- + +## Task 12: Auth Store, Router Guard & Login Page + +**Files:** +- Create: `src/AccountTracking.Web/src/stores/auth.ts` +- Modify: `src/AccountTracking.Web/src/router/index.ts` +- Create: `src/AccountTracking.Web/src/views/LoginView.vue` + +- [ ] **Step 1: Create auth Pinia store** + +`src/AccountTracking.Web/src/stores/auth.ts`: +```ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '../api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token')) + const expiresAt = ref(localStorage.getItem('expiresAt')) + + const isAuthenticated = computed(() => { + if (!token.value || !expiresAt.value) return false + return Date.now() < Date.parse(expiresAt.value) + }) + + async function login(username: string, password: string): Promise { + const response = await authApi.login({ username, password }) + token.value = response.token ?? null + expiresAt.value = response.expiresAt ?? null + if (token.value) localStorage.setItem('token', token.value) + if (expiresAt.value) localStorage.setItem('expiresAt', expiresAt.value) + } + + function logout() { + token.value = null + expiresAt.value = null + localStorage.removeItem('token') + localStorage.removeItem('expiresAt') + } + + return { token, isAuthenticated, login, logout } +}) +``` + +- [ ] **Step 2: Update router with navigation guard** + +`src/AccountTracking.Web/src/router/index.ts`: +```ts +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } }, + { path: '/', component: () => import('../views/DashboardView.vue') }, + { path: '/transactions', component: () => import('../views/TransactionsView.vue') }, + ], +}) + +router.beforeEach((to) => { + if (to.meta.public) return true + const auth = useAuthStore() + if (!auth.isAuthenticated) return '/login' + return true +}) + +export default router +``` + +- [ ] **Step 3: Create LoginView** + +`src/AccountTracking.Web/src/views/LoginView.vue`: +```vue + + + +``` + +- [ ] **Step 4: Create placeholder views so the app compiles** + +`src/AccountTracking.Web/src/views/DashboardView.vue`: +```vue + +``` + +`src/AccountTracking.Web/src/views/TransactionsView.vue`: +```vue + +``` + +- [ ] **Step 5: Build and verify** + +```bash +cd src/AccountTracking.Web && npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +git add src/AccountTracking.Web/src/stores/auth.ts \ + src/AccountTracking.Web/src/router/index.ts \ + src/AccountTracking.Web/src/views/ +git commit -m "feat: auth store, router guard and login page" +``` + +--- + +## Task 13: Dashboard Page + +**Files:** +- Create: `src/AccountTracking.Web/src/stores/dashPeriod.ts` +- Replace: `src/AccountTracking.Web/src/views/DashboardView.vue` +- Create: `src/AccountTracking.Web/src/components/YearSummary.vue` +- Create: `src/AccountTracking.Web/src/components/MonthSummary.vue` +- Create: `src/AccountTracking.Web/src/components/DonutChart.vue` +- Create: `src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue` +- Create: `src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue` +- Create: `src/AccountTracking.Web/src/components/UploadCsvButton.vue` + +- [ ] **Step 1: Create dashPeriod store** + +`src/AccountTracking.Web/src/stores/dashPeriod.ts`: +```ts +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useDashPeriodStore = defineStore('dashPeriod', () => { + const now = new Date() + const year = ref(now.getFullYear()) + const month = ref(now.getMonth() + 1) // 1-based + + const monthLabel = computed(() => { + const d = new Date(year.value, month.value - 1, 1) + return d.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' }) + }) + + function prevMonth() { + if (month.value === 1) { month.value = 12; year.value-- } + else month.value-- + } + + function nextMonth() { + if (month.value === 12) { month.value = 1; year.value++ } + else month.value++ + } + + function prevYear() { year.value-- } + function nextYear() { year.value++ } + + return { year, month, monthLabel, prevMonth, nextMonth, prevYear, nextYear } +}) +``` + +- [ ] **Step 2: Create UploadCsvButton component** + +`src/AccountTracking.Web/src/components/UploadCsvButton.vue`: +```vue + + + +``` + +- [ ] **Step 3: Create DonutChart component** + +`src/AccountTracking.Web/src/components/DonutChart.vue`: +```vue + + + +``` + +- [ ] **Step 4: Create MonthlyBalancesChart component** + +`src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue`: +```vue + + + +``` + +- [ ] **Step 5: Create CumulativeSpendingChart component** + +`src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue`: +```vue + + + +``` + +- [ ] **Step 6: Create YearSummary component** + +`src/AccountTracking.Web/src/components/YearSummary.vue`: +```vue + + + +``` + +- [ ] **Step 7: Create MonthSummary component** + +`src/AccountTracking.Web/src/components/MonthSummary.vue`: +```vue + + + +``` + +- [ ] **Step 8: Create DashboardView** + +`src/AccountTracking.Web/src/views/DashboardView.vue`: +```vue + + + +``` + +- [ ] **Step 9: Build and verify** + +```bash +cd src/AccountTracking.Web && npm run build +``` + +Expected: build succeeds with no type errors. + +- [ ] **Step 10: Commit** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +git add src/AccountTracking.Web/src/ +git commit -m "feat: dashboard page with year/month summary, charts and CSV upload" +``` + +--- + +## Task 14: Transactions Page + +**Files:** +- Replace: `src/AccountTracking.Web/src/views/TransactionsView.vue` +- Create: `src/AccountTracking.Web/src/stores/txPeriod.ts` + +- [ ] **Step 1: Create txPeriod store** + +`src/AccountTracking.Web/src/stores/txPeriod.ts`: +```ts +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useTxPeriodStore = defineStore('txPeriod', () => { + const now = new Date() + const year = ref(now.getFullYear()) + const month = ref(null) + + return { year, month } +}) +``` + +- [ ] **Step 2: Create TransactionsView** + +`src/AccountTracking.Web/src/views/TransactionsView.vue`: +```vue + + + +``` + +- [ ] **Step 3: Add nav link from Dashboard to Transactions** + +In `DashboardView.vue`, add a navigation button next to logout: +```vue + +Transakce +``` + +- [ ] **Step 4: Build and verify** + +```bash +cd src/AccountTracking.Web && npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/c/Repos/Personal/account-tracking +git add src/AccountTracking.Web/src/views/TransactionsView.vue \ + src/AccountTracking.Web/src/stores/txPeriod.ts \ + src/AccountTracking.Web/src/views/DashboardView.vue +git commit -m "feat: transactions page with server-side filtering and pagination" +``` + +--- + +## Task 15: Docker & Deployment + +**Files:** +- Create: `src/AccountTracking.Api/Dockerfile` +- Create: `src/AccountTracking.Web/Dockerfile` +- Create: `src/AccountTracking.Web/nginx.conf` +- Create: `docker-compose.yml` +- Create: `.env.example` + +- [ ] **Step 1: Create API Dockerfile** + +`src/AccountTracking.Api/Dockerfile`: +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY AccountTracking.Api.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 5000 +ENV ASPNETCORE_URLS=http://+:5000 +ENTRYPOINT ["dotnet", "AccountTracking.Api.dll"] +``` + +- [ ] **Step 2: Create web Nginx config** + +`src/AccountTracking.Web/nginx.conf`: +```nginx +server { + listen 3000; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Step 3: Create web Dockerfile** + +`src/AccountTracking.Web/Dockerfile`: +```dockerfile +# Stage 1: build .NET API to get openapi.json +# Build context is repo root (set in docker-compose.yml), so paths are relative to repo root +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS api-build +WORKDIR /src +COPY src/AccountTracking.Api/AccountTracking.Api.csproj ./AccountTracking.Api/ +RUN dotnet restore AccountTracking.Api/AccountTracking.Api.csproj +COPY src/AccountTracking.Api/ ./AccountTracking.Api/ +RUN dotnet build AccountTracking.Api/AccountTracking.Api.csproj -c Release + +# Stage 2: build Vue frontend with NSwag client +FROM node:20-alpine AS web-build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY --from=api-build /src/AccountTracking.Api/openapi.json ./openapi.json +COPY . . +RUN npx nswag run nswag.json +RUN npm run build + +# Stage 3: serve with Nginx +FROM nginx:alpine +COPY --from=web-build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +``` + +Note: the web Dockerfile is built from the repo root (so it can COPY from the sibling Api project). The `docker-compose.yml` sets `context` accordingly. + +- [ ] **Step 4: Create docker-compose.yml** + +`docker-compose.yml`: +```yaml +version: '3.8' + +services: + db: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: accounttracking + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + api: + build: + context: src/AccountTracking.Api + dockerfile: Dockerfile + environment: + DB_CONNECTION: "Server=db;Database=accounttracking;User=root;Password=${DB_ROOT_PASSWORD};" + JWT_SECRET: ${JWT_SECRET} + APP_USERNAME: ${APP_USERNAME} + APP_PASSWORD: ${APP_PASSWORD} + ALLOWED_ORIGIN: ${ALLOWED_ORIGIN} + ports: + - "5000:5000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + web: + build: + context: . + dockerfile: src/AccountTracking.Web/Dockerfile + ports: + - "3000:3000" + restart: unless-stopped + +volumes: + db_data: +``` + +- [ ] **Step 5: Create .env.example** + +`.env.example`: +``` +# MySQL root password (used by both db and api services) +DB_ROOT_PASSWORD=changeme + +# JWT signing secret: generate with: openssl rand -base64 32 +JWT_SECRET=CHANGE_ME_base64_encoded_32_bytes + +# Single-user login credentials +APP_USERNAME=admin +# bcrypt hash of your password — generate with: htpasswd -bnBC 10 "" yourpassword | tr -d ':' +APP_PASSWORD=$2b$10$CHANGE_ME_bcrypt_hash + +# Frontend origin for CORS (your home server URL) +ALLOWED_ORIGIN=http://finance.home +``` + +- [ ] **Step 6: Create actual .env from the example** + +```bash +cp .env.example .env +# Edit .env with your real values: +# 1. Generate JWT_SECRET: openssl rand -base64 32 +# 2. Generate APP_PASSWORD bcrypt hash (e.g. with a small Node script or Python): +# node -e "const b=require('bcryptjs');console.log(b.hashSync('yourpassword',10))" +``` + +- [ ] **Step 7: Build and start all services** + +```bash +docker compose build +docker compose up -d +docker compose logs -f api +``` + +Expected: API logs show "Now listening on: http://[::]:5000" after migrations run. + +- [ ] **Step 8: Verify the app works end-to-end** + +```bash +# Test login +curl -s -X POST http://localhost:5000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"yourpassword"}' | jq . +``` + +Expected: `{"token":"eyJ...","expiresAt":"..."}`. + +Open `http://localhost:3000` in a browser — should show the login page. + +- [ ] **Step 9: Add .env to .gitignore (if not already)** + +```bash +grep -q '^\.env$' .gitignore || echo '.env' >> .gitignore +``` + +- [ ] **Step 10: Commit** + +```bash +git add src/AccountTracking.Api/Dockerfile \ + src/AccountTracking.Web/Dockerfile \ + src/AccountTracking.Web/nginx.conf \ + docker-compose.yml \ + .env.example \ + .gitignore +git commit -m "feat: Docker build and docker-compose deployment config" +``` + +--- + +## Summary + +| Task | Deliverable | +|------|-------------| +| 1 | .NET solution, projects, NuGet packages | +| 2 | EF Core entities and DTOs | +| 3 | Program.cs with auth, CORS, DB, Polly | +| 4 | JWT auth endpoint | +| 5 | CSV import service (TDD — 8 tests) | +| 6 | Transactions REST API | +| 7 | Dashboard service + controller (TDD — 9 tests) | +| 8 | EF Core migrations | +| 9 | NSwag OpenAPI spec generation | +| 10 | Vue 3 + Vuetify frontend scaffold | +| 11 | NSwag TypeScript client + authenticated wrapper | +| 12 | Auth store, router guard, login page | +| 13 | Dashboard page (period store, year/month columns, charts, CSV upload) | +| 14 | Transactions page | +| 15 | Dockerfiles, docker-compose, .env |