diff --git a/src/AccountTracking.Api/Program.cs b/src/AccountTracking.Api/Program.cs index 9327932..27dd1cc 100644 --- a/src/AccountTracking.Api/Program.cs +++ b/src/AccountTracking.Api/Program.cs @@ -1,34 +1,116 @@ +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); -// Add services to the container. +// ── 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(); -// Configure the HTTP request pipeline. - -app.UseHttpsRedirection(); - -var summaries = new[] +// ── Migrate DB (skip when running without a real DB) ─────────────────────── +if (!string.IsNullOrEmpty(connectionString)) { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; + 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...")); -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}); + 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(); -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} +// Accessible by tests +public partial class Program { } + +// Credential container registered in DI +public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes); diff --git a/src/AccountTracking.Api/Services/CsvImportService.cs b/src/AccountTracking.Api/Services/CsvImportService.cs new file mode 100644 index 0000000..3313096 --- /dev/null +++ b/src/AccountTracking.Api/Services/CsvImportService.cs @@ -0,0 +1,144 @@ +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 }; +} diff --git a/src/AccountTracking.Api/Services/DashboardService.cs b/src/AccountTracking.Api/Services/DashboardService.cs new file mode 100644 index 0000000..5032516 --- /dev/null +++ b/src/AccountTracking.Api/Services/DashboardService.cs @@ -0,0 +1,91 @@ +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; + + if (day <= lastDay) + result.Add(new CumulativeSpendingDto(day, running)); + } + + return result; + } +}