From 2ace39c5440b559f8f373f6989f9c49b19d74af9 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:16:39 -0500 Subject: [PATCH] Implement Phase 2: end-to-end import system Complete the import flow from file upload through duplicate resolution to transaction creation. Previously, ResolveDuplicatesAsync was a stub that only incremented counters, FileWatcherService detected files but didn't process them, and ImportsView.vue ignored the API response. Backend: - Add ParsedDataJson column to ImportBatch to persist parsed data between upload and confirm steps - Rewrite ImportService.ResolveDuplicatesAsync to deserialize parsed transactions and create/merge/skip based on user decisions, with payee matching, fingerprinting, and account balance updates - Add GetBatchesAsync for import history - Complete FileWatcherService: wait for file ready, resolve account by filename, call UploadAsync, send SignalR notification, archive file - Add GET /api/imports/batches endpoint Frontend: - Create imports API service (frontend/src/services/imports.ts) - Add ImportBatch, DuplicateResolution types - Rewrite ImportsView.vue with 3-step Vuetify stepper wizard: upload, review with duplicate handling, completion summary, plus import history table Co-Authored-By: Claude Opus 4.6 --- frontend/src/services/imports.ts | 21 + frontend/src/types/index.ts | 26 + frontend/src/views/imports/ImportsView.vue | 338 ++++- .../Controllers/ImportsController.cs | 10 +- src/Purrse.Api/Services/FileWatcherService.cs | 130 +- src/Purrse.Api/Services/ImportService.cs | 170 ++- .../Interfaces/Services/IImportService.cs | 6 +- src/Purrse.Core/Models/ImportBatch.cs | 1 + .../ImportBatchConfiguration.cs | 1 + ...836_AddParsedDataToImportBatch.Designer.cs | 1149 +++++++++++++++++ ...260208155836_AddParsedDataToImportBatch.cs | 28 + .../PurrseDbContextModelSnapshot.cs | 3 + 12 files changed, 1844 insertions(+), 39 deletions(-) create mode 100644 frontend/src/services/imports.ts create mode 100644 src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.Designer.cs create mode 100644 src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.cs diff --git a/frontend/src/services/imports.ts b/frontend/src/services/imports.ts new file mode 100644 index 0000000..221924e --- /dev/null +++ b/frontend/src/services/imports.ts @@ -0,0 +1,21 @@ +import api from './api' +import type { ImportUploadResponse, ImportBatch, ResolveDuplicatesRequest } from '@/types' + +export const importsApi = { + upload: (accountId: string, file: File) => { + const formData = new FormData() + formData.append('file', file) + return api.post(`/imports/upload?accountId=${accountId}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + + confirmBatch: (batchId: string) => + api.post(`/imports/batches/${batchId}/confirm`), + + resolveDuplicates: (batchId: string, request: ResolveDuplicatesRequest) => + api.post(`/imports/batches/${batchId}/resolve-duplicates`, request), + + getBatches: () => + api.get('/imports/batches'), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 88430e4..5d3e763 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -235,3 +235,29 @@ export interface DuplicateMatch { confidence: number matchReason: string } + +export interface ImportBatch { + id: string + accountId: string + accountName: string + fileName: string + fileType: string + totalCount: number + importedCount: number + duplicateCount: number + skippedCount: number + mergedCount: number + status: string + importedAt: string +} + +export type DuplicateAction = 'Import' | 'Skip' | 'Merge' + +export interface DuplicateResolution { + index: number + action: DuplicateAction +} + +export interface ResolveDuplicatesRequest { + resolutions: DuplicateResolution[] +} diff --git a/frontend/src/views/imports/ImportsView.vue b/frontend/src/views/imports/ImportsView.vue index 77550c0..d3f81af 100644 --- a/frontend/src/views/imports/ImportsView.vue +++ b/frontend/src/views/imports/ImportsView.vue @@ -1,42 +1,348 @@ diff --git a/src/Purrse.Api/Controllers/ImportsController.cs b/src/Purrse.Api/Controllers/ImportsController.cs index 8056c09..90e8d51 100644 --- a/src/Purrse.Api/Controllers/ImportsController.cs +++ b/src/Purrse.Api/Controllers/ImportsController.cs @@ -32,9 +32,13 @@ public class ImportsController : ControllerBase => Ok(await _importService.ConfirmBatchAsync(UserId, batchId)); [HttpPost("batches/{batchId}/resolve-duplicates")] - public async Task ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request) + public async Task> ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request) { - await _importService.ResolveDuplicatesAsync(UserId, batchId, request); - return NoContent(); + var result = await _importService.ResolveDuplicatesAsync(UserId, batchId, request); + return Ok(result); } + + [HttpGet("batches")] + public async Task>> GetBatches() + => Ok(await _importService.GetBatchesAsync(UserId)); } diff --git a/src/Purrse.Api/Services/FileWatcherService.cs b/src/Purrse.Api/Services/FileWatcherService.cs index 6d1669a..bdf82b8 100644 --- a/src/Purrse.Api/Services/FileWatcherService.cs +++ b/src/Purrse.Api/Services/FileWatcherService.cs @@ -1,16 +1,30 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Purrse.Api.Hubs; +using Purrse.Core.Interfaces.Services; +using Purrse.Data; + namespace Purrse.Api.Services; public class FileWatcherService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IHubContext _hubContext; private readonly string _importsPath; private FileSystemWatcher? _watcher; - public FileWatcherService(IServiceScopeFactory scopeFactory, ILogger logger, IConfiguration config) + private static readonly string[] SupportedExtensions = { ".ofx", ".qfx", ".csv", ".qif" }; + + public FileWatcherService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration config, + IHubContext hubContext) { _scopeFactory = scopeFactory; _logger = logger; + _hubContext = hubContext; _importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports"); } @@ -19,10 +33,15 @@ public class FileWatcherService : BackgroundService if (!Directory.Exists(_importsPath)) Directory.CreateDirectory(_importsPath); + var archivePath = Path.Combine(_importsPath, "archive"); + if (!Directory.Exists(archivePath)) + Directory.CreateDirectory(archivePath); + _watcher = new FileSystemWatcher(_importsPath) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, - EnableRaisingEvents = true + EnableRaisingEvents = true, + IncludeSubdirectories = false }; _watcher.Created += OnFileCreated; @@ -38,10 +57,109 @@ public class FileWatcherService : BackgroundService return Task.CompletedTask; } - private void OnFileCreated(object sender, FileSystemEventArgs e) + private async void OnFileCreated(object sender, FileSystemEventArgs e) { - _logger.LogInformation("New file detected: {FileName}", e.Name); - // Files dropped here can be processed by the import service - // A SignalR notification would be sent to connected clients + try + { + var extension = Path.GetExtension(e.FullPath).ToLowerInvariant(); + if (!SupportedExtensions.Contains(extension)) + { + _logger.LogDebug("Ignoring non-supported file: {FileName}", e.Name); + return; + } + + _logger.LogInformation("New import file detected: {FileName}", e.Name); + + // Wait for file to finish writing + await WaitForFileReady(e.FullPath); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var importService = scope.ServiceProvider.GetRequiredService(); + + // Find the target account by matching filename to account name + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(e.Name); + var account = await db.Accounts + .Where(a => a.IsActive && !a.IsClosed) + .FirstOrDefaultAsync(a => a.Name.ToLower() == fileNameWithoutExt!.ToLower()); + + // Fall back to first active account + account ??= await db.Accounts + .Where(a => a.IsActive && !a.IsClosed) + .OrderBy(a => a.SortOrder) + .FirstOrDefaultAsync(); + + if (account == null) + { + _logger.LogWarning("No active account found for file import: {FileName}", e.Name); + return; + } + + _logger.LogInformation("Importing {FileName} into account {AccountName} ({AccountId})", + e.Name, account.Name, account.Id); + + await using var fileStream = new FileStream(e.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var result = await importService.UploadAsync(account.UserId, account.Id, e.Name!, fileStream); + + _logger.LogInformation( + "File import complete: {FileName} - {Total} transactions ({New} new, {Duplicates} duplicates)", + e.Name, result.TotalTransactions, result.NewTransactions, result.PossibleDuplicates); + + // Send SignalR notification to the account owner + await _hubContext.Clients.Group(account.UserId.ToString()).SendAsync("FileImportReady", new + { + batchId = result.BatchId, + fileName = e.Name, + accountId = account.Id, + accountName = account.Name, + totalTransactions = result.TotalTransactions, + newTransactions = result.NewTransactions, + possibleDuplicates = result.PossibleDuplicates + }); + + // Archive the processed file + ArchiveFile(e.FullPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing imported file: {FileName}", e.Name); + } + } + + private static async Task WaitForFileReady(string filePath, int maxRetries = 30, int delayMs = 500) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None); + return; // File is ready + } + catch (IOException) + { + await Task.Delay(delayMs); + } + } + + throw new IOException($"File {filePath} was not ready after {maxRetries * delayMs}ms"); + } + + private void ArchiveFile(string filePath) + { + try + { + var archivePath = Path.Combine(_importsPath, "archive"); + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + var fileName = Path.GetFileName(filePath); + var archiveFileName = $"{timestamp}_{fileName}"; + var destination = Path.Combine(archivePath, archiveFileName); + + File.Move(filePath, destination); + _logger.LogInformation("Archived import file to: {ArchivePath}", destination); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to archive file: {FilePath}", filePath); + } } } diff --git a/src/Purrse.Api/Services/ImportService.cs b/src/Purrse.Api/Services/ImportService.cs index 81073d2..60e41ea 100644 --- a/src/Purrse.Api/Services/ImportService.cs +++ b/src/Purrse.Api/Services/ImportService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Purrse.Core.DTOs; using Purrse.Core.Enums; @@ -5,6 +6,7 @@ using Purrse.Core.Helpers; using Purrse.Core.Interfaces.Services; using Purrse.Core.Models; using Purrse.Plugins.Abstractions; +using Purrse.Plugins.Abstractions.Models; using Purrse.Data; namespace Purrse.Api.Services; @@ -46,7 +48,8 @@ public class ImportService : IImportService FileName = fileName, FileType = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(), TotalCount = parseResult.Transactions.Count, - Status = ImportStatus.Pending + Status = ImportStatus.Pending, + ParsedDataJson = JsonSerializer.Serialize(parseResult.Transactions) }; var previews = new List(); @@ -72,37 +75,178 @@ public class ImportService : IImportService public async Task ConfirmBatchAsync(Guid userId, Guid batchId) { - var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) + var batch = await _db.ImportBatches + .Include(b => b.Account) + .FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) ?? throw new KeyNotFoundException("Import batch not found"); batch.Status = ImportStatus.Confirmed; await _db.SaveChangesAsync(); - return new ImportBatchResponse(batch.Id, batch.FileName, batch.FileType, batch.TotalCount, - batch.ImportedCount, batch.DuplicateCount, batch.SkippedCount, batch.Status.ToString(), batch.ImportedAt); + return MapBatchResponse(batch); } - public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request) + public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request) { - var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) + var batch = await _db.ImportBatches + .Include(b => b.Account) + .FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) ?? throw new KeyNotFoundException("Import batch not found"); - foreach (var resolution in request.Resolutions) + if (string.IsNullOrEmpty(batch.ParsedDataJson)) + throw new InvalidOperationException("No parsed data available for this batch. It may have already been processed."); + + var parsedTransactions = JsonSerializer.Deserialize>(batch.ParsedDataJson) + ?? throw new InvalidOperationException("Failed to deserialize parsed data"); + + var resolutionMap = request.Resolutions.ToDictionary(r => r.Index, r => r.Action); + + int importedCount = 0; + int skippedCount = 0; + int mergedCount = 0; + + for (int i = 0; i < parsedTransactions.Count; i++) { - switch (resolution.Action) + var parsed = parsedTransactions[i]; + var action = resolutionMap.GetValueOrDefault(i, DuplicateAction.Import); + + switch (action) { - case DuplicateAction.Skip: - batch.SkippedCount++; - break; case DuplicateAction.Import: - batch.ImportedCount++; + await CreateTransaction(userId, batch, parsed); + importedCount++; break; + + case DuplicateAction.Skip: + skippedCount++; + break; + case DuplicateAction.Merge: - batch.DuplicateCount++; + var merged = await MergeTransaction(batch.AccountId, parsed); + if (merged) + mergedCount++; + else + { + // If no existing transaction found to merge with, import instead + await CreateTransaction(userId, batch, parsed); + importedCount++; + } break; } } + batch.ImportedCount = importedCount; + batch.SkippedCount = skippedCount; + batch.DuplicateCount = mergedCount; + batch.ParsedDataJson = null; + batch.Status = ImportStatus.Confirmed; + await _db.SaveChangesAsync(); + + return MapBatchResponse(batch); + } + + public async Task> GetBatchesAsync(Guid userId) + { + return await _db.ImportBatches + .Include(b => b.Account) + .Where(b => b.UserId == userId) + .OrderByDescending(b => b.ImportedAt) + .Select(b => new ImportBatchResponse( + b.Id, + b.AccountId, + b.Account.Name, + b.FileName, + b.FileType, + b.TotalCount, + b.ImportedCount, + b.DuplicateCount, + b.SkippedCount, + 0, + b.Status.ToString(), + b.ImportedAt)) + .ToListAsync(); + } + + private async Task CreateTransaction(Guid userId, ImportBatch batch, ParsedTransaction parsed) + { + var fingerprint = TransactionFingerprint.Generate(batch.AccountId, parsed.Date, parsed.Amount, parsed.FitId); + + var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty); + + var transaction = new Transaction + { + Id = Guid.NewGuid(), + AccountId = batch.AccountId, + Date = parsed.Date, + Amount = parsed.Amount, + PayeeId = matchedPayee?.Id, + PayeeName = parsed.PayeeName, + CategoryId = matchedPayee?.DefaultCategoryId, + Memo = parsed.Memo, + ReferenceNumber = parsed.ReferenceNumber, + CheckNumber = parsed.CheckNumber, + FitId = parsed.FitId, + Fingerprint = fingerprint, + Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit, + Status = TransactionStatus.Uncleared, + ImportBatchId = batch.Id + }; + + _db.Transactions.Add(transaction); + + // Update account balance + batch.Account.Balance += parsed.Amount; + batch.Account.UpdatedAt = DateTime.UtcNow; + } + + private async Task MergeTransaction(Guid accountId, ParsedTransaction parsed) + { + var duplicate = await _duplicateService.FindDuplicateAsync( + accountId, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.FitId, parsed.CheckNumber); + + if (duplicate == null) + return false; + + var existing = await _db.Transactions.FindAsync(duplicate.ExistingTransactionId); + if (existing == null) + return false; + + // Fill in blank fields without overwriting user-edited data + if (string.IsNullOrEmpty(existing.Memo) && !string.IsNullOrEmpty(parsed.Memo)) + existing.Memo = parsed.Memo; + + if (string.IsNullOrEmpty(existing.FitId) && !string.IsNullOrEmpty(parsed.FitId)) + existing.FitId = parsed.FitId; + + if (string.IsNullOrEmpty(existing.CheckNumber) && !string.IsNullOrEmpty(parsed.CheckNumber)) + existing.CheckNumber = parsed.CheckNumber; + + if (string.IsNullOrEmpty(existing.ReferenceNumber) && !string.IsNullOrEmpty(parsed.ReferenceNumber)) + existing.ReferenceNumber = parsed.ReferenceNumber; + + if (string.IsNullOrEmpty(existing.Fingerprint)) + existing.Fingerprint = TransactionFingerprint.Generate(accountId, parsed.Date, parsed.Amount, parsed.FitId); + + existing.UpdatedAt = DateTime.UtcNow; + + return true; + } + + private static ImportBatchResponse MapBatchResponse(ImportBatch batch) + { + return new ImportBatchResponse( + batch.Id, + batch.AccountId, + batch.Account?.Name ?? string.Empty, + batch.FileName, + batch.FileType, + batch.TotalCount, + batch.ImportedCount, + batch.DuplicateCount, + batch.SkippedCount, + batch.DuplicateCount, + batch.Status.ToString(), + batch.ImportedAt); } } diff --git a/src/Purrse.Core/Interfaces/Services/IImportService.cs b/src/Purrse.Core/Interfaces/Services/IImportService.cs index ca052e9..3674827 100644 --- a/src/Purrse.Core/Interfaces/Services/IImportService.cs +++ b/src/Purrse.Core/Interfaces/Services/IImportService.cs @@ -6,17 +6,21 @@ public interface IImportService { Task UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream); Task ConfirmBatchAsync(Guid userId, Guid batchId); - Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request); + Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request); + Task> GetBatchesAsync(Guid userId); } public record ImportBatchResponse( Guid Id, + Guid AccountId, + string AccountName, string FileName, string FileType, int TotalCount, int ImportedCount, int DuplicateCount, int SkippedCount, + int MergedCount, string Status, DateTime ImportedAt ); diff --git a/src/Purrse.Core/Models/ImportBatch.cs b/src/Purrse.Core/Models/ImportBatch.cs index edda8cc..0c747d6 100644 --- a/src/Purrse.Core/Models/ImportBatch.cs +++ b/src/Purrse.Core/Models/ImportBatch.cs @@ -14,6 +14,7 @@ public class ImportBatch public int DuplicateCount { get; set; } public int SkippedCount { get; set; } public ImportStatus Status { get; set; } = ImportStatus.Pending; + public string? ParsedDataJson { get; set; } public DateTime ImportedAt { get; set; } = DateTime.UtcNow; public User User { get; set; } = null!; diff --git a/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs b/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs index 2099525..d7751b3 100644 --- a/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs +++ b/src/Purrse.Data/Configurations/ImportBatchConfiguration.cs @@ -12,6 +12,7 @@ public class ImportBatchConfiguration : IEntityTypeConfiguration builder.HasKey(b => b.Id); builder.Property(b => b.FileName).HasMaxLength(500).IsRequired(); builder.Property(b => b.FileType).HasMaxLength(50).IsRequired(); + builder.Property(b => b.ParsedDataJson).HasColumnType("text"); builder.HasOne(b => b.User) .WithMany() diff --git a/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.Designer.cs b/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.Designer.cs new file mode 100644 index 0000000..005baf7 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.Designer.cs @@ -0,0 +1,1149 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Purrse.Data; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + [DbContext(typeof(PurrseDbContext))] + [Migration("20260208155836_AddParsedDataToImportBatch")] + partial class AddParsedDataToImportBatch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Balance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditLimit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Institution") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LoanDetailId") + .HasColumnType("uuid"); + + b.Property("PaymentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentNumber") + .HasColumnType("integer"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RemainingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("LoanDetailId"); + + b.ToTable("amortization_entries", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique(); + + b.ToTable("budgets", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("BudgetedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("budget_items", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("UserId", "Name", "ParentId") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DuplicateCount") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedCount") + .HasColumnType("integer"); + + b.Property("ParsedDataJson") + .HasColumnType("text"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("UserId"); + + b.ToTable("import_batches", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AsOfDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostBasis") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.Property("Shares") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SecurityId"); + + b.ToTable("investment_holdings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExtraPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("MaturityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MonthlyPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginalBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TermMonths") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("loan_details", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultCategoryId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCategoryId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("payees", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Alias"); + + b.HasIndex("PayeeId"); + + b.ToTable("payee_aliases", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginRegistrationId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("PluginRegistrationId", "Key") + .IsUnique(); + + b.ToTable("plugin_configurations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryAssembly") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PluginId") + .IsUnique(); + + b.ToTable("plugin_registrations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("StatementBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StatementDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("reconciliations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AutoPost") + .HasColumnType("boolean"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextDueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReminderDaysBefore") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("UserId"); + + b.ToTable("scheduled_transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol") + .IsUnique(); + + b.ToTable("securities", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SecurityId", "Date") + .IsUnique(); + + b.ToTable("security_prices", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CheckNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FitId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ImportBatchId") + .HasColumnType("uuid"); + + b.Property("IsVoid") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciliationId") + .HasColumnType("uuid"); + + b.Property("ReferenceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TransferTransactionId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Date"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("FitId"); + + b.HasIndex("ImportBatchId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("ReconciliationId"); + + b.HasIndex("TransferTransactionId") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TransactionId"); + + b.ToTable("transaction_splits", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") + .WithMany("AmortizationEntries") + .HasForeignKey("LoanDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoanDetail"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.HasOne("Purrse.Core.Models.Budget", "Budget") + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("BudgetItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.HasOne("Purrse.Core.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("InvestmentHoldings") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Holdings") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithOne("LoanDetail") + .HasForeignKey("Purrse.Core.Models.LoanDetail", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.HasOne("Purrse.Core.Models.Category", "DefaultCategory") + .WithMany() + .HasForeignKey("DefaultCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DefaultCategory"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Aliases") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.HasOne("Purrse.Core.Models.PluginRegistration", "PluginRegistration") + .WithMany("Configurations") + .HasForeignKey("PluginRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PluginRegistration"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Reconciliations") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("Payee"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Prices") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.ImportBatch", "ImportBatch") + .WithMany("Transactions") + .HasForeignKey("ImportBatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Transactions") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Reconciliation", "Reconciliation") + .WithMany("Transactions") + .HasForeignKey("ReconciliationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "TransferTransaction") + .WithOne() + .HasForeignKey("Purrse.Core.Models.Transaction", "TransferTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ImportBatch"); + + b.Navigation("Payee"); + + b.Navigation("Reconciliation"); + + b.Navigation("TransferTransaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "Transaction") + .WithMany("Splits") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Navigation("InvestmentHoldings"); + + b.Navigation("LoanDetail"); + + b.Navigation("Reconciliations"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Navigation("BudgetItems"); + + b.Navigation("Children"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Navigation("AmortizationEntries"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Navigation("Aliases"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Navigation("Holdings"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Navigation("Splits"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.cs b/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.cs new file mode 100644 index 0000000..b11353e --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208155836_AddParsedDataToImportBatch.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddParsedDataToImportBatch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParsedDataJson", + table: "import_batches", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ParsedDataJson", + table: "import_batches"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index 1f4d073..dd0eb0f 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -249,6 +249,9 @@ namespace Purrse.Data.Migrations b.Property("ImportedCount") .HasColumnType("integer"); + b.Property("ParsedDataJson") + .HasColumnType("text"); + b.Property("SkippedCount") .HasColumnType("integer");