Compare commits

..

27 Commits

Author SHA1 Message Date
aj f187b741a2 fix(mcp): set config base path so appsettings.json is found regardless of working directory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:55:38 -04:00
aj 274569bd79 refactor(mcp): rewrite all tools to use MoneyMapApiClient instead of direct DB access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:40:23 -04:00
aj 4bee73ba26 refactor(mcp): remove Core dependency, switch to HttpClient-based architecture
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:45 -04:00
aj 6c4f4bea7f feat(mcp): add MoneyMapApiClient typed HttpClient for API communication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:37:36 -04:00
aj db1d96476b feat(api): add DashboardController with overview and monthly-trend endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:57 -04:00
aj 51d6aee434 feat(api): add AccountsController with accounts and cards list endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:53 -04:00
aj c34ea74459 feat(api): add MerchantsController with list and merge endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:49 -04:00
aj 9dc1a9064d feat(api): add ReceiptsController with list, detail, image, and text endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:35:06 -04:00
aj 5b4a673f9d feat(api): add CategoriesController with list, mappings, and add-mapping endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:36 -04:00
aj 004f99c2b4 feat(api): add BudgetsController with status, create, update endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:34:07 -04:00
aj e773a0f218 feat(api): add TransactionsController with search, detail, category, and summary endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:33:02 -04:00
aj ccedea6e67 feat(api): add Health and Audit controllers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:32:12 -04:00
aj 768b5e015e feat(api): add controller infrastructure, Swagger, remove inline /api/audit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 20:30:49 -04:00
aj 2a75c9550e chore: add docs/superpowers, .playwright-mcp, settings.local.json to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:45:13 -04:00
aj 7b2d6203df fix: update Dockerfile for multi-project solution structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:42:01 -04:00
aj cbc46314db feat(mcp): implement all MCP tools (transactions, budgets, categories, receipts, merchants, accounts, dashboard)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:27:09 -04:00
aj f54c5ed54d feat: add MoneyMap.Mcp project skeleton with stdio transport
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:21:47 -04:00
aj 62fa1d5c4c refactor: consolidate service registration into AddMoneyMapCore extension
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:20:23 -04:00
aj d63ded45e1 refactor: abstract IWebHostEnvironment to IReceiptStorageOptions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:19:31 -04:00
aj 3b01efd8a6 refactor: move services and AITools to MoneyMap.Core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:18:20 -04:00
aj 3deca29f05 refactor: extract Models and Data into MoneyMap.Core shared library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 18:16:33 -04:00
aj d831991ad0 Add implementation plan for MoneyMap MCP server
14 tasks covering: Core library extraction, service migration,
IWebHostEnvironment abstraction, shared DI registration, MCP project
skeleton, and all 20 MCP tools across 7 tool files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:25:25 -04:00
aj dcb57c5cf6 Add design spec for MoneyMap MCP server
Shared class library (MoneyMap.Core) extraction with MCP console app
for conversational financial analysis, category correction with receipt
image verification, and budget feasibility modeling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 16:17:54 -04:00
aj aa82ee542c Fix: add libman restore to Dockerfile for client-side libraries
Bootstrap CSS/JS and jQuery were gitignored (wwwroot/lib/) so they
were missing from Docker builds, causing all dropdown menus and
styling to break on the deployed version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:31:03 -05:00
aj 2f3047d432 Hide spending by category when only one category exists
Both the doughnut chart and top categories table are now hidden
when there's only a single category, as they provide no useful
breakdown in that case. The net cash flow chart expands to full
width when the category chart is hidden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:17:19 -05:00
aj 7725bdb159 Improve: Move select-all checkbox into table header row
Move the "Select all on page" checkbox from the card header into the
first column header of the transactions table, aligned with per-row
checkboxes for a cleaner layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:09:05 -05:00
aj 59b8adc2d8 Improve: Restyle UI with modern fintech light theme
Replace the generic Bootstrap dark theme with a polished light theme
featuring an indigo primary color, refined cards with subtle shadows,
uppercase table headers, and updated chart palettes. Pure CSS restyle
with no layout or functionality changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:09:05 -05:00
77 changed files with 2115 additions and 126 deletions
+9
View File
@@ -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/
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Data namespace MoneyMap.Data
{ {
+5
View File
@@ -0,0 +1,5 @@
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Http;
@@ -1,4 +1,3 @@
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MoneyMap.Models; namespace MoneyMap.Models;
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="PdfPig" Version="0.1.11" />
</ItemGroup>
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MoneyMap.Data;
using MoneyMap.Services;
using MoneyMap.Services.AITools;
namespace MoneyMap.Core;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMoneyMapCore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<MoneyMapContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
services.AddMemoryCache();
// Core transaction and import services
services.AddScoped<ITransactionImporter, TransactionImporter>();
services.AddScoped<ICardResolver, CardResolver>();
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
services.AddScoped<ITransactionService, TransactionService>();
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
// Entity management services
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<ICardService, CardService>();
services.AddScoped<IMerchantService, MerchantService>();
services.AddScoped<IBudgetService, BudgetService>();
// Receipt services
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
services.AddScoped<IReceiptManager, ReceiptManager>();
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
// Reference data and dashboard
services.AddScoped<IReferenceDataService, ReferenceDataService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
// AI services
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
return services;
}
}
@@ -0,0 +1,6 @@
namespace MoneyMap.Services;
public interface IReceiptStorageOptions
{
string ReceiptsBasePath { get; }
}
@@ -21,8 +21,7 @@ namespace MoneyMap.Services
public class ReceiptManager : IReceiptManager public class ReceiptManager : IReceiptManager
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly IWebHostEnvironment _environment; private readonly IReceiptStorageOptions _receiptStorage;
private readonly IConfiguration _configuration;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IReceiptParseQueue _parseQueue; private readonly IReceiptParseQueue _parseQueue;
private readonly ILogger<ReceiptManager> _logger; private readonly ILogger<ReceiptManager> _logger;
@@ -46,15 +45,13 @@ namespace MoneyMap.Services
public ReceiptManager( public ReceiptManager(
MoneyMapContext db, MoneyMapContext db,
IWebHostEnvironment environment, IReceiptStorageOptions receiptStorage,
IConfiguration configuration,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IReceiptParseQueue parseQueue, IReceiptParseQueue parseQueue,
ILogger<ReceiptManager> logger) ILogger<ReceiptManager> logger)
{ {
_db = db; _db = db;
_environment = environment; _receiptStorage = receiptStorage;
_configuration = configuration;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_parseQueue = parseQueue; _parseQueue = parseQueue;
_logger = logger; _logger = logger;
@@ -62,9 +59,7 @@ namespace MoneyMap.Services
private string GetReceiptsBasePath() private string GetReceiptsBasePath()
{ {
// Get from config, default to "receipts" in wwwroot return _receiptStorage.ReceiptsBasePath;
var relativePath = _configuration["Receipts:StoragePath"] ?? "receipts";
return Path.Combine(_environment.WebRootPath, relativePath);
} }
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file) public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+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}" : "";
}
}
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoneyMap.Mcp;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
});
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
await app.RunAsync();
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class AccountTools
{
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
public static async Task<string> ListAccounts(
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.ListCardsAsync(accountId);
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class BudgetTools
{
[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,
MoneyMapApiClient api = default!)
{
return await api.GetBudgetStatusAsync(asOfDate);
}
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
public static async Task<string> CreateBudget(
[Description("Budget amount limit")] decimal amount,
[Description("Period: Weekly, Monthly, or Yearly")] string period,
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
[Description("Category name (omit for total spending budget)")] string? category = null,
MoneyMapApiClient api = default!)
{
return await api.CreateBudgetAsync(category, amount, period, startDate);
}
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
public static async Task<string> UpdateBudget(
[Description("Budget ID to update")] int budgetId,
[Description("New budget amount")] decimal? amount = null,
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
[Description("Set active/inactive")] bool? isActive = null,
MoneyMapApiClient api = default!)
{
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class CategoryTools
{
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
public static async Task<string> ListCategories(
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.GetCategoryMappingsAsync(category);
}
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
public static async Task<string> AddCategoryMapping(
[Description("Pattern to match in transaction name (case-insensitive)")] string pattern,
[Description("Category to assign when pattern matches")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
MoneyMapApiClient api = default!)
{
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class DashboardTools
{
[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,
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.GetMonthlyTrendAsync(months, category);
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class MerchantTools
{
[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,
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.MergeMerchantsAsync(sourceMerchantId, targetMerchantId);
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class ReceiptTools
{
[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,
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.GetReceiptTextAsync(receiptId);
}
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
public static async Task<string> ListReceipts(
[Description("Filter by transaction ID")] long? transactionId = null,
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
[Description("Max results (default 50)")] int? limit = null,
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.GetReceiptDetailsAsync(receiptId);
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class TransactionTools
{
[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,
[Description("Start date (inclusive), e.g. 2026-01-01")] string? startDate = null,
[Description("End date (inclusive), e.g. 2026-01-31")] string? endDate = null,
[Description("Filter by category name (exact match)")] string? category = null,
[Description("Filter by merchant name (contains)")] string? merchantName = null,
[Description("Minimum amount (absolute value)")] decimal? minAmount = null,
[Description("Maximum amount (absolute value)")] decimal? maxAmount = null,
[Description("Filter by account ID")] int? accountId = null,
[Description("Filter by card ID")] int? cardId = null,
[Description("Filter by type: 'debit' or 'credit'")] string? type = null,
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
[Description("Max results to return (default 50)")] int? limit = null,
MoneyMapApiClient api = default!)
{
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,
MoneyMapApiClient api = default!)
{
return await api.GetTransactionAsync(transactionId);
}
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
public static async Task<string> GetSpendingSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapApiClient api = default!)
{
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
public static async Task<string> GetIncomeSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapApiClient api = default!)
{
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
public static async Task<string> UpdateTransactionCategory(
[Description("Array of transaction IDs to update")] long[] transactionIds,
[Description("New category to assign")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
MoneyMapApiClient api = default!)
{
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.")]
public static async Task<string> BulkRecategorize(
[Description("Pattern to match in transaction name (case-insensitive contains)")] string namePattern,
[Description("New category to assign")] string toCategory,
[Description("Only recategorize transactions currently in this category")] string? fromCategory = null,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("If true (default), only shows what would change without applying")] bool dryRun = true,
MoneyMapApiClient api = default!)
{
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"MoneyMapApi": {
"BaseUrl": "http://barge.lan:5010/"
}
}
+1
View File
@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" /> <ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+48
View File
@@ -7,20 +7,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyM
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Core", "MoneyMap.Core\MoneyMap.Core.csproj", "{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Mcp", "MoneyMap.Mcp\MoneyMap.Mcp.csproj", "{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.Build.0 = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.ActiveCfg = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.Build.0 = Debug|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.Build.0 = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.ActiveCfg = Release|Any CPU
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.ActiveCfg = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.Build.0 = Debug|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.Build.0 = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.ActiveCfg = Release|Any CPU
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.ActiveCfg = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.Build.0 = Debug|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.Build.0 = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.ActiveCfg = Release|Any CPU
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -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;
}
+11 -3
View File
@@ -2,12 +2,20 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src WORKDIR /src
# Copy csproj and restore dependencies # Copy solution and project files for restore
COPY MoneyMap.csproj . COPY MoneyMap.sln .
RUN dotnet restore COPY MoneyMap/MoneyMap.csproj MoneyMap/
COPY MoneyMap.Core/MoneyMap.Core.csproj MoneyMap.Core/
RUN dotnet restore MoneyMap/MoneyMap.csproj
# Install libman CLI for client-side library restore
RUN dotnet tool install -g Microsoft.Web.LibraryManager.Cli
ENV PATH="${PATH}:/root/.dotnet/tools"
# Copy everything else and build # Copy everything else and build
COPY . . COPY . .
WORKDIR /src/MoneyMap
RUN libman restore
RUN dotnet publish -c Release -o /app/publish RUN dotnet publish -c Release -o /app/publish
# Runtime stage # Runtime stage
+9 -10
View File
@@ -18,25 +18,24 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" /> <PrivateAssets>all</PrivateAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" /> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9">
<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="PdfPig" Version="0.1.11" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
+13 -10
View File
@@ -117,6 +117,8 @@
</div> </div>
</div> </div>
<div class="row g-3 my-2"> <div class="row g-3 my-2">
@if (Model.TopCategories.Count > 1)
{
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header">Spending by category (last 90 days)</div> <div class="card-header">Spending by category (last 90 days)</div>
@@ -125,7 +127,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-6"> }
<div class="@(Model.TopCategories.Count > 1 ? "col-lg-6" : "col-lg-12")">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header">Net cash flow (last 30 days)</div> <div class="card-header">Net cash flow (last 30 days)</div>
<div class="card-body"> <div class="card-body">
@@ -206,7 +209,7 @@
</div> </div>
} }
@if (Model.TopCategories.Any()) @if (Model.TopCategories.Count > 1)
{ {
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header"> <div class="card-header">
@@ -320,11 +323,11 @@
labels: topLabels, labels: topLabels,
datasets: [{ datasets: [{
data: topValues, data: topValues,
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7'] backgroundColor: ['#6366f1','#f59e0b','#ef4444','#10b981','#06b6d4','#8b5cf6','#f97316','#ec4899']
}] }]
}, },
options: { options: {
plugins: { legend: { position: 'bottom', labels: { color: '#adb5bd' } } }, plugins: { legend: { position: 'bottom', labels: { color: '#64748b', font: { size: 12 } } } },
maintainAspectRatio: false maintainAspectRatio: false
} }
}); });
@@ -339,8 +342,8 @@
datasets: [{ datasets: [{
label: 'Net Cash Flow', label: 'Net Cash Flow',
data: trendBalance, data: trendBalance,
borderColor: '#6ea8fe', borderColor: '#6366f1',
backgroundColor: 'rgba(110,168,254,0.15)', backgroundColor: 'rgba(99,102,241,0.10)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
pointRadius: 0, pointRadius: 0,
@@ -351,14 +354,14 @@
scales: { scales: {
y: { y: {
ticks: { ticks: {
color: '#adb5bd', color: '#64748b',
callback: function(value) { return '$' + value.toLocaleString(); } callback: function(value) { return '$' + value.toLocaleString(); }
}, },
grid: { color: 'rgba(255,255,255,0.1)' } grid: { color: 'rgba(0,0,0,0.06)' }
}, },
x: { x: {
ticks: { color: '#adb5bd', maxTicksLimit: 10 }, ticks: { color: '#64748b', maxTicksLimit: 10 },
grid: { color: 'rgba(255,255,255,0.05)' } grid: { color: 'rgba(0,0,0,0.03)' }
} }
}, },
plugins: { plugins: {
+3 -3
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,7 +10,7 @@
</head> </head>
<body> <body>
<header> <header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-dark bg-dark border-bottom box-shadow mb-3"> <nav class="navbar navbar-expand-sm navbar-toggleable-sm border-bottom box-shadow mb-3">
<div class="container"> <div class="container">
<a class="navbar-brand fw-bold" asp-page="/Index">MoneyMap</a> <a class="navbar-brand fw-bold" asp-page="/Index">MoneyMap</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
@@ -111,7 +111,7 @@
<footer class="border-top footer text-body-secondary"> <footer class="border-top footer text-body-secondary">
<div class="container"> <div class="container">
&copy; 2025 - MoneyMap &copy; 2026 - MoneyMap
</div> </div>
</footer> </footer>
+5 -7
View File
@@ -169,17 +169,15 @@
<span class="text-muted">- @Model.Stats.Count total</span> <span class="text-muted">- @Model.Stats.Count total</span>
} }
</div> </div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)">
<label class="form-check-label small" for="selectAllCheckbox">Select all on page</label>
</div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover table-sm mb-0"> <table class="table table-hover table-sm mb-0">
<thead> <thead>
<tr> <tr>
<th style="width: 40px;"></th> <th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)" title="Select all on page">
</th>
<th style="width: 70px;">ID</th> <th style="width: 70px;">ID</th>
<th style="width: 110px;">Date</th> <th style="width: 110px;">Date</th>
<th>Name</th> <th>Name</th>
@@ -359,11 +357,11 @@ else
labels: categoryLabels, labels: categoryLabels,
datasets: [{ datasets: [{
data: categoryValues, data: categoryValues,
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'] backgroundColor: ['#6366f1','#f59e0b','#ef4444','#10b981','#06b6d4','#8b5cf6','#f97316','#ec4899','#84cc16','#a78bfa']
}] }]
}, },
options: { options: {
plugins: { legend: { position: 'bottom', labels: { color: '#adb5bd' } } }, plugins: { legend: { position: 'bottom', labels: { color: '#64748b', font: { size: 12 } } } },
maintainAspectRatio: false maintainAspectRatio: false
} }
}); });
+15 -58
View File
@@ -1,8 +1,7 @@
using System.Globalization; using System.Globalization;
using Microsoft.EntityFrameworkCore; using MoneyMap.Core;
using MoneyMap.Data;
using MoneyMap.Services; using MoneyMap.Services;
using MoneyMap.Services.AITools; using MoneyMap.WebApp.Services;
// Set default culture to en-US for currency formatting ($) // Set default culture to en-US for currency formatting ($)
var culture = new CultureInfo("en-US"); var culture = new CultureInfo("en-US");
@@ -11,11 +10,8 @@ CultureInfo.DefaultThreadCurrentUICulture = culture;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MoneyMapContext>(options => builder.Services.AddMoneyMapCore(builder.Configuration);
options.UseSqlServer(builder.Configuration.GetConnectionString("MoneyMapDb"))); builder.Services.AddSingleton<IReceiptStorageOptions, WebReceiptStorageOptions>();
// Add memory cache for services like TransactionCategorizer
builder.Services.AddMemoryCache();
// Add session support // Add session support
builder.Services.AddDistributedMemoryCache(); builder.Services.AddDistributedMemoryCache();
@@ -24,43 +20,16 @@ builder.Services.AddSession(options =>
options.IdleTimeout = TimeSpan.FromMinutes(30); options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true; options.Cookie.IsEssential = true;
options.IOTimeout = TimeSpan.FromMinutes(5); // Increase timeout for large data options.IOTimeout = TimeSpan.FromMinutes(5);
}); });
// Use session-based TempData provider to avoid cookie size limits // Use session-based TempData provider to avoid cookie size limits
builder.Services.AddRazorPages() builder.Services.AddRazorPages()
.AddSessionStateTempDataProvider(); .AddSessionStateTempDataProvider();
// Core transaction and import services builder.Services.AddControllers();
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddScoped<ICardResolver, CardResolver>(); builder.Services.AddSwaggerGen();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
builder.Services.AddScoped<ITransactionService, TransactionService>();
builder.Services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
// Entity management services
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<ICardService, CardService>();
builder.Services.AddScoped<IMerchantService, MerchantService>();
builder.Services.AddScoped<IBudgetService, BudgetService>();
// Receipt services
builder.Services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
// Reference data services
builder.Services.AddScoped<IReferenceDataService, ReferenceDataService>();
// Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
builder.Services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
// Receipt services
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
builder.Services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
// Receipt parse queue and background worker // Receipt parse queue and background worker
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>(); builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
@@ -72,18 +41,14 @@ builder.Services.AddHttpClient<ClaudeVisionClient>();
builder.Services.AddHttpClient<OllamaVisionClient>(); builder.Services.AddHttpClient<OllamaVisionClient>();
builder.Services.AddHttpClient<LlamaCppVisionClient>(); builder.Services.AddHttpClient<LlamaCppVisionClient>();
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>(); builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>(); builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
// AI categorization service // AI categorization service
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>(); builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
// Model warmup service - preloads the configured AI model on startup // Model warmup service
builder.Services.AddHostedService<ModelWarmupService>(); builder.Services.AddHostedService<ModelWarmupService>();
// Financial audit API service
builder.Services.AddScoped<IFinancialAuditService, FinancialAuditService>();
var app = builder.Build(); var app = builder.Build();
// Seed default category mappings on startup // Seed default category mappings on startup
@@ -109,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();
@@ -0,0 +1,17 @@
using MoneyMap.Services;
namespace MoneyMap.WebApp.Services;
public class WebReceiptStorageOptions : IReceiptStorageOptions
{
public string ReceiptsBasePath { get; }
public WebReceiptStorageOptions(IWebHostEnvironment env, IConfiguration config)
{
var relativePath = config["Receipts:StoragePath"] ?? "receipts";
if (Path.IsPathRooted(relativePath))
ReceiptsBasePath = relativePath;
else
ReceiptsBasePath = Path.Combine(env.WebRootPath, relativePath);
}
}
+396 -23
View File
@@ -1,51 +1,172 @@
/* ============================================
MoneyMap Modern Fintech Light Theme
============================================ */
/* --- Palette (CSS custom properties) --- */
:root {
--mm-bg: #f7f8fc;
--mm-surface: #ffffff;
--mm-primary: #6366f1;
--mm-primary-hover:#4f46e5;
--mm-primary-soft: rgba(99,102,241,0.10);
--mm-success: #10b981;
--mm-success-soft: rgba(16,185,129,0.10);
--mm-danger: #ef4444;
--mm-danger-soft: rgba(239,68,68,0.10);
--mm-warning: #f59e0b;
--mm-warning-soft: rgba(245,158,11,0.10);
--mm-info: #06b6d4;
--mm-info-soft: rgba(6,182,212,0.10);
--mm-text: #1e293b;
--mm-text-secondary: #64748b;
--mm-border: #e2e8f0;
--mm-shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--mm-shadow: 0 4px 12px rgba(0,0,0,0.06);
--mm-radius: 12px;
--mm-radius-sm: 8px;
}
/* --- Base --- */
html { html {
font-size: 14px; font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.25rem rgba(37, 140, 251, 0.5);
}
html {
position: relative; position: relative;
min-height: 100%; min-height: 100%;
} }
@media (min-width: 768px) {
html { font-size: 16px; }
}
body { body {
margin-bottom: 60px; margin-bottom: 60px;
background-color: var(--mm-bg);
color: var(--mm-text);
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
} }
/* Active dropdown parent highlighting */ /* --- Focus rings --- */
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus,
.form-control:focus, .form-check-input:focus, .form-select:focus {
box-shadow: 0 0 0 0.2rem rgba(99,102,241,0.35);
border-color: var(--mm-primary);
}
/* --- Navbar --- */
.navbar {
background-color: var(--mm-surface);
border-bottom: 1px solid var(--mm-border) !important;
box-shadow: var(--mm-shadow-sm);
padding-top: 0.65rem;
padding-bottom: 0.65rem;
}
.navbar-brand {
color: var(--mm-primary) !important;
font-weight: 700;
font-size: 1.2rem;
letter-spacing: -0.02em;
}
.navbar .nav-link {
color: var(--mm-text-secondary) !important;
font-weight: 500;
font-size: 0.9rem;
transition: color 0.15s ease;
}
.navbar .nav-link:hover,
.navbar .nav-link:focus {
color: var(--mm-primary) !important;
}
.navbar .nav-item.dropdown .nav-link.dropdown-toggle.active-parent { .navbar .nav-item.dropdown .nav-link.dropdown-toggle.active-parent {
color: rgba(255, 255, 255, 0.9); color: var(--mm-primary) !important;
}
.navbar .dropdown-menu {
border: 1px solid var(--mm-border);
border-radius: var(--mm-radius-sm);
box-shadow: var(--mm-shadow);
padding: 0.35rem 0;
}
.navbar .dropdown-item {
font-size: 0.875rem;
padding: 0.45rem 1rem;
color: var(--mm-text);
}
.navbar .dropdown-item:hover,
.navbar .dropdown-item:focus {
background-color: var(--mm-primary-soft);
color: var(--mm-primary);
}
.navbar .btn-primary,
.navbar .btn-sm.btn-primary {
background-color: var(--mm-primary);
border-color: var(--mm-primary);
border-radius: var(--mm-radius-sm);
font-weight: 600;
font-size: 0.85rem;
}
.navbar .btn-primary:hover {
background-color: var(--mm-primary-hover);
border-color: var(--mm-primary-hover);
}
/* Hamburger icon for light navbar */
.navbar-toggler {
border-color: var(--mm-border);
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(100,116,139,0.9)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
} }
/* Breadcrumb styling */ /* --- Breadcrumbs --- */
.breadcrumb-nav { .breadcrumb-nav { margin-bottom: 1rem; }
margin-bottom: 1rem;
}
.breadcrumb-nav .breadcrumb { .breadcrumb-nav .breadcrumb {
background: transparent; background: transparent;
padding: 0; padding: 0;
margin-bottom: 0; margin-bottom: 0;
font-size: 0.875rem; font-size: 0.85rem;
}
.breadcrumb-nav .breadcrumb a {
color: var(--mm-primary);
}
.breadcrumb-item.active {
color: var(--mm-text-secondary);
} }
/* Quick-action cards on dashboard */ /* --- Cards --- */
.card {
background-color: var(--mm-surface);
border: 1px solid var(--mm-border);
border-radius: var(--mm-radius) !important;
box-shadow: var(--mm-shadow-sm);
transition: box-shadow 0.2s ease;
}
.card:hover {
box-shadow: var(--mm-shadow);
}
.card-header {
background-color: transparent;
border-bottom: 1px solid var(--mm-border);
font-weight: 600;
font-size: 0.95rem;
color: var(--mm-text);
padding: 0.85rem 1.15rem;
}
.card-body {
padding: 1.15rem;
}
/* Stat cards on dashboard */
.card .fs-3 {
color: var(--mm-text);
font-weight: 700;
}
/* --- Quick-action cards --- */
.quick-action-card { .quick-action-card {
transition: transform 0.15s ease, box-shadow 0.15s ease; transition: transform 0.15s ease, box-shadow 0.15s ease;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
.quick-action-card:hover { .quick-action-card:hover {
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.3) !important; box-shadow: 0 6px 20px rgba(99,102,241,0.12) !important;
} }
.quick-action-icon { .quick-action-icon {
width: 48px; width: 48px;
@@ -56,3 +177,255 @@ body {
justify-content: center; justify-content: center;
font-size: 1.25rem; font-size: 1.25rem;
} }
/* --- Buttons --- */
.btn-primary {
background-color: var(--mm-primary);
border-color: var(--mm-primary);
border-radius: var(--mm-radius-sm);
font-weight: 600;
}
.btn-primary:hover, .btn-primary:active {
background-color: var(--mm-primary-hover);
border-color: var(--mm-primary-hover);
}
.btn-outline-primary {
color: var(--mm-primary);
border-color: var(--mm-primary);
border-radius: var(--mm-radius-sm);
}
.btn-outline-primary:hover {
background-color: var(--mm-primary);
border-color: var(--mm-primary);
color: #fff;
}
.btn-outline-secondary {
color: var(--mm-text-secondary);
border-color: var(--mm-border);
border-radius: var(--mm-radius-sm);
}
.btn-outline-secondary:hover {
background-color: var(--mm-bg);
border-color: var(--mm-border);
color: var(--mm-text);
}
.btn-success {
background-color: var(--mm-success);
border-color: var(--mm-success);
border-radius: var(--mm-radius-sm);
}
.btn-danger {
background-color: var(--mm-danger);
border-color: var(--mm-danger);
border-radius: var(--mm-radius-sm);
}
/* --- Tables --- */
.table {
--bs-table-hover-bg: rgba(99,102,241,0.04);
--bs-table-striped-bg: rgba(0,0,0,0.015);
color: var(--mm-text);
font-size: 0.9rem;
}
.table > thead > tr > th {
background-color: var(--mm-bg);
color: var(--mm-text-secondary);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 2px solid var(--mm-border);
padding: 0.7rem 0.75rem;
white-space: nowrap;
}
.table > tbody > tr > td {
padding: 0.65rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.table > tbody > tr:last-child > td {
border-bottom: none;
}
.table-hover > tbody > tr {
transition: background-color 0.1s ease;
}
/* --- Forms --- */
.form-control, .form-select {
border-radius: var(--mm-radius-sm);
border: 1px solid var(--mm-border);
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
.form-control:hover, .form-select:hover {
border-color: #cbd5e1;
}
.form-label {
color: var(--mm-text);
font-weight: 600;
font-size: 0.875rem;
}
/* --- Badges --- */
.badge {
font-weight: 600;
font-size: 0.75rem;
border-radius: 6px;
padding: 0.3em 0.6em;
}
.badge.bg-success {
background-color: var(--mm-success) !important;
}
.badge.bg-danger {
background-color: var(--mm-danger) !important;
}
.badge.bg-warning {
background-color: var(--mm-warning) !important;
}
.badge.bg-info {
background-color: var(--mm-info) !important;
}
.badge.bg-primary {
background-color: var(--mm-primary) !important;
}
.badge.bg-secondary {
background-color: #e2e8f0 !important;
color: var(--mm-text-secondary) !important;
}
/* --- Progress bars --- */
.progress {
background-color: #e2e8f0;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
}
.progress-bar.bg-success { background-color: var(--mm-success) !important; }
.progress-bar.bg-warning { background-color: var(--mm-warning) !important; }
.progress-bar.bg-danger { background-color: var(--mm-danger) !important; }
/* --- Alerts --- */
.alert {
border-radius: var(--mm-radius-sm);
font-size: 0.9rem;
}
.alert-success {
background-color: var(--mm-success-soft);
border-color: rgba(16,185,129,0.2);
color: #065f46;
}
.alert-danger {
background-color: var(--mm-danger-soft);
border-color: rgba(239,68,68,0.2);
color: #991b1b;
}
.alert-info {
background-color: var(--mm-info-soft);
border-color: rgba(6,182,212,0.2);
color: #155e75;
}
/* --- Modals --- */
.modal-content {
border-radius: var(--mm-radius);
border: 1px solid var(--mm-border);
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.modal-header {
border-bottom: 1px solid var(--mm-border);
}
.modal-footer {
border-top: 1px solid var(--mm-border);
}
/* --- Footer --- */
.footer {
background-color: var(--mm-surface);
border-top: 1px solid var(--mm-border) !important;
padding: 1rem 0;
font-size: 0.85rem;
color: var(--mm-text-secondary);
}
/* --- Links --- */
a {
color: var(--mm-primary);
}
a:hover {
color: var(--mm-primary-hover);
}
.text-decoration-none.text-body:hover {
color: var(--mm-primary) !important;
}
/* --- Amount coloring --- */
.text-success { color: var(--mm-success) !important; }
.text-danger { color: var(--mm-danger) !important; }
/* --- Muted text --- */
.text-muted {
color: var(--mm-text-secondary) !important;
}
/* --- Pagination --- */
.page-link {
border-radius: var(--mm-radius-sm);
border: 1px solid var(--mm-border);
color: var(--mm-text-secondary);
font-size: 0.875rem;
margin: 0 2px;
}
.page-link:hover {
background-color: var(--mm-primary-soft);
border-color: var(--mm-primary);
color: var(--mm-primary);
}
.page-item.active .page-link {
background-color: var(--mm-primary);
border-color: var(--mm-primary);
color: #fff;
}
/* --- Selection bar (Transactions page) --- */
.alert.alert-info.sticky-top {
background-color: var(--mm-primary-soft);
border-color: rgba(99,102,241,0.2);
color: var(--mm-primary);
}
/* --- Headings --- */
h1, h2, h3, h4, h5, h6 {
color: var(--mm-text);
font-weight: 700;
letter-spacing: -0.02em;
}
/* --- Utility overrides for light theme --- */
.bg-primary.bg-opacity-25 {
background-color: var(--mm-primary-soft) !important;
}
.bg-warning.bg-opacity-25 {
background-color: var(--mm-warning-soft) !important;
}
.bg-info.bg-opacity-25 {
background-color: var(--mm-info-soft) !important;
}
.bg-success.bg-opacity-25 {
background-color: var(--mm-success-soft) !important;
}
.text-primary { color: var(--mm-primary) !important; }
.text-warning { color: var(--mm-warning) !important; }
.text-info { color: var(--mm-info) !important; }
/* --- Scrollbar (subtle for light theme) --- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }