diff --git a/MoneyMap.Mcp/MoneyMap.Mcp.csproj b/MoneyMap.Mcp/MoneyMap.Mcp.csproj index b5a3596..752b275 100644 --- a/MoneyMap.Mcp/MoneyMap.Mcp.csproj +++ b/MoneyMap.Mcp/MoneyMap.Mcp.csproj @@ -11,7 +11,7 @@ - + diff --git a/MoneyMap.Mcp/Tools/AccountTools.cs b/MoneyMap.Mcp/Tools/AccountTools.cs new file mode 100644 index 0000000..0c387b5 --- /dev/null +++ b/MoneyMap.Mcp/Tools/AccountTools.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class AccountTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")] + public static async Task ListAccounts( + MoneyMapContext db = default!) + { + var accounts = await db.Accounts + .Include(a => a.Cards) + .Include(a => a.Transactions) + .OrderBy(a => a.Institution).ThenBy(a => a.Last4) + .Select(a => new + { + a.Id, + a.Institution, + a.Last4, + a.Owner, + Label = a.DisplayLabel, + TransactionCount = a.Transactions.Count, + CardCount = a.Cards.Count + }) + .ToListAsync(); + + return JsonSerializer.Serialize(accounts, JsonOptions); + } + + [McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")] + public static async Task ListCards( + [Description("Filter cards by account ID")] int? accountId = null, + MoneyMapContext db = default!) + { + var q = db.Cards + .Include(c => c.Account) + .Include(c => c.Transactions) + .AsQueryable(); + + if (accountId.HasValue) + q = q.Where(c => c.AccountId == accountId.Value); + + var cards = await q + .OrderBy(c => c.Owner).ThenBy(c => c.Last4) + .Select(c => new + { + c.Id, + c.Issuer, + c.Last4, + c.Owner, + Label = c.DisplayLabel, + Account = c.Account != null ? c.Account.Institution + " " + c.Account.Last4 : null, + AccountId = c.AccountId, + TransactionCount = c.Transactions.Count + }) + .ToListAsync(); + + return JsonSerializer.Serialize(cards, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/BudgetTools.cs b/MoneyMap.Mcp/Tools/BudgetTools.cs new file mode 100644 index 0000000..f80dd07 --- /dev/null +++ b/MoneyMap.Mcp/Tools/BudgetTools.cs @@ -0,0 +1,96 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class BudgetTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")] + public static async Task GetBudgetStatus( + [Description("Date to calculate status for (defaults to today)")] string? asOfDate = null, + IBudgetService budgetService = default!) + { + DateTime? date = null; + if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed)) + date = parsed; + + var statuses = await budgetService.GetAllBudgetStatusesAsync(date); + + var result = statuses.Select(s => new + { + s.Budget.Id, + Category = s.Budget.DisplayName, + s.Budget.Amount, + Period = s.Budget.Period.ToString(), + s.PeriodStart, + s.PeriodEnd, + s.Spent, + s.Remaining, + PercentUsed = Math.Round(s.PercentUsed, 1), + s.IsOverBudget + }).ToList(); + + return JsonSerializer.Serialize(result, JsonOptions); + } + + [McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")] + public static async Task CreateBudget( + [Description("Budget amount limit")] decimal amount, + [Description("Period: Weekly, Monthly, or Yearly")] string period, + [Description("Start date for period calculation, e.g. 2026-01-01")] string startDate, + [Description("Category name (omit for total spending budget)")] string? category = null, + IBudgetService budgetService = default!) + { + if (!Enum.TryParse(period, true, out var budgetPeriod)) + return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly."; + + var budget = new Budget + { + Category = category, + Amount = amount, + Period = budgetPeriod, + StartDate = DateTime.Parse(startDate), + IsActive = true + }; + + var result = await budgetService.CreateBudgetAsync(budget); + + return JsonSerializer.Serialize(new { result.Success, result.Message, BudgetId = budget.Id }, JsonOptions); + } + + [McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")] + public static async Task UpdateBudget( + [Description("Budget ID to update")] int budgetId, + [Description("New budget amount")] decimal? amount = null, + [Description("New period: Weekly, Monthly, or Yearly")] string? period = null, + [Description("Set active/inactive")] bool? isActive = null, + IBudgetService budgetService = default!) + { + var budget = await budgetService.GetBudgetByIdAsync(budgetId); + if (budget == null) + return "Budget not found"; + + if (amount.HasValue) + budget.Amount = amount.Value; + + if (!string.IsNullOrWhiteSpace(period)) + { + if (!Enum.TryParse(period, true, out var budgetPeriod)) + return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly."; + budget.Period = budgetPeriod; + } + + if (isActive.HasValue) + budget.IsActive = isActive.Value; + + var result = await budgetService.UpdateBudgetAsync(budget); + + return JsonSerializer.Serialize(new { result.Success, result.Message }, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/CategoryTools.cs b/MoneyMap.Mcp/Tools/CategoryTools.cs new file mode 100644 index 0000000..bc93b8a --- /dev/null +++ b/MoneyMap.Mcp/Tools/CategoryTools.cs @@ -0,0 +1,80 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; +using MoneyMap.Services; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class CategoryTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")] + public static async Task ListCategories( + MoneyMapContext db = default!) + { + var categories = await db.Transactions + .Where(t => t.Category != null && t.Category != "") + .GroupBy(t => t.Category!) + .Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) }) + .OrderByDescending(x => x.Count) + .ToListAsync(); + + var uncategorized = await db.Transactions + .CountAsync(t => t.Category == null || t.Category == ""); + + return JsonSerializer.Serialize(new { Categories = categories, UncategorizedCount = uncategorized }, JsonOptions); + } + + [McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")] + public static async Task GetCategoryMappings( + [Description("Filter mappings to a specific category")] string? category = null, + ITransactionCategorizer categorizer = default!) + { + var mappings = await categorizer.GetAllMappingsAsync(); + + if (!string.IsNullOrWhiteSpace(category)) + mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList(); + + var result = mappings.Select(m => new + { + m.Id, + m.Pattern, + m.Category, + m.MerchantId, + m.Priority + }).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList(); + + return JsonSerializer.Serialize(result, JsonOptions); + } + + [McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")] + public static async Task AddCategoryMapping( + [Description("Pattern to match in transaction name (case-insensitive)")] string pattern, + [Description("Category to assign when pattern matches")] string category, + [Description("Merchant name to assign (creates if new)")] string? merchantName = null, + [Description("Priority (higher = checked first, default 0)")] int priority = 0, + MoneyMapContext db = default!, + IMerchantService merchantService = default!) + { + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(merchantName)) + merchantId = await merchantService.GetOrCreateIdAsync(merchantName); + + var mapping = new MoneyMap.Models.CategoryMapping + { + Pattern = pattern, + Category = category, + MerchantId = merchantId, + Priority = priority + }; + + db.CategoryMappings.Add(mapping); + await db.SaveChangesAsync(); + + return JsonSerializer.Serialize(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = merchantName, mapping.Priority }, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/DashboardTools.cs b/MoneyMap.Mcp/Tools/DashboardTools.cs new file mode 100644 index 0000000..98d80c2 --- /dev/null +++ b/MoneyMap.Mcp/Tools/DashboardTools.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; +using MoneyMap.Services; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class DashboardTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")] + public static async Task GetDashboard( + [Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null, + [Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null, + IDashboardService dashboardService = default!) + { + var data = await dashboardService.GetDashboardDataAsync( + topCategoriesCount ?? 8, + recentTransactionsCount ?? 20); + + return JsonSerializer.Serialize(data, JsonOptions); + } + + [McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")] + public static async Task GetMonthlyTrend( + [Description("Number of months to include (default 6)")] int? months = null, + [Description("Filter to a specific category")] string? category = null, + MoneyMapContext db = default!) + { + var monthCount = months ?? 6; + var endDate = DateTime.Today; + var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1)); + + var q = db.Transactions + .Where(t => t.Date >= startDate && t.Date <= endDate) + .Where(t => t.Amount < 0) + .Where(t => t.TransferToAccountId == null) + .ExcludeTransfers(); + + if (!string.IsNullOrWhiteSpace(category)) + q = q.Where(t => t.Category == category); + + var monthly = await q + .GroupBy(t => new { t.Date.Year, t.Date.Month }) + .Select(g => new + { + Year = g.Key.Year, + Month = g.Key.Month, + Total = g.Sum(t => Math.Abs(t.Amount)), + Count = g.Count() + }) + .OrderBy(x => x.Year).ThenBy(x => x.Month) + .ToListAsync(); + + var result = monthly.Select(m => new + { + Period = $"{m.Year}-{m.Month:D2}", + m.Total, + m.Count + }).ToList(); + + return JsonSerializer.Serialize(new { Category = category ?? "All Spending", Months = result }, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/MerchantTools.cs b/MoneyMap.Mcp/Tools/MerchantTools.cs new file mode 100644 index 0000000..2a7b3fb --- /dev/null +++ b/MoneyMap.Mcp/Tools/MerchantTools.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class MerchantTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")] + public static async Task ListMerchants( + [Description("Filter merchants by name (contains)")] string? query = null, + MoneyMapContext db = default!) + { + var q = db.Merchants + .Include(m => m.Transactions) + .Include(m => m.CategoryMappings) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + q = q.Where(m => m.Name.Contains(query)); + + var merchants = await q + .OrderBy(m => m.Name) + .Select(m => new + { + m.Id, + m.Name, + TransactionCount = m.Transactions.Count, + MappingCount = m.CategoryMappings.Count, + Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList() + }) + .ToListAsync(); + + return JsonSerializer.Serialize(new { Count = merchants.Count, Merchants = merchants }, JsonOptions); + } + + [McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")] + public static async Task MergeMerchants( + [Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId, + [Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId, + MoneyMapContext db = default!) + { + if (sourceMerchantId == targetMerchantId) + return "Source and target merchant cannot be the same"; + + var source = await db.Merchants.FindAsync(sourceMerchantId); + var target = await db.Merchants.FindAsync(targetMerchantId); + + if (source == null) + return $"Source merchant {sourceMerchantId} not found"; + if (target == null) + return $"Target merchant {targetMerchantId} not found"; + + var transactions = await db.Transactions + .Where(t => t.MerchantId == sourceMerchantId) + .ToListAsync(); + + foreach (var t in transactions) + t.MerchantId = targetMerchantId; + + var sourceMappings = await db.CategoryMappings + .Where(cm => cm.MerchantId == sourceMerchantId) + .ToListAsync(); + + var targetMappingPatterns = await db.CategoryMappings + .Where(cm => cm.MerchantId == targetMerchantId) + .Select(cm => cm.Pattern) + .ToListAsync(); + + foreach (var mapping in sourceMappings) + { + if (targetMappingPatterns.Contains(mapping.Pattern)) + db.CategoryMappings.Remove(mapping); + else + mapping.MerchantId = targetMerchantId; + } + + db.Merchants.Remove(source); + await db.SaveChangesAsync(); + + return JsonSerializer.Serialize(new + { + Merged = true, + Source = new { source.Id, source.Name }, + Target = new { target.Id, target.Name }, + TransactionsReassigned = transactions.Count, + MappingsReassigned = sourceMappings.Count + }, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/ReceiptTools.cs b/MoneyMap.Mcp/Tools/ReceiptTools.cs new file mode 100644 index 0000000..6b721d2 --- /dev/null +++ b/MoneyMap.Mcp/Tools/ReceiptTools.cs @@ -0,0 +1,199 @@ +using System.ComponentModel; +using System.Text.Json; +using ImageMagick; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class ReceiptTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "get_receipt_image"), Description("Get a receipt image for visual inspection. Returns the image as base64-encoded data. Useful for verifying transaction categories.")] + public static async Task GetReceiptImage( + [Description("Receipt ID")] long receiptId, + MoneyMapContext db = default!, + IReceiptStorageOptions storageOptions = default!) + { + var receipt = await db.Receipts.FindAsync(receiptId); + if (receipt == null) + return "Receipt not found"; + + var basePath = Path.GetFullPath(storageOptions.ReceiptsBasePath); + var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath)); + + if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) + return "Invalid receipt path"; + + if (!File.Exists(fullPath)) + return "Receipt file not found on disk"; + + byte[] imageBytes; + string mimeType; + + if (receipt.ContentType == "application/pdf") + { + var settings = new MagickReadSettings { Density = new Density(220) }; + using var image = new MagickImage(fullPath + "[0]", settings); + image.Format = MagickFormat.Png; + image.BackgroundColor = MagickColors.White; + image.Alpha(AlphaOption.Remove); + imageBytes = image.ToByteArray(); + mimeType = "image/png"; + } + else + { + imageBytes = await File.ReadAllBytesAsync(fullPath); + mimeType = receipt.ContentType; + } + + var base64 = Convert.ToBase64String(imageBytes); + return JsonSerializer.Serialize(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length }, JsonOptions); + } + + [McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")] + public static async Task GetReceiptText( + [Description("Receipt ID")] long receiptId, + MoneyMapContext db = default!) + { + var receipt = await db.Receipts + .Include(r => r.LineItems) + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return "Receipt not found"; + + if (receipt.ParseStatus != ReceiptParseStatus.Completed) + return JsonSerializer.Serialize(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() }, JsonOptions); + + var result = new + { + receipt.Id, + receipt.Merchant, + receipt.ReceiptDate, + receipt.DueDate, + receipt.Subtotal, + receipt.Tax, + receipt.Total, + receipt.Currency, + LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null, + LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new + { + li.LineNumber, + li.Description, + li.Quantity, + li.UnitPrice, + li.LineTotal, + li.Category + }).ToList() + }; + + return JsonSerializer.Serialize(result, JsonOptions); + } + + [McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")] + public static async Task ListReceipts( + [Description("Filter by transaction ID")] long? transactionId = null, + [Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null, + [Description("Max results (default 50)")] int? limit = null, + MoneyMapContext db = default!) + { + var q = db.Receipts + .Include(r => r.Transaction) + .AsQueryable(); + + if (transactionId.HasValue) + q = q.Where(r => r.TransactionId == transactionId.Value); + + if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse(parseStatus, true, out var status)) + q = q.Where(r => r.ParseStatus == status); + + var results = await q + .OrderByDescending(r => r.UploadedAtUtc) + .Take(limit ?? 50) + .Select(r => new + { + r.Id, + r.FileName, + ParseStatus = r.ParseStatus.ToString(), + r.Merchant, + r.Total, + r.ReceiptDate, + r.UploadedAtUtc, + TransactionId = r.TransactionId, + TransactionName = r.Transaction != null ? r.Transaction.Name : null + }) + .ToListAsync(); + + return JsonSerializer.Serialize(new { Count = results.Count, Receipts = results }, JsonOptions); + } + + [McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")] + public static async Task GetReceiptDetails( + [Description("Receipt ID")] long receiptId, + MoneyMapContext db = default!) + { + var receipt = await db.Receipts + .Include(r => r.LineItems) + .Include(r => r.Transaction) + .Include(r => r.ParseLogs) + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return "Receipt not found"; + + var result = new + { + receipt.Id, + receipt.FileName, + receipt.ContentType, + receipt.FileSizeBytes, + receipt.UploadedAtUtc, + ParseStatus = receipt.ParseStatus.ToString(), + ParsedData = new + { + receipt.Merchant, + receipt.ReceiptDate, + receipt.DueDate, + receipt.Subtotal, + receipt.Tax, + receipt.Total, + receipt.Currency + }, + LinkedTransaction = receipt.Transaction != null ? new + { + receipt.Transaction.Id, + receipt.Transaction.Name, + receipt.Transaction.Date, + receipt.Transaction.Amount, + receipt.Transaction.Category + } : null, + LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new + { + li.LineNumber, + li.Description, + li.Quantity, + li.UnitPrice, + li.LineTotal, + li.Category + }).ToList(), + ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new + { + pl.Provider, + pl.Model, + pl.Success, + pl.Confidence, + pl.Error, + pl.StartedAtUtc + }).ToList() + }; + + return JsonSerializer.Serialize(result, JsonOptions); + } +} diff --git a/MoneyMap.Mcp/Tools/TransactionTools.cs b/MoneyMap.Mcp/Tools/TransactionTools.cs new file mode 100644 index 0000000..c3412b5 --- /dev/null +++ b/MoneyMap.Mcp/Tools/TransactionTools.cs @@ -0,0 +1,266 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; +using MoneyMap.Data; +using MoneyMap.Services; + +namespace MoneyMap.Mcp.Tools; + +[McpServerToolType] +public static class TransactionTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")] + public static async Task SearchTransactions( + [Description("Full-text search across name, memo, and category")] string? query = null, + [Description("Start date (inclusive), e.g. 2026-01-01")] string? startDate = null, + [Description("End date (inclusive), e.g. 2026-01-31")] string? endDate = null, + [Description("Filter by category name (exact match)")] string? category = null, + [Description("Filter by merchant name (contains)")] string? merchantName = null, + [Description("Minimum amount (absolute value)")] decimal? minAmount = null, + [Description("Maximum amount (absolute value)")] decimal? maxAmount = null, + [Description("Filter by account ID")] int? accountId = null, + [Description("Filter by card ID")] int? cardId = null, + [Description("Filter by type: 'debit' or 'credit'")] string? type = null, + [Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null, + [Description("Max results to return (default 50)")] int? limit = null, + MoneyMapContext db = default!) + { + var q = db.Transactions + .Include(t => t.Merchant) + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Receipts) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query))); + + if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start)) + q = q.Where(t => t.Date >= start); + + if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end)) + q = q.Where(t => t.Date <= end); + + if (!string.IsNullOrWhiteSpace(category)) + q = q.Where(t => t.Category == category); + + if (!string.IsNullOrWhiteSpace(merchantName)) + q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName)); + + if (minAmount.HasValue) + q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value); + + if (maxAmount.HasValue) + q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value); + + if (accountId.HasValue) + q = q.Where(t => t.AccountId == accountId.Value); + + if (cardId.HasValue) + q = q.Where(t => t.CardId == cardId.Value); + + if (type?.ToLower() == "debit") + q = q.Where(t => t.Amount < 0); + else if (type?.ToLower() == "credit") + q = q.Where(t => t.Amount > 0); + + if (uncategorizedOnly == true) + q = q.Where(t => t.Category == null || t.Category == ""); + + var results = await q + .OrderByDescending(t => t.Date).ThenByDescending(t => t.Id) + .Take(limit ?? 50) + .Select(t => new + { + t.Id, + t.Date, + t.Name, + t.Memo, + t.Amount, + t.Category, + Merchant = t.Merchant != null ? t.Merchant.Name : null, + Account = t.Account!.Institution + " " + t.Account.Last4, + Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null, + ReceiptCount = t.Receipts.Count, + t.TransferToAccountId + }) + .ToListAsync(); + + return JsonSerializer.Serialize(new { Count = results.Count, Transactions = results }, JsonOptions); + } + + [McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")] + public static async Task GetTransaction( + [Description("Transaction ID")] long transactionId, + MoneyMapContext db = default!) + { + var t = await db.Transactions + .Include(t => t.Merchant) + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Receipts) + .Where(t => t.Id == transactionId) + .Select(t => new + { + t.Id, + t.Date, + t.Name, + t.Memo, + t.Amount, + t.TransactionType, + t.Category, + Merchant = t.Merchant != null ? t.Merchant.Name : null, + MerchantId = t.MerchantId, + Account = t.Account!.Institution + " " + t.Account.Last4, + AccountId = t.AccountId, + Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null, + CardId = t.CardId, + t.Notes, + t.TransferToAccountId, + Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList() + }) + .FirstOrDefaultAsync(); + + if (t == null) + return "Transaction not found"; + + return JsonSerializer.Serialize(t, JsonOptions); + } + + [McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")] + public static async Task GetSpendingSummary( + [Description("Start date (inclusive), e.g. 2026-01-01")] string startDate, + [Description("End date (inclusive), e.g. 2026-01-31")] string endDate, + [Description("Filter to specific account ID")] int? accountId = null, + MoneyMapContext db = default!) + { + var start = DateTime.Parse(startDate); + var end = DateTime.Parse(endDate); + + var q = db.Transactions + .Where(t => t.Date >= start && t.Date <= end) + .Where(t => t.Amount < 0) + .Where(t => t.TransferToAccountId == null) + .ExcludeTransfers(); + + if (accountId.HasValue) + q = q.Where(t => t.AccountId == accountId.Value); + + var summary = await q + .GroupBy(t => t.Category ?? "Uncategorized") + .Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() }) + .OrderByDescending(x => x.Total) + .ToListAsync(); + + var grandTotal = summary.Sum(x => x.Total); + + return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary }, JsonOptions); + } + + [McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")] + public static async Task GetIncomeSummary( + [Description("Start date (inclusive), e.g. 2026-01-01")] string startDate, + [Description("End date (inclusive), e.g. 2026-01-31")] string endDate, + [Description("Filter to specific account ID")] int? accountId = null, + MoneyMapContext db = default!) + { + var start = DateTime.Parse(startDate); + var end = DateTime.Parse(endDate); + + var q = db.Transactions + .Where(t => t.Date >= start && t.Date <= end) + .Where(t => t.Amount > 0) + .Where(t => t.TransferToAccountId == null) + .ExcludeTransfers(); + + if (accountId.HasValue) + q = q.Where(t => t.AccountId == accountId.Value); + + var summary = await q + .GroupBy(t => t.Name) + .Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() }) + .OrderByDescending(x => x.Total) + .ToListAsync(); + + var grandTotal = summary.Sum(x => x.Total); + + return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary }, JsonOptions); + } + + [McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")] + public static async Task UpdateTransactionCategory( + [Description("Array of transaction IDs to update")] long[] transactionIds, + [Description("New category to assign")] string category, + [Description("Merchant name to assign (creates if new)")] string? merchantName = null, + MoneyMapContext db = default!, + IMerchantService merchantService = default!) + { + var transactions = await db.Transactions + .Where(t => transactionIds.Contains(t.Id)) + .ToListAsync(); + + if (!transactions.Any()) + return "No transactions found with the provided IDs"; + + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(merchantName)) + merchantId = await merchantService.GetOrCreateIdAsync(merchantName); + + foreach (var t in transactions) + { + t.Category = category; + if (merchantId.HasValue) + t.MerchantId = merchantId; + } + + await db.SaveChangesAsync(); + + return JsonSerializer.Serialize(new { Updated = transactions.Count, Category = category, Merchant = merchantName }, JsonOptions); + } + + [McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")] + public static async Task BulkRecategorize( + [Description("Pattern to match in transaction name (case-insensitive contains)")] string namePattern, + [Description("New category to assign")] string toCategory, + [Description("Only recategorize transactions currently in this category")] string? fromCategory = null, + [Description("Merchant name to assign (creates if new)")] string? merchantName = null, + [Description("If true (default), only shows what would change without applying")] bool dryRun = true, + MoneyMapContext db = default!, + IMerchantService merchantService = default!) + { + var q = db.Transactions + .Where(t => t.Name.Contains(namePattern)); + + if (!string.IsNullOrWhiteSpace(fromCategory)) + q = q.Where(t => t.Category == fromCategory); + + var transactions = await q.ToListAsync(); + + if (!transactions.Any()) + return JsonSerializer.Serialize(new { Message = "No transactions match the pattern", Pattern = namePattern, FromCategory = fromCategory }, JsonOptions); + + if (dryRun) + { + var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList(); + return JsonSerializer.Serialize(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, ToCategory = toCategory }, JsonOptions); + } + + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(merchantName)) + merchantId = await merchantService.GetOrCreateIdAsync(merchantName); + + foreach (var t in transactions) + { + t.Category = toCategory; + if (merchantId.HasValue) + t.MerchantId = merchantId; + } + + await db.SaveChangesAsync(); + + return JsonSerializer.Serialize(new { Applied = true, Updated = transactions.Count, ToCategory = toCategory, Merchant = merchantName }, JsonOptions); + } +}