# 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 Finance Tracker {{ error }} Přihlásit se ``` - [ ] **Step 4: Create placeholder views so the app compiles** `src/AccountTracking.Web/src/views/DashboardView.vue`: ```vue Dashboard ``` `src/AccountTracking.Web/src/views/TransactionsView.vue`: ```vue Transactions ``` - [ ] **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 Nahrát CSV {{ snackbarText }} ``` - [ ] **Step 3: Create DonutChart component** `src/AccountTracking.Web/src/components/DonutChart.vue`: ```vue Žádné výdaje {{ item.label }} {{ item.total.toLocaleString('cs-CZ') }} Kč ``` - [ ] **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 Žádné výdaje ``` - [ ] **Step 6: Create YearSummary component** `src/AccountTracking.Web/src/components/YearSummary.vue`: ```vue {{ period.year }} VÝDAJE {{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč PŘÍJMY {{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč VÝDAJE DLE KATEGORIÍ MĚSÍČNÍ ZŮSTATKY ``` - [ ] **Step 7: Create MonthSummary component** `src/AccountTracking.Web/src/components/MonthSummary.vue`: ```vue {{ period.monthLabel }} VÝDAJE {{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč PŘÍJMY {{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč VÝDAJE DLE KATEGORIÍ KUMULATIVNÍ VÝDAJE ``` - [ ] **Step 8: Create DashboardView** `src/AccountTracking.Web/src/views/DashboardView.vue`: ```vue Finance Tracker ``` - [ ] **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 Finance Tracker — Transakce Dashboard {{ item.bookingDate }} {{ item.counterPartyName }} {{ item.message }} {{ formatAmount(item.amount) }} {{ formatAmount(item.balance) }} ``` - [ ] **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 |