refactor: move services and AITools to MoneyMap.Core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services.AITools;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptParser
|
||||
{
|
||||
Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null);
|
||||
}
|
||||
|
||||
public class AIReceiptParser : IReceiptParser
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly IPdfToImageConverter _pdfConverter;
|
||||
private readonly IAIVisionClientResolver _clientResolver;
|
||||
private readonly IMerchantService _merchantService;
|
||||
private readonly IAIToolExecutor _toolExecutor;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AIReceiptParser> _logger;
|
||||
private string? _promptTemplate;
|
||||
|
||||
public AIReceiptParser(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
IPdfToImageConverter pdfConverter,
|
||||
IAIVisionClientResolver clientResolver,
|
||||
IMerchantService merchantService,
|
||||
IAIToolExecutor toolExecutor,
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<AIReceiptParser> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_pdfConverter = pdfConverter;
|
||||
_clientResolver = clientResolver;
|
||||
_merchantService = merchantService;
|
||||
_toolExecutor = toolExecutor;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId, string? model = null, string? notes = null)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
|
||||
if (receipt == null)
|
||||
return ReceiptParseResult.Failure("Receipt not found.");
|
||||
|
||||
var filePath = _receiptManager.GetReceiptPhysicalPath(receipt);
|
||||
if (!File.Exists(filePath))
|
||||
return ReceiptParseResult.Failure("Receipt file not found on disk.");
|
||||
|
||||
// Fall back to receipt.ParsingNotes if notes parameter is null
|
||||
var effectiveNotes = notes ?? receipt.ParsingNotes;
|
||||
|
||||
var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
var (client, provider) = _clientResolver.Resolve(selectedModel);
|
||||
|
||||
// Let model-aware clients evaluate tool support for the specific model
|
||||
if (client is LlamaCppVisionClient llamaCpp)
|
||||
llamaCpp.SetCurrentModel(selectedModel);
|
||||
|
||||
var parseLog = new ReceiptParseLog
|
||||
{
|
||||
ReceiptId = receiptId,
|
||||
Provider = provider,
|
||||
Model = selectedModel,
|
||||
StartedAtUtc = DateTime.UtcNow,
|
||||
Success = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
|
||||
var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
|
||||
var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
|
||||
|
||||
if (!visionResult.IsSuccess)
|
||||
{
|
||||
await SaveParseLogAsync(parseLog, visionResult.ErrorMessage);
|
||||
return ReceiptParseResult.Failure(visionResult.ErrorMessage!);
|
||||
}
|
||||
|
||||
var parseData = ParseResponse(visionResult.Content);
|
||||
await ApplyParseResultAsync(receipt, receiptId, parseData, effectiveNotes);
|
||||
|
||||
parseLog.Success = true;
|
||||
parseLog.Confidence = parseData.Confidence;
|
||||
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
|
||||
await SaveParseLogAsync(parseLog);
|
||||
|
||||
await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
|
||||
|
||||
var lineCount = parseData.LineItems.Count;
|
||||
return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SaveParseLogAsync(parseLog, ex.Message);
|
||||
_logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
||||
return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
|
||||
/// </summary>
|
||||
private async Task<VisionApiResult> CallVisionClientAsync(
|
||||
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
|
||||
{
|
||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||
{
|
||||
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
|
||||
var tools = AIToolRegistry.GetAllTools();
|
||||
|
||||
return await toolAwareClient.AnalyzeImageWithToolsAsync(
|
||||
base64Data, mediaType, prompt, model,
|
||||
tools,
|
||||
toolCall => _toolExecutor.ExecuteAsync(toolCall),
|
||||
maxToolRounds: 5);
|
||||
}
|
||||
|
||||
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
|
||||
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
|
||||
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
|
||||
{
|
||||
if (receipt.ContentType == "application/pdf")
|
||||
{
|
||||
var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath);
|
||||
return (base64, "image/png");
|
||||
}
|
||||
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||
return (Convert.ToBase64String(fileBytes), receipt.ContentType);
|
||||
}
|
||||
|
||||
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client)
|
||||
{
|
||||
var promptText = await LoadPromptTemplateAsync();
|
||||
|
||||
var transactionName = receipt.Transaction?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(transactionName))
|
||||
{
|
||||
promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear.";
|
||||
}
|
||||
|
||||
var parsingNotes = _configuration["AI:ReceiptParsingNotes"];
|
||||
if (!string.IsNullOrWhiteSpace(parsingNotes))
|
||||
{
|
||||
promptText += $"\n\nAdditional notes: {parsingNotes}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userNotes))
|
||||
{
|
||||
promptText += $"\n\nUser notes for this receipt: {userNotes}";
|
||||
}
|
||||
|
||||
// Add tool-use or enriched context instructions based on client capability
|
||||
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
|
||||
{
|
||||
// Tool-aware client: instruct to use tools for lookups
|
||||
promptText += @"
|
||||
|
||||
TOOL USE INSTRUCTIONS:
|
||||
You have access to tools that can query the application's database. You MUST call them before generating your JSON response:
|
||||
1. Call search_categories to find existing category names. Use ONLY categories returned by this tool for suggestedCategory and line item category fields. Do not invent new category names.
|
||||
2. Call search_transactions to find a matching bank transaction for this receipt (search by date, amount, merchant name). Set suggestedTransactionId to the numeric ID of the best match, or null if no good match. Remember: suggestedTransactionId must be a JSON integer or null, never a string.
|
||||
3. Call search_merchants to look up the correct merchant name.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-tool client (Ollama): inject pre-fetched database context
|
||||
try
|
||||
{
|
||||
var merchantHint = receipt.Transaction?.Name ?? receipt.Merchant;
|
||||
var enrichedContext = await _toolExecutor.GetEnrichedContextAsync(
|
||||
receipt.ReceiptDate,
|
||||
receipt.Total,
|
||||
merchantHint);
|
||||
|
||||
promptText += $"\n\n{enrichedContext}";
|
||||
promptText += @"
|
||||
|
||||
Using the database context above, populate these fields in your JSON response:
|
||||
- suggestedCategory: Use the best matching category name from the EXISTING CATEGORIES list. Do not invent new categories.
|
||||
- suggestedTransactionId: Use the numeric transaction ID from CANDIDATE TRANSACTIONS that best matches this receipt, or null if none match. Must be a JSON integer or null, never a string.
|
||||
- For each line item, set category to the best matching category from the EXISTING CATEGORIES list.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get enriched context for Ollama, proceeding without it");
|
||||
}
|
||||
}
|
||||
|
||||
promptText += "\n\nRespond ONLY with valid JSON, no other text.";
|
||||
return promptText;
|
||||
}
|
||||
|
||||
private static ParsedReceiptData ParseResponse(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return new ParsedReceiptData();
|
||||
|
||||
return JsonSerializer.Deserialize<ParsedReceiptData>(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}) ?? new ParsedReceiptData();
|
||||
}
|
||||
|
||||
private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes)
|
||||
{
|
||||
// Update receipt fields
|
||||
receipt.ParsingNotes = notes;
|
||||
receipt.Merchant = parseData.Merchant;
|
||||
receipt.Total = parseData.Total;
|
||||
receipt.Subtotal = parseData.Subtotal;
|
||||
receipt.Tax = parseData.Tax;
|
||||
receipt.ReceiptDate = parseData.ReceiptDate;
|
||||
receipt.DueDate = parseData.DueDate;
|
||||
|
||||
// Update transaction merchant if needed
|
||||
if (receipt.Transaction != null &&
|
||||
!string.IsNullOrWhiteSpace(parseData.Merchant) &&
|
||||
receipt.Transaction.MerchantId == null)
|
||||
{
|
||||
var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant);
|
||||
receipt.Transaction.MerchantId = merchantId;
|
||||
}
|
||||
|
||||
// Update transaction category if AI suggested one and the transaction has no category
|
||||
if (receipt.Transaction != null &&
|
||||
!string.IsNullOrWhiteSpace(parseData.SuggestedCategory) &&
|
||||
string.IsNullOrWhiteSpace(receipt.Transaction.Category))
|
||||
{
|
||||
receipt.Transaction.Category = parseData.SuggestedCategory;
|
||||
_logger.LogInformation("Set transaction {TransactionId} category to '{Category}' from AI suggestion",
|
||||
receipt.Transaction.Id, parseData.SuggestedCategory);
|
||||
}
|
||||
|
||||
// Replace line items
|
||||
var existingItems = await _db.ReceiptLineItems
|
||||
.Where(li => li.ReceiptId == receiptId)
|
||||
.ToListAsync();
|
||||
_db.ReceiptLineItems.RemoveRange(existingItems);
|
||||
|
||||
var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
|
||||
{
|
||||
ReceiptId = receiptId,
|
||||
LineNumber = index + 1,
|
||||
Description = item.Description,
|
||||
Sku = item.Upc,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
LineTotal = item.LineTotal,
|
||||
Category = item.Category,
|
||||
Voided = item.Voided
|
||||
}).ToList();
|
||||
|
||||
_db.ReceiptLineItems.AddRange(lineItems);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null)
|
||||
{
|
||||
parseLog.Error = error;
|
||||
parseLog.CompletedAtUtc = DateTime.UtcNow;
|
||||
_db.ReceiptParseLogs.Add(parseLog);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId, long? suggestedTransactionId)
|
||||
{
|
||||
// If AI suggested a specific transaction, try mapping directly
|
||||
if (!receipt.TransactionId.HasValue && suggestedTransactionId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _db.Transactions.FindAsync(suggestedTransactionId.Value);
|
||||
if (transaction != null)
|
||||
{
|
||||
// Verify the transaction isn't already mapped to another receipt
|
||||
var alreadyMapped = await _db.Receipts
|
||||
.AnyAsync(r => r.TransactionId == suggestedTransactionId.Value && r.Id != receiptId);
|
||||
|
||||
if (!alreadyMapped)
|
||||
{
|
||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, suggestedTransactionId.Value);
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"AI-suggested mapping: receipt {ReceiptId} → transaction {TransactionId}",
|
||||
receiptId, suggestedTransactionId.Value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI-suggested mapping failed for receipt {ReceiptId} → transaction {TransactionId}",
|
||||
receiptId, suggestedTransactionId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the existing auto-mapper
|
||||
if (receipt.TransactionId.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var autoMapper = scope.ServiceProvider.GetRequiredService<IReceiptAutoMapper>();
|
||||
await autoMapper.AutoMapReceiptAsync(receiptId);
|
||||
_logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> LoadPromptTemplateAsync()
|
||||
{
|
||||
if (_promptTemplate != null)
|
||||
return _promptTemplate;
|
||||
|
||||
var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "ReceiptParserPrompt.txt");
|
||||
|
||||
if (!File.Exists(promptPath))
|
||||
throw new FileNotFoundException($"Receipt parser prompt template not found at: {promptPath}");
|
||||
|
||||
_promptTemplate = await File.ReadAllTextAsync(promptPath);
|
||||
return _promptTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the appropriate AI vision client based on model name.
|
||||
/// </summary>
|
||||
public interface IAIVisionClientResolver
|
||||
{
|
||||
(IAIVisionClient Client, string Provider) Resolve(string model);
|
||||
}
|
||||
|
||||
public class AIVisionClientResolver : IAIVisionClientResolver
|
||||
{
|
||||
private readonly OpenAIVisionClient _openAIClient;
|
||||
private readonly ClaudeVisionClient _claudeClient;
|
||||
private readonly OllamaVisionClient _ollamaClient;
|
||||
private readonly LlamaCppVisionClient _llamaCppClient;
|
||||
|
||||
public AIVisionClientResolver(
|
||||
OpenAIVisionClient openAIClient,
|
||||
ClaudeVisionClient claudeClient,
|
||||
OllamaVisionClient ollamaClient,
|
||||
LlamaCppVisionClient llamaCppClient)
|
||||
{
|
||||
_openAIClient = openAIClient;
|
||||
_claudeClient = claudeClient;
|
||||
_ollamaClient = ollamaClient;
|
||||
_llamaCppClient = llamaCppClient;
|
||||
}
|
||||
|
||||
public (IAIVisionClient Client, string Provider) Resolve(string model)
|
||||
{
|
||||
if (model.StartsWith("llamacpp:"))
|
||||
return (_llamaCppClient, "LlamaCpp");
|
||||
|
||||
if (model.StartsWith("ollama:"))
|
||||
return (_ollamaClient, "Ollama");
|
||||
|
||||
if (model.StartsWith("claude-"))
|
||||
return (_claudeClient, "Anthropic");
|
||||
|
||||
return (_openAIClient, "OpenAI");
|
||||
}
|
||||
}
|
||||
|
||||
public class ParsedReceiptData
|
||||
{
|
||||
public string? Merchant { get; set; }
|
||||
public DateTime? ReceiptDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal? Subtotal { get; set; }
|
||||
public decimal? Tax { get; set; }
|
||||
public decimal? Total { get; set; }
|
||||
public decimal Confidence { get; set; } = 0.5m;
|
||||
public string? SuggestedCategory { get; set; }
|
||||
[JsonConverter(typeof(NullableLongConverter))]
|
||||
public long? SuggestedTransactionId { get; set; }
|
||||
public List<ParsedLineItem> LineItems { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ParsedLineItem
|
||||
{
|
||||
public string Description { get; set; } = "";
|
||||
public string? Upc { get; set; }
|
||||
public decimal? Quantity { get; set; }
|
||||
public decimal? UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public bool Voided { get; set; }
|
||||
}
|
||||
|
||||
public class ReceiptParseResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public string? Message { get; init; }
|
||||
|
||||
public static ReceiptParseResult Success(string message) =>
|
||||
new() { IsSuccess = true, Message = message };
|
||||
|
||||
public static ReceiptParseResult Failure(string message) =>
|
||||
new() { IsSuccess = false, Message = message };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
|
||||
/// instead of as a JSON number or null.
|
||||
/// </summary>
|
||||
public class NullableLongConverter : JsonConverter<long?>
|
||||
{
|
||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Number:
|
||||
return reader.GetInt64();
|
||||
case JsonTokenType.String:
|
||||
var str = reader.GetString();
|
||||
if (string.IsNullOrWhiteSpace(str) ||
|
||||
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
|
||||
str.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return long.TryParse(str, out var val) ? val : null;
|
||||
case JsonTokenType.Null:
|
||||
return null;
|
||||
default:
|
||||
reader.Skip();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value.HasValue)
|
||||
writer.WriteNumberValue(value.Value);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider-agnostic tool definition for AI function calling.
|
||||
/// </summary>
|
||||
public class AIToolDefinition
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<AIToolParameter> Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AIToolParameter
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Type { get; set; } = "string"; // string, number, integer
|
||||
public string Description { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tool call from the AI model.
|
||||
/// </summary>
|
||||
public class AIToolCall
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public Dictionary<string, object?> Arguments { get; set; } = new();
|
||||
|
||||
public string? GetString(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
return val.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
public decimal? GetDecimal(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (decimal.TryParse(val.ToString(), out var d))
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int? GetInt(string key)
|
||||
{
|
||||
if (Arguments.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (int.TryParse(val.ToString(), out var i))
|
||||
return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a tool, returned to the AI.
|
||||
/// </summary>
|
||||
public class AIToolResult
|
||||
{
|
||||
public string ToolCallId { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
public bool IsError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of all tools available to the receipt parsing AI.
|
||||
/// </summary>
|
||||
public static class AIToolRegistry
|
||||
{
|
||||
public static List<AIToolDefinition> GetAllTools() => new()
|
||||
{
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_categories",
|
||||
Description = "Search existing expense categories in the system. Returns category names with their matching patterns and associated merchants. Use this to find the correct category name for line items and the overall receipt instead of inventing new ones.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_transactions",
|
||||
Description = "Search bank transactions to find one that matches this receipt. Returns transaction ID, date, amount, name, merchant, and category. Use this to suggest which transaction this receipt belongs to.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "merchant",
|
||||
Type = "string",
|
||||
Description = "Merchant or store name to search for (partial match)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minDate",
|
||||
Type = "string",
|
||||
Description = "Earliest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxDate",
|
||||
Type = "string",
|
||||
Description = "Latest transaction date (YYYY-MM-DD format)",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "minAmount",
|
||||
Type = "number",
|
||||
Description = "Minimum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "maxAmount",
|
||||
Type = "number",
|
||||
Description = "Maximum absolute transaction amount",
|
||||
Required = false
|
||||
},
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "limit",
|
||||
Type = "integer",
|
||||
Description = "Maximum results to return (default 10, max 20)",
|
||||
Required = false
|
||||
}
|
||||
}
|
||||
},
|
||||
new AIToolDefinition
|
||||
{
|
||||
Name = "search_merchants",
|
||||
Description = "Search known merchants by name. Returns merchant name, transaction count, and most common category. Use this to find the correct merchant name and see what category is typically used for them.",
|
||||
Parameters = new()
|
||||
{
|
||||
new AIToolParameter
|
||||
{
|
||||
Name = "query",
|
||||
Type = "string",
|
||||
Description = "Merchant name to search for (partial match)",
|
||||
Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Services.AITools
|
||||
{
|
||||
public interface IAIToolExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute a single tool call and return the result as JSON.
|
||||
/// </summary>
|
||||
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
|
||||
/// </summary>
|
||||
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
|
||||
}
|
||||
|
||||
public class AIToolExecutor : IAIToolExecutor
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ILogger<AIToolExecutor> _logger;
|
||||
private const int MaxResults = 20;
|
||||
|
||||
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
|
||||
{
|
||||
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
|
||||
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
|
||||
|
||||
try
|
||||
{
|
||||
var result = toolCall.Name switch
|
||||
{
|
||||
"search_categories" => await SearchCategoriesAsync(toolCall),
|
||||
"search_transactions" => await SearchTransactionsAsync(toolCall),
|
||||
"search_merchants" => await SearchMerchantsAsync(toolCall),
|
||||
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
|
||||
};
|
||||
|
||||
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
|
||||
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = result
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
|
||||
return new AIToolResult
|
||||
{
|
||||
ToolCallId = toolCall.Id,
|
||||
Content = JsonSerializer.Serialize(new { error = ex.Message }),
|
||||
IsError = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
|
||||
sb.AppendLine();
|
||||
|
||||
// Categories
|
||||
var categories = await _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = categories.GroupBy(c => c.Category).ToList();
|
||||
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var patterns = group.Select(c => c.Pattern).Take(5);
|
||||
var merchants = group.Where(c => c.Merchant != null).Select(c => c.Merchant!.Name).Distinct().Take(3);
|
||||
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
|
||||
if (merchants.Any())
|
||||
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Merchants matching hint
|
||||
if (!string.IsNullOrWhiteSpace(merchantHint))
|
||||
{
|
||||
var matchingMerchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(merchantHint))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Name,
|
||||
TransactionCount = m.Transactions.Count,
|
||||
TopCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (matchingMerchants.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
|
||||
foreach (var m in matchingMerchants)
|
||||
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Matching transactions
|
||||
if (receiptDate.HasValue || total.HasValue)
|
||||
{
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (receiptDate.HasValue)
|
||||
{
|
||||
var minDate = receiptDate.Value.AddDays(-1);
|
||||
var maxDate = receiptDate.Value.AddDays(7);
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
|
||||
if (total.HasValue)
|
||||
{
|
||||
var absTotal = Math.Abs(total.Value);
|
||||
var minAmt = absTotal * 0.9m;
|
||||
var maxAmt = absTotal * 1.1m;
|
||||
txQuery = txQuery.Where(t =>
|
||||
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
|
||||
(t.Amount >= minAmt && t.Amount <= maxAmt));
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderBy(t => t.Date)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
if (transactions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
|
||||
foreach (var t in transactions)
|
||||
{
|
||||
sb.AppendLine($" - ID={t.Id}, Date={t.Date:yyyy-MM-dd}, Amount={t.Amount:C}, Name=\"{t.Name}\", " +
|
||||
$"Merchant={t.Merchant?.Name ?? "none"}, Category={t.Category}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("=== END DATABASE CONTEXT ===");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query");
|
||||
|
||||
var mappings = _db.CategoryMappings
|
||||
.Include(cm => cm.Merchant)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
mappings = mappings.Where(cm => cm.Category.Contains(query));
|
||||
|
||||
var results = await mappings
|
||||
.OrderBy(cm => cm.Category)
|
||||
.ToListAsync();
|
||||
|
||||
var grouped = results
|
||||
.GroupBy(c => c.Category)
|
||||
.Take(MaxResults)
|
||||
.Select(g => new
|
||||
{
|
||||
category = g.Key,
|
||||
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
|
||||
merchants = g.Where(c => c.Merchant != null)
|
||||
.Select(c => c.Merchant!.Name)
|
||||
.Distinct()
|
||||
.Take(5)
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return JsonSerializer.Serialize(new { categories = grouped });
|
||||
}
|
||||
|
||||
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var merchant = toolCall.GetString("merchant");
|
||||
var minDateStr = toolCall.GetString("minDate");
|
||||
var maxDateStr = toolCall.GetString("maxDate");
|
||||
var minAmount = toolCall.GetDecimal("minAmount");
|
||||
var maxAmount = toolCall.GetDecimal("maxAmount");
|
||||
var limit = toolCall.GetInt("limit") ?? 10;
|
||||
limit = Math.Min(limit, MaxResults);
|
||||
|
||||
var txQuery = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(merchant))
|
||||
{
|
||||
txQuery = txQuery.Where(t =>
|
||||
t.Name.Contains(merchant) ||
|
||||
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(minDateStr, out var minDate))
|
||||
txQuery = txQuery.Where(t => t.Date >= minDate);
|
||||
|
||||
if (DateTime.TryParse(maxDateStr, out var maxDate))
|
||||
txQuery = txQuery.Where(t => t.Date <= maxDate);
|
||||
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
var min = minAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
var max = maxAmount.Value;
|
||||
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
|
||||
}
|
||||
|
||||
var transactions = await txQuery
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(limit)
|
||||
.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
date = t.Date.ToString("yyyy-MM-dd"),
|
||||
amount = t.Amount,
|
||||
name = t.Name,
|
||||
merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||
category = t.Category
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { transactions });
|
||||
}
|
||||
|
||||
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
|
||||
{
|
||||
var query = toolCall.GetString("query") ?? "";
|
||||
|
||||
var merchants = await _db.Merchants
|
||||
.Where(m => m.Name.Contains(query))
|
||||
.Select(m => new
|
||||
{
|
||||
name = m.Name,
|
||||
transactionCount = m.Transactions.Count,
|
||||
topCategory = m.Transactions
|
||||
.Where(t => t.Category != "")
|
||||
.GroupBy(t => t.Category)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Take(MaxResults)
|
||||
.ToListAsync();
|
||||
|
||||
return JsonSerializer.Serialize(new { merchants });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for account management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface IAccountService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an account by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts with optional statistics.
|
||||
/// </summary>
|
||||
Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets account details with cards and transaction count.
|
||||
/// </summary>
|
||||
Task<AccountDetails?> GetAccountDetailsAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an account can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteAccountAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an account if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteAccountAsync(int id);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public AccountService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Account?> GetAccountByIdAsync(int id, bool includeRelated = false)
|
||||
{
|
||||
var query = _db.Accounts.AsQueryable();
|
||||
|
||||
if (includeRelated)
|
||||
{
|
||||
query = query
|
||||
.Include(a => a.Cards)
|
||||
.Include(a => a.Transactions);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<AccountWithStats>> GetAllAccountsWithStatsAsync()
|
||||
{
|
||||
var accounts = await _db.Accounts
|
||||
.Include(a => a.Transactions)
|
||||
.OrderBy(a => a.Owner)
|
||||
.ThenBy(a => a.Institution)
|
||||
.ThenBy(a => a.Last4)
|
||||
.ToListAsync();
|
||||
|
||||
return accounts.Select(a => new AccountWithStats
|
||||
{
|
||||
Id = a.Id,
|
||||
Institution = a.Institution,
|
||||
AccountType = a.AccountType,
|
||||
Last4 = a.Last4,
|
||||
Owner = a.Owner,
|
||||
Nickname = a.Nickname,
|
||||
TransactionCount = a.Transactions.Count
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<AccountDetails?> GetAccountDetailsAsync(int id)
|
||||
{
|
||||
var account = await _db.Accounts.FindAsync(id);
|
||||
if (account == null)
|
||||
return null;
|
||||
|
||||
// Single query with projection to avoid N+1
|
||||
var cardStats = await _db.Cards
|
||||
.Where(c => c.AccountId == id)
|
||||
.OrderBy(c => c.Owner)
|
||||
.ThenBy(c => c.Last4)
|
||||
.Select(c => new CardWithStats
|
||||
{
|
||||
Card = c,
|
||||
TransactionCount = c.Transactions.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Get transaction count for this account
|
||||
var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id);
|
||||
|
||||
return new AccountDetails
|
||||
{
|
||||
Account = account,
|
||||
Cards = cardStats,
|
||||
TransactionCount = accountTransactionCount
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DeleteValidationResult> CanDeleteAccountAsync(int id)
|
||||
{
|
||||
var account = await _db.Accounts
|
||||
.Include(a => a.Transactions)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (account == null)
|
||||
return new DeleteValidationResult
|
||||
{
|
||||
CanDelete = false,
|
||||
Reason = "Account not found."
|
||||
};
|
||||
|
||||
if (account.Transactions.Any())
|
||||
return new DeleteValidationResult
|
||||
{
|
||||
CanDelete = false,
|
||||
Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it."
|
||||
};
|
||||
|
||||
return new DeleteValidationResult { CanDelete = true };
|
||||
}
|
||||
|
||||
public async Task<DeleteResult> DeleteAccountAsync(int id)
|
||||
{
|
||||
var validation = await CanDeleteAccountAsync(id);
|
||||
if (!validation.CanDelete)
|
||||
{
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = false,
|
||||
Message = validation.Reason ?? "Cannot delete account."
|
||||
};
|
||||
}
|
||||
|
||||
var account = await _db.Accounts.FindAsync(id);
|
||||
if (account == null)
|
||||
{
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Account not found."
|
||||
};
|
||||
}
|
||||
|
||||
_db.Accounts.Remove(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Deleted account {account.Institution} {account.Last4}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class AccountWithStats
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Institution { get; set; } = "";
|
||||
public AccountType AccountType { get; set; }
|
||||
public string Last4 { get; set; } = "";
|
||||
public string Owner { get; set; } = "";
|
||||
public string? Nickname { get; set; }
|
||||
public int TransactionCount { get; set; }
|
||||
}
|
||||
|
||||
public class AccountDetails
|
||||
{
|
||||
public Account Account { get; set; } = null!;
|
||||
public List<CardWithStats> Cards { get; set; } = new();
|
||||
public int TransactionCount { get; set; }
|
||||
}
|
||||
|
||||
public class CardWithStats
|
||||
{
|
||||
public Card Card { get; set; } = null!;
|
||||
public int TransactionCount { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteValidationResult
|
||||
{
|
||||
public bool CanDelete { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IBudgetService
|
||||
{
|
||||
// CRUD operations
|
||||
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
|
||||
Task<Budget?> GetBudgetByIdAsync(int id);
|
||||
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
|
||||
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
|
||||
|
||||
// Budget status calculations
|
||||
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
|
||||
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
|
||||
|
||||
// Helper methods
|
||||
Task<List<string>> GetAvailableCategoriesAsync();
|
||||
(DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate);
|
||||
}
|
||||
|
||||
public class BudgetService : IBudgetService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public BudgetService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
#region CRUD Operations
|
||||
|
||||
public async Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true)
|
||||
{
|
||||
var query = _db.Budgets.AsQueryable();
|
||||
|
||||
if (activeOnly)
|
||||
query = query.Where(b => b.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(b => b.Category == null) // Total budget last
|
||||
.ThenBy(b => b.Category)
|
||||
.ThenBy(b => b.Period)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Budget?> GetBudgetByIdAsync(int id)
|
||||
{
|
||||
return await _db.Budgets.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> CreateBudgetAsync(Budget budget)
|
||||
{
|
||||
// Validate amount
|
||||
if (budget.Amount <= 0)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget amount must be greater than zero."
|
||||
};
|
||||
}
|
||||
|
||||
// Check for duplicate active budget (same category + period)
|
||||
var existing = await _db.Budgets
|
||||
.Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
var categoryName = budget.Category ?? "Total Spending";
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||
};
|
||||
}
|
||||
|
||||
budget.IsActive = true;
|
||||
_db.Budgets.Add(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget created successfully.",
|
||||
BudgetId = budget.Id
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget)
|
||||
{
|
||||
var existing = await _db.Budgets.FindAsync(budget.Id);
|
||||
if (existing == null)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget not found."
|
||||
};
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
if (budget.Amount <= 0)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget amount must be greater than zero."
|
||||
};
|
||||
}
|
||||
|
||||
// Check for duplicate if category or period changed
|
||||
if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period))
|
||||
{
|
||||
var duplicate = await _db.Budgets
|
||||
.Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (duplicate != null)
|
||||
{
|
||||
var categoryName = budget.Category ?? "Total Spending";
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
existing.Category = budget.Category;
|
||||
existing.Amount = budget.Amount;
|
||||
existing.Period = budget.Period;
|
||||
existing.StartDate = budget.StartDate;
|
||||
existing.IsActive = budget.IsActive;
|
||||
existing.Notes = budget.Notes;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget updated successfully.",
|
||||
BudgetId = existing.Id
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BudgetOperationResult> DeleteBudgetAsync(int id)
|
||||
{
|
||||
var budget = await _db.Budgets.FindAsync(id);
|
||||
if (budget == null)
|
||||
{
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Budget not found."
|
||||
};
|
||||
}
|
||||
|
||||
_db.Budgets.Remove(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new BudgetOperationResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Budget deleted successfully."
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Budget Status Calculations
|
||||
|
||||
public async Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
|
||||
{
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
var budgets = await GetAllBudgetsAsync(activeOnly: true);
|
||||
var statuses = new List<BudgetStatus>();
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var status = await CalculateBudgetStatusAsync(budget, date);
|
||||
statuses.Add(status);
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)
|
||||
{
|
||||
var budget = await GetBudgetByIdAsync(budgetId);
|
||||
if (budget == null)
|
||||
return null;
|
||||
|
||||
var date = asOfDate ?? DateTime.Today;
|
||||
return await CalculateBudgetStatusAsync(budget, date);
|
||||
}
|
||||
|
||||
private async Task<BudgetStatus> CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate)
|
||||
{
|
||||
var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate);
|
||||
|
||||
// Calculate spending for the period
|
||||
var query = _db.Transactions
|
||||
.Where(t => t.Date >= periodStart && t.Date <= periodEnd)
|
||||
.Where(t => t.Amount < 0) // Only debits (spending)
|
||||
.Where(t => t.TransferToAccountId == null); // Exclude transfers
|
||||
|
||||
// For category-specific budgets, filter by category (case-insensitive)
|
||||
if (budget.Category != null)
|
||||
{
|
||||
query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower());
|
||||
}
|
||||
|
||||
var spent = await query.SumAsync(t => Math.Abs(t.Amount));
|
||||
var remaining = budget.Amount - spent;
|
||||
var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0;
|
||||
|
||||
return new BudgetStatus
|
||||
{
|
||||
Budget = budget,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
Spent = spent,
|
||||
Remaining = remaining,
|
||||
PercentUsed = percentUsed,
|
||||
IsOverBudget = spent > budget.Amount
|
||||
};
|
||||
}
|
||||
|
||||
public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate),
|
||||
BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate),
|
||||
BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(period))
|
||||
};
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Find which week we're in relative to the start date
|
||||
var daysSinceStart = (asOfDate - startDate.Date).Days;
|
||||
|
||||
if (daysSinceStart < 0)
|
||||
{
|
||||
// Before start date - use the week containing start date
|
||||
return (startDate.Date, startDate.Date.AddDays(6));
|
||||
}
|
||||
|
||||
var weekNumber = daysSinceStart / 7;
|
||||
var periodStart = startDate.Date.AddDays(weekNumber * 7);
|
||||
var periodEnd = periodStart.AddDays(6);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Use the start date's day of month as the boundary
|
||||
var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month));
|
||||
|
||||
DateTime periodStart;
|
||||
if (asOfDate.Day >= dayOfMonth)
|
||||
{
|
||||
// We're in the current period
|
||||
periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth);
|
||||
}
|
||||
else
|
||||
{
|
||||
// We're before this month's boundary, so use last month
|
||||
var lastMonth = asOfDate.AddMonths(-1);
|
||||
dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month));
|
||||
periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth);
|
||||
}
|
||||
|
||||
// End is the day before the next period starts
|
||||
var nextPeriodStart = periodStart.AddMonths(1);
|
||||
var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month));
|
||||
nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth);
|
||||
var periodEnd = nextPeriodStart.AddDays(-1);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate)
|
||||
{
|
||||
// Find which year period we're in
|
||||
var yearsSinceStart = asOfDate.Year - startDate.Year;
|
||||
|
||||
// Check if we're before the anniversary this year
|
||||
var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month,
|
||||
Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month)));
|
||||
|
||||
if (asOfDate < anniversaryThisYear)
|
||||
yearsSinceStart--;
|
||||
|
||||
var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart));
|
||||
var periodEnd = periodStart.AddYears(1).AddDays(-1);
|
||||
|
||||
return (periodStart, periodEnd);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Where(t => !string.IsNullOrEmpty(t.Category))
|
||||
.Select(t => t.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class BudgetOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public int? BudgetId { get; set; }
|
||||
}
|
||||
|
||||
public class BudgetStatus
|
||||
{
|
||||
public Budget Budget { get; set; } = null!;
|
||||
public DateTime PeriodStart { get; set; }
|
||||
public DateTime PeriodEnd { get; set; }
|
||||
public decimal Spent { get; set; }
|
||||
public decimal Remaining { get; set; }
|
||||
public decimal PercentUsed { get; set; }
|
||||
public bool IsOverBudget { get; set; }
|
||||
|
||||
// Helper for display
|
||||
public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success";
|
||||
public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}";
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models.Import;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for resolving payment methods (cards/accounts) for transactions.
|
||||
/// </summary>
|
||||
public interface ICardResolver
|
||||
{
|
||||
Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context);
|
||||
}
|
||||
|
||||
public class CardResolver : ICardResolver
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardResolver(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<PaymentResolutionResult> ResolvePaymentAsync(string? memo, ImportContext context)
|
||||
{
|
||||
if (context.PaymentMode == PaymentSelectMode.Card)
|
||||
return ResolveCard(context);
|
||||
|
||||
if (context.PaymentMode == PaymentSelectMode.Account)
|
||||
return ResolveAccount(context);
|
||||
|
||||
return await ResolveAutomaticallyAsync(memo, context);
|
||||
}
|
||||
|
||||
private PaymentResolutionResult ResolveCard(ImportContext context)
|
||||
{
|
||||
if (context.SelectedCardId is null)
|
||||
return PaymentResolutionResult.Failure("Pick a card or switch to Auto.");
|
||||
|
||||
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
|
||||
if (card is null)
|
||||
return PaymentResolutionResult.Failure("Selected card not found.");
|
||||
|
||||
// Card must have a linked account
|
||||
if (!card.AccountId.HasValue)
|
||||
return PaymentResolutionResult.Failure($"Card {card.DisplayLabel} is not linked to an account. Please link it to an account first.");
|
||||
|
||||
return PaymentResolutionResult.SuccessCard(card.Id, card.AccountId.Value, card.Last4);
|
||||
}
|
||||
|
||||
private PaymentResolutionResult ResolveAccount(ImportContext context)
|
||||
{
|
||||
if (context.SelectedAccountId is null)
|
||||
return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode.");
|
||||
|
||||
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId);
|
||||
if (account is null)
|
||||
return PaymentResolutionResult.Failure("Selected account not found.");
|
||||
|
||||
return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4);
|
||||
}
|
||||
|
||||
private Task<PaymentResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
|
||||
{
|
||||
// Extract last4 from both memo and filename
|
||||
var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName);
|
||||
var last4FromMemo = CardIdentifierExtractor.FromMemo(memo);
|
||||
|
||||
// PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765")
|
||||
if (!string.IsNullOrWhiteSpace(last4FromMemo))
|
||||
{
|
||||
var result = TryResolveByLast4(last4FromMemo, context);
|
||||
if (result != null) return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card)
|
||||
if (!string.IsNullOrWhiteSpace(last4FromFile))
|
||||
{
|
||||
var result = TryResolveByLast4(last4FromFile, context);
|
||||
if (result != null) return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// Nothing found - error
|
||||
var searchedLast4 = last4FromMemo ?? last4FromFile;
|
||||
if (string.IsNullOrWhiteSpace(searchedLast4))
|
||||
{
|
||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||
"Couldn't determine card or account from memo or file name. Choose an account manually."));
|
||||
}
|
||||
|
||||
return Task.FromResult(PaymentResolutionResult.Failure(
|
||||
$"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually."));
|
||||
}
|
||||
|
||||
private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context)
|
||||
{
|
||||
// Look for both card and account matches
|
||||
var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
|
||||
var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4);
|
||||
|
||||
// Prioritize card matches (for credit card CSVs or memo-based card detection)
|
||||
if (matchingCard != null)
|
||||
{
|
||||
// Card found - it must have an account
|
||||
if (!matchingCard.AccountId.HasValue)
|
||||
return PaymentResolutionResult.Failure($"Card {matchingCard.DisplayLabel} is not linked to an account. Please link it first or choose an account manually.");
|
||||
|
||||
return PaymentResolutionResult.SuccessCard(matchingCard.Id, matchingCard.AccountId.Value, matchingCard.Last4);
|
||||
}
|
||||
|
||||
// Fall back to account match (for direct account transactions)
|
||||
if (matchingAccount != null)
|
||||
{
|
||||
return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4);
|
||||
}
|
||||
|
||||
return null; // No match found
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for extracting card/account identifiers from memos and filenames.
|
||||
/// </summary>
|
||||
public static class CardIdentifierExtractor
|
||||
{
|
||||
// Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats
|
||||
private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled);
|
||||
private const int Last4Length = 4;
|
||||
|
||||
public static string? FromMemo(string? memo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(memo))
|
||||
return null;
|
||||
|
||||
// Try to find all matches and return the last one (most likely to be card/account number)
|
||||
var matches = MemoLast4Pattern.Matches(memo);
|
||||
if (matches.Count == 0)
|
||||
return null;
|
||||
|
||||
// Return the last match (typically the card number at the end)
|
||||
return matches[^1].Groups[1].Value;
|
||||
}
|
||||
|
||||
public static string? FromFileName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||
var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var part in parts.Select(p => p.Trim()))
|
||||
{
|
||||
if (part.Length == Last4Length && int.TryParse(part, out _))
|
||||
return part;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for card management including retrieval, validation, and deletion.
|
||||
/// </summary>
|
||||
public interface ICardService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a card by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with transaction statistics.
|
||||
/// </summary>
|
||||
Task<List<CardWithStats>> GetAllCardsWithStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a card can be deleted (no transactions exist).
|
||||
/// </summary>
|
||||
Task<DeleteValidationResult> CanDeleteCardAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a card if it has no associated transactions.
|
||||
/// </summary>
|
||||
Task<DeleteResult> DeleteCardAsync(int id);
|
||||
}
|
||||
|
||||
public class CardService : ICardService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CardService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Card?> GetCardByIdAsync(int id, bool includeRelated = false)
|
||||
{
|
||||
var query = _db.Cards.AsQueryable();
|
||||
|
||||
if (includeRelated)
|
||||
{
|
||||
query = query
|
||||
.Include(c => c.Account)
|
||||
.Include(c => c.Transactions);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CardWithStats>> GetAllCardsWithStatsAsync()
|
||||
{
|
||||
// Single query with projection to avoid N+1
|
||||
return await _db.Cards
|
||||
.Include(c => c.Account)
|
||||
.OrderBy(c => c.Owner)
|
||||
.ThenBy(c => c.Last4)
|
||||
.Select(c => new CardWithStats
|
||||
{
|
||||
Card = c,
|
||||
TransactionCount = c.Transactions.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<DeleteValidationResult> CanDeleteCardAsync(int id)
|
||||
{
|
||||
var card = await _db.Cards.FindAsync(id);
|
||||
if (card == null)
|
||||
return new DeleteValidationResult
|
||||
{
|
||||
CanDelete = false,
|
||||
Reason = "Card not found."
|
||||
};
|
||||
|
||||
var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id);
|
||||
if (transactionCount > 0)
|
||||
return new DeleteValidationResult
|
||||
{
|
||||
CanDelete = false,
|
||||
Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it."
|
||||
};
|
||||
|
||||
return new DeleteValidationResult { CanDelete = true };
|
||||
}
|
||||
|
||||
public async Task<DeleteResult> DeleteCardAsync(int id)
|
||||
{
|
||||
var validation = await CanDeleteCardAsync(id);
|
||||
if (!validation.CanDelete)
|
||||
{
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = false,
|
||||
Message = validation.Reason ?? "Cannot delete card."
|
||||
};
|
||||
}
|
||||
|
||||
var card = await _db.Cards.FindAsync(id);
|
||||
if (card == null)
|
||||
{
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Card not found."
|
||||
};
|
||||
}
|
||||
|
||||
_db.Cards.Remove(card);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new DeleteResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Card deleted successfully."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models.Dashboard;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for retrieving dashboard data.
|
||||
/// </summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
|
||||
}
|
||||
|
||||
public class DashboardService : IDashboardService
|
||||
{
|
||||
private readonly IDashboardStatsCalculator _statsCalculator;
|
||||
private readonly ITopCategoriesProvider _topCategoriesProvider;
|
||||
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
|
||||
private readonly ISpendTrendsProvider _spendTrendsProvider;
|
||||
|
||||
public DashboardService(
|
||||
IDashboardStatsCalculator statsCalculator,
|
||||
ITopCategoriesProvider topCategoriesProvider,
|
||||
IRecentTransactionsProvider recentTransactionsProvider,
|
||||
ISpendTrendsProvider spendTrendsProvider)
|
||||
{
|
||||
_statsCalculator = statsCalculator;
|
||||
_topCategoriesProvider = topCategoriesProvider;
|
||||
_recentTransactionsProvider = recentTransactionsProvider;
|
||||
_spendTrendsProvider = spendTrendsProvider;
|
||||
}
|
||||
|
||||
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
|
||||
{
|
||||
var stats = await _statsCalculator.CalculateAsync();
|
||||
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
|
||||
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
|
||||
var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30);
|
||||
|
||||
return new DashboardData
|
||||
{
|
||||
Stats = stats,
|
||||
TopCategories = topCategories,
|
||||
RecentTransactions = recent,
|
||||
Trends = trends
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates dashboard statistics.
|
||||
/// </summary>
|
||||
public interface IDashboardStatsCalculator
|
||||
{
|
||||
Task<DashboardStats> CalculateAsync();
|
||||
}
|
||||
|
||||
public class DashboardStatsCalculator : IDashboardStatsCalculator
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public DashboardStatsCalculator(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<DashboardStats> CalculateAsync()
|
||||
{
|
||||
var transactionStats = await GetTransactionStatsAsync();
|
||||
var receiptsCount = await _db.Receipts.CountAsync();
|
||||
var cardsCount = await _db.Cards.CountAsync();
|
||||
|
||||
return new DashboardStats(
|
||||
transactionStats.Total,
|
||||
transactionStats.Credits,
|
||||
transactionStats.Debits,
|
||||
transactionStats.Uncategorized,
|
||||
receiptsCount,
|
||||
cardsCount
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<TransactionStatsInternal> GetTransactionStatsAsync()
|
||||
{
|
||||
var stats = await _db.Transactions
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new TransactionStatsInternal
|
||||
{
|
||||
Total = g.Count(),
|
||||
Credits = g.Count(t => t.Amount > 0),
|
||||
Debits = g.Count(t => t.Amount < 0),
|
||||
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return stats ?? new TransactionStatsInternal();
|
||||
}
|
||||
|
||||
private class TransactionStatsInternal
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Credits { get; set; }
|
||||
public int Debits { get; set; }
|
||||
public int Uncategorized { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides top spending categories.
|
||||
/// </summary>
|
||||
public interface ITopCategoriesProvider
|
||||
{
|
||||
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
|
||||
}
|
||||
|
||||
public class TopCategoriesProvider : ITopCategoriesProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TopCategoriesProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
|
||||
{
|
||||
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
|
||||
|
||||
var expenseTransactions = await _db.Transactions
|
||||
.Where(t => t.Date >= since && t.Amount < 0)
|
||||
.ExcludeTransfers()
|
||||
.ToListAsync();
|
||||
|
||||
var totalSpend = expenseTransactions.Sum(t => -t.Amount);
|
||||
|
||||
var topCategories = expenseTransactions
|
||||
.GroupBy(t => t.Category ?? "")
|
||||
.Select(g => new TopCategoryRow
|
||||
{
|
||||
Category = g.Key,
|
||||
TotalSpend = g.Sum(x => -x.Amount),
|
||||
Count = g.Count(),
|
||||
PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0,
|
||||
AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0
|
||||
})
|
||||
.OrderByDescending(x => x.TotalSpend)
|
||||
.Take(count)
|
||||
.ToList();
|
||||
|
||||
return topCategories;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides recent transactions.
|
||||
/// </summary>
|
||||
public interface IRecentTransactionsProvider
|
||||
{
|
||||
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
|
||||
}
|
||||
|
||||
public class RecentTransactionsProvider : IRecentTransactionsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public RecentTransactionsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20)
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.OrderByDescending(t => t.Date)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Select(t => new RecentTransactionRow
|
||||
{
|
||||
Id = t.Id,
|
||||
Date = t.Date,
|
||||
Name = t.Name,
|
||||
Memo = t.Memo,
|
||||
Amount = t.Amount,
|
||||
Category = t.Category ?? "",
|
||||
CardLabel = t.PaymentMethodLabel,
|
||||
ReceiptCount = t.Receipts.Count()
|
||||
})
|
||||
.Take(count)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides spending trends over time.
|
||||
/// </summary>
|
||||
public interface ISpendTrendsProvider
|
||||
{
|
||||
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
|
||||
}
|
||||
|
||||
public class SpendTrendsProvider : ISpendTrendsProvider
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public SpendTrendsProvider(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var since = today.AddDays(-(lastDays - 1));
|
||||
|
||||
var raw = await _db.Transactions
|
||||
.Where(t => t.Date >= since)
|
||||
.ExcludeTransfers()
|
||||
.GroupBy(t => t.Date.Date)
|
||||
.Select(g => new
|
||||
{
|
||||
Date = g.Key,
|
||||
Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var dict = raw.ToDictionary(x => x.Date, x => x);
|
||||
var labels = new List<string>();
|
||||
var debitsAbs = new List<decimal>();
|
||||
var credits = new List<decimal>();
|
||||
var net = new List<decimal>();
|
||||
var runningBalance = new List<decimal>();
|
||||
decimal cumulative = 0;
|
||||
|
||||
for (var d = since; d <= today; d = d.AddDays(1))
|
||||
{
|
||||
labels.Add(d.ToString("MMM d"));
|
||||
if (dict.TryGetValue(d, out var v))
|
||||
{
|
||||
var debit = v.Debits;
|
||||
var credit = v.Credits;
|
||||
debitsAbs.Add(Math.Abs(debit));
|
||||
credits.Add(credit);
|
||||
net.Add(credit + debit);
|
||||
cumulative += credit + debit;
|
||||
}
|
||||
else
|
||||
{
|
||||
debitsAbs.Add(0);
|
||||
credits.Add(0);
|
||||
net.Add(0);
|
||||
}
|
||||
runningBalance.Add(cumulative);
|
||||
}
|
||||
|
||||
return new SpendTrends
|
||||
{
|
||||
Labels = labels,
|
||||
DebitsAbs = debitsAbs,
|
||||
Credits = credits,
|
||||
Net = net,
|
||||
RunningBalance = runningBalance
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Models.Api;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IFinancialAuditService
|
||||
{
|
||||
Task<FinancialAuditResponse> GenerateAuditAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
bool includeTransactions = false);
|
||||
}
|
||||
|
||||
public class FinancialAuditService : IFinancialAuditService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IBudgetService _budgetService;
|
||||
|
||||
public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService)
|
||||
{
|
||||
_db = db;
|
||||
_budgetService = budgetService;
|
||||
}
|
||||
|
||||
public async Task<FinancialAuditResponse> GenerateAuditAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
bool includeTransactions = false)
|
||||
{
|
||||
var response = new FinancialAuditResponse
|
||||
{
|
||||
GeneratedAt = DateTime.UtcNow,
|
||||
PeriodStart = startDate.Date,
|
||||
PeriodEnd = endDate.Date
|
||||
};
|
||||
|
||||
// Base query for the period
|
||||
var periodTransactions = _db.Transactions
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||
.AsNoTracking();
|
||||
|
||||
// Calculate all sections in parallel where possible
|
||||
response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate);
|
||||
response.Budgets = await GetBudgetStatusesAsync();
|
||||
response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets);
|
||||
response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions);
|
||||
response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate);
|
||||
response.Accounts = await GetAccountSummariesAsync(periodTransactions);
|
||||
response.Flags = GenerateAuditFlags(response);
|
||||
|
||||
if (includeTransactions)
|
||||
{
|
||||
response.Transactions = await GetTransactionListAsync(periodTransactions);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<AuditSummary> CalculateSummaryAsync(
|
||||
IQueryable<Transaction> transactions,
|
||||
DateTime startDate,
|
||||
DateTime endDate)
|
||||
{
|
||||
// Exclude transfers for spending calculations
|
||||
var nonTransferTxns = transactions.ExcludeTransfers();
|
||||
|
||||
var stats = await nonTransferTxns
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalCount = g.Count(),
|
||||
TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||
TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||
UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)),
|
||||
UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0)
|
||||
.Sum(t => Math.Abs(t.Amount))
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var daysInPeriod = (endDate.Date - startDate.Date).Days + 1;
|
||||
|
||||
return new AuditSummary
|
||||
{
|
||||
TotalTransactions = stats?.TotalCount ?? 0,
|
||||
TotalIncome = stats?.TotalIncome ?? 0,
|
||||
TotalExpenses = stats?.TotalExpenses ?? 0,
|
||||
NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0),
|
||||
DaysInPeriod = daysInPeriod,
|
||||
AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0,
|
||||
UncategorizedTransactions = stats?.UncategorizedCount ?? 0,
|
||||
UncategorizedAmount = stats?.UncategorizedAmount ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<BudgetStatusDto>> GetBudgetStatusesAsync()
|
||||
{
|
||||
var statuses = await _budgetService.GetAllBudgetStatusesAsync();
|
||||
|
||||
return statuses.Select(s => new BudgetStatusDto
|
||||
{
|
||||
BudgetId = s.Budget.Id,
|
||||
Category = s.Budget.DisplayName,
|
||||
Period = s.Budget.Period.ToString(),
|
||||
Limit = s.Budget.Amount,
|
||||
Spent = s.Spent,
|
||||
Remaining = s.Remaining,
|
||||
PercentUsed = s.PercentUsed,
|
||||
IsOverBudget = s.IsOverBudget,
|
||||
PeriodRange = s.PeriodDisplay
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<CategorySpendingDto>> GetCategorySpendingAsync(
|
||||
IQueryable<Transaction> transactions,
|
||||
List<BudgetStatusDto> budgets)
|
||||
{
|
||||
var categorySpending = await transactions
|
||||
.ExcludeTransfers()
|
||||
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
||||
.GroupBy(t => t.Category)
|
||||
.Select(g => new
|
||||
{
|
||||
Category = g.Key,
|
||||
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.TotalSpent)
|
||||
.ToListAsync();
|
||||
|
||||
var totalSpending = categorySpending.Sum(c => c.TotalSpent);
|
||||
|
||||
// Create a lookup for budget data by category
|
||||
var budgetLookup = budgets
|
||||
.Where(b => b.Category != "Total Spending")
|
||||
.ToDictionary(b => b.Category.ToLowerInvariant(), b => b);
|
||||
|
||||
return categorySpending.Select(c =>
|
||||
{
|
||||
var dto = new CategorySpendingDto
|
||||
{
|
||||
Category = c.Category ?? "Uncategorized",
|
||||
TotalSpent = c.TotalSpent,
|
||||
TransactionCount = c.Count,
|
||||
PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0,
|
||||
AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0
|
||||
};
|
||||
|
||||
// Add budget correlation if available
|
||||
if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget))
|
||||
{
|
||||
dto.BudgetLimit = budget.Limit;
|
||||
dto.BudgetRemaining = budget.Remaining;
|
||||
dto.IsOverBudget = budget.IsOverBudget;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
|
||||
IQueryable<Transaction> transactions)
|
||||
{
|
||||
var merchantSpending = await transactions
|
||||
.ExcludeTransfers()
|
||||
.Where(t => t.Amount < 0 && t.MerchantId != null)
|
||||
.GroupBy(t => new { t.MerchantId, t.Merchant!.Name })
|
||||
.Select(g => new
|
||||
{
|
||||
MerchantName = g.Key.Name,
|
||||
Category = g.Max(t => t.Category),
|
||||
TotalSpent = g.Sum(t => Math.Abs(t.Amount)),
|
||||
Count = g.Count(),
|
||||
FirstDate = g.Min(t => t.Date),
|
||||
LastDate = g.Max(t => t.Date)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalSpent)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
return merchantSpending.Select(m => new MerchantSpendingDto
|
||||
{
|
||||
MerchantName = m.MerchantName,
|
||||
Category = m.Category,
|
||||
TotalSpent = m.TotalSpent,
|
||||
TransactionCount = m.Count,
|
||||
AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0,
|
||||
FirstTransaction = m.FirstDate,
|
||||
LastTransaction = m.LastDate
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<MonthlyTrendDto>> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var monthlyData = await _db.Transactions
|
||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||
.ExcludeTransfers()
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.Year,
|
||||
g.Key.Month,
|
||||
Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||
Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderBy(x => x.Year)
|
||||
.ThenBy(x => x.Month)
|
||||
.ToListAsync();
|
||||
|
||||
// Get top categories per month
|
||||
var categoryByMonth = await _db.Transactions
|
||||
.Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date)
|
||||
.ExcludeTransfers()
|
||||
.Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category))
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.Year,
|
||||
g.Key.Month,
|
||||
g.Key.Category,
|
||||
Total = g.Sum(t => Math.Abs(t.Amount))
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return monthlyData.Select(m =>
|
||||
{
|
||||
var topCategories = categoryByMonth
|
||||
.Where(c => c.Year == m.Year && c.Month == m.Month)
|
||||
.OrderByDescending(c => c.Total)
|
||||
.Take(5)
|
||||
.ToDictionary(c => c.Category ?? "Other", c => c.Total);
|
||||
|
||||
return new MonthlyTrendDto
|
||||
{
|
||||
Month = $"{m.Year}-{m.Month:D2}",
|
||||
Year = m.Year,
|
||||
Income = m.Income,
|
||||
Expenses = m.Expenses,
|
||||
NetCashFlow = m.Income - m.Expenses,
|
||||
TransactionCount = m.Count,
|
||||
TopCategories = topCategories
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<AccountSummaryDto>> GetAccountSummariesAsync(
|
||||
IQueryable<Transaction> transactions)
|
||||
{
|
||||
// Use only mapped columns in the GroupBy, compute DisplayLabel in memory
|
||||
var accountStats = await transactions
|
||||
.GroupBy(t => new {
|
||||
t.AccountId,
|
||||
t.Account.Institution,
|
||||
t.Account.Last4,
|
||||
t.Account.Nickname,
|
||||
t.Account.AccountType
|
||||
})
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.AccountId,
|
||||
g.Key.Institution,
|
||||
g.Key.Last4,
|
||||
g.Key.Nickname,
|
||||
g.Key.AccountType,
|
||||
Count = g.Count(),
|
||||
Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)),
|
||||
Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return accountStats.Select(a => new AccountSummaryDto
|
||||
{
|
||||
AccountId = a.AccountId,
|
||||
AccountName = string.IsNullOrEmpty(a.Nickname)
|
||||
? $"{a.Institution} {a.Last4} ({a.AccountType})"
|
||||
: $"{a.Nickname} ({a.Institution} {a.Last4})",
|
||||
Institution = a.Institution,
|
||||
AccountType = a.AccountType.ToString(),
|
||||
TransactionCount = a.Count,
|
||||
TotalDebits = a.Debits,
|
||||
TotalCredits = a.Credits,
|
||||
NetFlow = a.Credits - a.Debits
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<TransactionDto>> GetTransactionListAsync(
|
||||
IQueryable<Transaction> transactions)
|
||||
{
|
||||
// Fetch raw data without computed properties
|
||||
var rawTxns = await transactions
|
||||
.OrderByDescending(t => t.Date)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Date,
|
||||
t.Name,
|
||||
t.Memo,
|
||||
t.Amount,
|
||||
t.Category,
|
||||
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
||||
AccountInstitution = t.Account.Institution,
|
||||
AccountLast4 = t.Account.Last4,
|
||||
AccountNickname = t.Account.Nickname,
|
||||
AccountType = t.Account.AccountType,
|
||||
CardIssuer = t.Card != null ? t.Card.Issuer : null,
|
||||
CardLast4 = t.Card != null ? t.Card.Last4 : null,
|
||||
CardNickname = t.Card != null ? t.Card.Nickname : null,
|
||||
IsTransfer = t.TransferToAccountId != null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Map to DTOs with computed labels
|
||||
return rawTxns.Select(t => new TransactionDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Date = t.Date,
|
||||
Name = t.Name,
|
||||
Memo = t.Memo,
|
||||
Amount = t.Amount,
|
||||
Category = t.Category,
|
||||
MerchantName = t.MerchantName,
|
||||
AccountName = string.IsNullOrEmpty(t.AccountNickname)
|
||||
? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})"
|
||||
: $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})",
|
||||
CardLabel = t.CardIssuer != null
|
||||
? (string.IsNullOrEmpty(t.CardNickname)
|
||||
? $"{t.CardIssuer} {t.CardLast4}"
|
||||
: $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})")
|
||||
: null,
|
||||
IsTransfer = t.IsTransfer
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private List<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
|
||||
{
|
||||
var flags = new List<AuditFlagDto>();
|
||||
|
||||
// Flag: Over-budget categories
|
||||
foreach (var budget in response.Budgets.Where(b => b.IsOverBudget))
|
||||
{
|
||||
var overBy = budget.Spent - budget.Limit;
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "OverBudget",
|
||||
Severity = "Alert",
|
||||
Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)",
|
||||
Details = new
|
||||
{
|
||||
budget.BudgetId,
|
||||
budget.Category,
|
||||
budget.Limit,
|
||||
budget.Spent,
|
||||
OverAmount = overBy,
|
||||
budget.PercentUsed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flag: High budget utilization (>80% but not over)
|
||||
foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80))
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "HighBudgetUtilization",
|
||||
Severity = "Warning",
|
||||
Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)",
|
||||
Details = new
|
||||
{
|
||||
budget.BudgetId,
|
||||
budget.Category,
|
||||
budget.Limit,
|
||||
budget.Spent,
|
||||
budget.Remaining,
|
||||
budget.PercentUsed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flag: Uncategorized transactions
|
||||
if (response.Summary.UncategorizedTransactions > 0)
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "Uncategorized",
|
||||
Severity = "Warning",
|
||||
Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized",
|
||||
Details = new
|
||||
{
|
||||
Count = response.Summary.UncategorizedTransactions,
|
||||
Amount = response.Summary.UncategorizedAmount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flag: Negative net cash flow
|
||||
if (response.Summary.NetCashFlow < 0)
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "NegativeCashFlow",
|
||||
Severity = "Alert",
|
||||
Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period",
|
||||
Details = new
|
||||
{
|
||||
response.Summary.TotalIncome,
|
||||
response.Summary.TotalExpenses,
|
||||
response.Summary.NetCashFlow
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flag: Large single category spending (>30% of total)
|
||||
foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30))
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "HighCategoryConcentration",
|
||||
Severity = "Info",
|
||||
Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})",
|
||||
Details = new
|
||||
{
|
||||
category.Category,
|
||||
category.TotalSpent,
|
||||
category.PercentOfTotal,
|
||||
category.TransactionCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flag: Month-over-month spending increases
|
||||
if (response.MonthlyTrends.Count >= 2)
|
||||
{
|
||||
var recentMonths = response.MonthlyTrends.TakeLast(2).ToList();
|
||||
var previousMonth = recentMonths[0];
|
||||
var currentMonth = recentMonths[1];
|
||||
|
||||
if (previousMonth.Expenses > 0)
|
||||
{
|
||||
var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100;
|
||||
if (percentChange > 20)
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "SpendingIncrease",
|
||||
Severity = "Warning",
|
||||
Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})",
|
||||
Details = new
|
||||
{
|
||||
PreviousMonth = previousMonth.Month,
|
||||
PreviousExpenses = previousMonth.Expenses,
|
||||
CurrentMonth = currentMonth.Month,
|
||||
CurrentExpenses = currentMonth.Expenses,
|
||||
PercentChange = percentChange
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag: Categories without budgets (top spending categories)
|
||||
var topUnbudgetedCategories = response.SpendingByCategory
|
||||
.Where(c => c.BudgetLimit == null && c.TotalSpent > 100)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
if (topUnbudgetedCategories.Any())
|
||||
{
|
||||
flags.Add(new AuditFlagDto
|
||||
{
|
||||
Type = "NoBudget",
|
||||
Severity = "Info",
|
||||
Message = $"Top spending categories without budgets: {string.Join(", ", topUnbudgetedCategories.Select(c => $"{c.Category} ({c.TotalSpent:C})"))}",
|
||||
Details = topUnbudgetedCategories.Select(c => new { c.Category, c.TotalSpent }).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
return flags.OrderByDescending(f => f.Severity switch
|
||||
{
|
||||
"Alert" => 3,
|
||||
"Warning" => 2,
|
||||
"Info" => 1,
|
||||
_ => 0
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IMerchantService
|
||||
{
|
||||
Task<Merchant?> FindByNameAsync(string name);
|
||||
Task<Merchant> GetOrCreateAsync(string name);
|
||||
Task<int?> GetOrCreateIdAsync(string? name);
|
||||
Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false);
|
||||
Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync();
|
||||
Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName);
|
||||
Task<MerchantDeleteResult> DeleteMerchantAsync(int id);
|
||||
}
|
||||
|
||||
public class MerchantService : IMerchantService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public MerchantService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> FindByNameAsync(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
return await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == name.Trim());
|
||||
}
|
||||
|
||||
public async Task<Merchant> GetOrCreateAsync(string name)
|
||||
{
|
||||
var trimmedName = name.Trim();
|
||||
|
||||
var existing = await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == trimmedName);
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var merchant = new Merchant { Name = trimmedName };
|
||||
_db.Merchants.Add(merchant);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return merchant;
|
||||
}
|
||||
|
||||
public async Task<int?> GetOrCreateIdAsync(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var merchant = await GetOrCreateAsync(name);
|
||||
return merchant.Id;
|
||||
}
|
||||
|
||||
public async Task<Merchant?> GetMerchantByIdAsync(int id, bool includeRelated = false)
|
||||
{
|
||||
var query = _db.Merchants.AsQueryable();
|
||||
|
||||
if (includeRelated)
|
||||
{
|
||||
query = query
|
||||
.Include(m => m.Transactions)
|
||||
.Include(m => m.CategoryMappings);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(m => m.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<MerchantWithStats>> GetAllMerchantsWithStatsAsync()
|
||||
{
|
||||
var merchants = await _db.Merchants
|
||||
.Include(m => m.Transactions)
|
||||
.Include(m => m.CategoryMappings)
|
||||
.OrderBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return merchants.Select(m => new MerchantWithStats
|
||||
{
|
||||
Id = m.Id,
|
||||
Name = m.Name,
|
||||
TransactionCount = m.Transactions.Count,
|
||||
MappingCount = m.CategoryMappings.Count
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<MerchantUpdateResult> UpdateMerchantAsync(int id, string newName)
|
||||
{
|
||||
var merchant = await _db.Merchants.FindAsync(id);
|
||||
if (merchant == null)
|
||||
{
|
||||
return new MerchantUpdateResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Merchant not found."
|
||||
};
|
||||
}
|
||||
|
||||
var trimmedName = newName.Trim();
|
||||
|
||||
// Check if another merchant with the same name exists
|
||||
var existing = await _db.Merchants
|
||||
.FirstOrDefaultAsync(m => m.Name == trimmedName && m.Id != id);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
return new MerchantUpdateResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Merchant '{trimmedName}' already exists."
|
||||
};
|
||||
}
|
||||
|
||||
merchant.Name = trimmedName;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new MerchantUpdateResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Merchant updated successfully."
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MerchantDeleteResult> DeleteMerchantAsync(int id)
|
||||
{
|
||||
var merchant = await _db.Merchants
|
||||
.Include(m => m.Transactions)
|
||||
.Include(m => m.CategoryMappings)
|
||||
.FirstOrDefaultAsync(m => m.Id == id);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
return new MerchantDeleteResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Merchant not found."
|
||||
};
|
||||
}
|
||||
|
||||
var transactionCount = merchant.Transactions.Count;
|
||||
var mappingCount = merchant.CategoryMappings.Count;
|
||||
|
||||
_db.Merchants.Remove(merchant);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new MerchantDeleteResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.",
|
||||
TransactionCount = transactionCount,
|
||||
MappingCount = mappingCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class MerchantWithStats
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public int TransactionCount { get; set; }
|
||||
public int MappingCount { get; set; }
|
||||
}
|
||||
|
||||
public class MerchantUpdateResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public class MerchantDeleteResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public int TransactionCount { get; set; }
|
||||
public int MappingCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ImageMagick;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for converting PDF files to images for AI processing.
|
||||
/// </summary>
|
||||
public interface IPdfToImageConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the first page of a PDF to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(string pdfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Converts PDF bytes to a base64-encoded PNG image.
|
||||
/// </summary>
|
||||
Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes);
|
||||
}
|
||||
|
||||
public class PdfToImageConverter : IPdfToImageConverter
|
||||
{
|
||||
private const int DefaultDpi = 220;
|
||||
|
||||
public Task<string> ConvertFirstPageToBase64Async(string pdfPath)
|
||||
{
|
||||
var pdfBytes = File.ReadAllBytes(pdfPath);
|
||||
return ConvertFirstPageToBase64Async(pdfBytes);
|
||||
}
|
||||
|
||||
public Task<string> ConvertFirstPageToBase64Async(byte[] pdfBytes)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var settings = new MagickReadSettings
|
||||
{
|
||||
Density = new Density(DefaultDpi),
|
||||
BackgroundColor = MagickColors.White,
|
||||
ColorSpace = ColorSpace.sRGB
|
||||
};
|
||||
|
||||
using var pages = new MagickImageCollection();
|
||||
pages.Read(pdfBytes, settings);
|
||||
|
||||
if (pages.Count == 0)
|
||||
throw new InvalidOperationException("PDF has no pages");
|
||||
|
||||
using var img = (MagickImage)pages[0].Clone();
|
||||
|
||||
// Ensure we have a clean 8-bit RGB canvas
|
||||
img.ColorType = ColorType.TrueColor;
|
||||
img.Alpha(AlphaOption.Remove); // flatten onto white
|
||||
img.ResetPage();
|
||||
|
||||
// Convert to PNG bytes
|
||||
var imageBytes = img.ToByteArray(MagickFormat.Png);
|
||||
return Convert.ToBase64String(imageBytes);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptAutoMapper
|
||||
{
|
||||
Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId);
|
||||
Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync();
|
||||
Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptAutoMapper : IReceiptAutoMapper
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly LlamaCppVisionClient _llmClient;
|
||||
private readonly ILogger<ReceiptAutoMapper> _logger;
|
||||
|
||||
// Confidence thresholds
|
||||
private const double AutoMapThreshold = 0.85; // Auto-map if score >= 85%
|
||||
private const double LlmReviewThreshold = 0.50; // Use LLM if score between 50-85%
|
||||
|
||||
public ReceiptAutoMapper(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
LlamaCppVisionClient llmClient,
|
||||
ILogger<ReceiptAutoMapper> logger)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_llmClient = llmClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ReceiptAutoMapResult> AutoMapReceiptAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
|
||||
if (receipt == null)
|
||||
return ReceiptAutoMapResult.Failure("Receipt not found.");
|
||||
|
||||
if (receipt.TransactionId.HasValue)
|
||||
return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue)
|
||||
return ReceiptAutoMapResult.NotParsed();
|
||||
|
||||
var scoredCandidates = await FindAndScoreCandidatesAsync(receipt);
|
||||
|
||||
if (scoredCandidates.Count == 0)
|
||||
return ReceiptAutoMapResult.NoMatch();
|
||||
|
||||
var bestMatch = scoredCandidates[0];
|
||||
|
||||
// High confidence - auto-map directly
|
||||
if (bestMatch.Score >= AutoMapThreshold)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Auto-mapping receipt {ReceiptId} to transaction {TransactionId} with score {Score:P0}",
|
||||
receiptId, bestMatch.Transaction.Id, bestMatch.Score);
|
||||
|
||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, bestMatch.Transaction.Id);
|
||||
return success
|
||||
? ReceiptAutoMapResult.Success(bestMatch.Transaction.Id)
|
||||
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
||||
}
|
||||
|
||||
// Medium confidence - use LLM to decide
|
||||
if (bestMatch.Score >= LlmReviewThreshold)
|
||||
{
|
||||
var topCandidates = scoredCandidates.Take(5).ToList();
|
||||
var llmResult = await GetLlmMatchDecisionAsync(receipt, topCandidates);
|
||||
|
||||
if (llmResult != null && llmResult.Confidence >= 0.7)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"LLM matched receipt {ReceiptId} to transaction {TransactionId} with confidence {Confidence:P0}",
|
||||
receiptId, llmResult.TransactionId, llmResult.Confidence);
|
||||
|
||||
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, llmResult.TransactionId);
|
||||
return success
|
||||
? ReceiptAutoMapResult.Success(llmResult.TransactionId)
|
||||
: ReceiptAutoMapResult.Failure("Failed to map receipt to transaction.");
|
||||
}
|
||||
|
||||
// LLM uncertain - return multiple matches for manual review
|
||||
return ReceiptAutoMapResult.WithMultipleMatches(
|
||||
topCandidates.Select(c => c.Transaction).ToList());
|
||||
}
|
||||
|
||||
// Low confidence - no good matches
|
||||
if (scoredCandidates.Count > 1)
|
||||
{
|
||||
return ReceiptAutoMapResult.WithMultipleMatches(
|
||||
scoredCandidates.Take(5).Select(c => c.Transaction).ToList());
|
||||
}
|
||||
|
||||
return ReceiptAutoMapResult.NoMatch();
|
||||
}
|
||||
|
||||
public async Task<BulkAutoMapResult> AutoMapUnmappedReceiptsAsync()
|
||||
{
|
||||
var unmappedReceipts = await _db.Receipts
|
||||
.Where(r => r.TransactionId == null)
|
||||
.Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null)
|
||||
.ToListAsync();
|
||||
|
||||
var result = new BulkAutoMapResult();
|
||||
|
||||
foreach (var receipt in unmappedReceipts)
|
||||
{
|
||||
var mapResult = await AutoMapReceiptAsync(receipt.Id);
|
||||
|
||||
if (mapResult.Status == AutoMapStatus.Success)
|
||||
result.MappedCount++;
|
||||
else if (mapResult.Status == AutoMapStatus.MultipleMatches)
|
||||
result.MultipleMatchesCount++;
|
||||
else if (mapResult.Status == AutoMapStatus.NoMatch)
|
||||
result.NoMatchCount++;
|
||||
}
|
||||
|
||||
result.TotalProcessed = unmappedReceipts.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<ScoredCandidate>> GetScoredCandidatesAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
|
||||
if (receipt == null)
|
||||
return new List<ScoredCandidate>();
|
||||
|
||||
return await FindAndScoreCandidatesAsync(receipt);
|
||||
}
|
||||
|
||||
private async Task<List<ScoredCandidate>> FindAndScoreCandidatesAsync(Receipt receipt)
|
||||
{
|
||||
// Get transactions in a reasonable date range
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Merchant)
|
||||
.AsQueryable();
|
||||
|
||||
// Date range: use receipt date or due date
|
||||
// Transactions can't occur before the receipt date (you get a receipt when you buy something)
|
||||
DateTime? targetDate = receipt.ReceiptDate;
|
||||
DateTime? dueDate = receipt.DueDate;
|
||||
|
||||
if (targetDate.HasValue || dueDate.HasValue)
|
||||
{
|
||||
// Min date is the receipt date - transactions can't precede the receipt
|
||||
var minDate = targetDate ?? dueDate!.Value;
|
||||
var maxDate = (dueDate ?? targetDate!.Value).AddDays(7);
|
||||
query = query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No date info - can't match reliably
|
||||
return new List<ScoredCandidate>();
|
||||
}
|
||||
|
||||
var candidates = await query.ToListAsync();
|
||||
|
||||
// Exclude transactions that already have receipts
|
||||
var transactionsWithReceipts = await _db.Receipts
|
||||
.Where(r => r.TransactionId != null && r.Id != receipt.Id)
|
||||
.Select(r => r.TransactionId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
candidates = candidates
|
||||
.Where(t => !transactionsWithReceipts.Contains(t.Id))
|
||||
.ToList();
|
||||
|
||||
// Score each candidate
|
||||
var scored = candidates
|
||||
.Select(t => new ScoredCandidate
|
||||
{
|
||||
Transaction = t,
|
||||
Score = CalculateMatchScore(receipt, t)
|
||||
})
|
||||
.Where(s => s.Score > 0.1) // Filter out very poor matches
|
||||
.OrderByDescending(s => s.Score)
|
||||
.ToList();
|
||||
|
||||
return scored;
|
||||
}
|
||||
|
||||
private double CalculateMatchScore(Receipt receipt, Transaction transaction)
|
||||
{
|
||||
double score = 0;
|
||||
double totalWeight = 0;
|
||||
|
||||
// Amount matching (weight: 40%)
|
||||
if (receipt.Total.HasValue)
|
||||
{
|
||||
const double amountWeight = 0.40;
|
||||
totalWeight += amountWeight;
|
||||
|
||||
var receiptAmount = Math.Abs(receipt.Total.Value);
|
||||
var transactionAmount = Math.Abs(transaction.Amount);
|
||||
|
||||
if (receiptAmount > 0)
|
||||
{
|
||||
var difference = (double)(Math.Abs(receiptAmount - transactionAmount) / receiptAmount);
|
||||
|
||||
if (difference == 0)
|
||||
score += amountWeight * 1.0;
|
||||
else if (difference <= 0.01) // Within 1%
|
||||
score += amountWeight * 0.95;
|
||||
else if (difference <= 0.05) // Within 5%
|
||||
score += amountWeight * 0.80;
|
||||
else if (difference <= 0.10) // Within 10%
|
||||
score += amountWeight * 0.60;
|
||||
else if (difference <= 0.20) // Within 20%
|
||||
score += amountWeight * 0.30;
|
||||
// Beyond 20% = 0 points
|
||||
}
|
||||
}
|
||||
|
||||
// Date matching (weight: 25%)
|
||||
if (receipt.ReceiptDate.HasValue)
|
||||
{
|
||||
const double dateWeight = 0.25;
|
||||
totalWeight += dateWeight;
|
||||
|
||||
var daysDiff = Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays);
|
||||
|
||||
if (daysDiff == 0)
|
||||
score += dateWeight * 1.0;
|
||||
else if (daysDiff <= 1)
|
||||
score += dateWeight * 0.90;
|
||||
else if (daysDiff <= 3)
|
||||
score += dateWeight * 0.70;
|
||||
else if (daysDiff <= 5)
|
||||
score += dateWeight * 0.50;
|
||||
else if (daysDiff <= 7)
|
||||
score += dateWeight * 0.30;
|
||||
// Beyond 7 days = 0 points
|
||||
}
|
||||
|
||||
// Due date matching for bills (weight: 10% bonus)
|
||||
if (receipt.DueDate.HasValue)
|
||||
{
|
||||
const double dueDateWeight = 0.10;
|
||||
totalWeight += dueDateWeight;
|
||||
|
||||
var daysDiff = Math.Abs((transaction.Date - receipt.DueDate.Value).TotalDays);
|
||||
|
||||
if (daysDiff <= 1)
|
||||
score += dueDateWeight * 1.0;
|
||||
else if (daysDiff <= 3)
|
||||
score += dueDateWeight * 0.70;
|
||||
else if (daysDiff <= 5)
|
||||
score += dueDateWeight * 0.40;
|
||||
}
|
||||
|
||||
// Merchant/Name matching (weight: 35%)
|
||||
if (!string.IsNullOrWhiteSpace(receipt.Merchant))
|
||||
{
|
||||
const double merchantWeight = 0.35;
|
||||
totalWeight += merchantWeight;
|
||||
|
||||
var merchantScore = CalculateMerchantMatchScore(
|
||||
receipt.Merchant,
|
||||
transaction.Merchant?.Name,
|
||||
transaction.Name);
|
||||
|
||||
score += merchantWeight * merchantScore;
|
||||
}
|
||||
|
||||
// Normalize score if we didn't have all data points
|
||||
if (totalWeight > 0 && totalWeight < 1.0)
|
||||
{
|
||||
score = score / totalWeight;
|
||||
}
|
||||
|
||||
return Math.Min(score, 1.0);
|
||||
}
|
||||
|
||||
private double CalculateMerchantMatchScore(string receiptMerchant, string? transactionMerchant, string? transactionName)
|
||||
{
|
||||
var receiptLower = receiptMerchant.ToLowerInvariant().Trim();
|
||||
var merchantLower = transactionMerchant?.ToLowerInvariant().Trim() ?? "";
|
||||
var nameLower = transactionName?.ToLowerInvariant().Trim() ?? "";
|
||||
|
||||
// Exact match
|
||||
if (receiptLower == merchantLower || receiptLower == nameLower)
|
||||
return 1.0;
|
||||
|
||||
// Contains match
|
||||
if (merchantLower.Contains(receiptLower) || receiptLower.Contains(merchantLower))
|
||||
return 0.90;
|
||||
if (nameLower.Contains(receiptLower) || receiptLower.Contains(nameLower))
|
||||
return 0.85;
|
||||
|
||||
// Word-based matching
|
||||
var receiptWords = ExtractWords(receiptLower);
|
||||
var merchantWords = ExtractWords(merchantLower);
|
||||
var nameWords = ExtractWords(nameLower);
|
||||
|
||||
var merchantMatchRatio = CalculateWordMatchRatio(receiptWords, merchantWords);
|
||||
var nameMatchRatio = CalculateWordMatchRatio(receiptWords, nameWords);
|
||||
|
||||
return Math.Max(merchantMatchRatio, nameMatchRatio);
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractWords(string text)
|
||||
{
|
||||
return text
|
||||
.Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => w.Length > 1) // Skip single chars
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static double CalculateWordMatchRatio(HashSet<string> words1, HashSet<string> words2)
|
||||
{
|
||||
if (words1.Count == 0 || words2.Count == 0)
|
||||
return 0;
|
||||
|
||||
int matches = 0;
|
||||
foreach (var w1 in words1)
|
||||
{
|
||||
foreach (var w2 in words2)
|
||||
{
|
||||
if (w1 == w2 || w1.Contains(w2) || w2.Contains(w1))
|
||||
{
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return ratio of matched words from the smaller set
|
||||
var smallerCount = Math.Min(words1.Count, words2.Count);
|
||||
return (double)matches / smallerCount;
|
||||
}
|
||||
|
||||
private async Task<LlmMatchResult?> GetLlmMatchDecisionAsync(Receipt receipt, List<ScoredCandidate> candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prompt = BuildLlmPrompt(receipt, candidates);
|
||||
|
||||
_logger.LogInformation("Sending receipt matching prompt to LLM for receipt {ReceiptId}", receipt.Id);
|
||||
|
||||
var result = await _llmClient.SendTextPromptAsync(prompt);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning("LLM matching failed: {Error}", result.ErrorMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("LLM response: {Content}", result.Content);
|
||||
|
||||
return ParseLlmResponse(result.Content, candidates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during LLM match decision");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLlmPrompt(Receipt receipt, List<ScoredCandidate> candidates)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("You are matching a receipt to bank transactions. Analyze and pick the best match.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("RECEIPT:");
|
||||
sb.AppendLine($" Merchant: {receipt.Merchant ?? "Unknown"}");
|
||||
sb.AppendLine($" Date: {receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "Unknown"}");
|
||||
if (receipt.DueDate.HasValue)
|
||||
sb.AppendLine($" Due Date: {receipt.DueDate.Value:yyyy-MM-dd}");
|
||||
sb.AppendLine($" Total: {receipt.Total?.ToString("C") ?? "Unknown"}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("CANDIDATE TRANSACTIONS:");
|
||||
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
var t = candidates[i].Transaction;
|
||||
sb.AppendLine($" [{i + 1}] ID={t.Id}");
|
||||
sb.AppendLine($" Name: {t.Name}");
|
||||
if (t.Merchant != null)
|
||||
sb.AppendLine($" Merchant: {t.Merchant.Name}");
|
||||
sb.AppendLine($" Date: {t.Date:yyyy-MM-dd}");
|
||||
sb.AppendLine($" Amount: {t.Amount:C}");
|
||||
sb.AppendLine($" Current Score: {candidates[i].Score:P0}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("Respond with JSON only:");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" \"match_index\": <1-based index of best match, or 0 if none match>,");
|
||||
sb.AppendLine(" \"confidence\": <0.0 to 1.0>,");
|
||||
sb.AppendLine(" \"reason\": \"<brief explanation>\"");
|
||||
sb.AppendLine("}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private LlmMatchResult? ParseLlmResponse(string? content, List<ScoredCandidate> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
var matchIndex = json.GetProperty("match_index").GetInt32();
|
||||
var confidence = json.GetProperty("confidence").GetDouble();
|
||||
|
||||
if (matchIndex <= 0 || matchIndex > candidates.Count)
|
||||
return null;
|
||||
|
||||
return new LlmMatchResult
|
||||
{
|
||||
TransactionId = candidates[matchIndex - 1].Transaction.Id,
|
||||
Confidence = confidence
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse LLM response: {Content}", content);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ScoredCandidate
|
||||
{
|
||||
public required Transaction Transaction { get; set; }
|
||||
public double Score { get; set; }
|
||||
}
|
||||
|
||||
public class LlmMatchResult
|
||||
{
|
||||
public long TransactionId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
public class ReceiptAutoMapResult
|
||||
{
|
||||
public AutoMapStatus Status { get; init; }
|
||||
public long? TransactionId { get; init; }
|
||||
public List<Transaction> MultipleMatches { get; init; } = new();
|
||||
public string? Message { get; init; }
|
||||
|
||||
public static ReceiptAutoMapResult Success(long transactionId) =>
|
||||
new() { Status = AutoMapStatus.Success, TransactionId = transactionId };
|
||||
|
||||
public static ReceiptAutoMapResult AlreadyMapped(long transactionId) =>
|
||||
new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId };
|
||||
|
||||
public static ReceiptAutoMapResult NoMatch() =>
|
||||
new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." };
|
||||
|
||||
public static ReceiptAutoMapResult WithMultipleMatches(List<Transaction> matches) =>
|
||||
new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." };
|
||||
|
||||
public static ReceiptAutoMapResult NotParsed() =>
|
||||
new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." };
|
||||
|
||||
public static ReceiptAutoMapResult Failure(string message) =>
|
||||
new() { Status = AutoMapStatus.Failed, Message = message };
|
||||
}
|
||||
|
||||
public class BulkAutoMapResult
|
||||
{
|
||||
public int TotalProcessed { get; set; }
|
||||
public int MappedCount { get; set; }
|
||||
public int NoMatchCount { get; set; }
|
||||
public int MultipleMatchesCount { get; set; }
|
||||
}
|
||||
|
||||
public enum AutoMapStatus
|
||||
{
|
||||
Success,
|
||||
AlreadyMapped,
|
||||
NoMatch,
|
||||
MultipleMatches,
|
||||
NotParsed,
|
||||
Failed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptManager
|
||||
{
|
||||
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
||||
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
||||
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
||||
Task<bool> DeleteReceiptAsync(long receiptId);
|
||||
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
||||
Task<bool> UnmapReceiptAsync(long receiptId);
|
||||
string GetReceiptPhysicalPath(Receipt receipt);
|
||||
Task<Receipt?> GetReceiptAsync(long receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptManager : IReceiptManager
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IReceiptParseQueue _parseQueue;
|
||||
private readonly ILogger<ReceiptManager> _logger;
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
||||
|
||||
// Magic bytes for file type validation (prevents extension spoofing)
|
||||
private static readonly Dictionary<string, byte[][]> FileSignatures = new()
|
||||
{
|
||||
{ ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } },
|
||||
{ ".jpeg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } },
|
||||
{ ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
|
||||
{ ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a
|
||||
{ ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF
|
||||
{ ".heic", new[] {
|
||||
new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic
|
||||
new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic (variant)
|
||||
new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check)
|
||||
}}
|
||||
};
|
||||
|
||||
public ReceiptManager(
|
||||
MoneyMapContext db,
|
||||
IWebHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
IServiceProvider serviceProvider,
|
||||
IReceiptParseQueue parseQueue,
|
||||
ILogger<ReceiptManager> logger)
|
||||
{
|
||||
_db = db;
|
||||
_environment = environment;
|
||||
_configuration = configuration;
|
||||
_serviceProvider = serviceProvider;
|
||||
_parseQueue = parseQueue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetReceiptsBasePath()
|
||||
{
|
||||
// Get from config, default to "receipts" in wwwroot
|
||||
var relativePath = _configuration["Receipts:StoragePath"] ?? "receipts";
|
||||
return Path.Combine(_environment.WebRootPath, relativePath);
|
||||
}
|
||||
|
||||
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
||||
{
|
||||
// Verify transaction exists
|
||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||
if (transaction == null)
|
||||
return ReceiptUploadResult.Failure("Transaction not found.");
|
||||
|
||||
return await UploadReceiptInternalAsync(file, transactionId);
|
||||
}
|
||||
|
||||
public async Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file)
|
||||
{
|
||||
return await UploadReceiptInternalAsync(file, null);
|
||||
}
|
||||
|
||||
private async Task<ReceiptUploadResult> UploadReceiptInternalAsync(IFormFile file, long? transactionId)
|
||||
{
|
||||
// Validate file
|
||||
if (file == null || file.Length == 0)
|
||||
return ReceiptUploadResult.Failure("No file selected.");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
|
||||
|
||||
// Validate file content matches extension (magic bytes check)
|
||||
if (!await ValidateFileSignatureAsync(file, extension))
|
||||
return ReceiptUploadResult.Failure($"File content does not match {extension} format. The file may be corrupted or have an incorrect extension.");
|
||||
|
||||
// Create receipts directory if it doesn't exist
|
||||
var receiptsBasePath = GetReceiptsBasePath();
|
||||
if (!Directory.Exists(receiptsBasePath))
|
||||
Directory.CreateDirectory(receiptsBasePath);
|
||||
|
||||
// Calculate SHA256 hash
|
||||
string fileHash;
|
||||
using (var sha256 = SHA256.Create())
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var hashBytes = await sha256.ComputeHashAsync(stream);
|
||||
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Check for exact duplicate (same transaction + same hash)
|
||||
if (transactionId.HasValue)
|
||||
{
|
||||
var existingReceipt = await _db.Receipts
|
||||
.FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash);
|
||||
|
||||
if (existingReceipt != null)
|
||||
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
|
||||
}
|
||||
|
||||
// Check for potential duplicates (same hash, same name+size)
|
||||
var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length);
|
||||
|
||||
// Generate unique filename
|
||||
var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(receiptsBasePath, storedFileName);
|
||||
|
||||
// Save file
|
||||
using (var fileStream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
// Store just the filename in database (base path comes from config)
|
||||
var relativeStoragePath = storedFileName;
|
||||
|
||||
// Create receipt record
|
||||
var receipt = new Receipt
|
||||
{
|
||||
TransactionId = transactionId,
|
||||
FileName = SanitizeFileName(file.FileName),
|
||||
StoragePath = relativeStoragePath,
|
||||
FileSizeBytes = file.Length,
|
||||
ContentType = file.ContentType,
|
||||
FileHashSha256 = fileHash,
|
||||
UploadedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
_db.Receipts.Add(receipt);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _parseQueue.EnqueueAsync(receipt.Id);
|
||||
_logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id);
|
||||
|
||||
return ReceiptUploadResult.Success(receipt, duplicateWarnings);
|
||||
}
|
||||
|
||||
public async Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
|
||||
{
|
||||
var uploaded = new List<BulkUploadItem>();
|
||||
var failed = new List<BulkUploadFailure>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var result = await UploadReceiptInternalAsync(file, null);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
uploaded.Add(new BulkUploadItem
|
||||
{
|
||||
ReceiptId = result.Receipt!.Id,
|
||||
FileName = result.Receipt.FileName,
|
||||
DuplicateWarnings = result.DuplicateWarnings
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.Add(new BulkUploadFailure
|
||||
{
|
||||
FileName = file.FileName,
|
||||
ErrorMessage = result.ErrorMessage ?? "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new BulkUploadResult
|
||||
{
|
||||
Uploaded = uploaded,
|
||||
Failed = failed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
|
||||
{
|
||||
var warnings = new List<DuplicateWarning>();
|
||||
|
||||
// Check for receipts with same hash
|
||||
var hashMatches = await _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.Where(r => r.FileHashSha256 == fileHash)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var match in hashMatches)
|
||||
{
|
||||
warnings.Add(new DuplicateWarning
|
||||
{
|
||||
ReceiptId = match.Id,
|
||||
FileName = match.FileName,
|
||||
UploadedAtUtc = match.UploadedAtUtc,
|
||||
TransactionId = match.TransactionId,
|
||||
TransactionName = match.Transaction?.Name,
|
||||
Reason = "Identical file content (same hash)"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for receipts with same name and size (but different hash - might be resaved/edited)
|
||||
if (!warnings.Any())
|
||||
{
|
||||
var nameAndSizeMatches = await _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var match in nameAndSizeMatches)
|
||||
{
|
||||
warnings.Add(new DuplicateWarning
|
||||
{
|
||||
ReceiptId = match.Id,
|
||||
FileName = match.FileName,
|
||||
UploadedAtUtc = match.UploadedAtUtc,
|
||||
TransactionId = match.TransactionId,
|
||||
TransactionName = match.Transaction?.Name,
|
||||
Reason = "Same file name and size"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public async Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId)
|
||||
{
|
||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||
if (receipt == null)
|
||||
return false;
|
||||
|
||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||
if (transaction == null)
|
||||
return false;
|
||||
|
||||
// Allow remapping: simply update the TransactionId
|
||||
if (receipt.TransactionId == transactionId)
|
||||
return true;
|
||||
|
||||
receipt.TransactionId = transactionId;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UnmapReceiptAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||
if (receipt == null)
|
||||
return false;
|
||||
|
||||
// Set TransactionId to null to unmap
|
||||
receipt.TransactionId = null;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> ValidateFileSignatureAsync(IFormFile file, string extension)
|
||||
{
|
||||
if (!FileSignatures.TryGetValue(extension, out var signatures))
|
||||
return true; // No signature check for unknown extensions
|
||||
|
||||
var maxSignatureLength = signatures.Max(s => s.Length);
|
||||
var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)];
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
_ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length));
|
||||
|
||||
// Check if file starts with any of the valid signatures for this extension
|
||||
return signatures.Any(signature =>
|
||||
headerBytes.Length >= signature.Length &&
|
||||
headerBytes.Take(signature.Length).SequenceEqual(signature));
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return "receipt";
|
||||
|
||||
// Remove non-ASCII characters and replace them with safe equivalents
|
||||
var sanitized = new StringBuilder();
|
||||
foreach (var c in fileName)
|
||||
{
|
||||
if (c == '�' || c == '�' || c == '�')
|
||||
{
|
||||
// Skip trademark/copyright symbols
|
||||
continue;
|
||||
}
|
||||
else if (c >= 32 && c <= 126)
|
||||
{
|
||||
// Keep ASCII printable characters
|
||||
sanitized.Append(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Replace other non-ASCII with underscore
|
||||
sanitized.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
var result = sanitized.ToString().Trim();
|
||||
return string.IsNullOrWhiteSpace(result) ? "receipt" : result;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReceiptAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||
if (receipt == null)
|
||||
return false;
|
||||
|
||||
// Delete physical file
|
||||
var filePath = GetReceiptPhysicalPath(receipt);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue even if file delete fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete database record (cascade will handle ParseLogs and LineItems)
|
||||
_db.Receipts.Remove(receipt);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public string GetReceiptPhysicalPath(Receipt receipt)
|
||||
{
|
||||
// StoragePath is just the filename, combine with configured base path
|
||||
return Path.Combine(GetReceiptsBasePath(), receipt.StoragePath);
|
||||
}
|
||||
|
||||
public async Task<Receipt?> GetReceiptAsync(long receiptId)
|
||||
{
|
||||
return await _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.FirstOrDefaultAsync(r => r.Id == receiptId);
|
||||
}
|
||||
}
|
||||
|
||||
public class ReceiptUploadResult
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public Receipt? Receipt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; init; } = new();
|
||||
|
||||
public static ReceiptUploadResult Success(Receipt receipt, List<DuplicateWarning>? warnings = null) =>
|
||||
new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() };
|
||||
|
||||
public static ReceiptUploadResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
|
||||
public class DuplicateWarning
|
||||
{
|
||||
public long ReceiptId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public DateTime UploadedAtUtc { get; set; }
|
||||
public long? TransactionId { get; set; }
|
||||
public string? TransactionName { get; set; }
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
public class BulkUploadResult
|
||||
{
|
||||
public List<BulkUploadItem> Uploaded { get; init; } = new();
|
||||
public List<BulkUploadFailure> Failed { get; init; } = new();
|
||||
public int TotalCount => Uploaded.Count + Failed.Count;
|
||||
}
|
||||
|
||||
public class BulkUploadItem
|
||||
{
|
||||
public long ReceiptId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BulkUploadFailure
|
||||
{
|
||||
public string FileName { get; set; } = "";
|
||||
public string ErrorMessage { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for matching receipts to transactions based on date, merchant, and amount.
|
||||
/// </summary>
|
||||
public interface IReceiptMatchingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds matching transactions for a receipt based on date range, merchant name,
|
||||
/// and amount tolerance. Returns transactions sorted by relevance.
|
||||
/// </summary>
|
||||
Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a set of transaction IDs that already have receipts mapped to them.
|
||||
/// </summary>
|
||||
Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync();
|
||||
}
|
||||
|
||||
public class ReceiptMatchingService : IReceiptMatchingService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReceiptMatchingService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<HashSet<long>> GetTransactionIdsWithReceiptsAsync()
|
||||
{
|
||||
var transactionIds = await _db.Receipts
|
||||
.Where(r => r.TransactionId != null)
|
||||
.Select(r => r.TransactionId!.Value)
|
||||
.ToListAsync();
|
||||
|
||||
return new HashSet<long>(transactionIds);
|
||||
}
|
||||
|
||||
public async Task<List<TransactionMatch>> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)
|
||||
{
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id))
|
||||
.AsQueryable();
|
||||
|
||||
// Apply date filtering based on receipt type
|
||||
query = ApplyDateFilter(query, criteria);
|
||||
|
||||
// Get all candidates within date range
|
||||
var candidates = await query.ToListAsync();
|
||||
|
||||
// Sort by merchant/name relevance using word matching
|
||||
if (!string.IsNullOrWhiteSpace(criteria.MerchantName))
|
||||
{
|
||||
candidates = SortByMerchantRelevance(candidates, criteria.MerchantName);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No merchant filter, just sort by date
|
||||
candidates = candidates
|
||||
.OrderByDescending(t => t.Date)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Filter by amount (±10% tolerance) if receipt has a total
|
||||
if (criteria.Total.HasValue)
|
||||
{
|
||||
candidates = FilterByAmountTolerance(candidates, criteria.Total.Value);
|
||||
}
|
||||
|
||||
// Convert to match results with scoring
|
||||
var matches = ConvertToMatches(candidates, criteria);
|
||||
|
||||
// If no date-filtered matches, fall back to recent transactions
|
||||
if (!matches.Any() && !criteria.ReceiptDate.HasValue)
|
||||
{
|
||||
matches = await GetFallbackMatches(criteria.ExcludeTransactionIds);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static IQueryable<Transaction> ApplyDateFilter(IQueryable<Transaction> query, ReceiptMatchCriteria criteria)
|
||||
{
|
||||
// For bills with due dates: use range from bill date to due date + 5 days
|
||||
// (to account for auto-pay processing delays, weekends, etc.)
|
||||
if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue)
|
||||
{
|
||||
var minDate = criteria.ReceiptDate.Value;
|
||||
var maxDate = criteria.DueDate.Value.AddDays(5);
|
||||
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
|
||||
// For regular receipts: use +/- 3 days
|
||||
if (criteria.ReceiptDate.HasValue)
|
||||
{
|
||||
var minDate = criteria.ReceiptDate.Value.AddDays(-3);
|
||||
var maxDate = criteria.ReceiptDate.Value.AddDays(3);
|
||||
return query.Where(t => t.Date >= minDate && t.Date <= maxDate);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static List<Transaction> SortByMerchantRelevance(List<Transaction> candidates, string merchantName)
|
||||
{
|
||||
var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(t =>
|
||||
{
|
||||
var merchantNameLower = t.Merchant?.Name?.ToLower() ?? "";
|
||||
var transactionNameLower = t.Name?.ToLower() ?? "";
|
||||
|
||||
// Exact match gets highest score
|
||||
if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower())
|
||||
return 1000;
|
||||
|
||||
// Count matching words
|
||||
var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var transactionWords = transactionNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var merchantMatches = receiptWords.Count(rw => merchantWords.Any(mw => mw.Contains(rw) || rw.Contains(mw)));
|
||||
var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw)));
|
||||
|
||||
// Return the higher match count
|
||||
return Math.Max(merchantMatches * 10, transactionMatches * 10);
|
||||
})
|
||||
.ThenByDescending(t => t.Date)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<Transaction> FilterByAmountTolerance(List<Transaction> candidates, decimal total)
|
||||
{
|
||||
var receiptTotal = Math.Round(Math.Abs(total), 2);
|
||||
var tolerance = receiptTotal * 0.10m; // 10% tolerance
|
||||
var minAmount = receiptTotal - tolerance;
|
||||
var maxAmount = receiptTotal + tolerance;
|
||||
|
||||
return candidates
|
||||
.Where(t =>
|
||||
{
|
||||
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||
return transactionAmount >= minAmount && transactionAmount <= maxAmount;
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<TransactionMatch> ConvertToMatches(List<Transaction> candidates, ReceiptMatchCriteria criteria)
|
||||
{
|
||||
return candidates.Select(t =>
|
||||
{
|
||||
var match = new TransactionMatch
|
||||
{
|
||||
Id = t.Id,
|
||||
Date = t.Date,
|
||||
Name = t.Name,
|
||||
Amount = t.Amount,
|
||||
MerchantName = t.Merchant?.Name,
|
||||
PaymentMethod = t.PaymentMethodLabel,
|
||||
IsExactAmount = false,
|
||||
IsCloseAmount = false
|
||||
};
|
||||
|
||||
// Amount matching flags
|
||||
if (criteria.Total.HasValue)
|
||||
{
|
||||
var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2);
|
||||
var transactionAmount = Math.Round(Math.Abs(t.Amount), 2);
|
||||
match.IsExactAmount = transactionAmount == receiptTotal;
|
||||
var tolerance = receiptTotal * 0.10m;
|
||||
match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance;
|
||||
}
|
||||
|
||||
return match;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<TransactionMatch>> GetFallbackMatches(HashSet<long> excludeIds)
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => !excludeIds.Contains(t.Id))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Take(50)
|
||||
.Select(t => new TransactionMatch
|
||||
{
|
||||
Id = t.Id,
|
||||
Date = t.Date,
|
||||
Name = t.Name,
|
||||
Amount = t.Amount,
|
||||
MerchantName = t.Merchant != null ? t.Merchant.Name : null,
|
||||
PaymentMethod = t.PaymentMethodLabel,
|
||||
IsExactAmount = false,
|
||||
IsCloseAmount = false
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Criteria for matching receipts to transactions.
|
||||
/// </summary>
|
||||
public class ReceiptMatchCriteria
|
||||
{
|
||||
public DateTime? ReceiptDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal? Total { get; set; }
|
||||
public string? MerchantName { get; set; }
|
||||
public HashSet<long> ExcludeTransactionIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a transaction that matches a receipt, with scoring information.
|
||||
/// </summary>
|
||||
public class TransactionMatch
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string? MerchantName { get; set; }
|
||||
public string PaymentMethod { get; set; } = "";
|
||||
public bool IsExactAmount { get; set; }
|
||||
public bool IsCloseAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
public interface IReceiptParseQueue
|
||||
{
|
||||
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
|
||||
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
|
||||
ValueTask<long> DequeueAsync(CancellationToken ct);
|
||||
int QueueLength { get; }
|
||||
long? CurrentlyProcessingId { get; }
|
||||
void SetCurrentlyProcessing(long? receiptId);
|
||||
}
|
||||
|
||||
public class ReceiptParseQueue : IReceiptParseQueue
|
||||
{
|
||||
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
private long _currentlyProcessingId;
|
||||
|
||||
public int QueueLength => _channel.Reader.Count;
|
||||
|
||||
public long? CurrentlyProcessingId
|
||||
{
|
||||
get
|
||||
{
|
||||
var val = Interlocked.Read(ref _currentlyProcessingId);
|
||||
return val == 0 ? null : val;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCurrentlyProcessing(long? receiptId)
|
||||
{
|
||||
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(receiptId, ct);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var id in receiptIds)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(id, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<long> DequeueAsync(CancellationToken ct)
|
||||
{
|
||||
return await _channel.Reader.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving reference/lookup data used in dropdowns and filters.
|
||||
/// </summary>
|
||||
public interface IReferenceDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all distinct categories from transactions, sorted alphabetically.
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableCategoriesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all merchants, sorted by name.
|
||||
/// </summary>
|
||||
Task<List<Merchant>> GetAvailableMerchantsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cards with optional account information included.
|
||||
/// </summary>
|
||||
Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all accounts, sorted by institution and last4.
|
||||
/// </summary>
|
||||
Task<List<Account>> GetAvailableAccountsAsync();
|
||||
}
|
||||
|
||||
public class ReferenceDataService : IReferenceDataService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public ReferenceDataService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableCategoriesAsync()
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Select(t => t.Category ?? "")
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Merchant>> GetAvailableMerchantsAsync()
|
||||
{
|
||||
return await _db.Merchants
|
||||
.OrderBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Card>> GetAvailableCardsAsync(bool includeAccount = true)
|
||||
{
|
||||
var query = _db.Cards.AsQueryable();
|
||||
|
||||
if (includeAccount)
|
||||
{
|
||||
query = query.Include(c => c.Account);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(c => c.Owner)
|
||||
.ThenBy(c => c.Last4)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Account>> GetAvailableAccountsAsync()
|
||||
{
|
||||
return await _db.Accounts
|
||||
.OrderBy(a => a.Institution)
|
||||
.ThenBy(a => a.Last4)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface ITransactionAICategorizer
|
||||
{
|
||||
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
||||
Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true);
|
||||
}
|
||||
|
||||
public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly LlamaCppVisionClient _llamaClient;
|
||||
private readonly ILogger<TransactionAICategorizer> _logger;
|
||||
|
||||
public TransactionAICategorizer(
|
||||
HttpClient httpClient,
|
||||
MoneyMapContext db,
|
||||
IConfiguration config,
|
||||
LlamaCppVisionClient llamaClient,
|
||||
ILogger<TransactionAICategorizer> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_db = db;
|
||||
_config = config;
|
||||
_llamaClient = llamaClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
|
||||
{
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
var prompt = await BuildPromptAsync(transaction);
|
||||
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
|
||||
return new AICategoryProposal
|
||||
{
|
||||
TransactionId = transaction.Id,
|
||||
Category = response.Category ?? "",
|
||||
CanonicalMerchant = response.CanonicalMerchant,
|
||||
Pattern = response.Pattern,
|
||||
Priority = response.Priority,
|
||||
Confidence = response.Confidence,
|
||||
Reasoning = response.Reasoning,
|
||||
CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
||||
{
|
||||
var proposals = new List<AICategoryProposal>();
|
||||
|
||||
// Pre-fetch existing categories and all rules once to avoid concurrent DbContext access
|
||||
var existingCategories = await _db.CategoryMappings
|
||||
.Select(m => m.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
var allRules = await _db.CategoryMappings
|
||||
.Include(m => m.Merchant)
|
||||
.ToListAsync();
|
||||
|
||||
// Process transactions sequentially to avoid DbContext concurrency issues
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
|
||||
if (result != null)
|
||||
proposals.Add(result);
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
List<CategoryMapping> allRules,
|
||||
string? model = null)
|
||||
{
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
|
||||
// Find rules whose pattern matches this transaction name
|
||||
var matchingRules = allRules
|
||||
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.Priority)
|
||||
.ThenByDescending(r => r.Pattern.Length) // Prefer more specific patterns
|
||||
.ToList();
|
||||
|
||||
var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
|
||||
return new AICategoryProposal
|
||||
{
|
||||
TransactionId = transaction.Id,
|
||||
Category = response.Category ?? "",
|
||||
CanonicalMerchant = response.CanonicalMerchant,
|
||||
Pattern = response.Pattern,
|
||||
Priority = response.Priority,
|
||||
Confidence = response.Confidence,
|
||||
Reasoning = response.Reasoning,
|
||||
CreateRule = response.Confidence >= 0.7m
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true)
|
||||
{
|
||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||
if (transaction == null)
|
||||
return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" };
|
||||
|
||||
// Update transaction category
|
||||
transaction.Category = proposal.Category;
|
||||
|
||||
// Handle merchant
|
||||
if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant))
|
||||
{
|
||||
var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant);
|
||||
if (merchant == null)
|
||||
{
|
||||
merchant = new Merchant { Name = proposal.CanonicalMerchant };
|
||||
_db.Merchants.Add(merchant);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
transaction.MerchantId = merchant.Id;
|
||||
}
|
||||
|
||||
bool ruleCreated = false;
|
||||
bool ruleUpdated = false;
|
||||
|
||||
// Create or update category mapping rule if requested
|
||||
if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern))
|
||||
{
|
||||
var existingRule = await _db.CategoryMappings
|
||||
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
var newMapping = new CategoryMapping
|
||||
{
|
||||
Category = proposal.Category,
|
||||
Pattern = proposal.Pattern,
|
||||
MerchantId = transaction.MerchantId,
|
||||
Priority = proposal.Priority,
|
||||
Confidence = proposal.Confidence,
|
||||
CreatedBy = "AI",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.CategoryMappings.Add(newMapping);
|
||||
ruleCreated = true;
|
||||
}
|
||||
else if (existingRule.Category != proposal.Category)
|
||||
{
|
||||
existingRule.Category = proposal.Category;
|
||||
existingRule.MerchantId = transaction.MerchantId;
|
||||
existingRule.Priority = proposal.Priority;
|
||||
existingRule.Confidence = proposal.Confidence;
|
||||
existingRule.CreatedBy = "AI";
|
||||
existingRule.CreatedAt = DateTime.UtcNow;
|
||||
ruleUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ApplyProposalResult
|
||||
{
|
||||
Success = true,
|
||||
RuleCreated = ruleCreated,
|
||||
RuleUpdated = ruleUpdated
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> BuildPromptAsync(Transaction transaction)
|
||||
{
|
||||
// Get existing categories from database for better suggestions
|
||||
var existingCategories = await _db.CategoryMappings
|
||||
.Select(m => m.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all rules and find matches in memory (pattern-in-name is hard to express in SQL)
|
||||
var allRules = await _db.CategoryMappings
|
||||
.Include(m => m.Merchant)
|
||||
.ToListAsync();
|
||||
|
||||
var matchingRules = allRules
|
||||
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.Priority)
|
||||
.ThenByDescending(r => r.Pattern.Length)
|
||||
.ToList();
|
||||
|
||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||
}
|
||||
|
||||
private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories)
|
||||
{
|
||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
||||
}
|
||||
|
||||
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
||||
{
|
||||
var categoryList = existingCategories.Any()
|
||||
? string.Join(", ", existingCategories)
|
||||
: "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Transaction Details:");
|
||||
sb.AppendLine($"- Name: \"{transaction.Name}\"");
|
||||
sb.AppendLine($"- Memo: \"{transaction.Memo}\"");
|
||||
sb.AppendLine($"- Amount: {transaction.Amount:C}");
|
||||
sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}");
|
||||
sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Category))
|
||||
sb.AppendLine($"- Current Category: \"{transaction.Category}\"");
|
||||
|
||||
if (transaction.Merchant != null)
|
||||
sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\"");
|
||||
|
||||
if (transaction.Card != null)
|
||||
sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}");
|
||||
|
||||
if (transaction.Account != null)
|
||||
sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Notes))
|
||||
sb.AppendLine($"- Notes: \"{transaction.Notes}\"");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Last4))
|
||||
sb.AppendLine($"- Last 4 digits: {transaction.Last4}");
|
||||
|
||||
if (transaction.IsTransfer)
|
||||
sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}");
|
||||
|
||||
// Include matching rules so the AI respects existing mappings
|
||||
if (matchingRules.Any())
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("EXISTING RULES that match this transaction (you MUST use these categories unless clearly wrong):");
|
||||
foreach (var rule in matchingRules)
|
||||
{
|
||||
var createdBy = rule.CreatedBy ?? "Unknown";
|
||||
var merchantName = rule.Merchant?.Name;
|
||||
sb.Append($" - Pattern \"{rule.Pattern}\" → Category \"{rule.Category}\"");
|
||||
if (!string.IsNullOrWhiteSpace(merchantName))
|
||||
sb.Append($", Merchant \"{merchantName}\"");
|
||||
sb.AppendLine($" (created by {createdBy})");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Existing categories in this system: {categoryList}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Provide your analysis in JSON format:");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" \"category\": \"Category name\",");
|
||||
sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\",");
|
||||
sb.AppendLine(" \"pattern\": \"EXACT substring from the transaction Name that identifies this merchant\",");
|
||||
sb.AppendLine(" \"priority\": 0,");
|
||||
sb.AppendLine(" \"confidence\": 0.85,");
|
||||
sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
|
||||
sb.AppendLine("}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Guidelines:");
|
||||
sb.AppendLine("- If an existing rule matches this transaction, you MUST use that rule's category and merchant. Only deviate if the existing rule is clearly incorrect.");
|
||||
sb.AppendLine("- Prefer using existing categories when appropriate");
|
||||
sb.AppendLine("- CRITICAL: The pattern MUST be a substring that actually appears in the transaction Name field above. It is used for case-insensitive contains matching. Do NOT invent or clean up the pattern. Extract the shortest distinctive substring from the Name that would identify this merchant. For example, if the Name is 'DEBIT PURCHASE -VISA Kindle Unltd*0M6888', use 'Kindle Unltd' NOT 'Kindle Unlimited'. If the Name is 'WAL-MART #1234 SPRINGFIELD', use 'WAL-MART' NOT 'WALMART'.");
|
||||
sb.AppendLine("- confidence: Your certainty in this categorization (0.0-1.0). Use ~0.9+ for obvious matches like 'WALMART' -> Groceries. Use ~0.7-0.8 for likely matches. Use ~0.5-0.6 for uncertain/ambiguous transactions.");
|
||||
sb.AppendLine("- Return ONLY valid JSON, no additional text.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallModelAsync(string prompt, string model)
|
||||
{
|
||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
|
||||
return await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
|
||||
// Default to OpenAI
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
|
||||
return await CallOpenAIAsync(apiKey, prompt, model);
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = model,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
|
||||
new { role = "user", content = prompt }
|
||||
},
|
||||
temperature = 0.1,
|
||||
max_tokens = 300
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
||||
request.Headers.Add("Authorization", $"Bearer {apiKey}");
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(requestBody),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var apiResponse = JsonSerializer.Deserialize<OpenAIChatResponse>(json);
|
||||
|
||||
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
|
||||
return null;
|
||||
|
||||
var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<AICategorizationResponse>(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "OpenAI API request failed: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse OpenAI response JSON: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error calling OpenAI API: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallLlamaCppAsync(string prompt, string? model = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
var systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only.";
|
||||
var fullPrompt = $"{systemPrompt}\n\n{prompt}";
|
||||
|
||||
var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<AICategorizationResponse>(result.Content ?? "", new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API response models
|
||||
private class OpenAIChatResponse
|
||||
{
|
||||
[JsonPropertyName("choices")]
|
||||
public Choice[]? Choices { get; set; }
|
||||
}
|
||||
|
||||
private class Choice
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public Message? Message { get; set; }
|
||||
}
|
||||
|
||||
private class Message
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
private class AICategorizationResponse
|
||||
{
|
||||
[JsonPropertyName("category")]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[JsonPropertyName("canonical_merchant")]
|
||||
public string? CanonicalMerchant { get; set; }
|
||||
|
||||
[JsonPropertyName("pattern")]
|
||||
public string? Pattern { get; set; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; set; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public decimal Confidence { get; set; }
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class AICategoryProposal
|
||||
{
|
||||
public long TransactionId { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public string? CanonicalMerchant { get; set; }
|
||||
public string? Pattern { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public decimal Confidence { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
public bool CreateRule { get; set; }
|
||||
}
|
||||
|
||||
public class ApplyProposalResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool RuleCreated { get; set; }
|
||||
public bool RuleUpdated { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
|
||||
public interface ITransactionCategorizer
|
||||
{
|
||||
Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null);
|
||||
Task<List<CategoryMapping>> GetAllMappingsAsync();
|
||||
Task SeedDefaultMappingsAsync();
|
||||
void InvalidateMappingsCache();
|
||||
}
|
||||
|
||||
public class CategorizationResult
|
||||
{
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int? MerchantId { get; set; }
|
||||
}
|
||||
|
||||
// ===== Service Implementation =====
|
||||
|
||||
public class TransactionCategorizer : ITransactionCategorizer
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IMemoryCache _cache;
|
||||
private const decimal GasStationThreshold = -20m;
|
||||
private const string MappingsCacheKey = "CategoryMappings";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
|
||||
|
||||
public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public void InvalidateMappingsCache()
|
||||
{
|
||||
_cache.Remove(MappingsCacheKey);
|
||||
}
|
||||
|
||||
public async Task<CategorizationResult> CategorizeAsync(string merchantName, decimal? amount = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(merchantName))
|
||||
return new CategorizationResult();
|
||||
|
||||
var merchantUpper = merchantName.ToUpperInvariant();
|
||||
|
||||
// Get cached mappings or load from database
|
||||
var mappings = await GetCachedMappingsAsync();
|
||||
|
||||
// Special case: Gas stations with small purchases
|
||||
if (amount.HasValue && amount.Value > GasStationThreshold)
|
||||
{
|
||||
var gasMapping = mappings.FirstOrDefault(m =>
|
||||
m.Category == "Gas & Auto" &&
|
||||
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
|
||||
|
||||
if (gasMapping != null)
|
||||
return new CategorizationResult
|
||||
{
|
||||
Category = "Convenience Store",
|
||||
MerchantId = gasMapping.MerchantId
|
||||
};
|
||||
}
|
||||
|
||||
// Check each category's patterns
|
||||
foreach (var mapping in mappings)
|
||||
{
|
||||
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
|
||||
return new CategorizationResult
|
||||
{
|
||||
Category = mapping.Category,
|
||||
MerchantId = mapping.MerchantId
|
||||
};
|
||||
}
|
||||
|
||||
return new CategorizationResult(); // No match - needs manual categorization
|
||||
}
|
||||
|
||||
private async Task<List<CategoryMapping>> GetCachedMappingsAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(MappingsCacheKey, out List<CategoryMapping>? cachedMappings) && cachedMappings != null)
|
||||
{
|
||||
return cachedMappings;
|
||||
}
|
||||
|
||||
var mappings = await _db.CategoryMappings
|
||||
.OrderByDescending(m => m.Priority)
|
||||
.ThenBy(m => m.Category)
|
||||
.ToListAsync();
|
||||
|
||||
_cache.Set(MappingsCacheKey, mappings, CacheDuration);
|
||||
return mappings;
|
||||
}
|
||||
|
||||
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
|
||||
{
|
||||
var mappings = await GetCachedMappingsAsync();
|
||||
return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
||||
}
|
||||
|
||||
public async Task SeedDefaultMappingsAsync()
|
||||
{
|
||||
// Check if mappings already exist
|
||||
if (await _db.CategoryMappings.AnyAsync())
|
||||
return;
|
||||
|
||||
var defaultMappings = GetDefaultMappings();
|
||||
|
||||
_db.CategoryMappings.AddRange(defaultMappings);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static List<CategoryMapping> GetDefaultMappings()
|
||||
{
|
||||
var mappings = new List<CategoryMapping>();
|
||||
|
||||
// Online Shopping
|
||||
AddMappings("Online shopping", mappings,
|
||||
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
|
||||
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
|
||||
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
|
||||
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM");
|
||||
|
||||
// Walmart
|
||||
AddMappings("Walmart Online", mappings, "WALMART.COM");
|
||||
AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C");
|
||||
|
||||
// Pizza
|
||||
AddMappings("Pizza", mappings,
|
||||
"CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ",
|
||||
"PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES");
|
||||
|
||||
// Retail Stores
|
||||
AddMappings("Brick/mortar store", mappings,
|
||||
"DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE",
|
||||
"WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES",
|
||||
"GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT");
|
||||
|
||||
// Restaurants
|
||||
AddMappings("Eat out / Restaurants", mappings,
|
||||
"KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT",
|
||||
"STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL",
|
||||
"RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE",
|
||||
"EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR",
|
||||
"ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL");
|
||||
|
||||
// School
|
||||
AddMappings("School", mappings,
|
||||
"INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE");
|
||||
|
||||
// Health
|
||||
AddMappings("Health", mappings,
|
||||
"MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS",
|
||||
"WHITEWATER EYE", "GIESTING FAMILY DENTIS");
|
||||
|
||||
// Gas & Auto (higher priority for special handling)
|
||||
AddMappings("Gas & Auto", mappings, 100,
|
||||
"SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO",
|
||||
"WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR",
|
||||
"COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC");
|
||||
|
||||
// Utilities
|
||||
AddMappings("Utilities/Services", mappings,
|
||||
"SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT",
|
||||
"US MOBILE", "WHITEWATER VALLE", "RUMPKE");
|
||||
|
||||
// Entertainment
|
||||
AddMappings("Entertainment", mappings,
|
||||
"SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE",
|
||||
"GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX",
|
||||
"SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB");
|
||||
|
||||
// Banking (high priority to catch these first)
|
||||
AddMappings("Banking", mappings, 200,
|
||||
"ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE",
|
||||
"MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT",
|
||||
"MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE",
|
||||
"FRGN TRANS FEE", "START SCHEDULED TRANSFER");
|
||||
|
||||
// Mortgage
|
||||
AddMappings("Mortgage", mappings, "WAYNE BANK");
|
||||
|
||||
// Car Payment
|
||||
AddMappings("Car Payment", mappings, "UNION SAVINGS AN");
|
||||
|
||||
// Convenience Store
|
||||
AddMappings("Convenience Store", mappings,
|
||||
"PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK");
|
||||
|
||||
// Income (high priority)
|
||||
AddMappings("Income", mappings, 200,
|
||||
"MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS",
|
||||
"RPA PA", "REWARDS REDEEMED");
|
||||
|
||||
// Taxes
|
||||
AddMappings("Taxes", mappings, "MYERS INCOME TAX");
|
||||
|
||||
// Insurance
|
||||
AddMappings("Insurance", mappings,
|
||||
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
|
||||
|
||||
// Credit Card Payment (high priority to catch before Banking)
|
||||
AddMappings("Credit Card Payment", mappings, 200,
|
||||
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
|
||||
|
||||
// Ice Cream
|
||||
AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE");
|
||||
|
||||
// Government
|
||||
AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK");
|
||||
|
||||
// Home Services
|
||||
AddMappings("Home Services", mappings, "DUNGAN PLUMBING");
|
||||
|
||||
// Special Occasions
|
||||
AddMappings("Special Occasions", mappings,
|
||||
"CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR");
|
||||
|
||||
// Home Improvement
|
||||
AddMappings("Home Improvement", mappings,
|
||||
"SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907",
|
||||
"123FILTER", "O-RING STORE");
|
||||
|
||||
// Software/Subscriptions
|
||||
AddMappings("Software/Subscriptions", mappings,
|
||||
"GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP",
|
||||
"AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List");
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
|
||||
{
|
||||
AddMappings(category, mappings, 0, patterns);
|
||||
}
|
||||
|
||||
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
|
||||
{
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
mappings.Add(new CategoryMapping
|
||||
{
|
||||
Category = category,
|
||||
Pattern = pattern,
|
||||
MerchantId = null, // Will be set by users via UI
|
||||
Priority = priority
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Database Migration =====
|
||||
// Add this to your DbContext:
|
||||
// public DbSet<CategoryMapping> CategoryMappings { get; set; }
|
||||
//
|
||||
// Then create a migration:
|
||||
// dotnet ef migrations add AddCategoryMappings
|
||||
// dotnet ef database update
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for filtering transactions in queries
|
||||
/// </summary>
|
||||
public static class TransactionFilters
|
||||
{
|
||||
/// <summary>
|
||||
/// Categories that represent transfers between accounts, not actual spending.
|
||||
/// These should be excluded from spending reports and analytics.
|
||||
/// </summary>
|
||||
public static readonly string[] TransferCategories = new[]
|
||||
{
|
||||
"Credit Card Payment",
|
||||
"Bank Transfer",
|
||||
"Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Filter to exclude transfer transactions from spending queries
|
||||
/// </summary>
|
||||
public static IQueryable<Transaction> ExcludeTransfers(this IQueryable<Transaction> query)
|
||||
{
|
||||
return query.Where(t => !TransferCategories.Contains(t.Category ?? ""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a category represents a transfer (not actual spending)
|
||||
/// </summary>
|
||||
public static bool IsTransferCategory(string? category)
|
||||
{
|
||||
return TransferCategories.Contains(category ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Models.Import;
|
||||
using System.Globalization;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for importing transactions from CSV files.
|
||||
/// </summary>
|
||||
public interface ITransactionImporter
|
||||
{
|
||||
Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context);
|
||||
Task<ImportOperationResult> ImportAsync(List<Transaction> transactions);
|
||||
}
|
||||
|
||||
public class TransactionImporter : ITransactionImporter
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ICardResolver _cardResolver;
|
||||
|
||||
public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver)
|
||||
{
|
||||
_db = db;
|
||||
_cardResolver = cardResolver;
|
||||
}
|
||||
|
||||
public async Task<PreviewOperationResult> PreviewAsync(Stream csvStream, ImportContext context)
|
||||
{
|
||||
var previewItems = new List<TransactionPreview>();
|
||||
var addedInThisBatch = new HashSet<TransactionKey>();
|
||||
|
||||
// First pass: read CSV to get date range and all transactions
|
||||
var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>();
|
||||
DateTime? minDate = null;
|
||||
DateTime? maxDate = null;
|
||||
|
||||
using (var reader = new StreamReader(csvStream))
|
||||
using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true,
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
}))
|
||||
{
|
||||
csv.Read();
|
||||
csv.ReadHeader();
|
||||
var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory));
|
||||
|
||||
while (csv.Read())
|
||||
{
|
||||
var row = csv.GetRecord<TransactionCsvRow>();
|
||||
|
||||
var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context);
|
||||
if (!paymentResolution.IsSuccess)
|
||||
return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!);
|
||||
|
||||
var transaction = MapToTransaction(row, paymentResolution);
|
||||
var key = new TransactionKey(transaction);
|
||||
|
||||
csvTransactions.Add((row, transaction, key));
|
||||
|
||||
// Track date range
|
||||
if (minDate == null || transaction.Date < minDate) minDate = transaction.Date;
|
||||
if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date;
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing transactions within the date range for fast duplicate checking
|
||||
HashSet<TransactionKey> existingTransactions;
|
||||
if (minDate.HasValue && maxDate.HasValue)
|
||||
{
|
||||
// Add a buffer of 1 day on each side to catch any edge cases
|
||||
var startDate = minDate.Value.AddDays(-1);
|
||||
var endDate = maxDate.Value.AddDays(1);
|
||||
|
||||
existingTransactions = await _db.Transactions
|
||||
.Where(t => t.Date >= startDate && t.Date <= endDate)
|
||||
.Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId))
|
||||
.ToHashSetAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
existingTransactions = new HashSet<TransactionKey>();
|
||||
}
|
||||
|
||||
// Second pass: check for duplicates and build preview
|
||||
foreach (var (row, transaction, key) in csvTransactions)
|
||||
{
|
||||
// Fast in-memory duplicate checking
|
||||
bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key);
|
||||
|
||||
previewItems.Add(new TransactionPreview
|
||||
{
|
||||
Transaction = transaction,
|
||||
IsDuplicate = isDuplicate,
|
||||
PaymentMethodLabel = GetPaymentLabel(transaction, context)
|
||||
});
|
||||
|
||||
addedInThisBatch.Add(key);
|
||||
}
|
||||
|
||||
// Order by date descending (newest first)
|
||||
var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList();
|
||||
|
||||
return PreviewOperationResult.Success(orderedPreview);
|
||||
}
|
||||
|
||||
public async Task<ImportOperationResult> ImportAsync(List<Transaction> transactions)
|
||||
{
|
||||
int inserted = 0;
|
||||
int skipped = 0;
|
||||
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
_db.Transactions.Add(transaction);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = new ImportResult(
|
||||
transactions.Count,
|
||||
inserted,
|
||||
skipped,
|
||||
null
|
||||
);
|
||||
|
||||
return ImportOperationResult.Success(result);
|
||||
}
|
||||
|
||||
private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution)
|
||||
{
|
||||
return new Transaction
|
||||
{
|
||||
Date = row.Date,
|
||||
TransactionType = row.Transaction?.Trim() ?? "",
|
||||
Name = row.Name?.Trim() ?? "",
|
||||
Memo = row.Memo?.Trim() ?? "",
|
||||
Amount = row.Amount,
|
||||
Category = (row.Category ?? "").Trim(),
|
||||
Last4 = paymentResolution.Last4,
|
||||
CardId = paymentResolution.CardId,
|
||||
AccountId = paymentResolution.AccountId!.Value
|
||||
};
|
||||
}
|
||||
|
||||
private string GetPaymentLabel(Transaction transaction, ImportContext context)
|
||||
{
|
||||
var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId);
|
||||
var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}";
|
||||
|
||||
if (transaction.CardId.HasValue)
|
||||
{
|
||||
var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId);
|
||||
var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}";
|
||||
return $"{cardLabel} → {accountLabel}";
|
||||
}
|
||||
|
||||
return accountLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for core transaction operations including duplicate detection,
|
||||
/// retrieval, and deletion.
|
||||
/// </summary>
|
||||
public interface ITransactionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a transaction is a duplicate based on date, amount, name, memo,
|
||||
/// account, and card.
|
||||
/// </summary>
|
||||
Task<bool> IsDuplicateAsync(Transaction transaction);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transaction by ID with optional related data.
|
||||
/// </summary>
|
||||
Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a transaction and all related data (receipts, parse logs, line items).
|
||||
/// </summary>
|
||||
Task<bool> DeleteTransactionAsync(long id);
|
||||
}
|
||||
|
||||
public class TransactionService : ITransactionService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> IsDuplicateAsync(Transaction transaction)
|
||||
{
|
||||
return await _db.Transactions.AnyAsync(t =>
|
||||
t.Date == transaction.Date &&
|
||||
t.Amount == transaction.Amount &&
|
||||
t.Name == transaction.Name &&
|
||||
t.Memo == transaction.Memo &&
|
||||
t.AccountId == transaction.AccountId &&
|
||||
t.CardId == transaction.CardId);
|
||||
}
|
||||
|
||||
public async Task<Transaction?> GetTransactionByIdAsync(long id, bool includeRelated = false)
|
||||
{
|
||||
var query = _db.Transactions.AsQueryable();
|
||||
|
||||
if (includeRelated)
|
||||
{
|
||||
query = query
|
||||
.Include(t => t.Card)
|
||||
.ThenInclude(c => c!.Account)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.TransferToAccount)
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.Receipts)
|
||||
.ThenInclude(r => r.LineItems);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(t => t.Id == id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteTransactionAsync(long id)
|
||||
{
|
||||
var transaction = await _db.Transactions.FindAsync(id);
|
||||
if (transaction == null)
|
||||
return false;
|
||||
|
||||
_db.Transactions.Remove(transaction);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for calculating transaction statistics and aggregates.
|
||||
/// </summary>
|
||||
public interface ITransactionStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates statistics for a filtered set of transactions.
|
||||
/// </summary>
|
||||
Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets categorization statistics for the entire database.
|
||||
/// </summary>
|
||||
Task<CategorizationStats> GetCategorizationStatsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets card statistics for a specific account.
|
||||
/// </summary>
|
||||
Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
public class TransactionStatisticsService : ITransactionStatisticsService
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public TransactionStatisticsService(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<TransactionStats> CalculateStatsAsync(IQueryable<Transaction> query)
|
||||
{
|
||||
// Calculate stats at database level instead of loading all transactions into memory
|
||||
var stats = await query
|
||||
.GroupBy(_ => 1) // Group all into one group to aggregate
|
||||
.Select(g => new TransactionStats
|
||||
{
|
||||
Count = g.Count(),
|
||||
TotalDebits = g.Where(t => t.Amount < 0).Sum(t => t.Amount),
|
||||
TotalCredits = g.Where(t => t.Amount > 0).Sum(t => t.Amount),
|
||||
NetAmount = g.Sum(t => t.Amount)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return stats ?? new TransactionStats();
|
||||
}
|
||||
|
||||
public async Task<CategorizationStats> GetCategorizationStatsAsync()
|
||||
{
|
||||
var totalTransactions = await _db.Transactions.CountAsync();
|
||||
var uncategorized = await _db.Transactions
|
||||
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
|
||||
var categorized = totalTransactions - uncategorized;
|
||||
|
||||
return new CategorizationStats
|
||||
{
|
||||
TotalTransactions = totalTransactions,
|
||||
Categorized = categorized,
|
||||
Uncategorized = uncategorized
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<CardStats>> GetCardStatsForAccountAsync(int accountId)
|
||||
{
|
||||
// Single query with projection to avoid N+1
|
||||
return await _db.Cards
|
||||
.Where(c => c.AccountId == accountId)
|
||||
.OrderBy(c => c.Owner)
|
||||
.ThenBy(c => c.Last4)
|
||||
.Select(c => new CardStats
|
||||
{
|
||||
Card = c,
|
||||
TransactionCount = c.Transactions.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class TransactionStats
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public decimal NetAmount { get; set; }
|
||||
}
|
||||
|
||||
public class CategorizationStats
|
||||
{
|
||||
public int TotalTransactions { get; set; }
|
||||
public int Categorized { get; set; }
|
||||
public int Uncategorized { get; set; }
|
||||
}
|
||||
|
||||
public class CardStats
|
||||
{
|
||||
public Card Card { get; set; } = null!;
|
||||
public int TransactionCount { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user