Compare commits

...

12 Commits

Author SHA1 Message Date
aj 274569bd79 refactor(mcp): rewrite all tools to use MoneyMapApiClient instead of direct DB access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:40:23 -04:00
aj 4bee73ba26 refactor(mcp): remove Core dependency, switch to HttpClient-based architecture
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:45 -04:00
aj 6c4f4bea7f feat(mcp): add MoneyMapApiClient typed HttpClient for API communication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:36 -04:00
aj db1d96476b feat(api): add DashboardController with overview and monthly-trend endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:57 -04:00
aj 51d6aee434 feat(api): add AccountsController with accounts and cards list endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:53 -04:00
aj c34ea74459 feat(api): add MerchantsController with list and merge endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:49 -04:00
aj 9dc1a9064d feat(api): add ReceiptsController with list, detail, image, and text endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:06 -04:00
aj 5b4a673f9d feat(api): add CategoriesController with list, mappings, and add-mapping endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:36 -04:00
aj 004f99c2b4 feat(api): add BudgetsController with status, create, update endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:07 -04:00
aj e773a0f218 feat(api): add TransactionsController with search, detail, category, and summary endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:33:02 -04:00
aj ccedea6e67 feat(api): add Health and Audit controllers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:32:12 -04:00
aj 768b5e015e feat(api): add controller infrastructure, Swagger, remove inline /api/audit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:30:49 -04:00
23 changed files with 1245 additions and 696 deletions
@@ -1,15 +0,0 @@
using Microsoft.Extensions.Configuration;
using MoneyMap.Services;
namespace MoneyMap.Mcp;
public class ConfigReceiptStorageOptions : IReceiptStorageOptions
{
public string ReceiptsBasePath { get; }
public ConfigReceiptStorageOptions(IConfiguration config)
{
ReceiptsBasePath = config["Receipts:StoragePath"]
?? throw new InvalidOperationException("Receipts:StoragePath not configured");
}
}
+1 -8
View File
@@ -6,17 +6,10 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
+241
View File
@@ -0,0 +1,241 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace MoneyMap.Mcp;
public class MoneyMapApiClient
{
private readonly HttpClient _http;
public MoneyMapApiClient(HttpClient http) => _http = http;
public async Task<string> HealthCheckAsync()
{
return await GetAsync("/api/health");
}
// --- Transactions ---
public async Task<string> SearchTransactionsAsync(
string? query, string? startDate, string? endDate, string? category,
string? merchantName, decimal? minAmount, decimal? maxAmount,
int? accountId, int? cardId, string? type, bool? uncategorizedOnly, int? limit)
{
var qs = BuildQueryString(
("query", query), ("startDate", startDate), ("endDate", endDate),
("category", category), ("merchantName", merchantName),
("minAmount", minAmount?.ToString()), ("maxAmount", maxAmount?.ToString()),
("accountId", accountId?.ToString()), ("cardId", cardId?.ToString()),
("type", type), ("uncategorizedOnly", uncategorizedOnly?.ToString()),
("limit", limit?.ToString()));
return await GetAsync($"/api/transactions{qs}");
}
public async Task<string> GetTransactionAsync(long transactionId)
{
return await GetAsync($"/api/transactions/{transactionId}");
}
public async Task<string> UpdateTransactionCategoryAsync(long[] transactionIds, string category, string? merchantName)
{
var body = new { TransactionIds = transactionIds, Category = category, MerchantName = merchantName };
return await PutAsync($"/api/transactions/{transactionIds[0]}/category", body);
}
public async Task<string> BulkRecategorizeAsync(string namePattern, string toCategory, string? fromCategory, string? merchantName, bool dryRun)
{
var body = new { NamePattern = namePattern, ToCategory = toCategory, FromCategory = fromCategory, MerchantName = merchantName, DryRun = dryRun };
return await PostAsync("/api/transactions/bulk-recategorize", body);
}
public async Task<string> GetSpendingSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/spending-summary{qs}");
}
public async Task<string> GetIncomeSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/income-summary{qs}");
}
// --- Budgets ---
public async Task<string> GetBudgetStatusAsync(string? asOfDate)
{
var qs = BuildQueryString(("asOfDate", asOfDate));
return await GetAsync($"/api/budgets/status{qs}");
}
public async Task<string> CreateBudgetAsync(string? category, decimal amount, string period, string startDate)
{
var body = new { Category = category, Amount = amount, Period = period, StartDate = startDate };
return await PostAsync("/api/budgets", body);
}
public async Task<string> UpdateBudgetAsync(int budgetId, decimal? amount, string? period, bool? isActive)
{
var body = new { Amount = amount, Period = period, IsActive = isActive };
return await PutAsync($"/api/budgets/{budgetId}", body);
}
// --- Categories ---
public async Task<string> ListCategoriesAsync()
{
return await GetAsync("/api/categories");
}
public async Task<string> GetCategoryMappingsAsync(string? category)
{
var qs = BuildQueryString(("category", category));
return await GetAsync($"/api/categories/mappings{qs}");
}
public async Task<string> AddCategoryMappingAsync(string pattern, string category, string? merchantName, int priority)
{
var body = new { Pattern = pattern, Category = category, MerchantName = merchantName, Priority = priority };
return await PostAsync("/api/categories/mappings", body);
}
// --- Receipts ---
public async Task<string> ListReceiptsAsync(long? transactionId, string? parseStatus, int? limit)
{
var qs = BuildQueryString(("transactionId", transactionId?.ToString()), ("parseStatus", parseStatus), ("limit", limit?.ToString()));
return await GetAsync($"/api/receipts{qs}");
}
public async Task<string> GetReceiptDetailsAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}");
}
public async Task<string> GetReceiptImageAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/image");
}
public async Task<string> GetReceiptTextAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/text");
}
// --- Merchants ---
public async Task<string> ListMerchantsAsync(string? query)
{
var qs = BuildQueryString(("query", query));
return await GetAsync($"/api/merchants{qs}");
}
public async Task<string> MergeMerchantsAsync(int sourceMerchantId, int targetMerchantId)
{
var body = new { SourceMerchantId = sourceMerchantId, TargetMerchantId = targetMerchantId };
return await PostAsync("/api/merchants/merge", body);
}
// --- Accounts ---
public async Task<string> ListAccountsAsync()
{
return await GetAsync("/api/accounts");
}
public async Task<string> ListCardsAsync(int? accountId)
{
var qs = BuildQueryString(("accountId", accountId?.ToString()));
return await GetAsync($"/api/accounts/cards{qs}");
}
// --- Dashboard ---
public async Task<string> GetDashboardAsync(int? topCategoriesCount, int? recentTransactionsCount)
{
var qs = BuildQueryString(("topCategoriesCount", topCategoriesCount?.ToString()), ("recentTransactionsCount", recentTransactionsCount?.ToString()));
return await GetAsync($"/api/dashboard{qs}");
}
public async Task<string> GetMonthlyTrendAsync(int? months, string? category)
{
var qs = BuildQueryString(("months", months?.ToString()), ("category", category));
return await GetAsync($"/api/dashboard/monthly-trend{qs}");
}
// --- HTTP Helpers ---
private async Task<string> GetAsync(string path)
{
try
{
var response = await _http.GetAsync(path);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var body = await response.Content.ReadAsStringAsync();
return body.Length > 0 ? body : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PostAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PostAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PutAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PutAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private static string BuildQueryString(params (string key, string? value)[] parameters)
{
var pairs = parameters
.Where(p => !string.IsNullOrWhiteSpace(p.value))
.Select(p => $"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}");
var qs = string.Join("&", pairs);
return qs.Length > 0 ? $"?{qs}" : "";
}
}
+4 -4
View File
@@ -1,17 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoneyMap.Core;
using MoneyMap.Mcp;
using MoneyMap.Services;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMoneyMapCore(builder.Configuration);
builder.Services.AddSingleton<IReceiptStorageOptions, ConfigReceiptStorageOptions>();
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
});
builder.Services
.AddMcpServer()
+4 -48
View File
@@ -1,67 +1,23 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class AccountTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
public static async Task<string> ListAccounts(
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var accounts = await db.Accounts
.Include(a => a.Cards)
.Include(a => a.Transactions)
.OrderBy(a => a.Institution).ThenBy(a => a.Last4)
.Select(a => new
{
a.Id,
a.Institution,
a.Last4,
a.Owner,
Label = a.DisplayLabel,
TransactionCount = a.Transactions.Count,
CardCount = a.Cards.Count
})
.ToListAsync();
return JsonSerializer.Serialize(accounts, JsonOptions);
return await api.ListAccountsAsync();
}
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
public static async Task<string> ListCards(
[Description("Filter cards by account ID")] int? accountId = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var q = db.Cards
.Include(c => c.Account)
.Include(c => c.Transactions)
.AsQueryable();
if (accountId.HasValue)
q = q.Where(c => c.AccountId == accountId.Value);
var cards = await q
.OrderBy(c => c.Owner).ThenBy(c => c.Last4)
.Select(c => new
{
c.Id,
c.Issuer,
c.Last4,
c.Owner,
Label = c.DisplayLabel,
Account = c.Account != null ? c.Account.Institution + " " + c.Account.Last4 : null,
AccountId = c.AccountId,
TransactionCount = c.Transactions.Count
})
.ToListAsync();
return JsonSerializer.Serialize(cards, JsonOptions);
return await api.ListCardsAsync(accountId);
}
}
+6 -64
View File
@@ -1,42 +1,17 @@
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class BudgetTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")]
public static async Task<string> GetBudgetStatus(
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
IBudgetService budgetService = default!)
MoneyMapApiClient api = default!)
{
DateTime? date = null;
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
date = parsed;
var statuses = await budgetService.GetAllBudgetStatusesAsync(date);
var result = statuses.Select(s => new
{
s.Budget.Id,
Category = s.Budget.DisplayName,
s.Budget.Amount,
Period = s.Budget.Period.ToString(),
s.PeriodStart,
s.PeriodEnd,
s.Spent,
s.Remaining,
PercentUsed = Math.Round(s.PercentUsed, 1),
s.IsOverBudget
}).ToList();
return JsonSerializer.Serialize(result, JsonOptions);
return await api.GetBudgetStatusAsync(asOfDate);
}
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
@@ -45,23 +20,9 @@ public static class BudgetTools
[Description("Period: Weekly, Monthly, or Yearly")] string period,
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
[Description("Category name (omit for total spending budget)")] string? category = null,
IBudgetService budgetService = default!)
MoneyMapApiClient api = default!)
{
if (!Enum.TryParse<BudgetPeriod>(period, true, out var budgetPeriod))
return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly.";
var budget = new Budget
{
Category = category,
Amount = amount,
Period = budgetPeriod,
StartDate = DateTime.Parse(startDate),
IsActive = true
};
var result = await budgetService.CreateBudgetAsync(budget);
return JsonSerializer.Serialize(new { result.Success, result.Message, BudgetId = budget.Id }, JsonOptions);
return await api.CreateBudgetAsync(category, amount, period, startDate);
}
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
@@ -70,27 +31,8 @@ public static class BudgetTools
[Description("New budget amount")] decimal? amount = null,
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
[Description("Set active/inactive")] bool? isActive = null,
IBudgetService budgetService = default!)
MoneyMapApiClient api = default!)
{
var budget = await budgetService.GetBudgetByIdAsync(budgetId);
if (budget == null)
return "Budget not found";
if (amount.HasValue)
budget.Amount = amount.Value;
if (!string.IsNullOrWhiteSpace(period))
{
if (!Enum.TryParse<BudgetPeriod>(period, true, out var budgetPeriod))
return $"Invalid period '{period}'. Must be Weekly, Monthly, or Yearly.";
budget.Period = budgetPeriod;
}
if (isActive.HasValue)
budget.IsActive = isActive.Value;
var result = await budgetService.UpdateBudgetAsync(budget);
return JsonSerializer.Serialize(new { result.Success, result.Message }, JsonOptions);
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
}
}
+6 -52
View File
@@ -1,54 +1,24 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class CategoryTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
public static async Task<string> ListCategories(
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var categories = await db.Transactions
.Where(t => t.Category != null && t.Category != "")
.GroupBy(t => t.Category!)
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
.OrderByDescending(x => x.Count)
.ToListAsync();
var uncategorized = await db.Transactions
.CountAsync(t => t.Category == null || t.Category == "");
return JsonSerializer.Serialize(new { Categories = categories, UncategorizedCount = uncategorized }, JsonOptions);
return await api.ListCategoriesAsync();
}
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
public static async Task<string> GetCategoryMappings(
[Description("Filter mappings to a specific category")] string? category = null,
ITransactionCategorizer categorizer = default!)
MoneyMapApiClient api = default!)
{
var mappings = await categorizer.GetAllMappingsAsync();
if (!string.IsNullOrWhiteSpace(category))
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
var result = mappings.Select(m => new
{
m.Id,
m.Pattern,
m.Category,
m.MerchantId,
m.Priority
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
return JsonSerializer.Serialize(result, JsonOptions);
return await api.GetCategoryMappingsAsync(category);
}
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
@@ -57,24 +27,8 @@ public static class CategoryTools
[Description("Category to assign when pattern matches")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
MoneyMapContext db = default!,
IMerchantService merchantService = default!)
MoneyMapApiClient api = default!)
{
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(merchantName))
merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
var mapping = new MoneyMap.Models.CategoryMapping
{
Pattern = pattern,
Category = category,
MerchantId = merchantId,
Priority = priority
};
db.CategoryMappings.Add(mapping);
await db.SaveChangesAsync();
return JsonSerializer.Serialize(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = merchantName, mapping.Priority }, JsonOptions);
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
}
}
+4 -46
View File
@@ -1,68 +1,26 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class DashboardTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")]
public static async Task<string> GetDashboard(
[Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
[Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
IDashboardService dashboardService = default!)
MoneyMapApiClient api = default!)
{
var data = await dashboardService.GetDashboardDataAsync(
topCategoriesCount ?? 8,
recentTransactionsCount ?? 20);
return JsonSerializer.Serialize(data, JsonOptions);
return await api.GetDashboardAsync(topCategoriesCount, recentTransactionsCount);
}
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
public static async Task<string> GetMonthlyTrend(
[Description("Number of months to include (default 6)")] int? months = null,
[Description("Filter to a specific category")] string? category = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var monthCount = months ?? 6;
var endDate = DateTime.Today;
var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
var q = db.Transactions
.Where(t => t.Date >= startDate && t.Date <= endDate)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
var monthly = await q
.GroupBy(t => new { t.Date.Year, t.Date.Month })
.Select(g => new
{
Year = g.Key.Year,
Month = g.Key.Month,
Total = g.Sum(t => Math.Abs(t.Amount)),
Count = g.Count()
})
.OrderBy(x => x.Year).ThenBy(x => x.Month)
.ToListAsync();
var result = monthly.Select(m => new
{
Period = $"{m.Year}-{m.Month:D2}",
m.Total,
m.Count
}).ToList();
return JsonSerializer.Serialize(new { Category = category ?? "All Spending", Months = result }, JsonOptions);
return await api.GetMonthlyTrendAsync(months, category);
}
}
+4 -74
View File
@@ -1,95 +1,25 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class MerchantTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")]
public static async Task<string> ListMerchants(
[Description("Filter merchants by name (contains)")] string? query = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var q = db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(m => m.Name.Contains(query));
var merchants = await q
.OrderBy(m => m.Name)
.Select(m => new
{
m.Id,
m.Name,
TransactionCount = m.Transactions.Count,
MappingCount = m.CategoryMappings.Count,
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
})
.ToListAsync();
return JsonSerializer.Serialize(new { Count = merchants.Count, Merchants = merchants }, JsonOptions);
return await api.ListMerchantsAsync(query);
}
[McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")]
public static async Task<string> MergeMerchants(
[Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
[Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
if (sourceMerchantId == targetMerchantId)
return "Source and target merchant cannot be the same";
var source = await db.Merchants.FindAsync(sourceMerchantId);
var target = await db.Merchants.FindAsync(targetMerchantId);
if (source == null)
return $"Source merchant {sourceMerchantId} not found";
if (target == null)
return $"Target merchant {targetMerchantId} not found";
var transactions = await db.Transactions
.Where(t => t.MerchantId == sourceMerchantId)
.ToListAsync();
foreach (var t in transactions)
t.MerchantId = targetMerchantId;
var sourceMappings = await db.CategoryMappings
.Where(cm => cm.MerchantId == sourceMerchantId)
.ToListAsync();
var targetMappingPatterns = await db.CategoryMappings
.Where(cm => cm.MerchantId == targetMerchantId)
.Select(cm => cm.Pattern)
.ToListAsync();
foreach (var mapping in sourceMappings)
{
if (targetMappingPatterns.Contains(mapping.Pattern))
db.CategoryMappings.Remove(mapping);
else
mapping.MerchantId = targetMerchantId;
}
db.Merchants.Remove(source);
await db.SaveChangesAsync();
return JsonSerializer.Serialize(new
{
Merged = true,
Source = new { source.Id, source.Name },
Target = new { target.Id, target.Name },
TransactionsReassigned = transactions.Count,
MappingsReassigned = sourceMappings.Count
}, JsonOptions);
return await api.MergeMerchantsAsync(sourceMerchantId, targetMerchantId);
}
}
+8 -165
View File
@@ -1,100 +1,25 @@
using System.ComponentModel;
using System.Text.Json;
using ImageMagick;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class ReceiptTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "get_receipt_image"), Description("Get a receipt image for visual inspection. Returns the image as base64-encoded data. Useful for verifying transaction categories.")]
public static async Task<string> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!,
IReceiptStorageOptions storageOptions = default!)
MoneyMapApiClient api = default!)
{
var receipt = await db.Receipts.FindAsync(receiptId);
if (receipt == null)
return "Receipt not found";
var basePath = Path.GetFullPath(storageOptions.ReceiptsBasePath);
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return "Invalid receipt path";
if (!File.Exists(fullPath))
return "Receipt file not found on disk";
byte[] imageBytes;
string mimeType;
if (receipt.ContentType == "application/pdf")
{
var settings = new MagickReadSettings { Density = new Density(220) };
using var image = new MagickImage(fullPath + "[0]", settings);
image.Format = MagickFormat.Png;
image.BackgroundColor = MagickColors.White;
image.Alpha(AlphaOption.Remove);
imageBytes = image.ToByteArray();
mimeType = "image/png";
}
else
{
imageBytes = await File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
var base64 = Convert.ToBase64String(imageBytes);
return JsonSerializer.Serialize(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length }, JsonOptions);
return await api.GetReceiptImageAsync(receiptId);
}
[McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")]
public static async Task<string> GetReceiptText(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var receipt = await db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == receiptId);
if (receipt == null)
return "Receipt not found";
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
return JsonSerializer.Serialize(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() }, JsonOptions);
var result = new
{
receipt.Id,
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency,
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList()
};
return JsonSerializer.Serialize(result, JsonOptions);
return await api.GetReceiptTextAsync(receiptId);
}
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
@@ -102,98 +27,16 @@ public static class ReceiptTools
[Description("Filter by transaction ID")] long? transactionId = null,
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
[Description("Max results (default 50)")] int? limit = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var q = db.Receipts
.Include(r => r.Transaction)
.AsQueryable();
if (transactionId.HasValue)
q = q.Where(r => r.TransactionId == transactionId.Value);
if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse<ReceiptParseStatus>(parseStatus, true, out var status))
q = q.Where(r => r.ParseStatus == status);
var results = await q
.OrderByDescending(r => r.UploadedAtUtc)
.Take(limit ?? 50)
.Select(r => new
{
r.Id,
r.FileName,
ParseStatus = r.ParseStatus.ToString(),
r.Merchant,
r.Total,
r.ReceiptDate,
r.UploadedAtUtc,
TransactionId = r.TransactionId,
TransactionName = r.Transaction != null ? r.Transaction.Name : null
})
.ToListAsync();
return JsonSerializer.Serialize(new { Count = results.Count, Receipts = results }, JsonOptions);
return await api.ListReceiptsAsync(transactionId, parseStatus, limit);
}
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
public static async Task<string> GetReceiptDetails(
[Description("Receipt ID")] long receiptId,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var receipt = await db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.Include(r => r.ParseLogs)
.FirstOrDefaultAsync(r => r.Id == receiptId);
if (receipt == null)
return "Receipt not found";
var result = new
{
receipt.Id,
receipt.FileName,
receipt.ContentType,
receipt.FileSizeBytes,
receipt.UploadedAtUtc,
ParseStatus = receipt.ParseStatus.ToString(),
ParsedData = new
{
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency
},
LinkedTransaction = receipt.Transaction != null ? new
{
receipt.Transaction.Id,
receipt.Transaction.Name,
receipt.Transaction.Date,
receipt.Transaction.Amount,
receipt.Transaction.Category
} : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList(),
ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
{
pl.Provider,
pl.Model,
pl.Success,
pl.Confidence,
pl.Error,
pl.StartedAtUtc
}).ToList()
};
return JsonSerializer.Serialize(result, JsonOptions);
return await api.GetReceiptDetailsAsync(receiptId);
}
}
+12 -201
View File
@@ -1,17 +1,11 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class TransactionTools
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
[McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")]
public static async Task<string> SearchTransactions(
[Description("Full-text search across name, memo, and category")] string? query = null,
@@ -26,108 +20,17 @@ public static class TransactionTools
[Description("Filter by type: 'debit' or 'credit'")] string? type = null,
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
[Description("Max results to return (default 50)")] int? limit = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var q = db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
q = q.Where(t => t.Date >= start);
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
q = q.Where(t => t.Date <= end);
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
if (!string.IsNullOrWhiteSpace(merchantName))
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
if (minAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
if (maxAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
if (cardId.HasValue)
q = q.Where(t => t.CardId == cardId.Value);
if (type?.ToLower() == "debit")
q = q.Where(t => t.Amount < 0);
else if (type?.ToLower() == "credit")
q = q.Where(t => t.Amount > 0);
if (uncategorizedOnly == true)
q = q.Where(t => t.Category == null || t.Category == "");
var results = await q
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
.Take(limit ?? 50)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
Account = t.Account!.Institution + " " + t.Account.Last4,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
ReceiptCount = t.Receipts.Count,
t.TransferToAccountId
})
.ToListAsync();
return JsonSerializer.Serialize(new { Count = results.Count, Transactions = results }, JsonOptions);
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
}
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
public static async Task<string> GetTransaction(
[Description("Transaction ID")] long transactionId,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var t = await db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.Where(t => t.Id == transactionId)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.TransactionType,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
MerchantId = t.MerchantId,
Account = t.Account!.Institution + " " + t.Account.Last4,
AccountId = t.AccountId,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
CardId = t.CardId,
t.Notes,
t.TransferToAccountId,
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
})
.FirstOrDefaultAsync();
if (t == null)
return "Transaction not found";
return JsonSerializer.Serialize(t, JsonOptions);
return await api.GetTransactionAsync(transactionId);
}
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
@@ -135,29 +38,9 @@ public static class TransactionTools
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
var q = db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Category ?? "Uncategorized")
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary }, JsonOptions);
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
@@ -165,29 +48,9 @@ public static class TransactionTools
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapContext db = default!)
MoneyMapApiClient api = default!)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
var q = db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount > 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Name)
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return JsonSerializer.Serialize(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary }, JsonOptions);
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
@@ -195,30 +58,9 @@ public static class TransactionTools
[Description("Array of transaction IDs to update")] long[] transactionIds,
[Description("New category to assign")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
MoneyMapContext db = default!,
IMerchantService merchantService = default!)
MoneyMapApiClient api = default!)
{
var transactions = await db.Transactions
.Where(t => transactionIds.Contains(t.Id))
.ToListAsync();
if (!transactions.Any())
return "No transactions found with the provided IDs";
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(merchantName))
merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
foreach (var t in transactions)
{
t.Category = category;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await db.SaveChangesAsync();
return JsonSerializer.Serialize(new { Updated = transactions.Count, Category = category, Merchant = merchantName }, JsonOptions);
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
}
[McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
@@ -228,39 +70,8 @@ public static class TransactionTools
[Description("Only recategorize transactions currently in this category")] string? fromCategory = null,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("If true (default), only shows what would change without applying")] bool dryRun = true,
MoneyMapContext db = default!,
IMerchantService merchantService = default!)
MoneyMapApiClient api = default!)
{
var q = db.Transactions
.Where(t => t.Name.Contains(namePattern));
if (!string.IsNullOrWhiteSpace(fromCategory))
q = q.Where(t => t.Category == fromCategory);
var transactions = await q.ToListAsync();
if (!transactions.Any())
return JsonSerializer.Serialize(new { Message = "No transactions match the pattern", Pattern = namePattern, FromCategory = fromCategory }, JsonOptions);
if (dryRun)
{
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
return JsonSerializer.Serialize(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, ToCategory = toCategory }, JsonOptions);
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(merchantName))
merchantId = await merchantService.GetOrCreateIdAsync(merchantName);
foreach (var t in transactions)
{
t.Category = toCategory;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await db.SaveChangesAsync();
return JsonSerializer.Serialize(new { Applied = true, Updated = transactions.Count, ToCategory = toCategory, Merchant = merchantName }, JsonOptions);
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
}
}
+2 -5
View File
@@ -1,8 +1,5 @@
{
"ConnectionStrings": {
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
},
"Receipts": {
"StoragePath": "\\\\TRUENAS\\receipts"
"MoneyMapApi": {
"BaseUrl": "http://barge.lan:5010/"
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
private readonly MoneyMapContext _db;
public AccountsController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List()
{
var accounts = await _db.Accounts
.Include(a => a.Cards)
.Include(a => a.Transactions)
.OrderBy(a => a.Institution).ThenBy(a => a.Last4)
.Select(a => new
{
a.Id,
a.Institution,
a.Last4,
a.Owner,
Label = a.DisplayLabel,
TransactionCount = a.Transactions.Count,
CardCount = a.Cards.Count
})
.ToListAsync();
return Ok(accounts);
}
[HttpGet("cards")]
public async Task<IActionResult> ListCards([FromQuery] int? accountId = null)
{
var q = _db.Cards
.Include(c => c.Account)
.Include(c => c.Transactions)
.AsQueryable();
if (accountId.HasValue)
q = q.Where(c => c.AccountId == accountId.Value);
var cards = await q
.OrderBy(c => c.Owner).ThenBy(c => c.Last4)
.Select(c => new
{
c.Id,
c.Issuer,
c.Last4,
c.Owner,
Label = c.DisplayLabel,
Account = c.Account != null ? c.Account.Institution + " " + c.Account.Last4 : null,
AccountId = c.AccountId,
TransactionCount = c.Transactions.Count
})
.ToListAsync();
return Ok(cards);
}
}
+26
View File
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuditController : ControllerBase
{
private readonly IFinancialAuditService _auditService;
public AuditController(IFinancialAuditService auditService) => _auditService = auditService;
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] bool includeTransactions = false)
{
var end = endDate ?? DateTime.Today;
var start = startDate ?? end.AddDays(-90);
var result = await _auditService.GenerateAuditAsync(start, end, includeTransactions);
return Ok(result);
}
}
+103
View File
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BudgetsController : ControllerBase
{
private readonly IBudgetService _budgetService;
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
[HttpGet("status")]
public async Task<IActionResult> GetStatus([FromQuery] string? asOfDate = null)
{
DateTime? date = null;
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
date = parsed;
var statuses = await _budgetService.GetAllBudgetStatusesAsync(date);
var result = statuses.Select(s => new
{
s.Budget.Id,
Category = s.Budget.DisplayName,
s.Budget.Amount,
Period = s.Budget.Period.ToString(),
s.PeriodStart,
s.PeriodEnd,
s.Spent,
s.Remaining,
PercentUsed = Math.Round(s.PercentUsed, 1),
s.IsOverBudget
}).ToList();
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest request)
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
if (!DateTime.TryParse(request.StartDate, out var startDate))
return BadRequest(new { message = "Invalid start date format" });
var budget = new Budget
{
Category = request.Category,
Amount = request.Amount,
Period = budgetPeriod,
StartDate = startDate,
IsActive = true
};
var result = await _budgetService.CreateBudgetAsync(budget);
return Ok(new { result.Success, result.Message, BudgetId = budget.Id });
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetRequest request)
{
var budget = await _budgetService.GetBudgetByIdAsync(id);
if (budget == null)
return NotFound(new { message = "Budget not found" });
if (request.Amount.HasValue)
budget.Amount = request.Amount.Value;
if (!string.IsNullOrWhiteSpace(request.Period))
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
budget.Period = budgetPeriod;
}
if (request.IsActive.HasValue)
budget.IsActive = request.IsActive.Value;
var result = await _budgetService.UpdateBudgetAsync(budget);
return Ok(new { result.Success, result.Message });
}
}
public class CreateBudgetRequest
{
public string? Category { get; set; }
public decimal Amount { get; set; }
public string Period { get; set; } = "";
public string StartDate { get; set; } = "";
}
public class UpdateBudgetRequest
{
public decimal? Amount { get; set; }
public string? Period { get; set; }
public bool? IsActive { get; set; }
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoriesController(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{
_db = db;
_categorizer = categorizer;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> List()
{
var categories = await _db.Transactions
.Where(t => t.Category != null && t.Category != "")
.GroupBy(t => t.Category!)
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
.OrderByDescending(x => x.Count)
.ToListAsync();
var uncategorized = await _db.Transactions
.CountAsync(t => t.Category == null || t.Category == "");
return Ok(new { Categories = categories, UncategorizedCount = uncategorized });
}
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings([FromQuery] string? category = null)
{
var mappings = await _categorizer.GetAllMappingsAsync();
if (!string.IsNullOrWhiteSpace(category))
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
var result = mappings.Select(m => new
{
m.Id,
m.Pattern,
m.Category,
m.MerchantId,
m.Priority
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
return Ok(result);
}
[HttpPost("mappings")]
public async Task<IActionResult> AddMapping([FromBody] CreateCategoryMappingRequest request)
{
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
var mapping = new CategoryMapping
{
Pattern = request.Pattern,
Category = request.Category,
MerchantId = merchantId,
Priority = request.Priority
};
_db.CategoryMappings.Add(mapping);
await _db.SaveChangesAsync();
return Ok(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = request.MerchantName, mapping.Priority });
}
}
public class CreateCategoryMappingRequest
{
public string Pattern { get; set; } = "";
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
public int Priority { get; set; }
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboardService;
private readonly MoneyMapContext _db;
public DashboardController(IDashboardService dashboardService, MoneyMapContext db)
{
_dashboardService = dashboardService;
_db = db;
}
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] int? topCategoriesCount = null,
[FromQuery] int? recentTransactionsCount = null)
{
var data = await _dashboardService.GetDashboardDataAsync(
topCategoriesCount ?? 8,
recentTransactionsCount ?? 20);
return Ok(data);
}
[HttpGet("monthly-trend")]
public async Task<IActionResult> MonthlyTrend(
[FromQuery] int? months = null,
[FromQuery] string? category = null)
{
var monthCount = months ?? 6;
var endDate = DateTime.Today;
var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
var q = _db.Transactions
.Where(t => t.Date >= startDate && t.Date <= endDate)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
var monthly = await q
.GroupBy(t => new { t.Date.Year, t.Date.Month })
.Select(g => new
{
Year = g.Key.Year,
Month = g.Key.Month,
Total = g.Sum(t => Math.Abs(t.Amount)),
Count = g.Count()
})
.OrderBy(x => x.Year).ThenBy(x => x.Month)
.ToListAsync();
var result = monthly.Select(m => new
{
Period = $"{m.Year}-{m.Month:D2}",
m.Total,
m.Count
}).ToList();
return Ok(new { Category = category ?? "All Spending", Months = result });
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
private readonly MoneyMapContext _db;
public HealthController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get()
{
var canConnect = await _db.Database.CanConnectAsync();
if (!canConnect)
return StatusCode(503, new { status = "unhealthy", reason = "database unreachable" });
return Ok(new { status = "healthy" });
}
}
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MerchantsController : ControllerBase
{
private readonly MoneyMapContext _db;
public MerchantsController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? query = null)
{
var q = _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(m => m.Name.Contains(query));
var merchants = await q
.OrderBy(m => m.Name)
.Select(m => new
{
m.Id,
m.Name,
TransactionCount = m.Transactions.Count,
MappingCount = m.CategoryMappings.Count,
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
})
.ToListAsync();
return Ok(new { Count = merchants.Count, Merchants = merchants });
}
[HttpPost("merge")]
public async Task<IActionResult> Merge([FromBody] MergeMerchantsRequest request)
{
if (request.SourceMerchantId == request.TargetMerchantId)
return BadRequest(new { message = "Source and target merchant cannot be the same" });
var source = await _db.Merchants.FindAsync(request.SourceMerchantId);
var target = await _db.Merchants.FindAsync(request.TargetMerchantId);
if (source == null)
return NotFound(new { message = $"Source merchant {request.SourceMerchantId} not found" });
if (target == null)
return NotFound(new { message = $"Target merchant {request.TargetMerchantId} not found" });
var transactions = await _db.Transactions
.Where(t => t.MerchantId == request.SourceMerchantId)
.ToListAsync();
foreach (var t in transactions)
t.MerchantId = request.TargetMerchantId;
var sourceMappings = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.SourceMerchantId)
.ToListAsync();
var targetMappingPatterns = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.TargetMerchantId)
.Select(cm => cm.Pattern)
.ToListAsync();
foreach (var mapping in sourceMappings)
{
if (targetMappingPatterns.Contains(mapping.Pattern))
_db.CategoryMappings.Remove(mapping);
else
mapping.MerchantId = request.TargetMerchantId;
}
_db.Merchants.Remove(source);
await _db.SaveChangesAsync();
return Ok(new
{
Merged = true,
Source = new { source.Id, source.Name },
Target = new { target.Id, target.Name },
TransactionsReassigned = transactions.Count,
MappingsReassigned = sourceMappings.Count
});
}
}
public class MergeMerchantsRequest
{
public int SourceMerchantId { get; set; }
public int TargetMerchantId { get; set; }
}
+197
View File
@@ -0,0 +1,197 @@
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReceiptsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IReceiptStorageOptions _storageOptions;
public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions)
{
_db = db;
_storageOptions = storageOptions;
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] long? transactionId = null,
[FromQuery] string? parseStatus = null,
[FromQuery] int? limit = null)
{
var q = _db.Receipts
.Include(r => r.Transaction)
.AsQueryable();
if (transactionId.HasValue)
q = q.Where(r => r.TransactionId == transactionId.Value);
if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse<ReceiptParseStatus>(parseStatus, true, out var status))
q = q.Where(r => r.ParseStatus == status);
var results = await q
.OrderByDescending(r => r.UploadedAtUtc)
.Take(limit ?? 50)
.Select(r => new
{
r.Id,
r.FileName,
ParseStatus = r.ParseStatus.ToString(),
r.Merchant,
r.Total,
r.ReceiptDate,
r.UploadedAtUtc,
TransactionId = r.TransactionId,
TransactionName = r.Transaction != null ? r.Transaction.Name : null
})
.ToListAsync();
return Ok(new { Count = results.Count, Receipts = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetDetails(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.Include(r => r.ParseLogs)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
var result = new
{
receipt.Id,
receipt.FileName,
receipt.ContentType,
receipt.FileSizeBytes,
receipt.UploadedAtUtc,
ParseStatus = receipt.ParseStatus.ToString(),
ParsedData = new
{
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency
},
LinkedTransaction = receipt.Transaction != null ? new
{
receipt.Transaction.Id,
receipt.Transaction.Name,
receipt.Transaction.Date,
receipt.Transaction.Amount,
receipt.Transaction.Category
} : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList(),
ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
{
pl.Provider,
pl.Model,
pl.Success,
pl.Confidence,
pl.Error,
pl.StartedAtUtc
}).ToList()
};
return Ok(result);
}
[HttpGet("{id}/image")]
public async Task<IActionResult> GetImage(long id)
{
var receipt = await _db.Receipts.FindAsync(id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
var basePath = Path.GetFullPath(_storageOptions.ReceiptsBasePath);
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { message = "Invalid receipt path" });
if (!System.IO.File.Exists(fullPath))
return NotFound(new { message = "Receipt file not found on disk" });
byte[] imageBytes;
string mimeType;
if (receipt.ContentType == "application/pdf")
{
var settings = new MagickReadSettings { Density = new Density(220) };
using var image = new MagickImage(fullPath + "[0]", settings);
image.Format = MagickFormat.Png;
image.BackgroundColor = MagickColors.White;
image.Alpha(AlphaOption.Remove);
imageBytes = image.ToByteArray();
mimeType = "image/png";
}
else
{
imageBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
var base64 = Convert.ToBase64String(imageBytes);
return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length });
}
[HttpGet("{id}/text")]
public async Task<IActionResult> GetText(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() });
var result = new
{
receipt.Id,
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency,
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList()
};
return Ok(result);
}
}
@@ -0,0 +1,271 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IMerchantService _merchantService;
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
{
_db = db;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? query = null,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] string? category = null,
[FromQuery] string? merchantName = null,
[FromQuery] decimal? minAmount = null,
[FromQuery] decimal? maxAmount = null,
[FromQuery] int? accountId = null,
[FromQuery] int? cardId = null,
[FromQuery] string? type = null,
[FromQuery] bool? uncategorizedOnly = null,
[FromQuery] int? limit = null)
{
var q = _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
q = q.Where(t => t.Date >= start);
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
q = q.Where(t => t.Date <= end);
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
if (!string.IsNullOrWhiteSpace(merchantName))
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
if (minAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
if (maxAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
if (cardId.HasValue)
q = q.Where(t => t.CardId == cardId.Value);
if (type?.ToLower() == "debit")
q = q.Where(t => t.Amount < 0);
else if (type?.ToLower() == "credit")
q = q.Where(t => t.Amount > 0);
if (uncategorizedOnly == true)
q = q.Where(t => t.Category == null || t.Category == "");
var results = await q
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
.Take(limit ?? 50)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
Account = t.Account!.Institution + " " + t.Account.Last4,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
ReceiptCount = t.Receipts.Count,
t.TransferToAccountId
})
.ToListAsync();
return Ok(new { Count = results.Count, Transactions = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
var t = await _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.Where(t => t.Id == id)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.TransactionType,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
MerchantId = t.MerchantId,
Account = t.Account!.Institution + " " + t.Account.Last4,
AccountId = t.AccountId,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
CardId = t.CardId,
t.Notes,
t.TransferToAccountId,
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
})
.FirstOrDefaultAsync();
if (t == null)
return NotFound(new { message = "Transaction not found" });
return Ok(t);
}
[HttpPut("{id}/category")]
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
{
var transactions = await _db.Transactions
.Where(t => request.TransactionIds.Contains(t.Id))
.ToListAsync();
if (!transactions.Any())
return NotFound(new { message = "No transactions found with the provided IDs" });
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.Category;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
}
[HttpPost("bulk-recategorize")]
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
{
var q = _db.Transactions
.Where(t => t.Name.Contains(request.NamePattern));
if (!string.IsNullOrWhiteSpace(request.FromCategory))
q = q.Where(t => t.Category == request.FromCategory);
var transactions = await q.ToListAsync();
if (!transactions.Any())
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
if (request.DryRun)
{
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.ToCategory;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
}
[HttpGet("spending-summary")]
public async Task<IActionResult> SpendingSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Category ?? "Uncategorized")
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
}
[HttpGet("income-summary")]
public async Task<IActionResult> IncomeSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount > 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Name)
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
}
}
public class UpdateCategoryRequest
{
public long[] TransactionIds { get; set; } = [];
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
}
public class BulkRecategorizeRequest
{
public string NamePattern { get; set; } = "";
public string ToCategory { get; set; } = "";
public string? FromCategory { get; set; }
public string? MerchantName { get; set; }
public bool DryRun { get; set; } = true;
}
+1
View File
@@ -26,6 +26,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
+10 -14
View File
@@ -27,6 +27,10 @@ builder.Services.AddSession(options =>
builder.Services.AddRazorPages()
.AddSessionStateTempDataProvider();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Receipt parse queue and background worker
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
builder.Services.AddHostedService<ReceiptParseWorkerService>();
@@ -70,21 +74,13 @@ app.UseSession();
app.UseAuthorization();
app.MapRazorPages();
// Financial Audit API endpoint
app.MapGet("/api/audit", async (
IFinancialAuditService auditService,
DateTime? startDate,
DateTime? endDate,
bool includeTransactions = false) =>
if (app.Environment.IsDevelopment())
{
var end = endDate ?? DateTime.Today;
var start = startDate ?? end.AddDays(-90);
app.UseSwagger();
app.UseSwaggerUI();
}
var result = await auditService.GenerateAuditAsync(start, end, includeTransactions);
return Results.Ok(result);
})
.WithName("GetFinancialAudit");
app.MapRazorPages();
app.MapControllers();
app.Run();