Compare commits
14 Commits
7b2d6203df
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f187b741a2 | |||
| 274569bd79 | |||
| 4bee73ba26 | |||
| 6c4f4bea7f | |||
| db1d96476b | |||
| 51d6aee434 | |||
| c34ea74459 | |||
| 9dc1a9064d | |||
| 5b4a673f9d | |||
| 004f99c2b4 | |||
| e773a0f218 | |||
| ccedea6e67 | |||
| 768b5e015e | |||
| 2a75c9550e |
@@ -37,3 +37,12 @@ packages/
|
|||||||
|
|
||||||
# Environment files with secrets
|
# Environment files with secrets
|
||||||
.env
|
.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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,10 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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}" : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MoneyMap.Core;
|
|
||||||
using MoneyMap.Mcp;
|
using MoneyMap.Mcp;
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
|
||||||
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
|
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||||
|
|
||||||
builder.Services.AddMoneyMapCore(builder.Configuration);
|
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
|
||||||
builder.Services.AddSingleton<IReceiptStorageOptions, ConfigReceiptStorageOptions>();
|
{
|
||||||
|
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddMcpServer()
|
.AddMcpServer()
|
||||||
|
|||||||
@@ -1,67 +1,23 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class AccountTools
|
public static class AccountTools
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
||||||
|
|
||||||
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
|
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
|
||||||
public static async Task<string> ListAccounts(
|
public static async Task<string> ListAccounts(
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var accounts = await db.Accounts
|
return await api.ListAccountsAsync();
|
||||||
.Include(a => a.Cards)
|
|
||||||
.Include(a => a.Transactions)
|
|
||||||
.OrderBy(a => a.Institution).ThenBy(a => a.Last4)
|
|
||||||
.Select(a => new
|
|
||||||
{
|
|
||||||
a.Id,
|
|
||||||
a.Institution,
|
|
||||||
a.Last4,
|
|
||||||
a.Owner,
|
|
||||||
Label = a.DisplayLabel,
|
|
||||||
TransactionCount = a.Transactions.Count,
|
|
||||||
CardCount = a.Cards.Count
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(accounts, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
|
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
|
||||||
public static async Task<string> ListCards(
|
public static async Task<string> ListCards(
|
||||||
[Description("Filter cards by account ID")] int? accountId = null,
|
[Description("Filter cards by account ID")] int? accountId = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var q = db.Cards
|
return await api.ListCardsAsync(accountId);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,17 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Models;
|
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class BudgetTools
|
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.")]
|
[McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")]
|
||||||
public static async Task<string> GetBudgetStatus(
|
public static async Task<string> GetBudgetStatus(
|
||||||
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
|
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
|
||||||
IBudgetService budgetService = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
DateTime? date = null;
|
return await api.GetBudgetStatusAsync(asOfDate);
|
||||||
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
|
|
||||||
date = parsed;
|
|
||||||
|
|
||||||
var statuses = await budgetService.GetAllBudgetStatusesAsync(date);
|
|
||||||
|
|
||||||
var result = statuses.Select(s => new
|
|
||||||
{
|
|
||||||
s.Budget.Id,
|
|
||||||
Category = s.Budget.DisplayName,
|
|
||||||
s.Budget.Amount,
|
|
||||||
Period = s.Budget.Period.ToString(),
|
|
||||||
s.PeriodStart,
|
|
||||||
s.PeriodEnd,
|
|
||||||
s.Spent,
|
|
||||||
s.Remaining,
|
|
||||||
PercentUsed = Math.Round(s.PercentUsed, 1),
|
|
||||||
s.IsOverBudget
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(result, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
|
[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("Period: Weekly, Monthly, or Yearly")] string period,
|
||||||
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
|
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
|
||||||
[Description("Category name (omit for total spending budget)")] string? category = null,
|
[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 await api.CreateBudgetAsync(category, amount, period, startDate);
|
||||||
return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly.";
|
|
||||||
|
|
||||||
var budget = new Budget
|
|
||||||
{
|
|
||||||
Category = category,
|
|
||||||
Amount = amount,
|
|
||||||
Period = budgetPeriod,
|
|
||||||
StartDate = DateTime.Parse(startDate),
|
|
||||||
IsActive = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await budgetService.CreateBudgetAsync(budget);
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { result.Success, result.Message, BudgetId = budget.Id }, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
|
[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 budget amount")] decimal? amount = null,
|
||||||
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
|
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
|
||||||
[Description("Set active/inactive")] bool? isActive = null,
|
[Description("Set active/inactive")] bool? isActive = null,
|
||||||
IBudgetService budgetService = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var budget = await budgetService.GetBudgetByIdAsync(budgetId);
|
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,24 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class CategoryTools
|
public static class CategoryTools
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|
||||||
|
|
||||||
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
|
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
|
||||||
public static async Task<string> ListCategories(
|
public static async Task<string> ListCategories(
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var categories = await db.Transactions
|
return await api.ListCategoriesAsync();
|
||||||
.Where(t => t.Category != null && t.Category != "")
|
|
||||||
.GroupBy(t => t.Category!)
|
|
||||||
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
|
|
||||||
.OrderByDescending(x => x.Count)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var uncategorized = await db.Transactions
|
|
||||||
.CountAsync(t => t.Category == null || t.Category == "");
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { Categories = categories, UncategorizedCount = uncategorized }, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
|
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
|
||||||
public static async Task<string> GetCategoryMappings(
|
public static async Task<string> GetCategoryMappings(
|
||||||
[Description("Filter mappings to a specific category")] string? category = null,
|
[Description("Filter mappings to a specific category")] string? category = null,
|
||||||
ITransactionCategorizer categorizer = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var mappings = await categorizer.GetAllMappingsAsync();
|
return await api.GetCategoryMappingsAsync(category);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(category))
|
|
||||||
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
||||||
|
|
||||||
var result = mappings.Select(m => new
|
|
||||||
{
|
|
||||||
m.Id,
|
|
||||||
m.Pattern,
|
|
||||||
m.Category,
|
|
||||||
m.MerchantId,
|
|
||||||
m.Priority
|
|
||||||
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(result, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
|
[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("Category to assign when pattern matches")] string category,
|
||||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
||||||
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
|
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
|
||||||
MoneyMapContext db = default!,
|
MoneyMapApiClient api = default!)
|
||||||
IMerchantService merchantService = default!)
|
|
||||||
{
|
{
|
||||||
int? merchantId = null;
|
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,26 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class DashboardTools
|
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.")]
|
[McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")]
|
||||||
public static async Task<string> GetDashboard(
|
public static async Task<string> GetDashboard(
|
||||||
[Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
|
[Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
|
||||||
[Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
|
[Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
|
||||||
IDashboardService dashboardService = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var data = await dashboardService.GetDashboardDataAsync(
|
return await api.GetDashboardAsync(topCategoriesCount, recentTransactionsCount);
|
||||||
topCategoriesCount ?? 8,
|
|
||||||
recentTransactionsCount ?? 20);
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(data, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
|
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
|
||||||
public static async Task<string> GetMonthlyTrend(
|
public static async Task<string> GetMonthlyTrend(
|
||||||
[Description("Number of months to include (default 6)")] int? months = null,
|
[Description("Number of months to include (default 6)")] int? months = null,
|
||||||
[Description("Filter to a specific category")] string? category = null,
|
[Description("Filter to a specific category")] string? category = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var monthCount = months ?? 6;
|
return await api.GetMonthlyTrendAsync(months, category);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,25 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class MerchantTools
|
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.")]
|
[McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")]
|
||||||
public static async Task<string> ListMerchants(
|
public static async Task<string> ListMerchants(
|
||||||
[Description("Filter merchants by name (contains)")] string? query = null,
|
[Description("Filter merchants by name (contains)")] string? query = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var q = db.Merchants
|
return await api.ListMerchantsAsync(query);
|
||||||
.Include(m => m.Transactions)
|
|
||||||
.Include(m => m.CategoryMappings)
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
|
||||||
q = q.Where(m => m.Name.Contains(query));
|
|
||||||
|
|
||||||
var merchants = await q
|
|
||||||
.OrderBy(m => m.Name)
|
|
||||||
.Select(m => new
|
|
||||||
{
|
|
||||||
m.Id,
|
|
||||||
m.Name,
|
|
||||||
TransactionCount = m.Transactions.Count,
|
|
||||||
MappingCount = m.CategoryMappings.Count,
|
|
||||||
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(new { Count = merchants.Count, Merchants = merchants }, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")]
|
[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(
|
public static async Task<string> MergeMerchants(
|
||||||
[Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
|
[Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
|
||||||
[Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
|
[Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
if (sourceMerchantId == targetMerchantId)
|
return await api.MergeMerchantsAsync(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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,25 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using ImageMagick;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
using MoneyMap.Models;
|
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class ReceiptTools
|
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.")]
|
[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(
|
public static async Task<string> GetReceiptImage(
|
||||||
[Description("Receipt ID")] long receiptId,
|
[Description("Receipt ID")] long receiptId,
|
||||||
MoneyMapContext db = default!,
|
MoneyMapApiClient api = default!)
|
||||||
IReceiptStorageOptions storageOptions = default!)
|
|
||||||
{
|
{
|
||||||
var receipt = await db.Receipts.FindAsync(receiptId);
|
return await api.GetReceiptImageAsync(receiptId);
|
||||||
if (receipt == null)
|
|
||||||
return "Receipt not found";
|
|
||||||
|
|
||||||
var basePath = Path.GetFullPath(storageOptions.ReceiptsBasePath);
|
|
||||||
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
|
|
||||||
|
|
||||||
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return "Invalid receipt path";
|
|
||||||
|
|
||||||
if (!File.Exists(fullPath))
|
|
||||||
return "Receipt file not found on disk";
|
|
||||||
|
|
||||||
byte[] imageBytes;
|
|
||||||
string mimeType;
|
|
||||||
|
|
||||||
if (receipt.ContentType == "application/pdf")
|
|
||||||
{
|
|
||||||
var settings = new MagickReadSettings { Density = new Density(220) };
|
|
||||||
using var image = new MagickImage(fullPath + "[0]", settings);
|
|
||||||
image.Format = MagickFormat.Png;
|
|
||||||
image.BackgroundColor = MagickColors.White;
|
|
||||||
image.Alpha(AlphaOption.Remove);
|
|
||||||
imageBytes = image.ToByteArray();
|
|
||||||
mimeType = "image/png";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
imageBytes = await File.ReadAllBytesAsync(fullPath);
|
|
||||||
mimeType = receipt.ContentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
var base64 = Convert.ToBase64String(imageBytes);
|
|
||||||
return JsonSerializer.Serialize(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length }, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")]
|
[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(
|
public static async Task<string> GetReceiptText(
|
||||||
[Description("Receipt ID")] long receiptId,
|
[Description("Receipt ID")] long receiptId,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var receipt = await db.Receipts
|
return await api.GetReceiptTextAsync(receiptId);
|
||||||
.Include(r => r.LineItems)
|
|
||||||
.Include(r => r.Transaction)
|
|
||||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
|
||||||
|
|
||||||
if (receipt == null)
|
|
||||||
return "Receipt not found";
|
|
||||||
|
|
||||||
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
|
|
||||||
return JsonSerializer.Serialize(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() }, JsonOptions);
|
|
||||||
|
|
||||||
var result = new
|
|
||||||
{
|
|
||||||
receipt.Id,
|
|
||||||
receipt.Merchant,
|
|
||||||
receipt.ReceiptDate,
|
|
||||||
receipt.DueDate,
|
|
||||||
receipt.Subtotal,
|
|
||||||
receipt.Tax,
|
|
||||||
receipt.Total,
|
|
||||||
receipt.Currency,
|
|
||||||
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
|
|
||||||
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
|
|
||||||
{
|
|
||||||
li.LineNumber,
|
|
||||||
li.Description,
|
|
||||||
li.Quantity,
|
|
||||||
li.UnitPrice,
|
|
||||||
li.LineTotal,
|
|
||||||
li.Category
|
|
||||||
}).ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(result, JsonOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
|
[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 transaction ID")] long? transactionId = null,
|
||||||
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
|
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
|
||||||
[Description("Max results (default 50)")] int? limit = null,
|
[Description("Max results (default 50)")] int? limit = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var q = db.Receipts
|
return await api.ListReceiptsAsync(transactionId, parseStatus, limit);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
|
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
|
||||||
public static async Task<string> GetReceiptDetails(
|
public static async Task<string> GetReceiptDetails(
|
||||||
[Description("Receipt ID")] long receiptId,
|
[Description("Receipt ID")] long receiptId,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var receipt = await db.Receipts
|
return await api.GetReceiptDetailsAsync(receiptId);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using MoneyMap.Data;
|
|
||||||
using MoneyMap.Services;
|
|
||||||
|
|
||||||
namespace MoneyMap.Mcp.Tools;
|
namespace MoneyMap.Mcp.Tools;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public static class TransactionTools
|
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.")]
|
[McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")]
|
||||||
public static async Task<string> SearchTransactions(
|
public static async Task<string> SearchTransactions(
|
||||||
[Description("Full-text search across name, memo, and category")] string? query = null,
|
[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("Filter by type: 'debit' or 'credit'")] string? type = null,
|
||||||
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
|
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
|
||||||
[Description("Max results to return (default 50)")] int? limit = null,
|
[Description("Max results to return (default 50)")] int? limit = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var q = db.Transactions
|
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
|
||||||
.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.")]
|
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
|
||||||
public static async Task<string> GetTransaction(
|
public static async Task<string> GetTransaction(
|
||||||
[Description("Transaction ID")] long transactionId,
|
[Description("Transaction ID")] long transactionId,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var t = await db.Transactions
|
return await api.GetTransactionAsync(transactionId);
|
||||||
.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.")]
|
[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("Start date (inclusive), e.g. 2026-01-01")] string startDate,
|
||||||
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
||||||
[Description("Filter to specific account ID")] int? accountId = null,
|
[Description("Filter to specific account ID")] int? accountId = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var start = DateTime.Parse(startDate);
|
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
|
||||||
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.")]
|
[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("Start date (inclusive), e.g. 2026-01-01")] string startDate,
|
||||||
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
||||||
[Description("Filter to specific account ID")] int? accountId = null,
|
[Description("Filter to specific account ID")] int? accountId = null,
|
||||||
MoneyMapContext db = default!)
|
MoneyMapApiClient api = default!)
|
||||||
{
|
{
|
||||||
var start = DateTime.Parse(startDate);
|
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
|
||||||
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.")]
|
[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("Array of transaction IDs to update")] long[] transactionIds,
|
||||||
[Description("New category to assign")] string category,
|
[Description("New category to assign")] string category,
|
||||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
||||||
MoneyMapContext db = default!,
|
MoneyMapApiClient api = default!)
|
||||||
IMerchantService merchantService = default!)
|
|
||||||
{
|
{
|
||||||
var transactions = await db.Transactions
|
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
|
||||||
.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.")]
|
[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("Only recategorize transactions currently in this category")] string? fromCategory = null,
|
||||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = 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,
|
[Description("If true (default), only shows what would change without applying")] bool dryRun = true,
|
||||||
MoneyMapContext db = default!,
|
MoneyMapApiClient api = default!)
|
||||||
IMerchantService merchantService = default!)
|
|
||||||
{
|
{
|
||||||
var q = db.Transactions
|
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"MoneyMapApi": {
|
||||||
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
|
"BaseUrl": "http://barge.lan:5010/"
|
||||||
},
|
|
||||||
"Receipts": {
|
|
||||||
"StoragePath": "\\\\TRUENAS\\receipts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+10
-14
@@ -27,6 +27,10 @@ builder.Services.AddSession(options =>
|
|||||||
builder.Services.AddRazorPages()
|
builder.Services.AddRazorPages()
|
||||||
.AddSessionStateTempDataProvider();
|
.AddSessionStateTempDataProvider();
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
// Receipt parse queue and background worker
|
// Receipt parse queue and background worker
|
||||||
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
|
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
|
||||||
builder.Services.AddHostedService<ReceiptParseWorkerService>();
|
builder.Services.AddHostedService<ReceiptParseWorkerService>();
|
||||||
@@ -70,21 +74,13 @@ app.UseSession();
|
|||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapRazorPages();
|
if (app.Environment.IsDevelopment())
|
||||||
|
|
||||||
// Financial Audit API endpoint
|
|
||||||
app.MapGet("/api/audit", async (
|
|
||||||
IFinancialAuditService auditService,
|
|
||||||
DateTime? startDate,
|
|
||||||
DateTime? endDate,
|
|
||||||
bool includeTransactions = false) =>
|
|
||||||
{
|
{
|
||||||
var end = endDate ?? DateTime.Today;
|
app.UseSwagger();
|
||||||
var start = startDate ?? end.AddDays(-90);
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
var result = await auditService.GenerateAuditAsync(start, end, includeTransactions);
|
app.MapRazorPages();
|
||||||
return Results.Ok(result);
|
app.MapControllers();
|
||||||
})
|
|
||||||
.WithName("GetFinancialAudit");
|
|
||||||
|
|
||||||
app.Run();
|
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 |
|
|
||||||
Reference in New Issue
Block a user