Compare commits

..

14 Commits

Author SHA1 Message Date
aj f187b741a2 fix(mcp): set config base path so appsettings.json is found regardless of working directory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:55:38 -04:00
aj 274569bd79 refactor(mcp): rewrite all tools to use MoneyMapApiClient instead of direct DB access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:40:23 -04:00
aj 4bee73ba26 refactor(mcp): remove Core dependency, switch to HttpClient-based architecture
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:45 -04:00
aj 6c4f4bea7f feat(mcp): add MoneyMapApiClient typed HttpClient for API communication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:36 -04:00
aj db1d96476b feat(api): add DashboardController with overview and monthly-trend endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:57 -04:00
aj 51d6aee434 feat(api): add AccountsController with accounts and cards list endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:53 -04:00
aj c34ea74459 feat(api): add MerchantsController with list and merge endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:49 -04:00
aj 9dc1a9064d feat(api): add ReceiptsController with list, detail, image, and text endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:06 -04:00
aj 5b4a673f9d feat(api): add CategoriesController with list, mappings, and add-mapping endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:36 -04:00
aj 004f99c2b4 feat(api): add BudgetsController with status, create, update endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:07 -04:00
aj e773a0f218 feat(api): add TransactionsController with search, detail, category, and summary endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:33:02 -04:00
aj ccedea6e67 feat(api): add Health and Audit controllers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:32:12 -04:00
aj 768b5e015e feat(api): add controller infrastructure, Swagger, remove inline /api/audit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:30:49 -04:00
aj 2a75c9550e chore: add docs/superpowers, .playwright-mcp, settings.local.json to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:45:13 -04:00
27 changed files with 1257 additions and 2914 deletions
+9
View File
@@ -37,3 +37,12 @@ packages/
# Environment files with secrets
.env
# Local settings
settings.local.json
# Superpowers plans/specs
docs/superpowers/
# Playwright MCP artifacts
.playwright-mcp/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 B

@@ -1,15 +0,0 @@
using Microsoft.Extensions.Configuration;
using MoneyMap.Services;
namespace MoneyMap.Mcp;
public class ConfigReceiptStorageOptions : IReceiptStorageOptions
{
public string ReceiptsBasePath { get; }
public ConfigReceiptStorageOptions(IConfiguration config)
{
ReceiptsBasePath = config["Receipts:StoragePath"]
?? throw new InvalidOperationException("Receipts:StoragePath not configured");
}
}
+1 -8
View File
@@ -6,17 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
+241
View File
@@ -0,0 +1,241 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace MoneyMap.Mcp;
public class MoneyMapApiClient
{
private readonly HttpClient _http;
public MoneyMapApiClient(HttpClient http) => _http = http;
public async Task<string> HealthCheckAsync()
{
return await GetAsync("/api/health");
}
// --- Transactions ---
public async Task<string> SearchTransactionsAsync(
string? query, string? startDate, string? endDate, string? category,
string? merchantName, decimal? minAmount, decimal? maxAmount,
int? accountId, int? cardId, string? type, bool? uncategorizedOnly, int? limit)
{
var qs = BuildQueryString(
("query", query), ("startDate", startDate), ("endDate", endDate),
("category", category), ("merchantName", merchantName),
("minAmount", minAmount?.ToString()), ("maxAmount", maxAmount?.ToString()),
("accountId", accountId?.ToString()), ("cardId", cardId?.ToString()),
("type", type), ("uncategorizedOnly", uncategorizedOnly?.ToString()),
("limit", limit?.ToString()));
return await GetAsync($"/api/transactions{qs}");
}
public async Task<string> GetTransactionAsync(long transactionId)
{
return await GetAsync($"/api/transactions/{transactionId}");
}
public async Task<string> UpdateTransactionCategoryAsync(long[] transactionIds, string category, string? merchantName)
{
var body = new { TransactionIds = transactionIds, Category = category, MerchantName = merchantName };
return await PutAsync($"/api/transactions/{transactionIds[0]}/category", body);
}
public async Task<string> BulkRecategorizeAsync(string namePattern, string toCategory, string? fromCategory, string? merchantName, bool dryRun)
{
var body = new { NamePattern = namePattern, ToCategory = toCategory, FromCategory = fromCategory, MerchantName = merchantName, DryRun = dryRun };
return await PostAsync("/api/transactions/bulk-recategorize", body);
}
public async Task<string> GetSpendingSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/spending-summary{qs}");
}
public async Task<string> GetIncomeSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/income-summary{qs}");
}
// --- Budgets ---
public async Task<string> GetBudgetStatusAsync(string? asOfDate)
{
var qs = BuildQueryString(("asOfDate", asOfDate));
return await GetAsync($"/api/budgets/status{qs}");
}
public async Task<string> CreateBudgetAsync(string? category, decimal amount, string period, string startDate)
{
var body = new { Category = category, Amount = amount, Period = period, StartDate = startDate };
return await PostAsync("/api/budgets", body);
}
public async Task<string> UpdateBudgetAsync(int budgetId, decimal? amount, string? period, bool? isActive)
{
var body = new { Amount = amount, Period = period, IsActive = isActive };
return await PutAsync($"/api/budgets/{budgetId}", body);
}
// --- Categories ---
public async Task<string> ListCategoriesAsync()
{
return await GetAsync("/api/categories");
}
public async Task<string> GetCategoryMappingsAsync(string? category)
{
var qs = BuildQueryString(("category", category));
return await GetAsync($"/api/categories/mappings{qs}");
}
public async Task<string> AddCategoryMappingAsync(string pattern, string category, string? merchantName, int priority)
{
var body = new { Pattern = pattern, Category = category, MerchantName = merchantName, Priority = priority };
return await PostAsync("/api/categories/mappings", body);
}
// --- Receipts ---
public async Task<string> ListReceiptsAsync(long? transactionId, string? parseStatus, int? limit)
{
var qs = BuildQueryString(("transactionId", transactionId?.ToString()), ("parseStatus", parseStatus), ("limit", limit?.ToString()));
return await GetAsync($"/api/receipts{qs}");
}
public async Task<string> GetReceiptDetailsAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}");
}
public async Task<string> GetReceiptImageAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/image");
}
public async Task<string> GetReceiptTextAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/text");
}
// --- Merchants ---
public async Task<string> ListMerchantsAsync(string? query)
{
var qs = BuildQueryString(("query", query));
return await GetAsync($"/api/merchants{qs}");
}
public async Task<string> MergeMerchantsAsync(int sourceMerchantId, int targetMerchantId)
{
var body = new { SourceMerchantId = sourceMerchantId, TargetMerchantId = targetMerchantId };
return await PostAsync("/api/merchants/merge", body);
}
// --- Accounts ---
public async Task<string> ListAccountsAsync()
{
return await GetAsync("/api/accounts");
}
public async Task<string> ListCardsAsync(int? accountId)
{
var qs = BuildQueryString(("accountId", accountId?.ToString()));
return await GetAsync($"/api/accounts/cards{qs}");
}
// --- Dashboard ---
public async Task<string> GetDashboardAsync(int? topCategoriesCount, int? recentTransactionsCount)
{
var qs = BuildQueryString(("topCategoriesCount", topCategoriesCount?.ToString()), ("recentTransactionsCount", recentTransactionsCount?.ToString()));
return await GetAsync($"/api/dashboard{qs}");
}
public async Task<string> GetMonthlyTrendAsync(int? months, string? category)
{
var qs = BuildQueryString(("months", months?.ToString()), ("category", category));
return await GetAsync($"/api/dashboard/monthly-trend{qs}");
}
// --- HTTP Helpers ---
private async Task<string> GetAsync(string path)
{
try
{
var response = await _http.GetAsync(path);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var body = await response.Content.ReadAsStringAsync();
return body.Length > 0 ? body : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PostAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PostAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PutAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PutAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private static string BuildQueryString(params (string key, string? value)[] parameters)
{
var pairs = parameters
.Where(p => !string.IsNullOrWhiteSpace(p.value))
.Select(p => $"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}");
var qs = string.Join("&", pairs);
return qs.Length > 0 ? $"?{qs}" : "";
}
}
+7 -4
View File
@@ -1,17 +1,20 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoneyMap.Core;
using MoneyMap.Mcp;
using MoneyMap.Services;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services.AddSingleton<IReceiptStorageOptions, ConfigReceiptStorageOptions>();
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
});
builder.Services
.AddMcpServer()
+4 -48
View File
@@ -1,67 +1,23 @@
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<string> ListAccounts(
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.ListAccountsAsync();
}
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
public static async Task<string> ListCards(
[Description("Filter cards by account ID")] int? accountId = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.ListCardsAsync(accountId);
}
}
+6 -64
View File
@@ -1,42 +1,17 @@
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<string> GetBudgetStatus(
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
IBudgetService budgetService = default!)
MoneyMapApiClient api = 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);
return await api.GetBudgetStatusAsync(asOfDate);
}
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
@@ -45,23 +20,9 @@ public static class BudgetTools
[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!)
MoneyMapApiClient api = default!)
{
if (!Enum.TryParse<BudgetPeriod>(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);
return await api.CreateBudgetAsync(category, amount, period, startDate);
}
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
@@ -70,27 +31,8 @@ public static class BudgetTools
[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!)
MoneyMapApiClient api = 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<BudgetPeriod>(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);
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
}
}
+6 -52
View File
@@ -1,54 +1,24 @@
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<string> ListCategories(
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.ListCategoriesAsync();
}
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
public static async Task<string> GetCategoryMappings(
[Description("Filter mappings to a specific category")] string? category = null,
ITransactionCategorizer categorizer = default!)
MoneyMapApiClient api = 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);
return await api.GetCategoryMappingsAsync(category);
}
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
@@ -57,24 +27,8 @@ public static class CategoryTools
[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!)
MoneyMapApiClient api = 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);
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
}
}
+4 -46
View File
@@ -1,68 +1,26 @@
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<string> 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!)
MoneyMapApiClient api = default!)
{
var data = await dashboardService.GetDashboardDataAsync(
topCategoriesCount ?? 8,
recentTransactionsCount ?? 20);
return JsonSerializer.Serialize(data, JsonOptions);
return await api.GetDashboardAsync(topCategoriesCount, recentTransactionsCount);
}
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
public static async Task<string> GetMonthlyTrend(
[Description("Number of months to include (default 6)")] int? months = null,
[Description("Filter to a specific category")] string? category = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.GetMonthlyTrendAsync(months, category);
}
}
+4 -74
View File
@@ -1,95 +1,25 @@
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<string> ListMerchants(
[Description("Filter merchants by name (contains)")] string? query = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.ListMerchantsAsync(query);
}
[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<string> 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!)
MoneyMapApiClient api = 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);
return await api.MergeMerchantsAsync(sourceMerchantId, targetMerchantId);
}
}
+8 -165
View File
@@ -1,100 +1,25 @@
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<string> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!,
IReceiptStorageOptions storageOptions = default!)
MoneyMapApiClient api = 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);
return await api.GetReceiptImageAsync(receiptId);
}
[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<string> GetReceiptText(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.GetReceiptTextAsync(receiptId);
}
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
@@ -102,98 +27,16 @@ public static class ReceiptTools
[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!)
MoneyMapApiClient api = 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<ReceiptParseStatus>(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);
return await api.ListReceiptsAsync(transactionId, parseStatus, limit);
}
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
public static async Task<string> GetReceiptDetails(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.GetReceiptDetailsAsync(receiptId);
}
}
+12 -201
View File
@@ -1,17 +1,11 @@
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<string> SearchTransactions(
[Description("Full-text search across name, memo, and category")] string? query = null,
@@ -26,108 +20,17 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
}
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
public static async Task<string> GetTransaction(
[Description("Transaction ID")] long transactionId,
MoneyMapContext db = default!)
MoneyMapApiClient api = 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);
return await api.GetTransactionAsync(transactionId);
}
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
@@ -135,29 +38,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
@@ -165,29 +48,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
@@ -195,30 +58,9 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
}
[McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
@@ -228,39 +70,8 @@ public static class TransactionTools
[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!)
MoneyMapApiClient api = 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);
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
}
}
+2 -5
View File
@@ -1,8 +1,5 @@
{
"ConnectionStrings": {
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
},
"Receipts": {
"StoragePath": "\\\\TRUENAS\\receipts"
"MoneyMapApi": {
"BaseUrl": "http://barge.lan:5010/"
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
private readonly MoneyMapContext _db;
public AccountsController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List()
{
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 Ok(accounts);
}
[HttpGet("cards")]
public async Task<IActionResult> ListCards([FromQuery] int? accountId = null)
{
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 Ok(cards);
}
}
+26
View File
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuditController : ControllerBase
{
private readonly IFinancialAuditService _auditService;
public AuditController(IFinancialAuditService auditService) => _auditService = auditService;
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] bool includeTransactions = false)
{
var end = endDate ?? DateTime.Today;
var start = startDate ?? end.AddDays(-90);
var result = await _auditService.GenerateAuditAsync(start, end, includeTransactions);
return Ok(result);
}
}
+103
View File
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BudgetsController : ControllerBase
{
private readonly IBudgetService _budgetService;
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
[HttpGet("status")]
public async Task<IActionResult> GetStatus([FromQuery] string? asOfDate = null)
{
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 Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest request)
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
if (!DateTime.TryParse(request.StartDate, out var startDate))
return BadRequest(new { message = "Invalid start date format" });
var budget = new Budget
{
Category = request.Category,
Amount = request.Amount,
Period = budgetPeriod,
StartDate = startDate,
IsActive = true
};
var result = await _budgetService.CreateBudgetAsync(budget);
return Ok(new { result.Success, result.Message, BudgetId = budget.Id });
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetRequest request)
{
var budget = await _budgetService.GetBudgetByIdAsync(id);
if (budget == null)
return NotFound(new { message = "Budget not found" });
if (request.Amount.HasValue)
budget.Amount = request.Amount.Value;
if (!string.IsNullOrWhiteSpace(request.Period))
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
budget.Period = budgetPeriod;
}
if (request.IsActive.HasValue)
budget.IsActive = request.IsActive.Value;
var result = await _budgetService.UpdateBudgetAsync(budget);
return Ok(new { result.Success, result.Message });
}
}
public class CreateBudgetRequest
{
public string? Category { get; set; }
public decimal Amount { get; set; }
public string Period { get; set; } = "";
public string StartDate { get; set; } = "";
}
public class UpdateBudgetRequest
{
public decimal? Amount { get; set; }
public string? Period { get; set; }
public bool? IsActive { get; set; }
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoriesController(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{
_db = db;
_categorizer = categorizer;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> List()
{
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 Ok(new { Categories = categories, UncategorizedCount = uncategorized });
}
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings([FromQuery] string? category = null)
{
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 Ok(result);
}
[HttpPost("mappings")]
public async Task<IActionResult> AddMapping([FromBody] CreateCategoryMappingRequest request)
{
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
var mapping = new CategoryMapping
{
Pattern = request.Pattern,
Category = request.Category,
MerchantId = merchantId,
Priority = request.Priority
};
_db.CategoryMappings.Add(mapping);
await _db.SaveChangesAsync();
return Ok(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = request.MerchantName, mapping.Priority });
}
}
public class CreateCategoryMappingRequest
{
public string Pattern { get; set; } = "";
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
public int Priority { get; set; }
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboardService;
private readonly MoneyMapContext _db;
public DashboardController(IDashboardService dashboardService, MoneyMapContext db)
{
_dashboardService = dashboardService;
_db = db;
}
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] int? topCategoriesCount = null,
[FromQuery] int? recentTransactionsCount = null)
{
var data = await _dashboardService.GetDashboardDataAsync(
topCategoriesCount ?? 8,
recentTransactionsCount ?? 20);
return Ok(data);
}
[HttpGet("monthly-trend")]
public async Task<IActionResult> MonthlyTrend(
[FromQuery] int? months = null,
[FromQuery] string? category = null)
{
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 Ok(new { Category = category ?? "All Spending", Months = result });
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
private readonly MoneyMapContext _db;
public HealthController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get()
{
var canConnect = await _db.Database.CanConnectAsync();
if (!canConnect)
return StatusCode(503, new { status = "unhealthy", reason = "database unreachable" });
return Ok(new { status = "healthy" });
}
}
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MerchantsController : ControllerBase
{
private readonly MoneyMapContext _db;
public MerchantsController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? query = null)
{
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 Ok(new { Count = merchants.Count, Merchants = merchants });
}
[HttpPost("merge")]
public async Task<IActionResult> Merge([FromBody] MergeMerchantsRequest request)
{
if (request.SourceMerchantId == request.TargetMerchantId)
return BadRequest(new { message = "Source and target merchant cannot be the same" });
var source = await _db.Merchants.FindAsync(request.SourceMerchantId);
var target = await _db.Merchants.FindAsync(request.TargetMerchantId);
if (source == null)
return NotFound(new { message = $"Source merchant {request.SourceMerchantId} not found" });
if (target == null)
return NotFound(new { message = $"Target merchant {request.TargetMerchantId} not found" });
var transactions = await _db.Transactions
.Where(t => t.MerchantId == request.SourceMerchantId)
.ToListAsync();
foreach (var t in transactions)
t.MerchantId = request.TargetMerchantId;
var sourceMappings = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.SourceMerchantId)
.ToListAsync();
var targetMappingPatterns = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.TargetMerchantId)
.Select(cm => cm.Pattern)
.ToListAsync();
foreach (var mapping in sourceMappings)
{
if (targetMappingPatterns.Contains(mapping.Pattern))
_db.CategoryMappings.Remove(mapping);
else
mapping.MerchantId = request.TargetMerchantId;
}
_db.Merchants.Remove(source);
await _db.SaveChangesAsync();
return Ok(new
{
Merged = true,
Source = new { source.Id, source.Name },
Target = new { target.Id, target.Name },
TransactionsReassigned = transactions.Count,
MappingsReassigned = sourceMappings.Count
});
}
}
public class MergeMerchantsRequest
{
public int SourceMerchantId { get; set; }
public int TargetMerchantId { get; set; }
}
+197
View File
@@ -0,0 +1,197 @@
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReceiptsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IReceiptStorageOptions _storageOptions;
public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions)
{
_db = db;
_storageOptions = storageOptions;
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] long? transactionId = null,
[FromQuery] string? parseStatus = null,
[FromQuery] int? limit = null)
{
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<ReceiptParseStatus>(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 Ok(new { Count = results.Count, Receipts = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetDetails(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.Include(r => r.ParseLogs)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "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 Ok(result);
}
[HttpGet("{id}/image")]
public async Task<IActionResult> GetImage(long id)
{
var receipt = await _db.Receipts.FindAsync(id);
if (receipt == null)
return NotFound(new { message = "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 BadRequest(new { message = "Invalid receipt path" });
if (!System.IO.File.Exists(fullPath))
return NotFound(new { message = "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 System.IO.File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
var base64 = Convert.ToBase64String(imageBytes);
return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length });
}
[HttpGet("{id}/text")]
public async Task<IActionResult> GetText(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() });
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 Ok(result);
}
}
@@ -0,0 +1,271 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IMerchantService _merchantService;
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
{
_db = db;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? query = null,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] string? category = null,
[FromQuery] string? merchantName = null,
[FromQuery] decimal? minAmount = null,
[FromQuery] decimal? maxAmount = null,
[FromQuery] int? accountId = null,
[FromQuery] int? cardId = null,
[FromQuery] string? type = null,
[FromQuery] bool? uncategorizedOnly = null,
[FromQuery] int? limit = null)
{
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 Ok(new { Count = results.Count, Transactions = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
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 == id)
.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 NotFound(new { message = "Transaction not found" });
return Ok(t);
}
[HttpPut("{id}/category")]
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
{
var transactions = await _db.Transactions
.Where(t => request.TransactionIds.Contains(t.Id))
.ToListAsync();
if (!transactions.Any())
return NotFound(new { message = "No transactions found with the provided IDs" });
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.Category;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
}
[HttpPost("bulk-recategorize")]
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
{
var q = _db.Transactions
.Where(t => t.Name.Contains(request.NamePattern));
if (!string.IsNullOrWhiteSpace(request.FromCategory))
q = q.Where(t => t.Category == request.FromCategory);
var transactions = await q.ToListAsync();
if (!transactions.Any())
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
if (request.DryRun)
{
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.ToCategory;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
}
[HttpGet("spending-summary")]
public async Task<IActionResult> SpendingSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
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 Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
}
[HttpGet("income-summary")]
public async Task<IActionResult> IncomeSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
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 Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
}
}
public class UpdateCategoryRequest
{
public long[] TransactionIds { get; set; } = [];
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
}
public class BulkRecategorizeRequest
{
public string NamePattern { get; set; } = "";
public string ToCategory { get; set; } = "";
public string? FromCategory { get; set; }
public string? MerchantName { get; set; }
public bool DryRun { get; set; } = true;
}
+1
View File
@@ -26,6 +26,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
+10 -14
View File
@@ -27,6 +27,10 @@ builder.Services.AddSession(options =>
builder.Services.AddRazorPages()
.AddSessionStateTempDataProvider();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Receipt parse queue and background worker
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
builder.Services.AddHostedService<ReceiptParseWorkerService>();
@@ -70,21 +74,13 @@ app.UseSession();
app.UseAuthorization();
app.MapRazorPages();
// Financial Audit API endpoint
app.MapGet("/api/audit", async (
IFinancialAuditService auditService,
DateTime? startDate,
DateTime? endDate,
bool includeTransactions = false) =>
if (app.Environment.IsDevelopment())
{
var end = endDate ?? DateTime.Today;
var start = startDate ?? end.AddDays(-90);
app.UseSwagger();
app.UseSwaggerUI();
}
var result = await auditService.GenerateAuditAsync(start, end, includeTransactions);
return Results.Ok(result);
})
.WithName("GetFinancialAudit");
app.MapRazorPages();
app.MapControllers();
app.Run();
File diff suppressed because it is too large Load Diff
@@ -1,376 +0,0 @@
# MoneyMap MCP Server — Design Spec
## Overview
Create an MCP (Model Context Protocol) console application that exposes MoneyMap's personal finance data and operations to Claude Code. This enables conversational financial analysis, category correction with receipt image verification, and budget feasibility modeling.
## Motivation
The primary use case is financial decision-making: analyzing household income vs. spending, identifying miscategorized transactions (with receipt images for verification), and modeling scenarios like income changes. Full read/write access allows Claude to both analyze and correct data in a single conversation.
## Architecture
### Solution Structure
```
MoneyMap.sln
├── MoneyMap.Core/ (NEW - shared class library)
│ ├── Models/ (moved from MoneyMap)
│ ├── Data/
│ │ └── MoneyMapContext.cs (moved from MoneyMap)
│ ├── Services/ (moved from MoneyMap)
│ │ ├── TransactionService.cs
│ │ ├── BudgetService.cs
│ │ ├── DashboardService.cs
│ │ ├── TransactionCategorizer.cs
│ │ ├── MerchantService.cs
│ │ ├── AccountService.cs
│ │ ├── CardService.cs
│ │ ├── ReceiptManager.cs
│ │ ├── ReceiptMatchingService.cs
│ │ ├── ReferenceDataService.cs
│ │ ├── TransactionStatisticsService.cs
│ │ └── AITools/
│ ├── ServiceCollectionExtensions.cs (shared DI registration)
│ └── MoneyMap.Core.csproj (EF Core, CsvHelper, Magick.NET, etc.)
├── MoneyMap/ (existing web app - slimmed)
│ ├── Pages/ (stays - thin PageModel delegation)
│ ├── Migrations/ (stays - deployment-specific)
│ ├── wwwroot/ (stays - includes receipt files)
│ ├── Program.cs (calls AddMoneyMapCore)
│ └── MoneyMap.csproj (references MoneyMap.Core)
├── MoneyMap.Mcp/ (NEW - MCP console app)
│ ├── Tools/
│ │ ├── TransactionTools.cs
│ │ ├── BudgetTools.cs
│ │ ├── CategoryTools.cs
│ │ ├── ReceiptTools.cs
│ │ ├── MerchantTools.cs
│ │ ├── AccountTools.cs
│ │ └── DashboardTools.cs
│ ├── ConfigReceiptStorageOptions.cs
│ ├── Program.cs
│ ├── appsettings.json
│ └── MoneyMap.Mcp.csproj (references MoneyMap.Core + MCP SDK)
└── MoneyMap.Tests/ (existing - updates reference to MoneyMap.Core)
```
### Key Architectural Decisions
1. **Shared class library (MoneyMap.Core)**: All models, DbContext, and services extracted into a shared library. Both the web app and MCP app reference it. This ensures logic never drifts between the two consumers.
2. **Shared DI registration**: A `ServiceCollectionExtensions.AddMoneyMapCore(IConfiguration)` extension method registers DbContext and all services. Both apps call this method, preventing registration drift.
3. **Migrations stay in web project**: EF migrations are deployment-specific and remain in the web app project.
4. **Receipt files stay in wwwroot**: The MCP server reads receipt images from the web app's `wwwroot/receipts/` directory via a configured absolute path.
5. **TransactionImporter stays in web app**: CSV import is a UI-driven workflow. It remains in `Upload.cshtml.cs` and is not exposed via MCP.
6. **ReceiptParseWorkerService stays in web app**: The background hosted service for async receipt parsing stays tied to the web process.
7. **Logging to stderr only**: MCP uses stdio for the protocol. All logging in the MCP app must be directed to stderr (or file) to avoid corrupting the MCP stream. The `AddMoneyMapCore` extension must not force a stdout-based logging provider.
8. **No IWebHostEnvironment dependency in Core**: Services that currently rely on `IWebHostEnvironment` to resolve file paths (e.g., `ReceiptManager`) will be refactored to use an `IReceiptStorageOptions` interface backed by configuration. This allows both the web app and MCP app to provide the correct path without web-host coupling.
9. **MoneyMap.Core targets `Microsoft.NET.Sdk`**: The shared library must not depend on `Microsoft.AspNetCore.App`. It uses only `Microsoft.Extensions.*` packages to stay lightweight for the console app consumer.
10. **Path traversal protection**: Receipt file access validates that resolved paths remain within the configured receipts directory to prevent directory traversal attacks.
## MCP Tools
### Transaction Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `search_transactions` | Filter and list transactions | `query?` (full-text across name/memo/category), `startDate?`, `endDate?`, `category?`, `merchantName?`, `minAmount?`, `maxAmount?`, `accountId?`, `cardId?`, `type?` (debit/credit), `uncategorizedOnly?`, `limit?` (default 50) | No |
| `get_transaction` | Get single transaction with full details | `transactionId` | No |
| `get_spending_summary` | Spending totals grouped by category for a date range (excludes transfers) | `startDate`, `endDate`, `accountId?` | No |
| `get_income_summary` | Credit totals grouped by source/name for a date range | `startDate`, `endDate`, `accountId?` | No |
| `update_transaction_category` | Change category and optionally merchant on transactions | `transactionIds` (array), `category`, `merchantName?` | Yes |
| `bulk_recategorize` | Recategorize all transactions matching a name pattern | `namePattern`, `fromCategory?`, `toCategory`, `merchantName?`, `dryRun?` (default true — returns preview of affected transactions without applying changes) | Yes |
### Budget Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_budget_status` | All active budgets with current period spending vs. limit | `asOfDate?` | No |
| `create_budget` | Create a new category or total budget | `category?` (null = total), `amount`, `period` (Weekly/Monthly/Yearly), `startDate` | Yes |
| `update_budget` | Modify budget amount, period, or active status | `budgetId`, `amount?`, `period?`, `isActive?` | Yes |
### Category Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_categories` | All categories with transaction counts | (none) | No |
| `get_category_mappings` | Auto-categorization pattern rules | `category?` (filter) | No |
| `add_category_mapping` | Add a new auto-categorization rule | `pattern`, `category`, `merchantName?`, `priority?` | Yes |
### Receipt Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_receipt_image` | Returns receipt as base64 image (PDF → PNG conversion). Path-traversal safe. | `receiptId` | No |
| `get_receipt_text` | Returns already-parsed receipt data (merchant, date, amounts, line items) as structured text — avoids re-OCR when parsed data exists | `receiptId` | No |
| `list_receipts` | List receipts with parse status | `transactionId?`, `parseStatus?`, `limit?` | No |
| `get_receipt_details` | Full receipt metadata, parsed data, and line items | `receiptId` | No |
### Merchant Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_merchants` | All merchants with transaction counts and category mapping info | `query?` (filter by name) | No |
| `merge_merchants` | Merge duplicate merchants — reassigns all transactions and mappings from source to target, then deletes source | `sourceMerchantId`, `targetMerchantId` | Yes |
### Account & Card Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `list_accounts` | All accounts with transaction counts | (none) | No |
| `list_cards` | All cards with account info and stats | `accountId?` | No |
### Dashboard Tools
| Tool | Description | Parameters | Mutates |
|------|-------------|------------|---------|
| `get_dashboard` | Top spending categories, recent transactions, aggregate stats | `topCategoriesCount?`, `recentTransactionsCount?` | No |
| `get_monthly_trend` | Month-over-month spending totals | `months?` (default 6), `category?` | No |
## Internal Design
### Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
// MCP uses stdio — all logging must go to stderr
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Configuration.AddJsonFile("appsettings.json");
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
await app.RunAsync();
```
### ServiceCollectionExtensions (MoneyMap.Core)
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMoneyMapCore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<MoneyMapContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
services.AddScoped<ITransactionService, TransactionService>();
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
services.AddScoped<IBudgetService, BudgetService>();
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
services.AddScoped<IMerchantService, MerchantService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<ICardService, CardService>();
services.AddScoped<IReceiptManager, ReceiptManager>();
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
services.AddScoped<IReferenceDataService, ReferenceDataService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
services.AddScoped<IAIToolRegistry, AIToolRegistry>();
return services;
}
}
```
### Tool Implementation Pattern
All tools return `McpToolResult` for consistency and to support multi-part responses (text + images).
```csharp
[McpServerTool]
[Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
public static async Task<McpToolResult> GetSpendingSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Optional: filter to specific account ID")] int? accountId,
MoneyMapContext db)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
var query = db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0) // debits only
.Where(t => t.TransferToAccountId == null); // exclude transfers
if (accountId.HasValue)
query = query.Where(t => t.AccountId == accountId.Value);
var summary = await query
.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();
return McpToolResult.Text(JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
}
```
### Receipt Image Tool
```csharp
[McpServerTool]
[Description("Get a receipt image for visual inspection. Returns the image as base64. Useful for verifying transaction categories.")]
public static async Task<McpToolResult> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db,
IConfiguration config)
{
var receipt = await db.Receipts.FindAsync(receiptId);
if (receipt == null)
return McpToolResult.Text("Receipt not found");
var basePath = Path.GetFullPath(config["Receipts:StoragePath"]!);
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
// Path traversal protection
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return McpToolResult.Text("Invalid receipt path");
if (!File.Exists(fullPath))
return McpToolResult.Text("Receipt file not found on disk");
byte[] imageBytes;
string mimeType;
if (receipt.ContentType == "application/pdf")
{
using var image = new MagickImage(fullPath + "[0]");
image.Density = new Density(220);
image.Format = MagickFormat.Png;
imageBytes = image.ToByteArray();
mimeType = "image/png";
}
else
{
imageBytes = await File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
return McpToolResult.Image(Convert.ToBase64String(imageBytes), mimeType);
}
```
## Configuration
### MoneyMap.Mcp/appsettings.json
```json
{
"ConnectionStrings": {
"MoneyMapDb": "Server=(localdb)\\mssqllocaldb;Database=MoneyMap;Trusted_Connection=True;"
},
"Receipts": {
"StoragePath": "C:/Users/AJ/Desktop/Projects/MoneyMap/MoneyMap/wwwroot/receipts"
}
}
```
## Publishing & Installation
### Build
```bash
dotnet publish MoneyMap.Mcp -c Release -o "$USERPROFILE/.claude/mcp/MoneyMap.Mcp"
```
### Register
```bash
claude mcp add --transport stdio --scope user moneymap -- "C:/Users/AJ/.claude/mcp/MoneyMap.Mcp/MoneyMap.Mcp.exe"
```
## What Stays in the Web App
- EF Migrations (`MoneyMap/Migrations/`)
- Receipt physical files (`MoneyMap/wwwroot/receipts/`)
- PageModel code (`Pages/*.cshtml.cs`) — thin delegation to Core services
- `TransactionImporter` & `CardResolver` — import-workflow-specific
- `ReceiptParseWorkerService` — background worker tied to web process
- `AIReceiptParser` — tied to the parse worker and web upload flow
## What Moves to MoneyMap.Core
- All models (`Models/`)
- `MoneyMapContext` (`Data/MoneyMapContext.cs`)
- All service interfaces and implementations (`Services/`)
- AITools (`Services/AITools/`)
- All DTOs and result types
## NuGet Dependencies (MoneyMap.Core)
- Microsoft.EntityFrameworkCore.SqlServer
- CsvHelper
- Magick.NET-Q8-AnyCPU (for receipt PDF→PNG)
- UglyToad.PdfPig (for PDF processing)
## NuGet Dependencies (MoneyMap.Mcp)
- ModelContextProtocol (prerelease)
- Microsoft.Extensions.Hosting
## Refactoring Notes
### IWebHostEnvironment Removal from Core
Services like `ReceiptManager` currently use `IWebHostEnvironment.WebRootPath` to resolve receipt file paths. This must be abstracted:
```csharp
// MoneyMap.Core
public interface IReceiptStorageOptions
{
string ReceiptsBasePath { get; }
}
// MoneyMap (web app) implementation
public class WebReceiptStorageOptions : IReceiptStorageOptions
{
public WebReceiptStorageOptions(IWebHostEnvironment env)
=> ReceiptsBasePath = Path.Combine(env.WebRootPath, "receipts");
public string ReceiptsBasePath { get; }
}
// MoneyMap.Mcp implementation
public class ConfigReceiptStorageOptions : IReceiptStorageOptions
{
public ConfigReceiptStorageOptions(IConfiguration config)
=> ReceiptsBasePath = config["Receipts:StoragePath"]!;
public string ReceiptsBasePath { get; }
}
```
Each host registers its own implementation. `AddMoneyMapCore` does NOT register `IReceiptStorageOptions` — that's host-specific.
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Large refactoring breaks web app | Run existing tests after extraction; web app behavior unchanged |
| Namespace changes break references | Use find-and-replace for `using` statements; Roslyn Bridge can verify |
| Receipt path differs per machine | Configurable via `IReceiptStorageOptions` + appsettings.json |
| MCP binary needs Magick.NET native libs | Included via NuGet package (AnyCPU variant) |
| Concurrent DB access (web + MCP) | EF Core handles this fine with scoped DbContext per request |
| Logging corrupts MCP stdio stream | All logging redirected to stderr in MCP host |
| Path traversal on receipt reads | Resolved paths validated against base directory |
| MoneyMap.Core pulls in ASP.NET deps | Target `Microsoft.NET.Sdk`, avoid `Microsoft.AspNetCore.App` framework ref |