Compare commits
27 Commits
f4ab4c4e7d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f187b741a2 | |||
| 274569bd79 | |||
| 4bee73ba26 | |||
| 6c4f4bea7f | |||
| db1d96476b | |||
| 51d6aee434 | |||
| c34ea74459 | |||
| 9dc1a9064d | |||
| 5b4a673f9d | |||
| 004f99c2b4 | |||
| e773a0f218 | |||
| ccedea6e67 | |||
| 768b5e015e | |||
| 2a75c9550e | |||
| 7b2d6203df | |||
| cbc46314db | |||
| f54c5ed54d | |||
| 62fa1d5c4c | |||
| d63ded45e1 | |||
| 3b01efd8a6 | |||
| 3deca29f05 | |||
| d831991ad0 | |||
| dcb57c5cf6 | |||
| aa82ee542c | |||
| 2f3047d432 | |||
| 7725bdb159 | |||
| 59b8adc2d8 |
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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}" : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"MoneyMapApi": {
|
||||||
|
"BaseUrl": "http://barge.lan:5010/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AuditController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IFinancialAuditService _auditService;
|
||||||
|
|
||||||
|
public AuditController(IFinancialAuditService auditService) => _auditService = auditService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get(
|
||||||
|
[FromQuery] DateTime? startDate,
|
||||||
|
[FromQuery] DateTime? endDate,
|
||||||
|
[FromQuery] bool includeTransactions = false)
|
||||||
|
{
|
||||||
|
var end = endDate ?? DateTime.Today;
|
||||||
|
var start = startDate ?? end.AddDays(-90);
|
||||||
|
|
||||||
|
var result = await _auditService.GenerateAuditAsync(start, end, includeTransactions);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class BudgetsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBudgetService _budgetService;
|
||||||
|
|
||||||
|
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
|
||||||
|
|
||||||
|
[HttpGet("status")]
|
||||||
|
public async Task<IActionResult> GetStatus([FromQuery] string? asOfDate = null)
|
||||||
|
{
|
||||||
|
DateTime? date = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
|
||||||
|
date = parsed;
|
||||||
|
|
||||||
|
var statuses = await _budgetService.GetAllBudgetStatusesAsync(date);
|
||||||
|
|
||||||
|
var result = statuses.Select(s => new
|
||||||
|
{
|
||||||
|
s.Budget.Id,
|
||||||
|
Category = s.Budget.DisplayName,
|
||||||
|
s.Budget.Amount,
|
||||||
|
Period = s.Budget.Period.ToString(),
|
||||||
|
s.PeriodStart,
|
||||||
|
s.PeriodEnd,
|
||||||
|
s.Spent,
|
||||||
|
s.Remaining,
|
||||||
|
PercentUsed = Math.Round(s.PercentUsed, 1),
|
||||||
|
s.IsOverBudget
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest request)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
|
||||||
|
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
|
||||||
|
|
||||||
|
if (!DateTime.TryParse(request.StartDate, out var startDate))
|
||||||
|
return BadRequest(new { message = "Invalid start date format" });
|
||||||
|
|
||||||
|
var budget = new Budget
|
||||||
|
{
|
||||||
|
Category = request.Category,
|
||||||
|
Amount = request.Amount,
|
||||||
|
Period = budgetPeriod,
|
||||||
|
StartDate = startDate,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _budgetService.CreateBudgetAsync(budget);
|
||||||
|
|
||||||
|
return Ok(new { result.Success, result.Message, BudgetId = budget.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetRequest request)
|
||||||
|
{
|
||||||
|
var budget = await _budgetService.GetBudgetByIdAsync(id);
|
||||||
|
if (budget == null)
|
||||||
|
return NotFound(new { message = "Budget not found" });
|
||||||
|
|
||||||
|
if (request.Amount.HasValue)
|
||||||
|
budget.Amount = request.Amount.Value;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Period))
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
|
||||||
|
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
|
||||||
|
budget.Period = budgetPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsActive.HasValue)
|
||||||
|
budget.IsActive = request.IsActive.Value;
|
||||||
|
|
||||||
|
var result = await _budgetService.UpdateBudgetAsync(budget);
|
||||||
|
|
||||||
|
return Ok(new { result.Success, result.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateBudgetRequest
|
||||||
|
{
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Period { get; set; } = "";
|
||||||
|
public string StartDate { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateBudgetRequest
|
||||||
|
{
|
||||||
|
public decimal? Amount { get; set; }
|
||||||
|
public string? Period { get; set; }
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class CategoriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
private readonly ITransactionCategorizer _categorizer;
|
||||||
|
private readonly IMerchantService _merchantService;
|
||||||
|
|
||||||
|
public CategoriesController(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_categorizer = categorizer;
|
||||||
|
_merchantService = merchantService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List()
|
||||||
|
{
|
||||||
|
var categories = await _db.Transactions
|
||||||
|
.Where(t => t.Category != null && t.Category != "")
|
||||||
|
.GroupBy(t => t.Category!)
|
||||||
|
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
|
||||||
|
.OrderByDescending(x => x.Count)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var uncategorized = await _db.Transactions
|
||||||
|
.CountAsync(t => t.Category == null || t.Category == "");
|
||||||
|
|
||||||
|
return Ok(new { Categories = categories, UncategorizedCount = uncategorized });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mappings")]
|
||||||
|
public async Task<IActionResult> GetMappings([FromQuery] string? category = null)
|
||||||
|
{
|
||||||
|
var mappings = await _categorizer.GetAllMappingsAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
var result = mappings.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
m.Pattern,
|
||||||
|
m.Category,
|
||||||
|
m.MerchantId,
|
||||||
|
m.Priority
|
||||||
|
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("mappings")]
|
||||||
|
public async Task<IActionResult> AddMapping([FromBody] CreateCategoryMappingRequest request)
|
||||||
|
{
|
||||||
|
int? merchantId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||||
|
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||||
|
|
||||||
|
var mapping = new CategoryMapping
|
||||||
|
{
|
||||||
|
Pattern = request.Pattern,
|
||||||
|
Category = request.Category,
|
||||||
|
MerchantId = merchantId,
|
||||||
|
Priority = request.Priority
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.CategoryMappings.Add(mapping);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = request.MerchantName, mapping.Priority });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateCategoryMappingRequest
|
||||||
|
{
|
||||||
|
public string Pattern { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string? MerchantName { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DashboardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDashboardService _dashboardService;
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public DashboardController(IDashboardService dashboardService, MoneyMapContext db)
|
||||||
|
{
|
||||||
|
_dashboardService = dashboardService;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get(
|
||||||
|
[FromQuery] int? topCategoriesCount = null,
|
||||||
|
[FromQuery] int? recentTransactionsCount = null)
|
||||||
|
{
|
||||||
|
var data = await _dashboardService.GetDashboardDataAsync(
|
||||||
|
topCategoriesCount ?? 8,
|
||||||
|
recentTransactionsCount ?? 20);
|
||||||
|
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monthly-trend")]
|
||||||
|
public async Task<IActionResult> MonthlyTrend(
|
||||||
|
[FromQuery] int? months = null,
|
||||||
|
[FromQuery] string? category = null)
|
||||||
|
{
|
||||||
|
var monthCount = months ?? 6;
|
||||||
|
var endDate = DateTime.Today;
|
||||||
|
var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
|
||||||
|
|
||||||
|
var q = _db.Transactions
|
||||||
|
.Where(t => t.Date >= startDate && t.Date <= endDate)
|
||||||
|
.Where(t => t.Amount < 0)
|
||||||
|
.Where(t => t.TransferToAccountId == null)
|
||||||
|
.ExcludeTransfers();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
q = q.Where(t => t.Category == category);
|
||||||
|
|
||||||
|
var monthly = await q
|
||||||
|
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Year = g.Key.Year,
|
||||||
|
Month = g.Key.Month,
|
||||||
|
Total = g.Sum(t => Math.Abs(t.Amount)),
|
||||||
|
Count = g.Count()
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.Year).ThenBy(x => x.Month)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var result = monthly.Select(m => new
|
||||||
|
{
|
||||||
|
Period = $"{m.Year}-{m.Month:D2}",
|
||||||
|
m.Total,
|
||||||
|
m.Count
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new { Category = category ?? "All Spending", Months = result });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class HealthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public HealthController(MoneyMapContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get()
|
||||||
|
{
|
||||||
|
var canConnect = await _db.Database.CanConnectAsync();
|
||||||
|
if (!canConnect)
|
||||||
|
return StatusCode(503, new { status = "unhealthy", reason = "database unreachable" });
|
||||||
|
|
||||||
|
return Ok(new { status = "healthy" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class MerchantsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
|
||||||
|
public MerchantsController(MoneyMapContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? query = null)
|
||||||
|
{
|
||||||
|
var q = _db.Merchants
|
||||||
|
.Include(m => m.Transactions)
|
||||||
|
.Include(m => m.CategoryMappings)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
q = q.Where(m => m.Name.Contains(query));
|
||||||
|
|
||||||
|
var merchants = await q
|
||||||
|
.OrderBy(m => m.Name)
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
m.Name,
|
||||||
|
TransactionCount = m.Transactions.Count,
|
||||||
|
MappingCount = m.CategoryMappings.Count,
|
||||||
|
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new { Count = merchants.Count, Merchants = merchants });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("merge")]
|
||||||
|
public async Task<IActionResult> Merge([FromBody] MergeMerchantsRequest request)
|
||||||
|
{
|
||||||
|
if (request.SourceMerchantId == request.TargetMerchantId)
|
||||||
|
return BadRequest(new { message = "Source and target merchant cannot be the same" });
|
||||||
|
|
||||||
|
var source = await _db.Merchants.FindAsync(request.SourceMerchantId);
|
||||||
|
var target = await _db.Merchants.FindAsync(request.TargetMerchantId);
|
||||||
|
|
||||||
|
if (source == null)
|
||||||
|
return NotFound(new { message = $"Source merchant {request.SourceMerchantId} not found" });
|
||||||
|
if (target == null)
|
||||||
|
return NotFound(new { message = $"Target merchant {request.TargetMerchantId} not found" });
|
||||||
|
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => t.MerchantId == request.SourceMerchantId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var t in transactions)
|
||||||
|
t.MerchantId = request.TargetMerchantId;
|
||||||
|
|
||||||
|
var sourceMappings = await _db.CategoryMappings
|
||||||
|
.Where(cm => cm.MerchantId == request.SourceMerchantId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var targetMappingPatterns = await _db.CategoryMappings
|
||||||
|
.Where(cm => cm.MerchantId == request.TargetMerchantId)
|
||||||
|
.Select(cm => cm.Pattern)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var mapping in sourceMappings)
|
||||||
|
{
|
||||||
|
if (targetMappingPatterns.Contains(mapping.Pattern))
|
||||||
|
_db.CategoryMappings.Remove(mapping);
|
||||||
|
else
|
||||||
|
mapping.MerchantId = request.TargetMerchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Merchants.Remove(source);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Merged = true,
|
||||||
|
Source = new { source.Id, source.Name },
|
||||||
|
Target = new { target.Id, target.Name },
|
||||||
|
TransactionsReassigned = transactions.Count,
|
||||||
|
MappingsReassigned = sourceMappings.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MergeMerchantsRequest
|
||||||
|
{
|
||||||
|
public int SourceMerchantId { get; set; }
|
||||||
|
public int TargetMerchantId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using ImageMagick;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ReceiptsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
private readonly IReceiptStorageOptions _storageOptions;
|
||||||
|
|
||||||
|
public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_storageOptions = storageOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] long? transactionId = null,
|
||||||
|
[FromQuery] string? parseStatus = null,
|
||||||
|
[FromQuery] int? limit = null)
|
||||||
|
{
|
||||||
|
var q = _db.Receipts
|
||||||
|
.Include(r => r.Transaction)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (transactionId.HasValue)
|
||||||
|
q = q.Where(r => r.TransactionId == transactionId.Value);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse<ReceiptParseStatus>(parseStatus, true, out var status))
|
||||||
|
q = q.Where(r => r.ParseStatus == status);
|
||||||
|
|
||||||
|
var results = await q
|
||||||
|
.OrderByDescending(r => r.UploadedAtUtc)
|
||||||
|
.Take(limit ?? 50)
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
r.FileName,
|
||||||
|
ParseStatus = r.ParseStatus.ToString(),
|
||||||
|
r.Merchant,
|
||||||
|
r.Total,
|
||||||
|
r.ReceiptDate,
|
||||||
|
r.UploadedAtUtc,
|
||||||
|
TransactionId = r.TransactionId,
|
||||||
|
TransactionName = r.Transaction != null ? r.Transaction.Name : null
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new { Count = results.Count, Receipts = results });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> GetDetails(long id)
|
||||||
|
{
|
||||||
|
var receipt = await _db.Receipts
|
||||||
|
.Include(r => r.LineItems)
|
||||||
|
.Include(r => r.Transaction)
|
||||||
|
.Include(r => r.ParseLogs)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
|
if (receipt == null)
|
||||||
|
return NotFound(new { message = "Receipt not found" });
|
||||||
|
|
||||||
|
var result = new
|
||||||
|
{
|
||||||
|
receipt.Id,
|
||||||
|
receipt.FileName,
|
||||||
|
receipt.ContentType,
|
||||||
|
receipt.FileSizeBytes,
|
||||||
|
receipt.UploadedAtUtc,
|
||||||
|
ParseStatus = receipt.ParseStatus.ToString(),
|
||||||
|
ParsedData = new
|
||||||
|
{
|
||||||
|
receipt.Merchant,
|
||||||
|
receipt.ReceiptDate,
|
||||||
|
receipt.DueDate,
|
||||||
|
receipt.Subtotal,
|
||||||
|
receipt.Tax,
|
||||||
|
receipt.Total,
|
||||||
|
receipt.Currency
|
||||||
|
},
|
||||||
|
LinkedTransaction = receipt.Transaction != null ? new
|
||||||
|
{
|
||||||
|
receipt.Transaction.Id,
|
||||||
|
receipt.Transaction.Name,
|
||||||
|
receipt.Transaction.Date,
|
||||||
|
receipt.Transaction.Amount,
|
||||||
|
receipt.Transaction.Category
|
||||||
|
} : null,
|
||||||
|
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
|
||||||
|
{
|
||||||
|
li.LineNumber,
|
||||||
|
li.Description,
|
||||||
|
li.Quantity,
|
||||||
|
li.UnitPrice,
|
||||||
|
li.LineTotal,
|
||||||
|
li.Category
|
||||||
|
}).ToList(),
|
||||||
|
ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
|
||||||
|
{
|
||||||
|
pl.Provider,
|
||||||
|
pl.Model,
|
||||||
|
pl.Success,
|
||||||
|
pl.Confidence,
|
||||||
|
pl.Error,
|
||||||
|
pl.StartedAtUtc
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/image")]
|
||||||
|
public async Task<IActionResult> GetImage(long id)
|
||||||
|
{
|
||||||
|
var receipt = await _db.Receipts.FindAsync(id);
|
||||||
|
if (receipt == null)
|
||||||
|
return NotFound(new { message = "Receipt not found" });
|
||||||
|
|
||||||
|
var basePath = Path.GetFullPath(_storageOptions.ReceiptsBasePath);
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
|
||||||
|
|
||||||
|
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return BadRequest(new { message = "Invalid receipt path" });
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(fullPath))
|
||||||
|
return NotFound(new { message = "Receipt file not found on disk" });
|
||||||
|
|
||||||
|
byte[] imageBytes;
|
||||||
|
string mimeType;
|
||||||
|
|
||||||
|
if (receipt.ContentType == "application/pdf")
|
||||||
|
{
|
||||||
|
var settings = new MagickReadSettings { Density = new Density(220) };
|
||||||
|
using var image = new MagickImage(fullPath + "[0]", settings);
|
||||||
|
image.Format = MagickFormat.Png;
|
||||||
|
image.BackgroundColor = MagickColors.White;
|
||||||
|
image.Alpha(AlphaOption.Remove);
|
||||||
|
imageBytes = image.ToByteArray();
|
||||||
|
mimeType = "image/png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
imageBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
|
||||||
|
mimeType = receipt.ContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
var base64 = Convert.ToBase64String(imageBytes);
|
||||||
|
return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/text")]
|
||||||
|
public async Task<IActionResult> GetText(long id)
|
||||||
|
{
|
||||||
|
var receipt = await _db.Receipts
|
||||||
|
.Include(r => r.LineItems)
|
||||||
|
.Include(r => r.Transaction)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
|
||||||
|
if (receipt == null)
|
||||||
|
return NotFound(new { message = "Receipt not found" });
|
||||||
|
|
||||||
|
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
|
||||||
|
return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() });
|
||||||
|
|
||||||
|
var result = new
|
||||||
|
{
|
||||||
|
receipt.Id,
|
||||||
|
receipt.Merchant,
|
||||||
|
receipt.ReceiptDate,
|
||||||
|
receipt.DueDate,
|
||||||
|
receipt.Subtotal,
|
||||||
|
receipt.Tax,
|
||||||
|
receipt.Total,
|
||||||
|
receipt.Currency,
|
||||||
|
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
|
||||||
|
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
|
||||||
|
{
|
||||||
|
li.LineNumber,
|
||||||
|
li.Description,
|
||||||
|
li.Quantity,
|
||||||
|
li.UnitPrice,
|
||||||
|
li.LineTotal,
|
||||||
|
li.Category
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Services;
|
||||||
|
|
||||||
|
namespace MoneyMap.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class TransactionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
private readonly IMerchantService _merchantService;
|
||||||
|
|
||||||
|
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_merchantService = merchantService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Search(
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string? startDate = null,
|
||||||
|
[FromQuery] string? endDate = null,
|
||||||
|
[FromQuery] string? category = null,
|
||||||
|
[FromQuery] string? merchantName = null,
|
||||||
|
[FromQuery] decimal? minAmount = null,
|
||||||
|
[FromQuery] decimal? maxAmount = null,
|
||||||
|
[FromQuery] int? accountId = null,
|
||||||
|
[FromQuery] int? cardId = null,
|
||||||
|
[FromQuery] string? type = null,
|
||||||
|
[FromQuery] bool? uncategorizedOnly = null,
|
||||||
|
[FromQuery] int? limit = null)
|
||||||
|
{
|
||||||
|
var q = _db.Transactions
|
||||||
|
.Include(t => t.Merchant)
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.Receipts)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
|
||||||
|
q = q.Where(t => t.Date >= start);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
|
||||||
|
q = q.Where(t => t.Date <= end);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
q = q.Where(t => t.Category == category);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(merchantName))
|
||||||
|
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
|
||||||
|
|
||||||
|
if (minAmount.HasValue)
|
||||||
|
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
|
||||||
|
|
||||||
|
if (maxAmount.HasValue)
|
||||||
|
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
|
||||||
|
|
||||||
|
if (accountId.HasValue)
|
||||||
|
q = q.Where(t => t.AccountId == accountId.Value);
|
||||||
|
|
||||||
|
if (cardId.HasValue)
|
||||||
|
q = q.Where(t => t.CardId == cardId.Value);
|
||||||
|
|
||||||
|
if (type?.ToLower() == "debit")
|
||||||
|
q = q.Where(t => t.Amount < 0);
|
||||||
|
else if (type?.ToLower() == "credit")
|
||||||
|
q = q.Where(t => t.Amount > 0);
|
||||||
|
|
||||||
|
if (uncategorizedOnly == true)
|
||||||
|
q = q.Where(t => t.Category == null || t.Category == "");
|
||||||
|
|
||||||
|
var results = await q
|
||||||
|
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
|
||||||
|
.Take(limit ?? 50)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
t.Id,
|
||||||
|
t.Date,
|
||||||
|
t.Name,
|
||||||
|
t.Memo,
|
||||||
|
t.Amount,
|
||||||
|
t.Category,
|
||||||
|
Merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
|
Account = t.Account!.Institution + " " + t.Account.Last4,
|
||||||
|
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
|
||||||
|
ReceiptCount = t.Receipts.Count,
|
||||||
|
t.TransferToAccountId
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new { Count = results.Count, Transactions = results });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> GetById(long id)
|
||||||
|
{
|
||||||
|
var t = await _db.Transactions
|
||||||
|
.Include(t => t.Merchant)
|
||||||
|
.Include(t => t.Card)
|
||||||
|
.Include(t => t.Account)
|
||||||
|
.Include(t => t.Receipts)
|
||||||
|
.Where(t => t.Id == id)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
t.Id,
|
||||||
|
t.Date,
|
||||||
|
t.Name,
|
||||||
|
t.Memo,
|
||||||
|
t.Amount,
|
||||||
|
t.TransactionType,
|
||||||
|
t.Category,
|
||||||
|
Merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||||
|
MerchantId = t.MerchantId,
|
||||||
|
Account = t.Account!.Institution + " " + t.Account.Last4,
|
||||||
|
AccountId = t.AccountId,
|
||||||
|
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
|
||||||
|
CardId = t.CardId,
|
||||||
|
t.Notes,
|
||||||
|
t.TransferToAccountId,
|
||||||
|
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (t == null)
|
||||||
|
return NotFound(new { message = "Transaction not found" });
|
||||||
|
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}/category")]
|
||||||
|
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
|
||||||
|
{
|
||||||
|
var transactions = await _db.Transactions
|
||||||
|
.Where(t => request.TransactionIds.Contains(t.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!transactions.Any())
|
||||||
|
return NotFound(new { message = "No transactions found with the provided IDs" });
|
||||||
|
|
||||||
|
int? merchantId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||||
|
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||||
|
|
||||||
|
foreach (var t in transactions)
|
||||||
|
{
|
||||||
|
t.Category = request.Category;
|
||||||
|
if (merchantId.HasValue)
|
||||||
|
t.MerchantId = merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("bulk-recategorize")]
|
||||||
|
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
|
||||||
|
{
|
||||||
|
var q = _db.Transactions
|
||||||
|
.Where(t => t.Name.Contains(request.NamePattern));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.FromCategory))
|
||||||
|
q = q.Where(t => t.Category == request.FromCategory);
|
||||||
|
|
||||||
|
var transactions = await q.ToListAsync();
|
||||||
|
|
||||||
|
if (!transactions.Any())
|
||||||
|
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
|
||||||
|
|
||||||
|
if (request.DryRun)
|
||||||
|
{
|
||||||
|
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
|
||||||
|
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
|
||||||
|
}
|
||||||
|
|
||||||
|
int? merchantId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||||
|
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||||
|
|
||||||
|
foreach (var t in transactions)
|
||||||
|
{
|
||||||
|
t.Category = request.ToCategory;
|
||||||
|
if (merchantId.HasValue)
|
||||||
|
t.MerchantId = merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("spending-summary")]
|
||||||
|
public async Task<IActionResult> SpendingSummary(
|
||||||
|
[FromQuery] string startDate,
|
||||||
|
[FromQuery] string endDate,
|
||||||
|
[FromQuery] int? accountId = null)
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
|
||||||
|
return BadRequest(new { message = "Invalid date format" });
|
||||||
|
|
||||||
|
var q = _db.Transactions
|
||||||
|
.Where(t => t.Date >= start && t.Date <= end)
|
||||||
|
.Where(t => t.Amount < 0)
|
||||||
|
.Where(t => t.TransferToAccountId == null)
|
||||||
|
.ExcludeTransfers();
|
||||||
|
|
||||||
|
if (accountId.HasValue)
|
||||||
|
q = q.Where(t => t.AccountId == accountId.Value);
|
||||||
|
|
||||||
|
var summary = await q
|
||||||
|
.GroupBy(t => t.Category ?? "Uncategorized")
|
||||||
|
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
|
||||||
|
.OrderByDescending(x => x.Total)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var grandTotal = summary.Sum(x => x.Total);
|
||||||
|
|
||||||
|
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("income-summary")]
|
||||||
|
public async Task<IActionResult> IncomeSummary(
|
||||||
|
[FromQuery] string startDate,
|
||||||
|
[FromQuery] string endDate,
|
||||||
|
[FromQuery] int? accountId = null)
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
|
||||||
|
return BadRequest(new { message = "Invalid date format" });
|
||||||
|
|
||||||
|
var q = _db.Transactions
|
||||||
|
.Where(t => t.Date >= start && t.Date <= end)
|
||||||
|
.Where(t => t.Amount > 0)
|
||||||
|
.Where(t => t.TransferToAccountId == null)
|
||||||
|
.ExcludeTransfers();
|
||||||
|
|
||||||
|
if (accountId.HasValue)
|
||||||
|
q = q.Where(t => t.AccountId == accountId.Value);
|
||||||
|
|
||||||
|
var summary = await q
|
||||||
|
.GroupBy(t => t.Name)
|
||||||
|
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
|
||||||
|
.OrderByDescending(x => x.Total)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var grandTotal = summary.Sum(x => x.Total);
|
||||||
|
|
||||||
|
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateCategoryRequest
|
||||||
|
{
|
||||||
|
public long[] TransactionIds { get; set; } = [];
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string? MerchantName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BulkRecategorizeRequest
|
||||||
|
{
|
||||||
|
public string NamePattern { get; set; } = "";
|
||||||
|
public string ToCategory { get; set; } = "";
|
||||||
|
public string? FromCategory { get; set; }
|
||||||
|
public string? MerchantName { get; set; }
|
||||||
|
public bool DryRun { get; set; } = true;
|
||||||
|
}
|
||||||
+11
-3
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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">
|
||||||
© 2025 - MoneyMap
|
© 2026 - MoneyMap
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user