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); } }