diff --git a/frontend/src/services/aiCategorization.ts b/frontend/src/services/aiCategorization.ts new file mode 100644 index 0000000..f528824 --- /dev/null +++ b/frontend/src/services/aiCategorization.ts @@ -0,0 +1,16 @@ +import api from './api' +import type { AiCategorizationSettings, OllamaConnectionTestResult, ClassifyUncategorizedResult } from '@/types' + +export const aiCategorizationApi = { + getSettings: () => + api.get('/ai-categorization/settings'), + + updateSettings: (data: AiCategorizationSettings) => + api.put('/ai-categorization/settings', data), + + testConnection: () => + api.post('/ai-categorization/test-connection'), + + classifyUncategorized: (data: { accountId?: string; startDate?: string; endDate?: string; searchText?: string; status?: string }) => + api.post('/ai-categorization/classify', data), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index df9ffa7..930585c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -485,6 +485,28 @@ export interface CreateLinkTokenResponse { expiration: string } +// AI Categorization +export interface AiCategorizationSettings { + isEnabled: boolean + ollamaUrl: string + modelName: string + confidenceThreshold: number + timeoutSeconds: number + updatePayeeDefaults: boolean +} + +export interface OllamaConnectionTestResult { + success: boolean + errorMessage: string | null + availableModels: string[] | null +} + +export interface ClassifyUncategorizedResult { + totalUncategorized: number + classified: number + skipped: number +} + // Split form helper export interface SplitFormItem { categoryId: string | null diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index db17678..7e38517 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -4,6 +4,7 @@ Profile Bank Connections Sync History + AI Categorization @@ -354,6 +355,124 @@ + + + + + AI Categorization uses a local Ollama LLM to automatically categorize transactions when payee matching doesn't produce a category. + Ollama runs locally on your machine — no data is sent to external services. + + + + AI Categorization Settings + + + + + + + + + + + + + + +
Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}
+ +
+ + + +
+ + + +
+ + Save + + + Test Connection + +
+ + + + + +
+
+ + + AI categorization settings saved + + + {{ aiErrorMessage }} + +
@@ -363,7 +482,8 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useAuthStore } from '@/stores/auth' import { bankSyncApi } from '@/services/bankSync' import { accountsApi } from '@/services/accounts' -import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials } from '@/types' +import { aiCategorizationApi } from '@/services/aiCategorization' +import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult } from '@/types' const authStore = useAuthStore() @@ -674,7 +794,63 @@ async function deleteConnection() { } } +// AI Categorization +const aiSettings = reactive({ + isEnabled: false, + ollamaUrl: 'http://localhost:11434', + modelName: 'llama3.1:8b', + confidenceThreshold: 0.7, + timeoutSeconds: 30, + updatePayeeDefaults: true +}) +const aiSaving = ref(false) +const aiTesting = ref(false) +const aiTestResult = ref(null) +const availableModels = ref([]) +const showAiSuccess = ref(false) +const showAiError = ref(false) +const aiErrorMessage = ref('') + +async function loadAiSettings() { + try { + const { data } = await aiCategorizationApi.getSettings() + Object.assign(aiSettings, data) + } catch { + // Not configured yet — defaults are fine + } +} + +async function saveAiSettings() { + aiSaving.value = true + try { + const { data } = await aiCategorizationApi.updateSettings({ ...aiSettings }) + Object.assign(aiSettings, data) + showAiSuccess.value = true + } catch (err: any) { + aiErrorMessage.value = err.response?.data?.error || 'Failed to save AI settings' + showAiError.value = true + } finally { + aiSaving.value = false + } +} + +async function testAiConnection() { + aiTesting.value = true + aiTestResult.value = null + try { + const { data } = await aiCategorizationApi.testConnection() + aiTestResult.value = data + if (data.availableModels && data.availableModels.length > 0) { + availableModels.value = data.availableModels + } + } catch (err: any) { + aiTestResult.value = { success: false, errorMessage: err.response?.data?.error || 'Connection test failed', availableModels: null } + } finally { + aiTesting.value = false + } +} + onMounted(async () => { - await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()]) + await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()]) }) diff --git a/frontend/src/views/transactions/TransactionsView.vue b/frontend/src/views/transactions/TransactionsView.vue index ed0132b..0a2af63 100644 --- a/frontend/src/views/transactions/TransactionsView.vue +++ b/frontend/src/views/transactions/TransactionsView.vue @@ -3,6 +3,16 @@

{{ accountName || 'Transactions' }}

+ + Classify Uncategorized + Transfer Add Transaction
@@ -171,6 +181,10 @@ + + + {{ classifySnackbarText }} + @@ -178,6 +192,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { transactionsApi } from '@/services/transactions' import { categoriesApi } from '@/services/categories' +import { aiCategorizationApi } from '@/services/aiCategorization' import { useAccountsStore } from '@/stores/accounts' import { formatCurrency, formatDate } from '@/utils/formatters' import type { Transaction, Category, SplitFormItem } from '@/types' @@ -198,6 +213,10 @@ const editingTxn = ref(false) const editingTxnId = ref('') const useSplits = ref(false) const splits = ref([]) +const classifying = ref(false) +const classifySnackbar = ref(false) +const classifySnackbarText = ref('') +const classifySnackbarColor = ref('success') const headers = [ { title: 'Date', key: 'date', width: '100px' }, @@ -369,4 +388,27 @@ async function createTransfer() { showTransferDialog.value = false await loadTransactions() } + +async function classifyUncategorized() { + classifying.value = true + try { + const { data } = await aiCategorizationApi.classifyUncategorized({ + accountId: props.id || undefined, + startDate: startDate.value || undefined, + endDate: endDate.value || undefined, + searchText: search.value || undefined, + status: statusFilter.value === 'All' ? undefined : statusFilter.value, + }) + classifySnackbarColor.value = 'success' + classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions` + classifySnackbar.value = true + await doSearch() + } catch (e: any) { + classifySnackbarColor.value = 'error' + classifySnackbarText.value = e?.response?.data?.message || 'Failed to classify transactions' + classifySnackbar.value = true + } finally { + classifying.value = false + } +} diff --git a/src/Purrse.Api/Controllers/AiCategorizationController.cs b/src/Purrse.Api/Controllers/AiCategorizationController.cs new file mode 100644 index 0000000..01f2647 --- /dev/null +++ b/src/Purrse.Api/Controllers/AiCategorizationController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Purrse.Core.DTOs; +using Purrse.Core.Interfaces.Services; +using System.Security.Claims; + +namespace Purrse.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/ai-categorization")] +public class AiCategorizationController : ControllerBase +{ + private readonly IAiCategorizationService _aiService; + private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public AiCategorizationController(IAiCategorizationService aiService) => _aiService = aiService; + + [HttpGet("settings")] + public async Task> GetSettings() + => Ok(await _aiService.GetSettingsAsync(UserId)); + + [HttpPut("settings")] + public async Task> UpdateSettings(UpdateAiCategorizationSettingsRequest request) + => Ok(await _aiService.UpdateSettingsAsync(UserId, request)); + + [HttpPost("test-connection")] + public async Task> TestConnection() + => Ok(await _aiService.TestConnectionAsync(UserId)); + + [HttpPost("classify")] + public async Task> ClassifyUncategorized(ClassifyUncategorizedRequest request) + => Ok(await _aiService.ClassifyUncategorizedAsync(UserId, request)); +} diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index b95264f..14865c1 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -78,7 +78,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("Ollama"); // Built-in file parsers builder.Services.AddSingleton(); diff --git a/src/Purrse.Api/Services/AiCategorizationService.cs b/src/Purrse.Api/Services/AiCategorizationService.cs new file mode 100644 index 0000000..8cb2591 --- /dev/null +++ b/src/Purrse.Api/Services/AiCategorizationService.cs @@ -0,0 +1,380 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Purrse.Core.DTOs; +using Purrse.Core.Enums; +using Purrse.Core.Interfaces.Services; +using Purrse.Core.Models; +using Purrse.Data; + +namespace Purrse.Api.Services; + +public class AiCategorizationService : IAiCategorizationService +{ + private readonly PurrseDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public AiCategorizationService( + PurrseDbContext db, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _db = db; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task GetSettingsAsync(Guid userId) + { + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + + if (settings == null) + { + return new AiCategorizationSettingsResponse( + false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true); + } + + return new AiCategorizationSettingsResponse( + settings.IsEnabled, settings.OllamaUrl, settings.ModelName, + settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults); + } + + public async Task UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request) + { + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + + if (settings == null) + { + settings = new AiCategorizationSettings + { + Id = Guid.NewGuid(), + UserId = userId + }; + _db.AiCategorizationSettings.Add(settings); + } + + settings.IsEnabled = request.IsEnabled; + settings.OllamaUrl = request.OllamaUrl; + settings.ModelName = request.ModelName; + settings.ConfidenceThreshold = request.ConfidenceThreshold; + settings.TimeoutSeconds = request.TimeoutSeconds; + settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults; + settings.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + + return new AiCategorizationSettingsResponse( + settings.IsEnabled, settings.OllamaUrl, settings.ModelName, + settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults); + } + + public async Task TestConnectionAsync(Guid userId) + { + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; + + try + { + var client = _httpClientFactory.CreateClient("Ollama"); + client.Timeout = TimeSpan.FromSeconds(10); + + var response = await client.GetAsync($"{ollamaUrl.TrimEnd('/')}/api/tags"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var tagsResponse = JsonSerializer.Deserialize(json, JsonOptions); + + var modelNames = tagsResponse?.Models?.Select(m => m.Name).ToList() ?? new List(); + + return new OllamaConnectionTestResponse(true, null, modelNames); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Ollama connection test failed for user {UserId}", userId); + return new OllamaConnectionTestResponse(false, ex.Message, null); + } + } + + public async Task> ClassifyTransactionsAsync(Guid userId, List uncategorizedTransactions) + { + if (uncategorizedTransactions.Count == 0) + return new List(); + + try + { + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + if (settings == null || !settings.IsEnabled) + return new List(); + + // Load all active categories with parent names + var categories = await _db.Categories + .Include(c => c.Parent) + .Where(c => c.UserId == userId && c.IsActive) + .ToListAsync(); + + if (categories.Count == 0) + return new List(); + + // Load 25 recent categorized transactions as few-shot examples + var recentCategorized = await _db.Transactions + .Include(t => t.Category).ThenInclude(c => c!.Parent) + .Include(t => t.Account) + .Where(t => t.Account.UserId == userId && t.CategoryId != null) + .OrderByDescending(t => t.Date) + .Take(25) + .ToListAsync(); + + var allClassifications = new List(); + + // Process in batches of 50 + for (int batchStart = 0; batchStart < uncategorizedTransactions.Count; batchStart += 50) + { + var batch = uncategorizedTransactions.Skip(batchStart).Take(50).ToList(); + var batchClassifications = await ClassifyBatchAsync(settings, categories, recentCategorized, batch, batchStart); + allClassifications.AddRange(batchClassifications); + } + + return allClassifications; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI categorization failed for user {UserId}", userId); + return new List(); + } + } + + private async Task> ClassifyBatchAsync( + AiCategorizationSettings settings, + List categories, + List recentCategorized, + List batch, + int globalOffset) + { + var systemMessage = BuildSystemMessage(categories); + var userMessage = BuildUserMessage(recentCategorized, batch); + + var chatRequest = new OllamaChatRequest( + settings.ModelName, + new List + { + new("system", systemMessage), + new("user", userMessage) + }, + false, + "json"); + + try + { + var client = _httpClientFactory.CreateClient("Ollama"); + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + + var requestJson = JsonSerializer.Serialize(chatRequest, JsonOptions); + var content = new StringContent(requestJson, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync($"{settings.OllamaUrl.TrimEnd('/')}/api/chat", content); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + var chatResponse = JsonSerializer.Deserialize(responseJson, JsonOptions); + + if (chatResponse?.Message?.Content == null) + return new List(); + + return ParseClassifications(chatResponse.Message.Content, categories, batch.Count, globalOffset, settings.ConfidenceThreshold); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Ollama API call failed for batch starting at {Offset}", globalOffset); + return new List(); + } + } + + private static string BuildSystemMessage(List categories) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category."); + sb.AppendLine("Respond with JSON: {\"classifications\": [{\"index\": 0, \"categoryId\": \"...\", \"categoryName\": \"...\", \"confidence\": 0.9}, ...]}"); + sb.AppendLine(); + sb.AppendLine("Available categories:"); + + foreach (var category in categories) + { + var parentInfo = category.Parent != null ? $", under \"{category.Parent.Name}\"" : ""; + sb.AppendLine($"- {category.Id} \"{category.Name}\" ({category.Type}{parentInfo})"); + } + + return sb.ToString(); + } + + private static string BuildUserMessage(List recentCategorized, List batch) + { + var sb = new StringBuilder(); + + if (recentCategorized.Count > 0) + { + sb.AppendLine("Recent categorized transactions (for reference):"); + for (int i = 0; i < recentCategorized.Count; i++) + { + var t = recentCategorized[i]; + var categoryName = t.Category?.Parent != null + ? $"{t.Category.Parent.Name} > {t.Category.Name}" + : t.Category?.Name ?? "Unknown"; + sb.AppendLine($"{i + 1}. \"{t.PayeeName}\" (${t.Amount:F2}) -> \"{categoryName}\""); + } + sb.AppendLine(); + } + + sb.AppendLine("Classify these uncategorized transactions:"); + for (int i = 0; i < batch.Count; i++) + { + var t = batch[i]; + var type = t.Amount >= 0 ? "credit" : "debit"; + sb.AppendLine($"{i}. \"{t.PayeeName}\" (${t.Amount:F2}, {type}, {t.Date:yyyy-MM-dd}, memo: \"{t.Memo}\")"); + } + + return sb.ToString(); + } + + private static List ParseClassifications( + string responseContent, + List categories, + int batchSize, + int globalOffset, + double confidenceThreshold) + { + try + { + using var doc = JsonDocument.Parse(responseContent); + var root = doc.RootElement; + + if (!root.TryGetProperty("classifications", out var classificationsArray)) + return new List(); + + var categoryLookup = categories.ToDictionary(c => c.Id); + var categoryNameLookup = categories + .GroupBy(c => c.Name.ToLowerInvariant()) + .ToDictionary(g => g.Key, g => g.First()); + + var results = new List(); + + foreach (var item in classificationsArray.EnumerateArray()) + { + if (!item.TryGetProperty("index", out var indexProp)) + continue; + + var localIndex = indexProp.GetInt32(); + if (localIndex < 0 || localIndex >= batchSize) + continue; + + var globalIndex = globalOffset + localIndex; + + double confidence = 0; + if (item.TryGetProperty("confidence", out var confProp)) + confidence = confProp.GetDouble(); + + if (confidence < confidenceThreshold) + continue; + + Guid? categoryId = null; + string? categoryName = null; + + // Try to resolve by categoryId first + if (item.TryGetProperty("categoryId", out var catIdProp) && catIdProp.ValueKind == JsonValueKind.String) + { + if (Guid.TryParse(catIdProp.GetString(), out var parsedId) && categoryLookup.ContainsKey(parsedId)) + { + categoryId = parsedId; + categoryName = categoryLookup[parsedId].Name; + } + } + + // Fall back to name matching + if (!categoryId.HasValue && item.TryGetProperty("categoryName", out var catNameProp) && catNameProp.ValueKind == JsonValueKind.String) + { + var name = catNameProp.GetString()?.ToLowerInvariant(); + if (name != null && categoryNameLookup.TryGetValue(name, out var matched)) + { + categoryId = matched.Id; + categoryName = matched.Name; + } + } + + if (categoryId.HasValue) + { + results.Add(new TransactionClassification(globalIndex, categoryId, categoryName, confidence)); + } + } + + return results; + } + catch (JsonException) + { + return new List(); + } + } + + public async Task ClassifyUncategorizedAsync(Guid userId, ClassifyUncategorizedRequest request) + { + var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); + if (settings == null || !settings.IsEnabled) + return new ClassifyUncategorizedResponse(0, 0, 0); + + var query = _db.Transactions + .Include(t => t.Account) + .Where(t => t.Account.UserId == userId && t.CategoryId == null); + + if (request.AccountId.HasValue) + query = query.Where(t => t.AccountId == request.AccountId.Value); + if (request.StartDate.HasValue) + query = query.Where(t => t.Date >= request.StartDate.Value); + if (request.EndDate.HasValue) + query = query.Where(t => t.Date <= request.EndDate.Value); + if (request.Status.HasValue) + query = query.Where(t => t.Status == request.Status.Value); + if (!string.IsNullOrEmpty(request.SearchText)) + query = query.Where(t => + (t.PayeeName != null && t.PayeeName.Contains(request.SearchText)) || + (t.Memo != null && t.Memo.Contains(request.SearchText)) || + (t.ReferenceNumber != null && t.ReferenceNumber.Contains(request.SearchText))); + + var uncategorized = await query.OrderByDescending(t => t.Date).ToListAsync(); + + if (uncategorized.Count == 0) + return new ClassifyUncategorizedResponse(0, 0, 0); + + var classifications = await ClassifyTransactionsAsync(userId, uncategorized); + + int classified = 0; + foreach (var c in classifications) + { + if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count) + { + uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value; + classified++; + + if (settings.UpdatePayeeDefaults) + { + var txn = uncategorized[c.TransactionIndex]; + if (txn.PayeeId.HasValue) + { + var payee = await _db.Payees.FindAsync(txn.PayeeId.Value); + if (payee != null && payee.DefaultCategoryId == null) + payee.DefaultCategoryId = c.CategoryId.Value; + } + } + } + } + + await _db.SaveChangesAsync(); + + return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified); + } +} diff --git a/src/Purrse.Api/Services/BankSyncService.cs b/src/Purrse.Api/Services/BankSyncService.cs index 086f2f9..285e1ba 100644 --- a/src/Purrse.Api/Services/BankSyncService.cs +++ b/src/Purrse.Api/Services/BankSyncService.cs @@ -20,6 +20,7 @@ public class BankSyncService : IBankSyncService private readonly SimpleFinSyncProvider _simpleFin; private readonly IDuplicateDetectionService _duplicateService; private readonly IPayeeService _payeeService; + private readonly IAiCategorizationService _aiCategorization; private readonly IHubContext _hub; private readonly ILogger _logger; @@ -30,6 +31,7 @@ public class BankSyncService : IBankSyncService SimpleFinSyncProvider simpleFin, IDuplicateDetectionService duplicateService, IPayeeService payeeService, + IAiCategorizationService aiCategorization, IHubContext hub, ILogger logger) { @@ -39,6 +41,7 @@ public class BankSyncService : IBankSyncService _simpleFin = simpleFin; _duplicateService = duplicateService; _payeeService = payeeService; + _aiCategorization = aiCategorization; _hub = hub; _logger = logger; } @@ -500,6 +503,39 @@ public class BankSyncService : IBankSyncService } } + // AI auto-categorization for uncategorized transactions + try + { + var uncategorized = _db.ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added && e.Entity.CategoryId == null) + .Select(e => e.Entity) + .ToList(); + + if (uncategorized.Count > 0) + { + var classifications = await _aiCategorization.ClassifyTransactionsAsync(userId, uncategorized); + foreach (var c in classifications) + { + if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count) + { + uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value; + + var txn = uncategorized[c.TransactionIndex]; + if (txn.PayeeId.HasValue) + { + var payee = await _db.Payees.FindAsync(txn.PayeeId.Value); + if (payee != null && payee.DefaultCategoryId == null) + payee.DefaultCategoryId = c.CategoryId.Value; + } + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI categorization failed during sync, continuing without"); + } + connection.LastSyncAt = DateTime.UtcNow; connection.LastSyncError = accountResults.Any(r => r.Error != null) ? accountResults.First(r => r.Error != null).Error diff --git a/src/Purrse.Api/Services/ImportService.cs b/src/Purrse.Api/Services/ImportService.cs index 60e41ea..d18e767 100644 --- a/src/Purrse.Api/Services/ImportService.cs +++ b/src/Purrse.Api/Services/ImportService.cs @@ -16,14 +16,18 @@ public class ImportService : IImportService private readonly PurrseDbContext _db; private readonly IDuplicateDetectionService _duplicateService; private readonly IPayeeService _payeeService; + private readonly IAiCategorizationService _aiCategorization; private readonly IEnumerable _parsers; + private readonly ILogger _logger; - public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable parsers) + public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IAiCategorizationService aiCategorization, IEnumerable parsers, ILogger logger) { _db = db; _duplicateService = duplicateService; _payeeService = payeeService; + _aiCategorization = aiCategorization; _parsers = parsers; + _logger = logger; } public async Task UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream) @@ -135,6 +139,39 @@ public class ImportService : IImportService } } + // AI auto-categorization for uncategorized transactions + try + { + var uncategorized = _db.ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added && e.Entity.CategoryId == null) + .Select(e => e.Entity) + .ToList(); + + if (uncategorized.Count > 0) + { + var classifications = await _aiCategorization.ClassifyTransactionsAsync(batch.UserId, uncategorized); + foreach (var c in classifications) + { + if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count) + { + uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value; + + var txn = uncategorized[c.TransactionIndex]; + if (txn.PayeeId.HasValue) + { + var payee = await _db.Payees.FindAsync(txn.PayeeId.Value); + if (payee != null && payee.DefaultCategoryId == null) + payee.DefaultCategoryId = c.CategoryId.Value; + } + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI categorization failed during import, continuing without"); + } + batch.ImportedCount = importedCount; batch.SkippedCount = skippedCount; batch.DuplicateCount = mergedCount; diff --git a/src/Purrse.Core/DTOs/AiCategorizationDtos.cs b/src/Purrse.Core/DTOs/AiCategorizationDtos.cs new file mode 100644 index 0000000..0c25cfc --- /dev/null +++ b/src/Purrse.Core/DTOs/AiCategorizationDtos.cs @@ -0,0 +1,49 @@ +using Purrse.Core.Enums; + +namespace Purrse.Core.DTOs; + +// Settings +public record AiCategorizationSettingsResponse( + bool IsEnabled, + string OllamaUrl, + string ModelName, + double ConfidenceThreshold, + int TimeoutSeconds, + bool UpdatePayeeDefaults); + +public record UpdateAiCategorizationSettingsRequest( + bool IsEnabled, + string OllamaUrl, + string ModelName, + double ConfidenceThreshold, + int TimeoutSeconds, + bool UpdatePayeeDefaults); + +// Connection test +public record OllamaConnectionTestResponse( + bool Success, + string? ErrorMessage, + List? AvailableModels); + +// Ollama API communication (internal) +public record OllamaChatRequest(string Model, List Messages, bool Stream, string Format); +public record OllamaChatMessage(string Role, string Content); +public record OllamaChatResponse(OllamaChatMessage? Message, bool Done); +public record OllamaTagsResponse(List? Models); +public record OllamaModelInfo(string Name, string? Model); + +// Classify uncategorized (manual trigger from Transactions screen) +public record ClassifyUncategorizedRequest( + Guid? AccountId, + DateTime? StartDate, + DateTime? EndDate, + string? SearchText, + TransactionStatus? Status); + +public record ClassifyUncategorizedResponse( + int TotalUncategorized, + int Classified, + int Skipped); + +// Classification result +public record TransactionClassification(int TransactionIndex, Guid? CategoryId, string? CategoryName, double Confidence); diff --git a/src/Purrse.Core/Interfaces/Services/IAiCategorizationService.cs b/src/Purrse.Core/Interfaces/Services/IAiCategorizationService.cs new file mode 100644 index 0000000..f1d6ac3 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IAiCategorizationService.cs @@ -0,0 +1,13 @@ +using Purrse.Core.DTOs; +using Purrse.Core.Models; + +namespace Purrse.Core.Interfaces.Services; + +public interface IAiCategorizationService +{ + Task GetSettingsAsync(Guid userId); + Task UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request); + Task TestConnectionAsync(Guid userId); + Task> ClassifyTransactionsAsync(Guid userId, List uncategorizedTransactions); + Task ClassifyUncategorizedAsync(Guid userId, ClassifyUncategorizedRequest request); +} diff --git a/src/Purrse.Core/Models/AiCategorizationSettings.cs b/src/Purrse.Core/Models/AiCategorizationSettings.cs new file mode 100644 index 0000000..0a16835 --- /dev/null +++ b/src/Purrse.Core/Models/AiCategorizationSettings.cs @@ -0,0 +1,16 @@ +namespace Purrse.Core.Models; + +public class AiCategorizationSettings +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public bool IsEnabled { get; set; } + public string OllamaUrl { get; set; } = "http://localhost:11434"; + public string ModelName { get; set; } = "llama3.1:8b"; + public double ConfidenceThreshold { get; set; } = 0.7; + public int TimeoutSeconds { get; set; } = 30; + public bool UpdatePayeeDefaults { get; set; } = true; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; +} diff --git a/src/Purrse.Core/Models/User.cs b/src/Purrse.Core/Models/User.cs index 595cd7e..26a7bbe 100644 --- a/src/Purrse.Core/Models/User.cs +++ b/src/Purrse.Core/Models/User.cs @@ -14,4 +14,5 @@ public class User public ICollection Accounts { get; set; } = new List(); public ICollection SyncConnections { get; set; } = new List(); public BankSyncSettings? BankSyncSettings { get; set; } + public AiCategorizationSettings? AiCategorizationSettings { get; set; } } diff --git a/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs b/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs new file mode 100644 index 0000000..6a19b33 --- /dev/null +++ b/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class AiCategorizationSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ai_categorization_settings"); + builder.HasKey(s => s.Id); + builder.Property(s => s.OllamaUrl).HasMaxLength(500); + builder.Property(s => s.ModelName).HasMaxLength(100); + + builder.HasOne(s => s.User) + .WithOne(u => u.AiCategorizationSettings) + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.UserId).IsUnique(); + } +} diff --git a/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.Designer.cs b/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.Designer.cs new file mode 100644 index 0000000..1f23d4c --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.Designer.cs @@ -0,0 +1,1464 @@ +// +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("20260208232419_AddAiCategorizationSettings")] + partial class AddAiCategorizationSettings + { + /// + 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.AiCategorizationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ModelName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OllamaUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatePayeeDefaults") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ai_categorization_settings", (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.BankSyncSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedPlaidClientId") + .HasColumnType("text"); + + b.Property("EncryptedPlaidSecret") + .HasColumnType("text"); + + b.Property("PlaidEnvironment") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("bank_sync_settings", (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.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SyncConnectionId", "ExternalAccountId") + .IsUnique(); + + b.ToTable("linked_accounts", (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.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlaidItemId"); + + b.HasIndex("UserId"); + + b.ToTable("sync_connections", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("TransactionsSkipped") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LinkedAccountId"); + + b.HasIndex("StartedAt"); + + b.HasIndex("SyncConnectionId"); + + b.ToTable("sync_logs", (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.AiCategorizationSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("AiCategorizationSettings") + .HasForeignKey("Purrse.Core.Models.AiCategorizationSettings", "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.BankSyncSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("BankSyncSettings") + .HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.LinkedAccount", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("LinkedAccounts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("LinkedAccounts") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("SyncConnection"); + }); + + 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.SyncConnection", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("SyncConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount") + .WithMany() + .HasForeignKey("LinkedAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("SyncLogs") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedAccount"); + + b.Navigation("SyncConnection"); + }); + + 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("LinkedAccounts"); + + 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.SyncConnection", b => + { + b.Navigation("LinkedAccounts"); + + b.Navigation("SyncLogs"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Navigation("Splits"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("AiCategorizationSettings"); + + b.Navigation("BankSyncSettings"); + + b.Navigation("SyncConnections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.cs b/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.cs new file mode 100644 index 0000000..b4cd19a --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208232419_AddAiCategorizationSettings.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddAiCategorizationSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ai_categorization_settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + OllamaUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + ModelName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ConfidenceThreshold = table.Column(type: "double precision", nullable: false), + TimeoutSeconds = table.Column(type: "integer", nullable: false), + UpdatePayeeDefaults = table.Column(type: "boolean", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ai_categorization_settings", x => x.Id); + table.ForeignKey( + name: "FK_ai_categorization_settings_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ai_categorization_settings_UserId", + table: "ai_categorization_settings", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ai_categorization_settings"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index 9acf69e..adceb29 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -85,6 +85,48 @@ namespace Purrse.Data.Migrations b.ToTable("accounts", (string)null); }); + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ModelName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OllamaUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatePayeeDefaults") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ai_categorization_settings", (string)null); + }); + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => { b.Property("Id") @@ -994,6 +1036,17 @@ namespace Purrse.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("AiCategorizationSettings") + .HasForeignKey("Purrse.Core.Models.AiCategorizationSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => { b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") @@ -1396,6 +1449,8 @@ namespace Purrse.Data.Migrations { b.Navigation("Accounts"); + b.Navigation("AiCategorizationSettings"); + b.Navigation("BankSyncSettings"); b.Navigation("SyncConnections"); diff --git a/src/Purrse.Data/PurrseDbContext.cs b/src/Purrse.Data/PurrseDbContext.cs index e7c925a..cdeb5f5 100644 --- a/src/Purrse.Data/PurrseDbContext.cs +++ b/src/Purrse.Data/PurrseDbContext.cs @@ -30,6 +30,7 @@ public class PurrseDbContext : DbContext public DbSet LinkedAccounts => Set(); public DbSet SyncLogs => Set(); public DbSet BankSyncSettings => Set(); + public DbSet AiCategorizationSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) {