Compare commits
46 Commits
865195ad16
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f187b741a2 | |||
| 274569bd79 | |||
| 4bee73ba26 | |||
| 6c4f4bea7f | |||
| db1d96476b | |||
| 51d6aee434 | |||
| c34ea74459 | |||
| 9dc1a9064d | |||
| 5b4a673f9d | |||
| 004f99c2b4 | |||
| e773a0f218 | |||
| ccedea6e67 | |||
| 768b5e015e | |||
| 2a75c9550e | |||
| 7b2d6203df | |||
| cbc46314db | |||
| f54c5ed54d | |||
| 62fa1d5c4c | |||
| d63ded45e1 | |||
| 3b01efd8a6 | |||
| 3deca29f05 | |||
| d831991ad0 | |||
| dcb57c5cf6 | |||
| aa82ee542c | |||
| 2f3047d432 | |||
| 7725bdb159 | |||
| 59b8adc2d8 | |||
| f4ab4c4e7d | |||
| a7c304ccb5 | |||
| f622912f2e | |||
| 299ea3d4fe | |||
| b5f46a7646 | |||
| 4be5658d32 | |||
| 324ab2c627 | |||
| e6512f9b7f | |||
| 516546b345 | |||
| 2be9990dbc | |||
| c3e88df43c | |||
| 444035fd72 | |||
| 6e3589f7da | |||
| 5eb27319e1 | |||
| 705f4ea201 | |||
| 16c8d121d4 | |||
| 396d5cfc1d | |||
| 167b7c2ec1 | |||
| 5c0f0f3fca |
@@ -37,3 +37,12 @@ packages/
|
||||
|
||||
# Environment files with secrets
|
||||
.env
|
||||
|
||||
# Local settings
|
||||
settings.local.json
|
||||
|
||||
# Superpowers plans/specs
|
||||
docs/superpowers/
|
||||
|
||||
# Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
|
||||
+189
-24
@@ -41,6 +41,11 @@ MoneyMap follows a clean, service-oriented architecture:
|
||||
│ - ReceiptMatchingService (NEW) │
|
||||
│ - ReceiptManager │
|
||||
│ - AIReceiptParser │
|
||||
│ - ReceiptParseQueue (singleton) │
|
||||
│ - ReceiptParseWorkerService (hosted) │
|
||||
│ AI Tool Use: │
|
||||
│ - AIToolExecutor (DB query tools) │
|
||||
│ - AIToolRegistry (tool definitions) │
|
||||
│ Reference & Dashboard: │
|
||||
│ - ReferenceDataService (NEW) │
|
||||
│ - DashboardService │
|
||||
@@ -134,6 +139,9 @@ Stores uploaded receipt files (images/PDFs) linked to transactions.
|
||||
- `FileHashSha256` (string, 64) - SHA256 hash for deduplication
|
||||
- `UploadedAtUtc` (DateTime) - Upload timestamp
|
||||
|
||||
**Parse Queue Status:**
|
||||
- `ParseStatus` (ReceiptParseStatus enum) - NotRequested(0), Queued(1), Parsing(2), Completed(3), Failed(4)
|
||||
|
||||
**Parsed Fields (populated by AI parser):**
|
||||
- `Merchant` (string, 200) - Merchant name extracted from receipt
|
||||
- `ReceiptDate` (DateTime?) - Date on receipt
|
||||
@@ -629,6 +637,11 @@ Represents a spending budget for a category or total spending.
|
||||
- Same as UploadReceiptAsync but for receipts without initial transaction mapping
|
||||
- TransactionId is null until later mapped
|
||||
|
||||
- `UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)`
|
||||
- Bulk upload multiple receipt files
|
||||
- Calls UploadReceiptInternalAsync for each file, collecting results
|
||||
- Returns `BulkUploadResult` with lists of uploaded items and failures
|
||||
|
||||
- `MapReceiptToTransactionAsync(long receiptId, long transactionId)`
|
||||
- Links an unmapped receipt to a transaction
|
||||
- Returns success boolean
|
||||
@@ -654,35 +667,62 @@ Represents a spending budget for a category or total spending.
|
||||
|
||||
**Location:** Services/ReceiptManager.cs:23-199
|
||||
|
||||
### OpenAIReceiptParser (Services/OpenAIReceiptParser.cs)
|
||||
### AIReceiptParser (Services/AIReceiptParser.cs)
|
||||
**Interface:** `IReceiptParser`
|
||||
|
||||
**Responsibility:** Parse receipts using OpenAI GPT-4o-mini Vision API.
|
||||
**Responsibility:** Parse receipts using AI vision APIs with tool-use support for database-aware parsing.
|
||||
|
||||
**Key Methods:**
|
||||
- `ParseReceiptAsync(long receiptId)`
|
||||
- `ParseReceiptAsync(long receiptId, string? model, string? notes)`
|
||||
- Loads receipt file from disk
|
||||
- Converts PDFs to PNG images using ImageMagick (220 DPI)
|
||||
- Calls OpenAI Vision API with structured prompt
|
||||
- Parses JSON response (merchant, date, due date, amounts, line items)
|
||||
- Resolves vision client based on model prefix (openai, claude-, llamacpp:, ollama:)
|
||||
- **Tool-aware clients (OpenAI, Claude, LlamaCpp):** Uses function calling to let the AI query the database during parsing
|
||||
- **Non-tool clients (Ollama):** Pre-fetches database context and injects it into the prompt
|
||||
- Parses JSON response (merchant, date, due date, amounts, line items, suggestedCategory, suggestedTransactionId)
|
||||
- Updates Receipt entity with extracted data
|
||||
- Replaces existing line items
|
||||
- Populates `ReceiptLineItem.Category` from AI response
|
||||
- If AI suggested a transaction ID, attempts direct mapping before falling back to ReceiptAutoMapper
|
||||
- Logs parse attempt in ReceiptParseLog
|
||||
- Attempts auto-mapping if receipt is unmapped
|
||||
- Returns `ReceiptParseResult`
|
||||
|
||||
**API Configuration:**
|
||||
- Model: `gpt-4o-mini`
|
||||
- Temperature: 0.1 (deterministic)
|
||||
- Max tokens: 2000
|
||||
- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey`
|
||||
**Tool-Use Flow (OpenAI, Claude, LlamaCpp):**
|
||||
```
|
||||
AI sees receipt image + prompt with tool instructions
|
||||
↓
|
||||
AI calls search_categories → gets existing categories from DB
|
||||
AI calls search_transactions → finds matching bank transactions
|
||||
AI calls search_merchants → normalizes merchant name
|
||||
↓ (up to 5 tool rounds)
|
||||
AI returns final JSON with:
|
||||
- Standard fields (merchant, date, total, lineItems)
|
||||
- suggestedCategory (from existing categories)
|
||||
- suggestedTransactionId (matched transaction)
|
||||
- Per-line-item category
|
||||
```
|
||||
|
||||
**Prompt Strategy:**
|
||||
- Structured JSON request with schema example
|
||||
- Extracts: merchant, date, dueDate (for bills), subtotal, tax, total, confidence
|
||||
- Line items with: description, quantity, unitPrice, lineTotal
|
||||
- Special handling: Services/fees have null quantity (not products)
|
||||
- Due date extraction: For bills (utility, credit card, etc.), extracts payment due date
|
||||
**Ollama Fallback (enriched prompt):**
|
||||
```
|
||||
Pre-fetch all categories, matching merchants, candidate transactions
|
||||
↓
|
||||
Inject as text block in prompt
|
||||
↓
|
||||
AI returns JSON using the provided context
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
| Provider | Model Prefix | Tool Use | Wire Format |
|
||||
|----------|-------------|----------|-------------|
|
||||
| OpenAI | (default) | Native | OpenAI /v1/chat/completions |
|
||||
| Anthropic | claude- | Native | Anthropic /v1/messages |
|
||||
| LlamaCpp | llamacpp: | Native | OpenAI-compatible /v1/chat/completions |
|
||||
| Ollama | ollama: | Enriched prompt fallback | /api/generate |
|
||||
|
||||
**Response Fields (ParsedReceiptData):**
|
||||
- `Merchant`, `ReceiptDate`, `DueDate`, `Subtotal`, `Tax`, `Total`, `Confidence` - Standard fields
|
||||
- `SuggestedCategory` (NEW) - AI's best category for the overall receipt
|
||||
- `SuggestedTransactionId` (NEW) - Transaction ID the AI thinks matches this receipt
|
||||
- `LineItems[].Category` (NEW) - Per-line-item category
|
||||
|
||||
**PDF Handling:**
|
||||
- ImageMagick converts first page to PNG at 220 DPI
|
||||
@@ -690,11 +730,47 @@ Represents a spending budget for a category or total spending.
|
||||
- TrueColor 8-bit RGB output
|
||||
|
||||
**Auto-Mapping Integration:**
|
||||
- After successful parse of unmapped receipts, triggers ReceiptAutoMapper
|
||||
- Attempts to automatically link receipt to matching transaction
|
||||
- If AI suggests a specific transaction, attempts direct mapping first
|
||||
- Falls back to ReceiptAutoMapper if AI mapping fails or no suggestion
|
||||
- Silently fails if auto-mapping unsuccessful (parsing still successful)
|
||||
|
||||
**Location:** Services/OpenAIReceiptParser.cs:23-342
|
||||
**Location:** Services/AIReceiptParser.cs
|
||||
|
||||
### AIToolExecutor (Services/AITools/AIToolExecutor.cs)
|
||||
**Interface:** `IAIToolExecutor`
|
||||
|
||||
**Responsibility:** Execute AI tool calls against the database during receipt parsing.
|
||||
|
||||
**Tools Available:**
|
||||
| Tool | Purpose | Parameters |
|
||||
|------|---------|------------|
|
||||
| `search_categories` | Find existing categories with patterns and merchants | `query?` (optional filter) |
|
||||
| `search_transactions` | Find unmapped bank transactions | `merchant?`, `minDate?`, `maxDate?`, `minAmount?`, `maxAmount?`, `limit?` |
|
||||
| `search_merchants` | Look up known merchants | `query` (required) |
|
||||
|
||||
**Key Methods:**
|
||||
- `ExecuteAsync(AIToolCall)` - Dispatches to the correct handler, runs EF Core query, returns JSON result
|
||||
- `GetEnrichedContextAsync(receiptDate?, total?, merchantHint?)` - Pre-fetches all data as text block for Ollama fallback
|
||||
|
||||
**Design Constraints:**
|
||||
- All tools are **read-only** database queries
|
||||
- Results capped at 20 items
|
||||
- Tool rounds capped at 5 per parse request
|
||||
- Transactions already mapped to receipts are excluded from search results
|
||||
|
||||
**Location:** Services/AITools/AIToolExecutor.cs
|
||||
|
||||
### AIToolRegistry (Services/AITools/AIToolDefinitions.cs)
|
||||
|
||||
**Responsibility:** Define tool schemas in a provider-agnostic format.
|
||||
|
||||
**Key Types:**
|
||||
- `AIToolDefinition` - Tool name, description, parameters
|
||||
- `AIToolParameter` - Parameter name, type, description, required flag
|
||||
- `AIToolCall` - Incoming tool call with arguments
|
||||
- `AIToolResult` - Tool execution result returned to the AI
|
||||
|
||||
**Location:** Services/AITools/AIToolDefinitions.cs
|
||||
|
||||
### ReceiptAutoMapper (Services/ReceiptAutoMapper.cs)
|
||||
**Interface:** `IReceiptAutoMapper`
|
||||
@@ -743,6 +819,30 @@ Represents a spending budget for a category or total spending.
|
||||
|
||||
**Location:** Services/ReceiptAutoMapper.cs
|
||||
|
||||
### ReceiptParseQueue (Services/ReceiptParseQueue.cs)
|
||||
**Interface:** `IReceiptParseQueue`
|
||||
**Lifetime:** Singleton
|
||||
|
||||
**Responsibility:** Thread-safe queue for receipt parsing jobs using `System.Threading.Channels`.
|
||||
|
||||
**Key Members:**
|
||||
- `EnqueueAsync(long receiptId)` - Add a receipt to the parse queue
|
||||
- `EnqueueManyAsync(IEnumerable<long> receiptIds)` - Bulk enqueue
|
||||
- `DequeueAsync(CancellationToken ct)` - Wait for and dequeue the next receipt
|
||||
- `QueueLength` - Current number of items waiting
|
||||
- `CurrentlyProcessingId` - Receipt ID currently being parsed (thread-safe via `Interlocked`)
|
||||
|
||||
### ReceiptParseWorkerService (Services/ReceiptParseWorkerService.cs)
|
||||
**Type:** `BackgroundService` (hosted service)
|
||||
|
||||
**Responsibility:** Continuously process receipts from the parse queue using the AI parser.
|
||||
|
||||
**Behavior:**
|
||||
1. **Startup Recovery:** Queries DB for receipts with `ParseStatus == Queued || Parsing`, re-enqueues them in upload order
|
||||
2. **Processing Loop:** Dequeues one receipt at a time, sets status to `Parsing`, calls `IReceiptParser.ParseReceiptAsync`, updates status to `Completed` or `Failed`
|
||||
3. **Status Updates:** Uses separate DB scopes for status writes to guarantee persistence even if parsing throws
|
||||
4. **Graceful Shutdown:** Respects `CancellationToken` from host
|
||||
|
||||
### FinancialAuditService (Services/FinancialAuditService.cs)
|
||||
**Interface:** `IFinancialAuditService`
|
||||
|
||||
@@ -952,6 +1052,29 @@ EF Core DbContext managing all database entities.
|
||||
|
||||
**Location:** Pages/Receipts.cshtml.cs
|
||||
|
||||
### BulkReceiptUpload.cshtml / BulkReceiptUploadModel
|
||||
**Route:** `/BulkReceiptUpload`
|
||||
|
||||
**Purpose:** Upload multiple receipt files at once with a queue dashboard showing parse progress.
|
||||
|
||||
**Features:**
|
||||
- Multi-file upload form with file list preview and upload spinner
|
||||
- Queue dashboard with tabs: Queued (with position), Completed (merchant/total/confidence/line items), Failed (error + retry button)
|
||||
- Currently-processing indicator with spinner
|
||||
- AJAX polling every 3 seconds while items are active (auto-stops when idle)
|
||||
- Each receipt links to `/ViewReceipt/{id}`
|
||||
- Retry button for failed receipts re-queues them
|
||||
|
||||
**Handlers:**
|
||||
- `OnGetAsync()` - Loads queue dashboard data
|
||||
- `OnPostUploadAsync(List<IFormFile> files)` - Calls `UploadManyUnmappedReceiptsAsync`
|
||||
- `OnGetQueueStatusAsync()` - Returns JSON for AJAX polling
|
||||
- `OnPostRetryAsync(long receiptId)` - Re-queues a failed receipt
|
||||
|
||||
**Dependencies:** `IReceiptManager`, `IReceiptParseQueue`
|
||||
|
||||
**Location:** Pages/BulkReceiptUpload.cshtml.cs
|
||||
|
||||
### EditTransaction.cshtml / EditTransactionModel
|
||||
**Route:** `/EditTransaction/{id}`
|
||||
|
||||
@@ -1170,8 +1293,16 @@ builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvid
|
||||
|
||||
// Receipt Services
|
||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
|
||||
// AI Vision Clients and Tool Use
|
||||
builder.Services.AddHttpClient<OpenAIVisionClient>();
|
||||
builder.Services.AddHttpClient<ClaudeVisionClient>();
|
||||
builder.Services.AddHttpClient<OllamaVisionClient>();
|
||||
builder.Services.AddHttpClient<LlamaCppVisionClient>();
|
||||
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
|
||||
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
|
||||
builder.Services.AddScoped<IMerchantService, MerchantService>();
|
||||
```
|
||||
|
||||
@@ -1240,7 +1371,9 @@ Subtotal (decimal(18,2))
|
||||
Tax (decimal(18,2))
|
||||
Total (decimal(18,2))
|
||||
Currency (nvarchar(8))
|
||||
ParseStatus (int, NOT NULL, DEFAULT 0) -- 0=NotRequested, 1=Queued, 2=Parsing, 3=Completed, 4=Failed
|
||||
UNIQUE INDEX: (TransactionId, FileHashSha256) WHERE TransactionId IS NOT NULL
|
||||
INDEX: ParseStatus
|
||||
```
|
||||
|
||||
### ReceiptParseLogs Table
|
||||
@@ -1683,10 +1816,31 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-15
|
||||
**Version:** 1.4
|
||||
**Last Updated:** 2026-02-11
|
||||
**Version:** 1.5
|
||||
**Framework:** ASP.NET Core 8.0 / EF Core 9.0
|
||||
|
||||
## Recent Changes (v1.5)
|
||||
|
||||
### AI Tool Use for Receipt Parsing
|
||||
- **Tool-Use Support**: AI can now query the database during receipt parsing via function calling
|
||||
- **Three Tools**: `search_categories`, `search_transactions`, `search_merchants` - all read-only DB queries
|
||||
- **Multi-Provider**: OpenAI, Claude, and LlamaCpp all support native tool calling; Ollama uses enriched prompt fallback
|
||||
- **New Response Fields**: `suggestedCategory` (overall receipt), `suggestedTransactionId` (matched transaction), per-line-item `category`
|
||||
- **AI-Suggested Mapping**: If the AI identifies a matching transaction, it's mapped directly before falling back to the scoring-based ReceiptAutoMapper
|
||||
- **Category Population**: `ReceiptLineItem.Category` is now populated from AI responses (was previously unused)
|
||||
- **Shared Tool-Use Helper**: `OpenAIToolUseHelper` implements the OpenAI-compatible tool-use loop shared by OpenAI and LlamaCpp clients
|
||||
- **Anthropic Tool Use**: `ClaudeVisionClient` implements Anthropic-specific `tool_use`/`tool_result` content block format
|
||||
|
||||
### New Files
|
||||
- `Services/AITools/AIToolDefinitions.cs` - Provider-agnostic tool schema models and registry
|
||||
- `Services/AITools/AIToolExecutor.cs` - Tool executor with database query handlers
|
||||
|
||||
### Modified Files
|
||||
- `Services/AIVisionClient.cs` - Added `IAIToolAwareVisionClient` interface, `OpenAIToolUseHelper`, tool-use implementations
|
||||
- `Services/AIReceiptParser.cs` - Integrated tool executor, new response fields, enriched prompt fallback
|
||||
- `Program.cs` - Registered `IAIToolExecutor` and `IAIVisionClientResolver`
|
||||
|
||||
## Recent Changes (v1.4)
|
||||
|
||||
### Financial Audit API
|
||||
@@ -1744,3 +1898,14 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
|
||||
- Implemented `ReceiptAutoMapper` service with intelligent matching algorithm
|
||||
- Updated `ReceiptManager` with unmapped receipt support and duplicate detection
|
||||
- Added `MerchantService` for merchant management
|
||||
|
||||
## Recent Changes (v1.3)
|
||||
|
||||
### Bulk Receipt Upload & Parse Queue
|
||||
- **ReceiptParseStatus**: New enum on `Receipt` model tracking parse lifecycle (NotRequested → Queued → Parsing → Completed/Failed)
|
||||
- **ReceiptParseQueue**: Singleton `Channel<long>`-based queue service replacing fire-and-forget `Task.Run` parsing
|
||||
- **ReceiptParseWorkerService**: `BackgroundService` that processes parse queue sequentially, with startup recovery for interrupted items
|
||||
- **Bulk Upload Page**: New `/BulkReceiptUpload` page with multi-file upload, queue dashboard (tabbed: Queued/Completed/Failed), AJAX polling, and retry for failed items
|
||||
- **ReceiptManager.UploadManyUnmappedReceiptsAsync**: New bulk upload method with per-file error handling
|
||||
- **ViewReceipt LLM Response**: Collapsible raw LLM response payload in parse log history
|
||||
- **Unified Queue**: Both single and bulk receipt uploads now go through the same parse queue
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Data
|
||||
{
|
||||
@@ -129,6 +128,9 @@ namespace MoneyMap.Data
|
||||
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
|
||||
e.Property(x => x.Currency).HasMaxLength(8);
|
||||
|
||||
e.Property(x => x.ParseStatus).HasDefaultValue(ReceiptParseStatus.NotRequested);
|
||||
e.HasIndex(x => x.ParseStatus);
|
||||
|
||||
// Receipt can optionally belong to a Transaction. If txn is deleted, cascade remove receipts.
|
||||
e.HasOne(x => x.Transaction)
|
||||
.WithMany(t => t.Receipts)
|
||||
@@ -0,0 +1,5 @@
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.AspNetCore.Hosting;
|
||||
global using Microsoft.AspNetCore.Http;
|
||||
@@ -1,4 +1,3 @@
|
||||
using MoneyMap.Services;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
@@ -4,6 +4,15 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum ReceiptParseStatus
|
||||
{
|
||||
NotRequested = 0,
|
||||
Queued = 1,
|
||||
Parsing = 2,
|
||||
Completed = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
|
||||
public class Receipt
|
||||
{
|
||||
@@ -55,6 +64,9 @@ public class Receipt
|
||||
[MaxLength(2000)]
|
||||
public string? ParsingNotes { get; set; }
|
||||
|
||||
// Parse queue status
|
||||
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
|
||||
|
||||
// One receipt -> many parse attempts + many line items
|
||||
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Prompts\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,62 @@
|
||||
Analyze this receipt image and extract structured data. Respond with a single JSON object matching this exact schema. Use JSON null (not the string "null") for missing values. Do not include comments in the JSON.
|
||||
|
||||
{
|
||||
"merchant": "store name",
|
||||
"receiptDate": "YYYY-MM-DD",
|
||||
"dueDate": null,
|
||||
"subtotal": 0.00,
|
||||
"tax": 0.00,
|
||||
"total": 0.00,
|
||||
"confidence": 0.95,
|
||||
"suggestedCategory": null,
|
||||
"suggestedTransactionId": null,
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "item name",
|
||||
"upc": null,
|
||||
"quantity": 1.0,
|
||||
"unitPrice": 0.00,
|
||||
"lineTotal": 0.00,
|
||||
"category": null,
|
||||
"voided": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
FIELD TYPES (you must follow these exactly):
|
||||
- merchant: string
|
||||
- receiptDate: string "YYYY-MM-DD" or null
|
||||
- dueDate: string "YYYY-MM-DD" or null (only for bills with a payment deadline)
|
||||
- subtotal: number or null
|
||||
- tax: number or null
|
||||
- total: number
|
||||
- confidence: number between 0 and 1
|
||||
- suggestedCategory: string or null
|
||||
- suggestedTransactionId: integer or null (MUST be a JSON number like 123, NEVER a string like "123")
|
||||
- lineItems: array of objects
|
||||
|
||||
LINE ITEM FIELDS:
|
||||
- description: string (the item or service name, include count/size info like "4CT" or "12 OZ")
|
||||
- upc: string or null (UPC/barcode number if visible, usually 12-13 digits)
|
||||
- quantity: number (default 1.0 for all retail products; null only for service fees or taxes)
|
||||
- unitPrice: number or null (lineTotal divided by quantity; null only if quantity is null)
|
||||
- lineTotal: number (the price shown on the receipt; 0.00 if voided)
|
||||
- category: string or null
|
||||
- voided: boolean
|
||||
|
||||
RULES FOR LINE ITEMS:
|
||||
- Extract ALL line items from top to bottom - never stop early
|
||||
- quantity is 1.0 for ALL physical retail items unless you see "2 @" or "QTY 3" etc.
|
||||
- Do not confuse product descriptions (like "4CT BLUE MUF" = 4-count muffin package) with quantity
|
||||
- UPC/barcode numbers are long numeric codes (12-13 digits) near the item
|
||||
|
||||
VOIDED ITEMS:
|
||||
- When you see "** VOIDED ENTRY **" or similar, the item immediately after it is voided
|
||||
- For voided items: set "voided": true and "lineTotal": 0.00
|
||||
- For all other items: set "voided": false
|
||||
- NEVER skip voided items - include them in the lineItems array
|
||||
- CONTINUE reading ALL items after void markers
|
||||
|
||||
DUE DATE:
|
||||
- Only for bills (utility, credit card, etc.) - extract the payment due date
|
||||
- For regular store receipts, dueDate must be null
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
using MoneyMap.Services.AITools;
|
||||
|
||||
namespace MoneyMap.Core;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMoneyMapCore(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<MoneyMapContext>(options =>
|
||||
options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb")));
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Core transaction and import services
|
||||
services.AddScoped<ITransactionImporter, TransactionImporter>();
|
||||
services.AddScoped<ICardResolver, CardResolver>();
|
||||
services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
|
||||
services.AddScoped<ITransactionService, TransactionService>();
|
||||
services.AddScoped<ITransactionStatisticsService, TransactionStatisticsService>();
|
||||
|
||||
// Entity management services
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
services.AddScoped<ICardService, CardService>();
|
||||
services.AddScoped<IMerchantService, MerchantService>();
|
||||
services.AddScoped<IBudgetService, BudgetService>();
|
||||
|
||||
// Receipt services
|
||||
services.AddScoped<IReceiptMatchingService, ReceiptMatchingService>();
|
||||
services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
|
||||
|
||||
// Reference data and dashboard
|
||||
services.AddScoped<IReferenceDataService, ReferenceDataService>();
|
||||
services.AddScoped<IDashboardService, DashboardService>();
|
||||
services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
|
||||
services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
|
||||
services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
|
||||
services.AddScoped<ISpendTrendsProvider, SpendTrendsProvider>();
|
||||
|
||||
// AI services
|
||||
services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||
services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
{
|
||||
@@ -17,6 +19,7 @@ namespace MoneyMap.Services
|
||||
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;
|
||||
@@ -28,6 +31,7 @@ namespace MoneyMap.Services
|
||||
IPdfToImageConverter pdfConverter,
|
||||
IAIVisionClientResolver clientResolver,
|
||||
IMerchantService merchantService,
|
||||
IAIToolExecutor toolExecutor,
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<AIReceiptParser> logger)
|
||||
@@ -37,6 +41,7 @@ namespace MoneyMap.Services
|
||||
_pdfConverter = pdfConverter;
|
||||
_clientResolver = clientResolver;
|
||||
_merchantService = merchantService;
|
||||
_toolExecutor = toolExecutor;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
@@ -55,9 +60,16 @@ namespace MoneyMap.Services
|
||||
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,
|
||||
@@ -70,8 +82,8 @@ namespace MoneyMap.Services
|
||||
try
|
||||
{
|
||||
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
|
||||
var promptText = await BuildPromptAsync(receipt, notes);
|
||||
var visionResult = await client.AnalyzeImageAsync(base64Data, mediaType, promptText, selectedModel);
|
||||
var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
|
||||
var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
|
||||
|
||||
if (!visionResult.IsSuccess)
|
||||
{
|
||||
@@ -80,14 +92,14 @@ namespace MoneyMap.Services
|
||||
}
|
||||
|
||||
var parseData = ParseResponse(visionResult.Content);
|
||||
await ApplyParseResultAsync(receipt, receiptId, parseData, notes);
|
||||
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);
|
||||
await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
|
||||
|
||||
var lineCount = parseData.LineItems.Count;
|
||||
return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
|
||||
@@ -100,6 +112,29 @@ namespace MoneyMap.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <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")
|
||||
@@ -112,7 +147,7 @@ namespace MoneyMap.Services
|
||||
return (Convert.ToBase64String(fileBytes), receipt.ContentType);
|
||||
}
|
||||
|
||||
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes = null)
|
||||
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client)
|
||||
{
|
||||
var promptText = await LoadPromptTemplateAsync();
|
||||
|
||||
@@ -133,6 +168,43 @@ namespace MoneyMap.Services
|
||||
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;
|
||||
}
|
||||
@@ -168,6 +240,16 @@ namespace MoneyMap.Services
|
||||
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)
|
||||
@@ -183,6 +265,7 @@ namespace MoneyMap.Services
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
LineTotal = item.LineTotal,
|
||||
Category = item.Category,
|
||||
Voided = item.Voided
|
||||
}).ToList();
|
||||
|
||||
@@ -198,8 +281,41 @@ namespace MoneyMap.Services
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId)
|
||||
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;
|
||||
|
||||
@@ -282,6 +398,9 @@ namespace MoneyMap.Services
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -292,6 +411,7 @@ namespace MoneyMap.Services
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -306,4 +426,41 @@ namespace MoneyMap.Services
|
||||
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,6 @@
|
||||
namespace MoneyMap.Services;
|
||||
|
||||
public interface IReceiptStorageOptions
|
||||
{
|
||||
string ReceiptsBasePath { get; }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace MoneyMap.Services
|
||||
{
|
||||
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);
|
||||
@@ -20,9 +21,9 @@ namespace MoneyMap.Services
|
||||
public class ReceiptManager : IReceiptManager
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IReceiptStorageOptions _receiptStorage;
|
||||
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" };
|
||||
@@ -44,23 +45,21 @@ namespace MoneyMap.Services
|
||||
|
||||
public ReceiptManager(
|
||||
MoneyMapContext db,
|
||||
IWebHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
IReceiptStorageOptions receiptStorage,
|
||||
IServiceProvider serviceProvider,
|
||||
IReceiptParseQueue parseQueue,
|
||||
ILogger<ReceiptManager> logger)
|
||||
{
|
||||
_db = db;
|
||||
_environment = environment;
|
||||
_configuration = configuration;
|
||||
_receiptStorage = receiptStorage;
|
||||
_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);
|
||||
return _receiptStorage.ReceiptsBasePath;
|
||||
}
|
||||
|
||||
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
|
||||
@@ -147,28 +146,50 @@ namespace MoneyMap.Services
|
||||
UploadedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
_db.Receipts.Add(receipt);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Automatically parse the receipt after upload (in background, don't wait for result)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var parser = scope.ServiceProvider.GetRequiredService<IReceiptParser>();
|
||||
await parser.ParseReceiptAsync(receipt.Id);
|
||||
_logger.LogInformation("Background parsing completed for receipt {ReceiptId}", receipt.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Background parsing failed for receipt {ReceiptId}: {Message}", receipt.Id, ex.Message);
|
||||
}
|
||||
});
|
||||
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>();
|
||||
@@ -361,4 +382,24 @@ namespace MoneyMap.Services
|
||||
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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
-48
@@ -38,26 +38,10 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
|
||||
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
|
||||
{
|
||||
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
var prompt = await BuildPromptAsync(transaction);
|
||||
|
||||
AICategorizationResponse? response;
|
||||
|
||||
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default");
|
||||
response = await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
response = await CallOpenAIAsync(apiKey, prompt);
|
||||
}
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
@@ -79,17 +63,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
{
|
||||
var proposals = new List<AICategoryProposal>();
|
||||
|
||||
// Pre-fetch existing categories once to avoid concurrent DbContext access
|
||||
// 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, model);
|
||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
|
||||
if (result != null)
|
||||
proposals.Add(result);
|
||||
}
|
||||
@@ -100,28 +88,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
List<CategoryMapping> allRules,
|
||||
string? model = null)
|
||||
{
|
||||
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
var prompt = BuildPromptWithCategories(transaction, existingCategories);
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
|
||||
AICategorizationResponse? response;
|
||||
// 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();
|
||||
|
||||
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default");
|
||||
response = await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
response = await CallOpenAIAsync(apiKey, prompt);
|
||||
}
|
||||
var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
@@ -161,27 +142,39 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
transaction.MerchantId = merchant.Id;
|
||||
}
|
||||
|
||||
// Create category mapping rule if requested
|
||||
bool ruleCreated = false;
|
||||
bool ruleUpdated = false;
|
||||
|
||||
// Create or update category mapping rule if requested
|
||||
if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern))
|
||||
{
|
||||
// Check if rule already exists
|
||||
var existingRule = await _db.CategoryMappings
|
||||
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
var merchantId = transaction.MerchantId;
|
||||
var newMapping = new CategoryMapping
|
||||
{
|
||||
Category = proposal.Category,
|
||||
Pattern = proposal.Pattern,
|
||||
MerchantId = merchantId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +183,8 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
return new ApplyProposalResult
|
||||
{
|
||||
Success = true,
|
||||
RuleCreated = createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)
|
||||
RuleCreated = ruleCreated,
|
||||
RuleUpdated = ruleUpdated
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,10 +197,26 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
return BuildPromptWithCategories(transaction, existingCategories);
|
||||
// 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)
|
||||
@@ -243,6 +253,22 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
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();
|
||||
@@ -250,27 +276,49 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" \"category\": \"Category name\",");
|
||||
sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\",");
|
||||
sb.AppendLine(" \"pattern\": \"Pattern to match future transactions (e.g., 'WALMART' or 'SUBWAY')\",");
|
||||
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?> CallOpenAIAsync(string apiKey, string prompt)
|
||||
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 = "gpt-4o-mini",
|
||||
model = model,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
|
||||
@@ -298,7 +346,7 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
|
||||
return null;
|
||||
|
||||
var content = apiResponse.Choices[0].Message?.Content;
|
||||
var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
@@ -414,5 +462,6 @@ 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,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Mcp;
|
||||
|
||||
public class MoneyMapApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public MoneyMapApiClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<string> HealthCheckAsync()
|
||||
{
|
||||
return await GetAsync("/api/health");
|
||||
}
|
||||
|
||||
// --- Transactions ---
|
||||
|
||||
public async Task<string> SearchTransactionsAsync(
|
||||
string? query, string? startDate, string? endDate, string? category,
|
||||
string? merchantName, decimal? minAmount, decimal? maxAmount,
|
||||
int? accountId, int? cardId, string? type, bool? uncategorizedOnly, int? limit)
|
||||
{
|
||||
var qs = BuildQueryString(
|
||||
("query", query), ("startDate", startDate), ("endDate", endDate),
|
||||
("category", category), ("merchantName", merchantName),
|
||||
("minAmount", minAmount?.ToString()), ("maxAmount", maxAmount?.ToString()),
|
||||
("accountId", accountId?.ToString()), ("cardId", cardId?.ToString()),
|
||||
("type", type), ("uncategorizedOnly", uncategorizedOnly?.ToString()),
|
||||
("limit", limit?.ToString()));
|
||||
|
||||
return await GetAsync($"/api/transactions{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> GetTransactionAsync(long transactionId)
|
||||
{
|
||||
return await GetAsync($"/api/transactions/{transactionId}");
|
||||
}
|
||||
|
||||
public async Task<string> UpdateTransactionCategoryAsync(long[] transactionIds, string category, string? merchantName)
|
||||
{
|
||||
var body = new { TransactionIds = transactionIds, Category = category, MerchantName = merchantName };
|
||||
return await PutAsync($"/api/transactions/{transactionIds[0]}/category", body);
|
||||
}
|
||||
|
||||
public async Task<string> BulkRecategorizeAsync(string namePattern, string toCategory, string? fromCategory, string? merchantName, bool dryRun)
|
||||
{
|
||||
var body = new { NamePattern = namePattern, ToCategory = toCategory, FromCategory = fromCategory, MerchantName = merchantName, DryRun = dryRun };
|
||||
return await PostAsync("/api/transactions/bulk-recategorize", body);
|
||||
}
|
||||
|
||||
public async Task<string> GetSpendingSummaryAsync(string startDate, string endDate, int? accountId)
|
||||
{
|
||||
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
|
||||
return await GetAsync($"/api/transactions/spending-summary{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> GetIncomeSummaryAsync(string startDate, string endDate, int? accountId)
|
||||
{
|
||||
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
|
||||
return await GetAsync($"/api/transactions/income-summary{qs}");
|
||||
}
|
||||
|
||||
// --- Budgets ---
|
||||
|
||||
public async Task<string> GetBudgetStatusAsync(string? asOfDate)
|
||||
{
|
||||
var qs = BuildQueryString(("asOfDate", asOfDate));
|
||||
return await GetAsync($"/api/budgets/status{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> CreateBudgetAsync(string? category, decimal amount, string period, string startDate)
|
||||
{
|
||||
var body = new { Category = category, Amount = amount, Period = period, StartDate = startDate };
|
||||
return await PostAsync("/api/budgets", body);
|
||||
}
|
||||
|
||||
public async Task<string> UpdateBudgetAsync(int budgetId, decimal? amount, string? period, bool? isActive)
|
||||
{
|
||||
var body = new { Amount = amount, Period = period, IsActive = isActive };
|
||||
return await PutAsync($"/api/budgets/{budgetId}", body);
|
||||
}
|
||||
|
||||
// --- Categories ---
|
||||
|
||||
public async Task<string> ListCategoriesAsync()
|
||||
{
|
||||
return await GetAsync("/api/categories");
|
||||
}
|
||||
|
||||
public async Task<string> GetCategoryMappingsAsync(string? category)
|
||||
{
|
||||
var qs = BuildQueryString(("category", category));
|
||||
return await GetAsync($"/api/categories/mappings{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> AddCategoryMappingAsync(string pattern, string category, string? merchantName, int priority)
|
||||
{
|
||||
var body = new { Pattern = pattern, Category = category, MerchantName = merchantName, Priority = priority };
|
||||
return await PostAsync("/api/categories/mappings", body);
|
||||
}
|
||||
|
||||
// --- Receipts ---
|
||||
|
||||
public async Task<string> ListReceiptsAsync(long? transactionId, string? parseStatus, int? limit)
|
||||
{
|
||||
var qs = BuildQueryString(("transactionId", transactionId?.ToString()), ("parseStatus", parseStatus), ("limit", limit?.ToString()));
|
||||
return await GetAsync($"/api/receipts{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> GetReceiptDetailsAsync(long receiptId)
|
||||
{
|
||||
return await GetAsync($"/api/receipts/{receiptId}");
|
||||
}
|
||||
|
||||
public async Task<string> GetReceiptImageAsync(long receiptId)
|
||||
{
|
||||
return await GetAsync($"/api/receipts/{receiptId}/image");
|
||||
}
|
||||
|
||||
public async Task<string> GetReceiptTextAsync(long receiptId)
|
||||
{
|
||||
return await GetAsync($"/api/receipts/{receiptId}/text");
|
||||
}
|
||||
|
||||
// --- Merchants ---
|
||||
|
||||
public async Task<string> ListMerchantsAsync(string? query)
|
||||
{
|
||||
var qs = BuildQueryString(("query", query));
|
||||
return await GetAsync($"/api/merchants{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> MergeMerchantsAsync(int sourceMerchantId, int targetMerchantId)
|
||||
{
|
||||
var body = new { SourceMerchantId = sourceMerchantId, TargetMerchantId = targetMerchantId };
|
||||
return await PostAsync("/api/merchants/merge", body);
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
public async Task<string> ListAccountsAsync()
|
||||
{
|
||||
return await GetAsync("/api/accounts");
|
||||
}
|
||||
|
||||
public async Task<string> ListCardsAsync(int? accountId)
|
||||
{
|
||||
var qs = BuildQueryString(("accountId", accountId?.ToString()));
|
||||
return await GetAsync($"/api/accounts/cards{qs}");
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
public async Task<string> GetDashboardAsync(int? topCategoriesCount, int? recentTransactionsCount)
|
||||
{
|
||||
var qs = BuildQueryString(("topCategoriesCount", topCategoriesCount?.ToString()), ("recentTransactionsCount", recentTransactionsCount?.ToString()));
|
||||
return await GetAsync($"/api/dashboard{qs}");
|
||||
}
|
||||
|
||||
public async Task<string> GetMonthlyTrendAsync(int? months, string? category)
|
||||
{
|
||||
var qs = BuildQueryString(("months", months?.ToString()), ("category", category));
|
||||
return await GetAsync($"/api/dashboard/monthly-trend{qs}");
|
||||
}
|
||||
|
||||
// --- HTTP Helpers ---
|
||||
|
||||
private async Task<string> GetAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync(path);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return body.Length > 0 ? body : "Not found";
|
||||
}
|
||||
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> PostAsync(string path, object body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync(path, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
return responseBody.Length > 0 ? responseBody : "Not found";
|
||||
}
|
||||
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> PutAsync(string path, object body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
var response = await _http.PutAsync(path, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
return responseBody.Length > 0 ? responseBody : "Not found";
|
||||
}
|
||||
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildQueryString(params (string key, string? value)[] parameters)
|
||||
{
|
||||
var pairs = parameters
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p.value))
|
||||
.Select(p => $"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}");
|
||||
|
||||
var qs = string.Join("&", pairs);
|
||||
return qs.Length > 0 ? $"?{qs}" : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoneyMap.Mcp;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly(typeof(Program).Assembly);
|
||||
|
||||
var app = builder.Build();
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class AccountTools
|
||||
{
|
||||
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
|
||||
public static async Task<string> ListAccounts(
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.ListAccountsAsync();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
|
||||
public static async Task<string> ListCards(
|
||||
[Description("Filter cards by account ID")] int? accountId = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.ListCardsAsync(accountId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class BudgetTools
|
||||
{
|
||||
[McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")]
|
||||
public static async Task<string> GetBudgetStatus(
|
||||
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetBudgetStatusAsync(asOfDate);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
|
||||
public static async Task<string> CreateBudget(
|
||||
[Description("Budget amount limit")] decimal amount,
|
||||
[Description("Period: Weekly, Monthly, or Yearly")] string period,
|
||||
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
|
||||
[Description("Category name (omit for total spending budget)")] string? category = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.CreateBudgetAsync(category, amount, period, startDate);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
|
||||
public static async Task<string> UpdateBudget(
|
||||
[Description("Budget ID to update")] int budgetId,
|
||||
[Description("New budget amount")] decimal? amount = null,
|
||||
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
|
||||
[Description("Set active/inactive")] bool? isActive = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class CategoryTools
|
||||
{
|
||||
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
|
||||
public static async Task<string> ListCategories(
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.ListCategoriesAsync();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
|
||||
public static async Task<string> GetCategoryMappings(
|
||||
[Description("Filter mappings to a specific category")] string? category = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetCategoryMappingsAsync(category);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
|
||||
public static async Task<string> AddCategoryMapping(
|
||||
[Description("Pattern to match in transaction name (case-insensitive)")] string pattern,
|
||||
[Description("Category to assign when pattern matches")] string category,
|
||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
||||
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class DashboardTools
|
||||
{
|
||||
[McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")]
|
||||
public static async Task<string> GetDashboard(
|
||||
[Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
|
||||
[Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetDashboardAsync(topCategoriesCount, recentTransactionsCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
|
||||
public static async Task<string> GetMonthlyTrend(
|
||||
[Description("Number of months to include (default 6)")] int? months = null,
|
||||
[Description("Filter to a specific category")] string? category = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetMonthlyTrendAsync(months, category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class MerchantTools
|
||||
{
|
||||
[McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")]
|
||||
public static async Task<string> ListMerchants(
|
||||
[Description("Filter merchants by name (contains)")] string? query = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.ListMerchantsAsync(query);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")]
|
||||
public static async Task<string> MergeMerchants(
|
||||
[Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
|
||||
[Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.MergeMerchantsAsync(sourceMerchantId, targetMerchantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class ReceiptTools
|
||||
{
|
||||
[McpServerTool(Name = "get_receipt_image"), Description("Get a receipt image for visual inspection. Returns the image as base64-encoded data. Useful for verifying transaction categories.")]
|
||||
public static async Task<string> GetReceiptImage(
|
||||
[Description("Receipt ID")] long receiptId,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetReceiptImageAsync(receiptId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")]
|
||||
public static async Task<string> GetReceiptText(
|
||||
[Description("Receipt ID")] long receiptId,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetReceiptTextAsync(receiptId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
|
||||
public static async Task<string> ListReceipts(
|
||||
[Description("Filter by transaction ID")] long? transactionId = null,
|
||||
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
|
||||
[Description("Max results (default 50)")] int? limit = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.ListReceiptsAsync(transactionId, parseStatus, limit);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
|
||||
public static async Task<string> GetReceiptDetails(
|
||||
[Description("Receipt ID")] long receiptId,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetReceiptDetailsAsync(receiptId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MoneyMap.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class TransactionTools
|
||||
{
|
||||
[McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")]
|
||||
public static async Task<string> SearchTransactions(
|
||||
[Description("Full-text search across name, memo, and category")] string? query = null,
|
||||
[Description("Start date (inclusive), e.g. 2026-01-01")] string? startDate = null,
|
||||
[Description("End date (inclusive), e.g. 2026-01-31")] string? endDate = null,
|
||||
[Description("Filter by category name (exact match)")] string? category = null,
|
||||
[Description("Filter by merchant name (contains)")] string? merchantName = null,
|
||||
[Description("Minimum amount (absolute value)")] decimal? minAmount = null,
|
||||
[Description("Maximum amount (absolute value)")] decimal? maxAmount = null,
|
||||
[Description("Filter by account ID")] int? accountId = null,
|
||||
[Description("Filter by card ID")] int? cardId = null,
|
||||
[Description("Filter by type: 'debit' or 'credit'")] string? type = null,
|
||||
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
|
||||
[Description("Max results to return (default 50)")] int? limit = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
|
||||
public static async Task<string> GetTransaction(
|
||||
[Description("Transaction ID")] long transactionId,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetTransactionAsync(transactionId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
|
||||
public static async Task<string> GetSpendingSummary(
|
||||
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
|
||||
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
||||
[Description("Filter to specific account ID")] int? accountId = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
|
||||
public static async Task<string> GetIncomeSummary(
|
||||
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
|
||||
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
|
||||
[Description("Filter to specific account ID")] int? accountId = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
|
||||
public static async Task<string> UpdateTransactionCategory(
|
||||
[Description("Array of transaction IDs to update")] long[] transactionIds,
|
||||
[Description("New category to assign")] string category,
|
||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
|
||||
public static async Task<string> BulkRecategorize(
|
||||
[Description("Pattern to match in transaction name (case-insensitive contains)")] string namePattern,
|
||||
[Description("New category to assign")] string toCategory,
|
||||
[Description("Only recategorize transactions currently in this category")] string? fromCategory = null,
|
||||
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
|
||||
[Description("If true (default), only shows what would change without applying")] bool dryRun = true,
|
||||
MoneyMapApiClient api = default!)
|
||||
{
|
||||
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"MoneyMapApi": {
|
||||
"BaseUrl": "http://barge.lan:5010/"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
|
||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,20 +7,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyM
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Core", "MoneyMap.Core\MoneyMap.Core.csproj", "{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Mcp", "MoneyMap.Mcp\MoneyMap.Mcp.csproj", "{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A927BF5C-8F88-43D0-9801-4587FEDFBAAF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6EBFB935-A23F-4A7B-B2DF-2C61458E88A8}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AccountsController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public AccountsController(MoneyMapContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var accounts = await _db.Accounts
|
||||
.Include(a => a.Cards)
|
||||
.Include(a => a.Transactions)
|
||||
.OrderBy(a => a.Institution).ThenBy(a => a.Last4)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id,
|
||||
a.Institution,
|
||||
a.Last4,
|
||||
a.Owner,
|
||||
Label = a.DisplayLabel,
|
||||
TransactionCount = a.Transactions.Count,
|
||||
CardCount = a.Cards.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(accounts);
|
||||
}
|
||||
|
||||
[HttpGet("cards")]
|
||||
public async Task<IActionResult> ListCards([FromQuery] int? accountId = null)
|
||||
{
|
||||
var q = _db.Cards
|
||||
.Include(c => c.Account)
|
||||
.Include(c => c.Transactions)
|
||||
.AsQueryable();
|
||||
|
||||
if (accountId.HasValue)
|
||||
q = q.Where(c => c.AccountId == accountId.Value);
|
||||
|
||||
var cards = await q
|
||||
.OrderBy(c => c.Owner).ThenBy(c => c.Last4)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Issuer,
|
||||
c.Last4,
|
||||
c.Owner,
|
||||
Label = c.DisplayLabel,
|
||||
Account = c.Account != null ? c.Account.Institution + " " + c.Account.Last4 : null,
|
||||
AccountId = c.AccountId,
|
||||
TransactionCount = c.Transactions.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(cards);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuditController : ControllerBase
|
||||
{
|
||||
private readonly IFinancialAuditService _auditService;
|
||||
|
||||
public AuditController(IFinancialAuditService auditService) => _auditService = auditService;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(
|
||||
[FromQuery] DateTime? startDate,
|
||||
[FromQuery] DateTime? endDate,
|
||||
[FromQuery] bool includeTransactions = false)
|
||||
{
|
||||
var end = endDate ?? DateTime.Today;
|
||||
var start = startDate ?? end.AddDays(-90);
|
||||
|
||||
var result = await _auditService.GenerateAuditAsync(start, end, includeTransactions);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BudgetsController : ControllerBase
|
||||
{
|
||||
private readonly IBudgetService _budgetService;
|
||||
|
||||
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus([FromQuery] string? asOfDate = null)
|
||||
{
|
||||
DateTime? date = null;
|
||||
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
|
||||
date = parsed;
|
||||
|
||||
var statuses = await _budgetService.GetAllBudgetStatusesAsync(date);
|
||||
|
||||
var result = statuses.Select(s => new
|
||||
{
|
||||
s.Budget.Id,
|
||||
Category = s.Budget.DisplayName,
|
||||
s.Budget.Amount,
|
||||
Period = s.Budget.Period.ToString(),
|
||||
s.PeriodStart,
|
||||
s.PeriodEnd,
|
||||
s.Spent,
|
||||
s.Remaining,
|
||||
PercentUsed = Math.Round(s.PercentUsed, 1),
|
||||
s.IsOverBudget
|
||||
}).ToList();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest request)
|
||||
{
|
||||
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
|
||||
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
|
||||
|
||||
if (!DateTime.TryParse(request.StartDate, out var startDate))
|
||||
return BadRequest(new { message = "Invalid start date format" });
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Category = request.Category,
|
||||
Amount = request.Amount,
|
||||
Period = budgetPeriod,
|
||||
StartDate = startDate,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var result = await _budgetService.CreateBudgetAsync(budget);
|
||||
|
||||
return Ok(new { result.Success, result.Message, BudgetId = budget.Id });
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetRequest request)
|
||||
{
|
||||
var budget = await _budgetService.GetBudgetByIdAsync(id);
|
||||
if (budget == null)
|
||||
return NotFound(new { message = "Budget not found" });
|
||||
|
||||
if (request.Amount.HasValue)
|
||||
budget.Amount = request.Amount.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Period))
|
||||
{
|
||||
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
|
||||
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
|
||||
budget.Period = budgetPeriod;
|
||||
}
|
||||
|
||||
if (request.IsActive.HasValue)
|
||||
budget.IsActive = request.IsActive.Value;
|
||||
|
||||
var result = await _budgetService.UpdateBudgetAsync(budget);
|
||||
|
||||
return Ok(new { result.Success, result.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateBudgetRequest
|
||||
{
|
||||
public string? Category { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string Period { get; set; } = "";
|
||||
public string StartDate { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpdateBudgetRequest
|
||||
{
|
||||
public decimal? Amount { get; set; }
|
||||
public string? Period { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CategoriesController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionCategorizer _categorizer;
|
||||
private readonly IMerchantService _merchantService;
|
||||
|
||||
public CategoriesController(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
|
||||
{
|
||||
_db = db;
|
||||
_categorizer = categorizer;
|
||||
_merchantService = merchantService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var categories = await _db.Transactions
|
||||
.Where(t => t.Category != null && t.Category != "")
|
||||
.GroupBy(t => t.Category!)
|
||||
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ToListAsync();
|
||||
|
||||
var uncategorized = await _db.Transactions
|
||||
.CountAsync(t => t.Category == null || t.Category == "");
|
||||
|
||||
return Ok(new { Categories = categories, UncategorizedCount = uncategorized });
|
||||
}
|
||||
|
||||
[HttpGet("mappings")]
|
||||
public async Task<IActionResult> GetMappings([FromQuery] string? category = null)
|
||||
{
|
||||
var mappings = await _categorizer.GetAllMappingsAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
var result = mappings.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
m.Pattern,
|
||||
m.Category,
|
||||
m.MerchantId,
|
||||
m.Priority
|
||||
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("mappings")]
|
||||
public async Task<IActionResult> AddMapping([FromBody] CreateCategoryMappingRequest request)
|
||||
{
|
||||
int? merchantId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||
|
||||
var mapping = new CategoryMapping
|
||||
{
|
||||
Pattern = request.Pattern,
|
||||
Category = request.Category,
|
||||
MerchantId = merchantId,
|
||||
Priority = request.Priority
|
||||
};
|
||||
|
||||
_db.CategoryMappings.Add(mapping);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = request.MerchantName, mapping.Priority });
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCategoryMappingRequest
|
||||
{
|
||||
public string Pattern { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string? MerchantName { get; set; }
|
||||
public int Priority { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboardService;
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public DashboardController(IDashboardService dashboardService, MoneyMapContext db)
|
||||
{
|
||||
_dashboardService = dashboardService;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(
|
||||
[FromQuery] int? topCategoriesCount = null,
|
||||
[FromQuery] int? recentTransactionsCount = null)
|
||||
{
|
||||
var data = await _dashboardService.GetDashboardDataAsync(
|
||||
topCategoriesCount ?? 8,
|
||||
recentTransactionsCount ?? 20);
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("monthly-trend")]
|
||||
public async Task<IActionResult> MonthlyTrend(
|
||||
[FromQuery] int? months = null,
|
||||
[FromQuery] string? category = null)
|
||||
{
|
||||
var monthCount = months ?? 6;
|
||||
var endDate = DateTime.Today;
|
||||
var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
|
||||
|
||||
var q = _db.Transactions
|
||||
.Where(t => t.Date >= startDate && t.Date <= endDate)
|
||||
.Where(t => t.Amount < 0)
|
||||
.Where(t => t.TransferToAccountId == null)
|
||||
.ExcludeTransfers();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
q = q.Where(t => t.Category == category);
|
||||
|
||||
var monthly = await q
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
.Select(g => new
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Total = g.Sum(t => Math.Abs(t.Amount)),
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderBy(x => x.Year).ThenBy(x => x.Month)
|
||||
.ToListAsync();
|
||||
|
||||
var result = monthly.Select(m => new
|
||||
{
|
||||
Period = $"{m.Year}-{m.Month:D2}",
|
||||
m.Total,
|
||||
m.Count
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { Category = category ?? "All Spending", Months = result });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MoneyMap.Data;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public HealthController(MoneyMapContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var canConnect = await _db.Database.CanConnectAsync();
|
||||
if (!canConnect)
|
||||
return StatusCode(503, new { status = "unhealthy", reason = "database unreachable" });
|
||||
|
||||
return Ok(new { status = "healthy" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class MerchantsController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public MerchantsController(MoneyMapContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? query = null)
|
||||
{
|
||||
var q = _db.Merchants
|
||||
.Include(m => m.Transactions)
|
||||
.Include(m => m.CategoryMappings)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
q = q.Where(m => m.Name.Contains(query));
|
||||
|
||||
var merchants = await q
|
||||
.OrderBy(m => m.Name)
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
m.Name,
|
||||
TransactionCount = m.Transactions.Count,
|
||||
MappingCount = m.CategoryMappings.Count,
|
||||
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Count = merchants.Count, Merchants = merchants });
|
||||
}
|
||||
|
||||
[HttpPost("merge")]
|
||||
public async Task<IActionResult> Merge([FromBody] MergeMerchantsRequest request)
|
||||
{
|
||||
if (request.SourceMerchantId == request.TargetMerchantId)
|
||||
return BadRequest(new { message = "Source and target merchant cannot be the same" });
|
||||
|
||||
var source = await _db.Merchants.FindAsync(request.SourceMerchantId);
|
||||
var target = await _db.Merchants.FindAsync(request.TargetMerchantId);
|
||||
|
||||
if (source == null)
|
||||
return NotFound(new { message = $"Source merchant {request.SourceMerchantId} not found" });
|
||||
if (target == null)
|
||||
return NotFound(new { message = $"Target merchant {request.TargetMerchantId} not found" });
|
||||
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => t.MerchantId == request.SourceMerchantId)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var t in transactions)
|
||||
t.MerchantId = request.TargetMerchantId;
|
||||
|
||||
var sourceMappings = await _db.CategoryMappings
|
||||
.Where(cm => cm.MerchantId == request.SourceMerchantId)
|
||||
.ToListAsync();
|
||||
|
||||
var targetMappingPatterns = await _db.CategoryMappings
|
||||
.Where(cm => cm.MerchantId == request.TargetMerchantId)
|
||||
.Select(cm => cm.Pattern)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var mapping in sourceMappings)
|
||||
{
|
||||
if (targetMappingPatterns.Contains(mapping.Pattern))
|
||||
_db.CategoryMappings.Remove(mapping);
|
||||
else
|
||||
mapping.MerchantId = request.TargetMerchantId;
|
||||
}
|
||||
|
||||
_db.Merchants.Remove(source);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Merged = true,
|
||||
Source = new { source.Id, source.Name },
|
||||
Target = new { target.Id, target.Name },
|
||||
TransactionsReassigned = transactions.Count,
|
||||
MappingsReassigned = sourceMappings.Count
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class MergeMerchantsRequest
|
||||
{
|
||||
public int SourceMerchantId { get; set; }
|
||||
public int TargetMerchantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using ImageMagick;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ReceiptsController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptStorageOptions _storageOptions;
|
||||
|
||||
public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions)
|
||||
{
|
||||
_db = db;
|
||||
_storageOptions = storageOptions;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] long? transactionId = null,
|
||||
[FromQuery] string? parseStatus = null,
|
||||
[FromQuery] int? limit = null)
|
||||
{
|
||||
var q = _db.Receipts
|
||||
.Include(r => r.Transaction)
|
||||
.AsQueryable();
|
||||
|
||||
if (transactionId.HasValue)
|
||||
q = q.Where(r => r.TransactionId == transactionId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse<ReceiptParseStatus>(parseStatus, true, out var status))
|
||||
q = q.Where(r => r.ParseStatus == status);
|
||||
|
||||
var results = await q
|
||||
.OrderByDescending(r => r.UploadedAtUtc)
|
||||
.Take(limit ?? 50)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.FileName,
|
||||
ParseStatus = r.ParseStatus.ToString(),
|
||||
r.Merchant,
|
||||
r.Total,
|
||||
r.ReceiptDate,
|
||||
r.UploadedAtUtc,
|
||||
TransactionId = r.TransactionId,
|
||||
TransactionName = r.Transaction != null ? r.Transaction.Name : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Count = results.Count, Receipts = results });
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetDetails(long id)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.Include(r => r.LineItems)
|
||||
.Include(r => r.Transaction)
|
||||
.Include(r => r.ParseLogs)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (receipt == null)
|
||||
return NotFound(new { message = "Receipt not found" });
|
||||
|
||||
var result = new
|
||||
{
|
||||
receipt.Id,
|
||||
receipt.FileName,
|
||||
receipt.ContentType,
|
||||
receipt.FileSizeBytes,
|
||||
receipt.UploadedAtUtc,
|
||||
ParseStatus = receipt.ParseStatus.ToString(),
|
||||
ParsedData = new
|
||||
{
|
||||
receipt.Merchant,
|
||||
receipt.ReceiptDate,
|
||||
receipt.DueDate,
|
||||
receipt.Subtotal,
|
||||
receipt.Tax,
|
||||
receipt.Total,
|
||||
receipt.Currency
|
||||
},
|
||||
LinkedTransaction = receipt.Transaction != null ? new
|
||||
{
|
||||
receipt.Transaction.Id,
|
||||
receipt.Transaction.Name,
|
||||
receipt.Transaction.Date,
|
||||
receipt.Transaction.Amount,
|
||||
receipt.Transaction.Category
|
||||
} : null,
|
||||
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
|
||||
{
|
||||
li.LineNumber,
|
||||
li.Description,
|
||||
li.Quantity,
|
||||
li.UnitPrice,
|
||||
li.LineTotal,
|
||||
li.Category
|
||||
}).ToList(),
|
||||
ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
|
||||
{
|
||||
pl.Provider,
|
||||
pl.Model,
|
||||
pl.Success,
|
||||
pl.Confidence,
|
||||
pl.Error,
|
||||
pl.StartedAtUtc
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/image")]
|
||||
public async Task<IActionResult> GetImage(long id)
|
||||
{
|
||||
var receipt = await _db.Receipts.FindAsync(id);
|
||||
if (receipt == null)
|
||||
return NotFound(new { message = "Receipt not found" });
|
||||
|
||||
var basePath = Path.GetFullPath(_storageOptions.ReceiptsBasePath);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
|
||||
|
||||
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new { message = "Invalid receipt path" });
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
return NotFound(new { message = "Receipt file not found on disk" });
|
||||
|
||||
byte[] imageBytes;
|
||||
string mimeType;
|
||||
|
||||
if (receipt.ContentType == "application/pdf")
|
||||
{
|
||||
var settings = new MagickReadSettings { Density = new Density(220) };
|
||||
using var image = new MagickImage(fullPath + "[0]", settings);
|
||||
image.Format = MagickFormat.Png;
|
||||
image.BackgroundColor = MagickColors.White;
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
imageBytes = image.ToByteArray();
|
||||
mimeType = "image/png";
|
||||
}
|
||||
else
|
||||
{
|
||||
imageBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
|
||||
mimeType = receipt.ContentType;
|
||||
}
|
||||
|
||||
var base64 = Convert.ToBase64String(imageBytes);
|
||||
return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length });
|
||||
}
|
||||
|
||||
[HttpGet("{id}/text")]
|
||||
public async Task<IActionResult> GetText(long id)
|
||||
{
|
||||
var receipt = await _db.Receipts
|
||||
.Include(r => r.LineItems)
|
||||
.Include(r => r.Transaction)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (receipt == null)
|
||||
return NotFound(new { message = "Receipt not found" });
|
||||
|
||||
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
|
||||
return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() });
|
||||
|
||||
var result = new
|
||||
{
|
||||
receipt.Id,
|
||||
receipt.Merchant,
|
||||
receipt.ReceiptDate,
|
||||
receipt.DueDate,
|
||||
receipt.Subtotal,
|
||||
receipt.Tax,
|
||||
receipt.Total,
|
||||
receipt.Currency,
|
||||
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
|
||||
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
|
||||
{
|
||||
li.LineNumber,
|
||||
li.Description,
|
||||
li.Quantity,
|
||||
li.UnitPrice,
|
||||
li.LineTotal,
|
||||
li.Category
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TransactionsController : ControllerBase
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IMerchantService _merchantService;
|
||||
|
||||
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
|
||||
{
|
||||
_db = db;
|
||||
_merchantService = merchantService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] string? startDate = null,
|
||||
[FromQuery] string? endDate = null,
|
||||
[FromQuery] string? category = null,
|
||||
[FromQuery] string? merchantName = null,
|
||||
[FromQuery] decimal? minAmount = null,
|
||||
[FromQuery] decimal? maxAmount = null,
|
||||
[FromQuery] int? accountId = null,
|
||||
[FromQuery] int? cardId = null,
|
||||
[FromQuery] string? type = null,
|
||||
[FromQuery] bool? uncategorizedOnly = null,
|
||||
[FromQuery] int? limit = null)
|
||||
{
|
||||
var q = _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Receipts)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
|
||||
q = q.Where(t => t.Date >= start);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
|
||||
q = q.Where(t => t.Date <= end);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
q = q.Where(t => t.Category == category);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(merchantName))
|
||||
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
|
||||
|
||||
if (minAmount.HasValue)
|
||||
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
|
||||
|
||||
if (accountId.HasValue)
|
||||
q = q.Where(t => t.AccountId == accountId.Value);
|
||||
|
||||
if (cardId.HasValue)
|
||||
q = q.Where(t => t.CardId == cardId.Value);
|
||||
|
||||
if (type?.ToLower() == "debit")
|
||||
q = q.Where(t => t.Amount < 0);
|
||||
else if (type?.ToLower() == "credit")
|
||||
q = q.Where(t => t.Amount > 0);
|
||||
|
||||
if (uncategorizedOnly == true)
|
||||
q = q.Where(t => t.Category == null || t.Category == "");
|
||||
|
||||
var results = await q
|
||||
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
|
||||
.Take(limit ?? 50)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Date,
|
||||
t.Name,
|
||||
t.Memo,
|
||||
t.Amount,
|
||||
t.Category,
|
||||
Merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||
Account = t.Account!.Institution + " " + t.Account.Last4,
|
||||
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
|
||||
ReceiptCount = t.Receipts.Count,
|
||||
t.TransferToAccountId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Count = results.Count, Transactions = results });
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(long id)
|
||||
{
|
||||
var t = await _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.Card)
|
||||
.Include(t => t.Account)
|
||||
.Include(t => t.Receipts)
|
||||
.Where(t => t.Id == id)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Date,
|
||||
t.Name,
|
||||
t.Memo,
|
||||
t.Amount,
|
||||
t.TransactionType,
|
||||
t.Category,
|
||||
Merchant = t.Merchant != null ? t.Merchant.Name : null,
|
||||
MerchantId = t.MerchantId,
|
||||
Account = t.Account!.Institution + " " + t.Account.Last4,
|
||||
AccountId = t.AccountId,
|
||||
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
|
||||
CardId = t.CardId,
|
||||
t.Notes,
|
||||
t.TransferToAccountId,
|
||||
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (t == null)
|
||||
return NotFound(new { message = "Transaction not found" });
|
||||
|
||||
return Ok(t);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/category")]
|
||||
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
|
||||
{
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => request.TransactionIds.Contains(t.Id))
|
||||
.ToListAsync();
|
||||
|
||||
if (!transactions.Any())
|
||||
return NotFound(new { message = "No transactions found with the provided IDs" });
|
||||
|
||||
int? merchantId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||
|
||||
foreach (var t in transactions)
|
||||
{
|
||||
t.Category = request.Category;
|
||||
if (merchantId.HasValue)
|
||||
t.MerchantId = merchantId;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
|
||||
}
|
||||
|
||||
[HttpPost("bulk-recategorize")]
|
||||
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
|
||||
{
|
||||
var q = _db.Transactions
|
||||
.Where(t => t.Name.Contains(request.NamePattern));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.FromCategory))
|
||||
q = q.Where(t => t.Category == request.FromCategory);
|
||||
|
||||
var transactions = await q.ToListAsync();
|
||||
|
||||
if (!transactions.Any())
|
||||
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
|
||||
|
||||
if (request.DryRun)
|
||||
{
|
||||
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
|
||||
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
|
||||
}
|
||||
|
||||
int? merchantId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.MerchantName))
|
||||
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
|
||||
|
||||
foreach (var t in transactions)
|
||||
{
|
||||
t.Category = request.ToCategory;
|
||||
if (merchantId.HasValue)
|
||||
t.MerchantId = merchantId;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
|
||||
}
|
||||
|
||||
[HttpGet("spending-summary")]
|
||||
public async Task<IActionResult> SpendingSummary(
|
||||
[FromQuery] string startDate,
|
||||
[FromQuery] string endDate,
|
||||
[FromQuery] int? accountId = null)
|
||||
{
|
||||
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
|
||||
return BadRequest(new { message = "Invalid date format" });
|
||||
|
||||
var q = _db.Transactions
|
||||
.Where(t => t.Date >= start && t.Date <= end)
|
||||
.Where(t => t.Amount < 0)
|
||||
.Where(t => t.TransferToAccountId == null)
|
||||
.ExcludeTransfers();
|
||||
|
||||
if (accountId.HasValue)
|
||||
q = q.Where(t => t.AccountId == accountId.Value);
|
||||
|
||||
var summary = await q
|
||||
.GroupBy(t => t.Category ?? "Uncategorized")
|
||||
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ToListAsync();
|
||||
|
||||
var grandTotal = summary.Sum(x => x.Total);
|
||||
|
||||
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
|
||||
}
|
||||
|
||||
[HttpGet("income-summary")]
|
||||
public async Task<IActionResult> IncomeSummary(
|
||||
[FromQuery] string startDate,
|
||||
[FromQuery] string endDate,
|
||||
[FromQuery] int? accountId = null)
|
||||
{
|
||||
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
|
||||
return BadRequest(new { message = "Invalid date format" });
|
||||
|
||||
var q = _db.Transactions
|
||||
.Where(t => t.Date >= start && t.Date <= end)
|
||||
.Where(t => t.Amount > 0)
|
||||
.Where(t => t.TransferToAccountId == null)
|
||||
.ExcludeTransfers();
|
||||
|
||||
if (accountId.HasValue)
|
||||
q = q.Where(t => t.AccountId == accountId.Value);
|
||||
|
||||
var summary = await q
|
||||
.GroupBy(t => t.Name)
|
||||
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
|
||||
.OrderByDescending(x => x.Total)
|
||||
.ToListAsync();
|
||||
|
||||
var grandTotal = summary.Sum(x => x.Total);
|
||||
|
||||
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateCategoryRequest
|
||||
{
|
||||
public long[] TransactionIds { get; set; } = [];
|
||||
public string Category { get; set; } = "";
|
||||
public string? MerchantName { get; set; }
|
||||
}
|
||||
|
||||
public class BulkRecategorizeRequest
|
||||
{
|
||||
public string NamePattern { get; set; } = "";
|
||||
public string ToCategory { get; set; } = "";
|
||||
public string? FromCategory { get; set; }
|
||||
public string? MerchantName { get; set; }
|
||||
public bool DryRun { get; set; } = true;
|
||||
}
|
||||
+11
-3
@@ -2,12 +2,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj and restore dependencies
|
||||
COPY MoneyMap.csproj .
|
||||
RUN dotnet restore
|
||||
# Copy solution and project files for restore
|
||||
COPY MoneyMap.sln .
|
||||
COPY MoneyMap/MoneyMap.csproj MoneyMap/
|
||||
COPY MoneyMap.Core/MoneyMap.Core.csproj MoneyMap.Core/
|
||||
RUN dotnet restore MoneyMap/MoneyMap.csproj
|
||||
|
||||
# Install libman CLI for client-side library restore
|
||||
RUN dotnet tool install -g Microsoft.Web.LibraryManager.Cli
|
||||
ENV PATH="${PATH}:/root/.dotnet/tools"
|
||||
|
||||
# Copy everything else and build
|
||||
COPY . .
|
||||
WORKDIR /src/MoneyMap
|
||||
RUN libman restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Runtime stage
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MoneyMap.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
[DbContext(typeof(MoneyMapContext))]
|
||||
[Migration("20260215030558_AddReceiptParseStatus")]
|
||||
partial class AddReceiptParseStatus
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Institution")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Institution", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Budget", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int>("Period")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Category", "Period")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsActive] = 1");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("Issuer", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.ToTable("CategoryMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Merchants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)")
|
||||
.HasDefaultValue("application/octet-stream");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("FileHashSha256")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Merchant")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("ParseStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("ParsingNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTime?>("ReceiptDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StoragePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<decimal?>("Subtotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Tax")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Total")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<long?>("TransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("UploadedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FileHashSha256");
|
||||
|
||||
b.HasIndex("ParseStatus");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique()
|
||||
.HasFilter("[TransactionId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("TransactionId", "ReceiptDate");
|
||||
|
||||
b.ToTable("Receipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int>("LineNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("LineTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Sku")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<decimal?>("UnitPrice")
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<bool>("Voided")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "LineNumber");
|
||||
|
||||
b.ToTable("ReceiptLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CompletedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("Confidence")
|
||||
.HasColumnType("decimal(5,4)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ExtractedTextPath")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("ProviderJobId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RawProviderPayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long>("ReceiptId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("StartedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReceiptId", "StartedAtUtc");
|
||||
|
||||
b.ToTable("ReceiptParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Memo")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<int?>("MerchantId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TransactionType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<int?>("TransferToAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Amount");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("MerchantId");
|
||||
|
||||
b.HasIndex("TransferToAccountId");
|
||||
|
||||
b.HasIndex("AccountId", "Category");
|
||||
|
||||
b.HasIndex("AccountId", "Date");
|
||||
|
||||
b.HasIndex("CardId", "Date");
|
||||
|
||||
b.HasIndex("MerchantId", "Date");
|
||||
|
||||
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
|
||||
.IsUnique()
|
||||
.HasFilter("[CardId] IS NOT NULL");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int?>("DestinationAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long?>("OriginalTransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("SourceAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("DestinationAccountId");
|
||||
|
||||
b.HasIndex("OriginalTransactionId");
|
||||
|
||||
b.HasIndex("SourceAccountId");
|
||||
|
||||
b.ToTable("Transfers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Cards")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("CategoryMappings")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Merchant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||
.WithMany("Receipts")
|
||||
.HasForeignKey("TransactionId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Transaction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("LineItems")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("ParseLogs")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("MerchantId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransferToAccountId");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Card");
|
||||
|
||||
b.Navigation("Merchant");
|
||||
|
||||
b.Navigation("TransferToAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
|
||||
.WithMany("DestinationTransfers")
|
||||
.HasForeignKey("DestinationAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginalTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
|
||||
.WithMany("SourceTransfers")
|
||||
.HasForeignKey("SourceAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DestinationAccount");
|
||||
|
||||
b.Navigation("OriginalTransaction");
|
||||
|
||||
b.Navigation("SourceAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
b.Navigation("DestinationTransfers");
|
||||
|
||||
b.Navigation("SourceTransfers");
|
||||
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
|
||||
{
|
||||
b.Navigation("CategoryMappings");
|
||||
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Navigation("LineItems");
|
||||
|
||||
b.Navigation("ParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Navigation("Receipts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReceiptParseStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ParseStatus",
|
||||
table: "Receipts",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Receipts_ParseStatus",
|
||||
table: "Receipts",
|
||||
column: "ParseStatus");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Receipts_ParseStatus",
|
||||
table: "Receipts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParseStatus",
|
||||
table: "Receipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,11 @@ namespace MoneyMap.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("ParseStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("ParsingNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
@@ -267,6 +272,8 @@ namespace MoneyMap.Migrations
|
||||
|
||||
b.HasIndex("FileHashSha256");
|
||||
|
||||
b.HasIndex("ParseStatus");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique()
|
||||
.HasFilter("[TransactionId] IS NOT NULL");
|
||||
|
||||
@@ -18,25 +18,24 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="PdfPig" Version="0.1.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Prompts\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.AICategorizePreviewModel
|
||||
@{
|
||||
ViewData["Title"] = "AI Categorization Preview";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Transactions", Url.Page("/Transactions")),
|
||||
("Recategorize", Url.Page("/Recategorize")),
|
||||
("AI Preview", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -65,7 +71,11 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var highConfidence = Model.Proposals.Where(p => p.Confidence >= 0.8m).ToList();
|
||||
var needsReview = Model.Proposals.Where(p => p.Confidence < 0.8m).ToList();
|
||||
|
||||
<form method="post" asp-page-handler="Apply">
|
||||
<input type="hidden" name="proposalsData" value="@Model.ProposalsJson" />
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
|
||||
@@ -75,89 +85,45 @@ else
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked>
|
||||
</th>
|
||||
<th>Transaction</th>
|
||||
<th>Current</th>
|
||||
<th>Proposed</th>
|
||||
<th>Merchant</th>
|
||||
<th style="width: 100px;">Confidence</th>
|
||||
<th style="width: 100px;">Create Rule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var proposal in Model.Proposals)
|
||||
{
|
||||
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
|
||||
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input proposal-checkbox"
|
||||
name="selectedIds" value="@proposal.TransactionId" checked>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@proposal.TransactionName</div>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
|
||||
{
|
||||
<small class="text-muted">@proposal.TransactionMemo</small>
|
||||
}
|
||||
<div class="small text-muted">
|
||||
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
|
||||
{
|
||||
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">(none)</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">@proposal.ProposedCategory</span>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
|
||||
{
|
||||
<div class="small text-muted mt-1" title="@proposal.Reasoning">
|
||||
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
|
||||
{
|
||||
<div>@proposal.ProposedMerchant</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
|
||||
{
|
||||
<code class="small">@proposal.ProposedPattern</code>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
name="createRules" value="@proposal.TransactionId"
|
||||
@(proposal.CreateRule ? "checked" : "")
|
||||
title="Create a mapping rule for this pattern">
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="nav nav-tabs px-3 pt-3" id="proposalTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="high-confidence-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#high-confidence" type="button" role="tab">
|
||||
High Confidence <span class="badge bg-success ms-1">@highConfidence.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="needs-review-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#needs-review" type="button" role="tab">
|
||||
Needs Review <span class="badge bg-warning text-dark ms-1">@needsReview.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="proposalTabContent">
|
||||
<div class="tab-pane fade show active" id="high-confidence" role="tabpanel">
|
||||
@if (highConfidence.Any())
|
||||
{
|
||||
@await Html.PartialAsync("_ProposalTable", highConfidence)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-4 text-center text-muted">No high confidence proposals.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="needs-review" role="tabpanel">
|
||||
@if (needsReview.Any())
|
||||
{
|
||||
@await Html.PartialAsync("_ProposalTable", needsReview)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-4 text-center text-muted">No proposals needing review.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<form method="post" asp-page-handler="Cancel" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
|
||||
</form>
|
||||
<a asp-page="/Recategorize" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Apply Selected Categorizations
|
||||
</button>
|
||||
@@ -180,11 +146,12 @@ else
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Create Rule</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
When checked, a category mapping rule will be created using the proposed pattern.
|
||||
Future transactions matching this pattern will be automatically categorized.
|
||||
</p>
|
||||
<h6>Rule Status</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><span class="small text-muted">Create</span> - No existing rule; check to create a new mapping rule</li>
|
||||
<li><span class="badge bg-warning text-dark">Update</span> - Pattern exists with a different category; check to update it</li>
|
||||
<li><span class="badge bg-info text-dark">Exists</span> - Rule already exists with the same category</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,9 +160,36 @@ else
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Select/deselect all proposals across both tabs
|
||||
function selectAll(checked) {
|
||||
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked);
|
||||
document.getElementById('selectAllCheckbox').checked = checked;
|
||||
document.querySelectorAll('.proposal-checkbox').forEach(cb => {
|
||||
cb.checked = checked;
|
||||
if (!checked) {
|
||||
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
|
||||
if (ruleCheckbox) ruleCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.select-all-tab').forEach(cb => cb.checked = checked);
|
||||
}
|
||||
|
||||
// Select/deselect all within a single tab
|
||||
function selectAllInTab(headerCheckbox) {
|
||||
var table = headerCheckbox.closest('table');
|
||||
table.querySelectorAll('.proposal-checkbox').forEach(cb => {
|
||||
cb.checked = headerCheckbox.checked;
|
||||
if (!headerCheckbox.checked) {
|
||||
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
|
||||
if (ruleCheckbox) ruleCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When a proposal checkbox is unchecked, also uncheck its create-rule checkbox
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.classList.contains('proposal-checkbox') && !e.target.checked) {
|
||||
var ruleCheckbox = e.target.closest('tr').querySelector('.create-rule-checkbox');
|
||||
if (ruleCheckbox) ruleCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -26,7 +26,17 @@ namespace MoneyMap.Pages
|
||||
|
||||
public List<ProposalViewModel> Proposals { get; set; } = new();
|
||||
public string ModelUsed { get; set; } = "";
|
||||
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
public string AIProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
var model = SelectedModel;
|
||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase)) return "LlamaCpp";
|
||||
if (model.StartsWith("ollama:", StringComparison.OrdinalIgnoreCase)) return "Ollama";
|
||||
if (model.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) return "Anthropic";
|
||||
return "OpenAI";
|
||||
}
|
||||
}
|
||||
public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
|
||||
[TempData]
|
||||
@@ -188,26 +198,37 @@ namespace MoneyMap.Pages
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules)
|
||||
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules, string? proposalsData)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ProposalsJson))
|
||||
// Read proposals from the hidden form field (not TempData, which can be lost on app restart)
|
||||
var json = proposalsData ?? ProposalsJson;
|
||||
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
ErrorMessage = "No proposals to apply. Please generate new suggestions.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(json);
|
||||
if (storedProposals == null || storedProposals.Count == 0)
|
||||
{
|
||||
ErrorMessage = "No proposals to apply.";
|
||||
ErrorMessage = "No proposals to apply (deserialization returned empty).";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>();
|
||||
var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>();
|
||||
|
||||
if (selectedSet.Count == 0)
|
||||
{
|
||||
ErrorMessage = $"No transactions were selected. ({storedProposals.Count} proposals available but 0 selectedIds received from form)";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
int applied = 0;
|
||||
int rulesCreated = 0;
|
||||
int rulesUpdated = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var stored in storedProposals)
|
||||
{
|
||||
@@ -226,7 +247,7 @@ namespace MoneyMap.Pages
|
||||
CreateRule = stored.CreateRule
|
||||
};
|
||||
|
||||
// Check if user wants to create rule for this one
|
||||
// Check if user wants to create/update rule for this one
|
||||
var shouldCreateRule = createRulesSet.Contains(stored.TransactionId);
|
||||
|
||||
var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
|
||||
@@ -235,15 +256,25 @@ namespace MoneyMap.Pages
|
||||
applied++;
|
||||
if (result.RuleCreated)
|
||||
rulesCreated++;
|
||||
if (result.RuleUpdated)
|
||||
rulesUpdated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
var parts = new List<string> { $"Applied {applied} of {selectedSet.Count} selected categorizations" };
|
||||
if (rulesCreated > 0) parts.Add($"created {rulesCreated} new rules");
|
||||
if (rulesUpdated > 0) parts.Add($"updated {rulesUpdated} existing rules");
|
||||
SuccessMessage = string.Join(". ", parts) + ".";
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
ErrorMessage = "Some proposals failed: " + string.Join("; ", errors);
|
||||
}
|
||||
|
||||
public IActionResult OnPostCancel()
|
||||
{
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
@@ -254,10 +285,25 @@ namespace MoneyMap.Pages
|
||||
.Where(t => transactionIds.Contains(t.Id))
|
||||
.ToDictionaryAsync(t => t.Id);
|
||||
|
||||
// Look up existing rules for all proposed patterns
|
||||
var proposedPatterns = storedProposals
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p.Pattern))
|
||||
.Select(p => p.Pattern!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var existingRules = await _db.CategoryMappings
|
||||
.Where(m => proposedPatterns.Contains(m.Pattern))
|
||||
.ToDictionaryAsync(m => m.Pattern, m => m.Category);
|
||||
|
||||
foreach (var stored in storedProposals)
|
||||
{
|
||||
if (transactions.TryGetValue(stored.TransactionId, out var txn))
|
||||
{
|
||||
string? existingCategory = null;
|
||||
var hasExisting = !string.IsNullOrWhiteSpace(stored.Pattern)
|
||||
&& existingRules.TryGetValue(stored.Pattern!, out existingCategory);
|
||||
|
||||
Proposals.Add(new ProposalViewModel
|
||||
{
|
||||
TransactionId = stored.TransactionId,
|
||||
@@ -271,7 +317,9 @@ namespace MoneyMap.Pages
|
||||
ProposedPattern = stored.Pattern,
|
||||
Confidence = stored.Confidence,
|
||||
Reasoning = stored.Reasoning,
|
||||
CreateRule = stored.CreateRule
|
||||
CreateRule = stored.CreateRule,
|
||||
HasExistingRule = hasExisting,
|
||||
ExistingRuleCategory = existingCategory
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -294,6 +342,13 @@ namespace MoneyMap.Pages
|
||||
public decimal Confidence { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
public bool CreateRule { get; set; }
|
||||
public bool HasExistingRule { get; set; }
|
||||
public string? ExistingRuleCategory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the pattern exists but is mapped to a different category than proposed.
|
||||
/// </summary>
|
||||
public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory;
|
||||
}
|
||||
|
||||
public class StoredProposal
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.AccountDetailsModel
|
||||
@{
|
||||
ViewData["Title"] = $"Account - {Model.Account.DisplayLabel}";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Accounts", Url.Page("/Accounts")),
|
||||
(Model.Account.DisplayLabel, null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@model MoneyMap.Pages.EditAccountModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsNew ? "Add Account" : "Edit Account";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Accounts", Url.Page("/Accounts")),
|
||||
(Model.IsNew ? "Add Account" : "Edit Account", null)
|
||||
};
|
||||
}
|
||||
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.EditCardModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsNewCard ? "Add Card" : "Edit Card";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Accounts", Url.Page("/Accounts")),
|
||||
("Cards", Url.Page("/Cards")),
|
||||
(Model.IsNewCard ? "Add Card" : "Edit Card", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.EditTransactionModel
|
||||
@{
|
||||
ViewData["Title"] = "Edit Transaction";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Transactions", Url.Page("/Transactions")),
|
||||
($"#{Model.Transaction.Id}", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
+76
-15
@@ -52,13 +52,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3 d-flex gap-2">
|
||||
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Budgets">Budgets</a>
|
||||
<div class="row g-3 my-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<a asp-page="/Upload" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="quick-action-icon bg-primary bg-opacity-25 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-body">Upload Transactions</div>
|
||||
<small class="text-muted">Import CSV files</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<a asp-page="/Transactions" asp-route-category="(blank)" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="quick-action-icon bg-warning bg-opacity-25 text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M7.005 3.1a1 1 0 1 1 1.99 0l-.388 6.35a.61.61 0 0 1-1.214 0zM7 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-body">Review Uncategorized</div>
|
||||
<small class="text-muted">@Model.Stats.Uncategorized transactions</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<a asp-page="/ReceiptQueue" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="quick-action-icon bg-info bg-opacity-25 text-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5z"/>
|
||||
<path d="M4.5 12.5A.5.5 0 0 1 5 12h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5m0-2A.5.5 0 0 1 5 10h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5m1.639-3.708 1.33.886 1.854-1.855a.25.25 0 0 1 .289-.047l1.888.974V8.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-body">Receipt Parse Queue</div>
|
||||
<small class="text-muted">Process pending receipts</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<a asp-page="/Budgets" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="quick-action-icon bg-success bg-opacity-25 text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 10.781c.148 1.667 1.513 2.85 3.591 3.003V15h1.043v-1.216c2.27-.179 3.678-1.438 3.678-3.3 0-1.59-.947-2.51-2.956-3.028l-.722-.187V3.467c1.122.11 1.879.714 2.07 1.616h1.47c-.166-1.6-1.54-2.748-3.54-2.875V1H7.591v1.233c-1.939.23-3.27 1.472-3.27 3.156 0 1.454.966 2.483 2.661 2.917l.61.162v4.031c-1.149-.17-1.94-.8-2.131-1.718zm3.391-3.836c-1.043-.263-1.6-.825-1.6-1.616 0-.944.704-1.641 1.8-1.828v3.495l-.2-.05zm1.591 1.872c1.287.323 1.852.859 1.852 1.769 0 1.097-.826 1.828-2.2 1.939V8.73z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-body">Budgets</div>
|
||||
<small class="text-muted">@Model.BudgetStatuses.Count active budgets</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 my-2">
|
||||
@if (Model.TopCategories.Count > 1)
|
||||
{
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header">Spending by category (last 90 days)</div>
|
||||
@@ -67,7 +127,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
}
|
||||
<div class="@(Model.TopCategories.Count > 1 ? "col-lg-6" : "col-lg-12")">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header">Net cash flow (last 30 days)</div>
|
||||
<div class="card-body">
|
||||
@@ -148,7 +209,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.TopCategories.Any())
|
||||
@if (Model.TopCategories.Count > 1)
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
@@ -262,11 +323,11 @@
|
||||
labels: topLabels,
|
||||
datasets: [{
|
||||
data: topValues,
|
||||
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7']
|
||||
backgroundColor: ['#6366f1','#f59e0b','#ef4444','#10b981','#06b6d4','#8b5cf6','#f97316','#ec4899']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#adb5bd' } } },
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#64748b', font: { size: 12 } } } },
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
@@ -281,8 +342,8 @@
|
||||
datasets: [{
|
||||
label: 'Net Cash Flow',
|
||||
data: trendBalance,
|
||||
borderColor: '#6ea8fe',
|
||||
backgroundColor: 'rgba(110,168,254,0.15)',
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99,102,241,0.10)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
@@ -293,14 +354,14 @@
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#adb5bd',
|
||||
color: '#64748b',
|
||||
callback: function(value) { return '$' + value.toLocaleString(); }
|
||||
},
|
||||
grid: { color: 'rgba(255,255,255,0.1)' }
|
||||
grid: { color: 'rgba(0,0,0,0.06)' }
|
||||
},
|
||||
x: {
|
||||
ticks: { color: '#adb5bd', maxTicksLimit: 10 },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||
ticks: { color: '#64748b', maxTicksLimit: 10 },
|
||||
grid: { color: 'rgba(0,0,0,0.03)' }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
@page
|
||||
@model PrivacyModel
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
|
||||
<p>Use this page to detail your site's privacy policy.</p>
|
||||
@@ -1,19 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class PrivacyModel : PageModel
|
||||
{
|
||||
private readonly ILogger<PrivacyModel> _logger;
|
||||
|
||||
public PrivacyModel(ILogger<PrivacyModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,13 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
|
||||
@@ -26,11 +26,24 @@ namespace MoneyMap.Pages
|
||||
}
|
||||
|
||||
public RecategorizeStats Stats { get; set; } = new();
|
||||
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
public string AIProvider
|
||||
{
|
||||
get
|
||||
{
|
||||
var model = _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase)) return "LlamaCpp";
|
||||
if (model.StartsWith("ollama:", StringComparison.OrdinalIgnoreCase)) return "Ollama";
|
||||
if (model.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) return "Anthropic";
|
||||
return "OpenAI";
|
||||
}
|
||||
}
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadStatsAsync();
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.ReceiptQueueModel
|
||||
@{
|
||||
ViewData["Title"] = "Receipt Queue";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Receipts", Url.Page("/Receipts")),
|
||||
("Parse Queue", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Receipt Queue</h2>
|
||||
<div>
|
||||
<a asp-page="/Receipts" class="btn btn-outline-secondary">Back to Receipts</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="alert @(Model.IsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
|
||||
@Model.Message
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Upload Form -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">
|
||||
<strong>Upload Receipts</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload" id="uploadForm">
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">Select Receipt Files</label>
|
||||
<input type="file" name="files" id="fileInput" class="form-control" multiple
|
||||
accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
||||
<div class="form-text">
|
||||
Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once.
|
||||
</div>
|
||||
</div>
|
||||
<div id="filePreview" class="mb-3" style="display:none;">
|
||||
<h6>Selected Files:</h6>
|
||||
<ul id="fileList" class="list-group list-group-flush small"></ul>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
|
||||
<span id="uploadBtnText">Upload</span>
|
||||
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currently Processing -->
|
||||
<div id="processingCard" class="card shadow-sm mb-4 border-info" style="display:@(Model.CurrentlyProcessing != null ? "block" : "none");">
|
||||
<div class="card-header bg-info text-white d-flex align-items-center">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<strong>Currently Processing</strong>
|
||||
</div>
|
||||
<div class="card-body" id="processingBody">
|
||||
@if (Model.CurrentlyProcessing != null)
|
||||
{
|
||||
<div>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@Model.CurrentlyProcessing.ReceiptId">
|
||||
@Model.CurrentlyProcessing.FileName
|
||||
</a>
|
||||
<span class="text-muted ms-2">
|
||||
(uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Dashboard Tabs -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="queueTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="queued-tab" data-bs-toggle="tab" data-bs-target="#queuedPane"
|
||||
type="button" role="tab">
|
||||
Queued <span class="badge bg-warning text-dark ms-1" id="queuedBadge">@Model.QueuedItems.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="completed-tab" data-bs-toggle="tab" data-bs-target="#completedPane"
|
||||
type="button" role="tab">
|
||||
Completed <span class="badge bg-success ms-1" id="completedBadge">@Model.CompletedItems.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="failed-tab" data-bs-toggle="tab" data-bs-target="#failedPane"
|
||||
type="button" role="tab">
|
||||
Failed <span class="badge bg-danger ms-1" id="failedBadge">@Model.FailedItems.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content" id="queueTabContent">
|
||||
<!-- Queued Tab -->
|
||||
<div class="tab-pane fade show active" id="queuedPane" role="tabpanel">
|
||||
@if (Model.QueuedItems.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">#</th>
|
||||
<th>File Name</th>
|
||||
<th style="width:160px;">Uploaded</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queuedBody">
|
||||
@foreach (var item in Model.QueuedItems)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="badge bg-warning text-dark">@item.QueuePosition</span></td>
|
||||
<td>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
|
||||
</td>
|
||||
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-3 text-center text-muted" id="queuedEmpty">No items in queue.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Completed Tab -->
|
||||
<div class="tab-pane fade" id="completedPane" role="tabpanel">
|
||||
@if (Model.CompletedItems.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>Merchant</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-center">Confidence</th>
|
||||
<th class="text-center">Items</th>
|
||||
<th style="width:80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="completedBody">
|
||||
@foreach (var item in Model.CompletedItems)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
|
||||
</td>
|
||||
<td>@(item.Merchant ?? "-")</td>
|
||||
<td class="text-end">@(item.Total?.ToString("C") ?? "-")</td>
|
||||
<td class="text-center">
|
||||
@if (item.Confidence.HasValue)
|
||||
{
|
||||
var pct = item.Confidence.Value * 100;
|
||||
var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger";
|
||||
<span class="badge bg-@cls">@pct.ToString("F0")%</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.LineItemCount</td>
|
||||
<td>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-3 text-center text-muted" id="completedEmpty">No completed items.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Failed Tab -->
|
||||
<div class="tab-pane fade" id="failedPane" role="tabpanel">
|
||||
@if (Model.FailedItems.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>Error</th>
|
||||
<th style="width:160px;">Uploaded</th>
|
||||
<th style="width:140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="failedBody">
|
||||
@foreach (var item in Model.FailedItems)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
|
||||
</td>
|
||||
<td class="small text-danger">@(item.ErrorMessage ?? "Unknown error")</td>
|
||||
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
<form method="post" asp-page-handler="Retry" asp-route-receiptId="@item.ReceiptId" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button>
|
||||
</form>
|
||||
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-3 text-center text-muted" id="failedEmpty">No failed items.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// File input preview
|
||||
document.getElementById('fileInput').addEventListener('change', function () {
|
||||
var preview = document.getElementById('filePreview');
|
||||
var list = document.getElementById('fileList');
|
||||
var btn = document.getElementById('uploadBtn');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (this.files.length > 0) {
|
||||
preview.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
for (var i = 0; i < this.files.length; i++) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'list-group-item py-1';
|
||||
var sizeKB = (this.files[i].size / 1024).toFixed(1);
|
||||
li.textContent = this.files[i].name + ' (' + sizeKB + ' KB)';
|
||||
list.appendChild(li);
|
||||
}
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Upload spinner
|
||||
document.getElementById('uploadForm').addEventListener('submit', function () {
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
document.getElementById('uploadBtnText').textContent = 'Uploading...';
|
||||
document.getElementById('uploadSpinner').style.display = 'inline-block';
|
||||
});
|
||||
|
||||
// AJAX polling for queue status
|
||||
var pollInterval = null;
|
||||
var hasActiveItems = @((Model.CurrentlyProcessing != null || Model.QueuedItems.Any()) ? "true" : "false");
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval) return;
|
||||
pollInterval = setInterval(fetchQueueStatus, 3000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchQueueStatus() {
|
||||
fetch('?handler=QueueStatus', {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updateDashboard(data);
|
||||
if (!data.currentlyProcessing && data.queued.length === 0) {
|
||||
stopPolling();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Poll error:', err));
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
// Processing card
|
||||
var procCard = document.getElementById('processingCard');
|
||||
var procBody = document.getElementById('processingBody');
|
||||
if (data.currentlyProcessing) {
|
||||
procCard.style.display = 'block';
|
||||
procBody.innerHTML = '<div><a href="/ViewReceipt/' + data.currentlyProcessing.receiptId + '">' +
|
||||
escapeHtml(data.currentlyProcessing.fileName) + '</a></div>';
|
||||
} else {
|
||||
procCard.style.display = 'none';
|
||||
}
|
||||
|
||||
// Badges
|
||||
document.getElementById('queuedBadge').textContent = data.queued.length;
|
||||
document.getElementById('completedBadge').textContent = data.completed.length;
|
||||
document.getElementById('failedBadge').textContent = data.failed.length;
|
||||
|
||||
// Queued table
|
||||
var queuedPane = document.getElementById('queuedPane');
|
||||
if (data.queued.length > 0) {
|
||||
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
|
||||
'<thead><tr><th style="width:60px;">#</th><th>File Name</th><th style="width:160px;">Uploaded</th><th style="width:80px;">Action</th></tr></thead><tbody>';
|
||||
data.queued.forEach(function(item) {
|
||||
html += '<tr><td><span class="badge bg-warning text-dark">' + item.queuePosition + '</span></td>' +
|
||||
'<td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
|
||||
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
|
||||
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
queuedPane.innerHTML = html;
|
||||
} else {
|
||||
queuedPane.innerHTML = '<div class="p-3 text-center text-muted">No items in queue.</div>';
|
||||
}
|
||||
|
||||
// Completed table
|
||||
var completedPane = document.getElementById('completedPane');
|
||||
if (data.completed.length > 0) {
|
||||
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
|
||||
'<thead><tr><th>File Name</th><th>Merchant</th><th class="text-end">Total</th><th class="text-center">Confidence</th><th class="text-center">Items</th><th style="width:80px;">Action</th></tr></thead><tbody>';
|
||||
data.completed.forEach(function(item) {
|
||||
var confHtml = '-';
|
||||
if (item.confidence != null) {
|
||||
var pct = item.confidence * 100;
|
||||
var cls = pct >= 80 ? 'success' : pct >= 50 ? 'warning' : 'danger';
|
||||
confHtml = '<span class="badge bg-' + cls + '">' + pct.toFixed(0) + '%</span>';
|
||||
}
|
||||
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
|
||||
'<td>' + escapeHtml(item.merchant || '-') + '</td>' +
|
||||
'<td class="text-end">' + (item.total != null ? '$' + item.total.toFixed(2) : '-') + '</td>' +
|
||||
'<td class="text-center">' + confHtml + '</td>' +
|
||||
'<td class="text-center">' + item.lineItemCount + '</td>' +
|
||||
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
completedPane.innerHTML = html;
|
||||
} else {
|
||||
completedPane.innerHTML = '<div class="p-3 text-center text-muted">No completed items.</div>';
|
||||
}
|
||||
|
||||
// Failed table
|
||||
var failedPane = document.getElementById('failedPane');
|
||||
if (data.failed.length > 0) {
|
||||
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
|
||||
'<thead><tr><th>File Name</th><th>Error</th><th style="width:160px;">Uploaded</th><th style="width:140px;">Actions</th></tr></thead><tbody>';
|
||||
data.failed.forEach(function(item) {
|
||||
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
|
||||
'<td class="small text-danger">' + escapeHtml(item.errorMessage || 'Unknown error') + '</td>' +
|
||||
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
|
||||
'<td><form method="post" action="?handler=Retry&receiptId=' + item.receiptId + '" style="display:inline;">' +
|
||||
'<input type="hidden" name="__RequestVerificationToken" value="' + getAntiForgeryToken() + '" />' +
|
||||
'<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button></form> ' +
|
||||
'<a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
failedPane.innerHTML = html;
|
||||
} else {
|
||||
failedPane.innerHTML = '<div class="p-3 text-center text-muted">No failed items.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(utcStr) {
|
||||
var d = new Date(utcStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getAntiForgeryToken() {
|
||||
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
if (hasActiveItems) {
|
||||
startPolling();
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class ReceiptQueueModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly IReceiptParseQueue _parseQueue;
|
||||
|
||||
public ReceiptQueueModel(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
IReceiptParseQueue parseQueue)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_parseQueue = parseQueue;
|
||||
}
|
||||
|
||||
public List<QueueItemViewModel> QueuedItems { get; set; } = new();
|
||||
public List<QueueItemViewModel> CompletedItems { get; set; } = new();
|
||||
public List<QueueItemViewModel> FailedItems { get; set; } = new();
|
||||
public QueueItemViewModel? CurrentlyProcessing { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[TempData]
|
||||
public bool IsSuccess { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadQueueDashboardAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
|
||||
{
|
||||
if (files == null || files.Count == 0)
|
||||
{
|
||||
Message = "Please select files to upload.";
|
||||
IsSuccess = false;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
|
||||
|
||||
var messages = new List<string>();
|
||||
if (result.Uploaded.Count > 0)
|
||||
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
|
||||
if (result.Failed.Count > 0)
|
||||
messages.Add($"{result.Failed.Count} failed: " +
|
||||
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
|
||||
|
||||
Message = string.Join(" ", messages);
|
||||
IsSuccess = result.Failed.Count == 0;
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetQueueStatusAsync()
|
||||
{
|
||||
await LoadQueueDashboardAsync();
|
||||
|
||||
var data = new
|
||||
{
|
||||
currentlyProcessing = CurrentlyProcessing,
|
||||
queued = QueuedItems,
|
||||
completed = CompletedItems,
|
||||
failed = FailedItems
|
||||
};
|
||||
|
||||
return new JsonResult(data);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostRetryAsync(long receiptId)
|
||||
{
|
||||
var receipt = await _db.Receipts.FindAsync(receiptId);
|
||||
if (receipt == null)
|
||||
{
|
||||
Message = "Receipt not found.";
|
||||
IsSuccess = false;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
await _db.SaveChangesAsync();
|
||||
await _parseQueue.EnqueueAsync(receiptId);
|
||||
|
||||
Message = $"Receipt \"{receipt.FileName}\" re-queued for parsing.";
|
||||
IsSuccess = true;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private async Task LoadQueueDashboardAsync()
|
||||
{
|
||||
var currentId = _parseQueue.CurrentlyProcessingId;
|
||||
|
||||
// Load all non-NotRequested receipts (recent first, limit to keep things manageable)
|
||||
var recentReceipts = await _db.Receipts
|
||||
.Where(r => r.ParseStatus != ReceiptParseStatus.NotRequested)
|
||||
.OrderByDescending(r => r.UploadedAtUtc)
|
||||
.Take(200)
|
||||
.Select(r => new QueueItemViewModel
|
||||
{
|
||||
ReceiptId = r.Id,
|
||||
FileName = r.FileName,
|
||||
UploadedAtUtc = r.UploadedAtUtc,
|
||||
ParseStatus = r.ParseStatus,
|
||||
Merchant = r.Merchant,
|
||||
Total = r.Total,
|
||||
LineItemCount = r.LineItems.Count
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Get error messages from latest parse log for failed items
|
||||
var failedIds = recentReceipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||
.Select(r => r.ReceiptId)
|
||||
.ToList();
|
||||
|
||||
if (failedIds.Count > 0)
|
||||
{
|
||||
var errorLogs = await _db.ReceiptParseLogs
|
||||
.Where(l => failedIds.Contains(l.ReceiptId) && !l.Success)
|
||||
.GroupBy(l => l.ReceiptId)
|
||||
.Select(g => new { ReceiptId = g.Key, Error = g.OrderByDescending(l => l.StartedAtUtc).First().Error })
|
||||
.ToListAsync();
|
||||
|
||||
var errorMap = errorLogs.ToDictionary(e => e.ReceiptId, e => e.Error);
|
||||
|
||||
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Failed))
|
||||
{
|
||||
item.ErrorMessage = errorMap.GetValueOrDefault(item.ReceiptId);
|
||||
}
|
||||
}
|
||||
|
||||
// Get confidence from latest successful parse log
|
||||
var completedIds = recentReceipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
||||
.Select(r => r.ReceiptId)
|
||||
.ToList();
|
||||
|
||||
if (completedIds.Count > 0)
|
||||
{
|
||||
var confidenceLogs = await _db.ReceiptParseLogs
|
||||
.Where(l => completedIds.Contains(l.ReceiptId) && l.Success)
|
||||
.GroupBy(l => l.ReceiptId)
|
||||
.Select(g => new { ReceiptId = g.Key, Confidence = g.OrderByDescending(l => l.StartedAtUtc).First().Confidence })
|
||||
.ToListAsync();
|
||||
|
||||
var confidenceMap = confidenceLogs.ToDictionary(c => c.ReceiptId, c => c.Confidence);
|
||||
|
||||
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Completed))
|
||||
{
|
||||
item.Confidence = confidenceMap.GetValueOrDefault(item.ReceiptId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign queue positions for queued items
|
||||
var queuedList = recentReceipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Queued)
|
||||
.OrderBy(r => r.UploadedAtUtc)
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < queuedList.Count; i++)
|
||||
queuedList[i].QueuePosition = i + 1;
|
||||
|
||||
QueuedItems = queuedList;
|
||||
CompletedItems = recentReceipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
|
||||
.OrderByDescending(r => r.UploadedAtUtc)
|
||||
.ToList();
|
||||
FailedItems = recentReceipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||
.OrderByDescending(r => r.UploadedAtUtc)
|
||||
.ToList();
|
||||
|
||||
if (currentId.HasValue)
|
||||
{
|
||||
CurrentlyProcessing = recentReceipts
|
||||
.FirstOrDefault(r => r.ReceiptId == currentId.Value);
|
||||
|
||||
// If currently processing item isn't in our recent list, load it
|
||||
if (CurrentlyProcessing == null)
|
||||
{
|
||||
CurrentlyProcessing = await _db.Receipts
|
||||
.Where(r => r.Id == currentId.Value)
|
||||
.Select(r => new QueueItemViewModel
|
||||
{
|
||||
ReceiptId = r.Id,
|
||||
FileName = r.FileName,
|
||||
UploadedAtUtc = r.UploadedAtUtc,
|
||||
ParseStatus = r.ParseStatus
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class QueueItemViewModel
|
||||
{
|
||||
public long ReceiptId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public DateTime UploadedAtUtc { get; set; }
|
||||
public ReceiptParseStatus ParseStatus { get; set; }
|
||||
public int QueuePosition { get; set; }
|
||||
public string? Merchant { get; set; }
|
||||
public decimal? Total { get; set; }
|
||||
public decimal? Confidence { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public int LineItemCount { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@
|
||||
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
|
||||
Review Mappings
|
||||
</a>
|
||||
<a asp-page="/ReceiptQueue" class="btn btn-info me-2">
|
||||
Parse Queue
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadReceiptModal">
|
||||
Upload Receipt
|
||||
Upload Receipts
|
||||
</button>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
@@ -115,25 +118,33 @@
|
||||
</script>
|
||||
}
|
||||
|
||||
<!-- Upload Receipt Modal -->
|
||||
<!-- Upload Receipts Modal -->
|
||||
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipt</h5>
|
||||
<h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipts</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
|
||||
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadToQueue" id="uploadForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="UploadFile" class="form-label">Select Receipt File</label>
|
||||
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
||||
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div>
|
||||
<label for="uploadFiles" class="form-label">Select Receipt Files</label>
|
||||
<input type="file" name="files" id="uploadFiles" class="form-control" multiple
|
||||
accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
||||
<div class="form-text">Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once.</div>
|
||||
</div>
|
||||
<div id="filePreview" class="mb-3" style="display:none;">
|
||||
<h6>Selected Files:</h6>
|
||||
<ul id="fileList" class="list-group list-group-flush small"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
<button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
|
||||
<span id="uploadBtnText">Upload & Parse</span>
|
||||
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -154,14 +165,24 @@
|
||||
<span class="text-muted">- 0 total</span>
|
||||
}
|
||||
</div>
|
||||
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
|
||||
{
|
||||
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
|
||||
🔗 Auto-Map Unmapped Receipts
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
<div class="d-flex gap-2">
|
||||
@if (Model.FailedParseCount > 0)
|
||||
{
|
||||
<form method="post" asp-page-handler="RetryFailedParses" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Re-queue all failed receipts for AI parsing">
|
||||
Retry @Model.FailedParseCount Failed Parse(s)
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
|
||||
{
|
||||
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
|
||||
Auto-Map Unmapped Receipts
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (Model.Receipts.Any())
|
||||
@@ -487,6 +508,7 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Map form validation
|
||||
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
|
||||
form.addEventListener('submit', function(e){
|
||||
var hidden = form.querySelector('input[type="hidden"][name="transactionId"]');
|
||||
@@ -498,6 +520,36 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Upload file preview
|
||||
document.getElementById('uploadFiles').addEventListener('change', function () {
|
||||
var preview = document.getElementById('filePreview');
|
||||
var list = document.getElementById('fileList');
|
||||
var btn = document.getElementById('uploadBtn');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (this.files.length > 0) {
|
||||
preview.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
for (var i = 0; i < this.files.length; i++) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'list-group-item py-1';
|
||||
var sizeKB = (this.files[i].size / 1024).toFixed(1);
|
||||
li.textContent = this.files[i].name + ' (' + sizeKB + ' KB)';
|
||||
list.appendChild(li);
|
||||
}
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Upload spinner
|
||||
document.getElementById('uploadForm').addEventListener('submit', function () {
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
document.getElementById('uploadBtnText').textContent = 'Uploading...';
|
||||
document.getElementById('uploadSpinner').style.display = 'inline-block';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
@@ -13,12 +14,15 @@ namespace MoneyMap.Pages
|
||||
private readonly IReceiptAutoMapper _autoMapper;
|
||||
private readonly IReceiptMatchingService _receiptMatchingService;
|
||||
|
||||
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService)
|
||||
private readonly IReceiptParseQueue _parseQueue;
|
||||
|
||||
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService, IReceiptParseQueue parseQueue)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_autoMapper = autoMapper;
|
||||
_receiptMatchingService = receiptMatchingService;
|
||||
_parseQueue = parseQueue;
|
||||
}
|
||||
|
||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||
@@ -53,10 +57,12 @@ namespace MoneyMap.Pages
|
||||
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
public bool ShowDuplicateModal { get; set; } = false;
|
||||
public int FailedParseCount { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadReceiptsAsync();
|
||||
FailedParseCount = await _db.Receipts.CountAsync(r => r.ParseStatus == ReceiptParseStatus.Failed);
|
||||
|
||||
// Show duplicate modal if warnings present
|
||||
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
|
||||
@@ -66,6 +72,29 @@ namespace MoneyMap.Pages
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUploadToQueueAsync(List<IFormFile> files)
|
||||
{
|
||||
if (files == null || files.Count == 0)
|
||||
{
|
||||
Message = "Please select files to upload.";
|
||||
IsSuccess = false;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
|
||||
|
||||
var messages = new List<string>();
|
||||
if (result.Uploaded.Count > 0)
|
||||
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
|
||||
if (result.Failed.Count > 0)
|
||||
messages.Add($"{result.Failed.Count} failed: " +
|
||||
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
|
||||
|
||||
Message = string.Join(" ", messages);
|
||||
IsSuccess = result.Failed.Count == 0;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUploadAsync()
|
||||
{
|
||||
if (UploadFile == null)
|
||||
@@ -227,6 +256,30 @@ namespace MoneyMap.Pages
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostRetryFailedParsesAsync()
|
||||
{
|
||||
var failedReceipts = await _db.Receipts
|
||||
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
|
||||
.ToListAsync();
|
||||
|
||||
if (failedReceipts.Count == 0)
|
||||
{
|
||||
Message = "No failed receipts to retry.";
|
||||
IsSuccess = false;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
foreach (var receipt in failedReceipts)
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _parseQueue.EnqueueManyAsync(failedReceipts.Select(r => r.Id));
|
||||
|
||||
Message = $"Re-queued {failedReceipts.Count} failed receipt(s) for parsing.";
|
||||
IsSuccess = true;
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
|
||||
{
|
||||
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.ReviewAISuggestionsModel
|
||||
@{
|
||||
ViewData["Title"] = "AI Categorization Suggestions";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>AI Categorization Suggestions</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-page="/Transactions" asp-route-category="(blank)" class="btn btn-outline-secondary">
|
||||
View Uncategorized
|
||||
</a>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@Model.SuccessMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">How AI Categorization Works</h5>
|
||||
<p class="card-text">
|
||||
This tool uses AI to analyze your uncategorized transactions and suggest:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Category</strong> - The most appropriate expense category</li>
|
||||
<li><strong>Merchant Name</strong> - A normalized merchant name (e.g., "Walmart" from "WAL-MART #1234")</li>
|
||||
<li><strong>Pattern Rule</strong> - An optional rule to auto-categorize similar transactions in the future</li>
|
||||
</ul>
|
||||
<p class="card-text">
|
||||
<strong>Cost:</strong> Approximately $0.00015 per transaction (~1.5 cents per 100 transactions)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Uncategorized Transactions (@Model.TotalUncategorized)</strong>
|
||||
<form method="post" asp-page-handler="GenerateSuggestions" class="d-inline">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Generate AI Suggestions (up to 20)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!Model.Transactions.Any())
|
||||
{
|
||||
<p class="text-muted">No uncategorized transactions found. Great job!</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-3">
|
||||
Showing the @Model.Transactions.Count most recent uncategorized transactions.
|
||||
Click "Generate AI Suggestions" to analyze up to 20 transactions.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Name</th>
|
||||
<th>Memo</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Transactions)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Transaction.Date.ToString("yyyy-MM-dd")</td>
|
||||
<td>@item.Transaction.Name</td>
|
||||
<td class="text-truncate" style="max-width: 300px;">@item.Transaction.Memo</td>
|
||||
<td class="text-end">
|
||||
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success")">
|
||||
@item.Transaction.Amount.ToString("C")
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-sm btn-outline-primary">
|
||||
Edit
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Quick Tips</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li>AI suggestions are based on transaction name, memo, amount, and date</li>
|
||||
<li>You can accept, reject, or modify each suggestion</li>
|
||||
<li>Creating rules helps auto-categorize future transactions</li>
|
||||
<li>High confidence suggestions (>80%) are more reliable</li>
|
||||
<li>You can manually edit any transaction from the Transactions page</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,114 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages;
|
||||
|
||||
public class ReviewAISuggestionsModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionAICategorizer _aiCategorizer;
|
||||
|
||||
public ReviewAISuggestionsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer)
|
||||
{
|
||||
_db = db;
|
||||
_aiCategorizer = aiCategorizer;
|
||||
}
|
||||
|
||||
public List<TransactionWithProposal> Transactions { get; set; } = new();
|
||||
public bool IsGenerating { get; set; }
|
||||
public int TotalUncategorized { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
// Get uncategorized transactions
|
||||
var uncategorized = await _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => string.IsNullOrEmpty(t.Category))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(50) // Limit to 50 most recent
|
||||
.ToListAsync();
|
||||
|
||||
TotalUncategorized = uncategorized.Count;
|
||||
Transactions = uncategorized.Select(t => new TransactionWithProposal
|
||||
{
|
||||
Transaction = t,
|
||||
Proposal = null // Will be populated via AJAX or on generate
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostGenerateSuggestionsAsync()
|
||||
{
|
||||
// Get uncategorized transactions
|
||||
var uncategorized = await _db.Transactions
|
||||
.Where(t => string.IsNullOrEmpty(t.Category))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(20) // Limit to 20 for cost control
|
||||
.ToListAsync();
|
||||
|
||||
if (!uncategorized.Any())
|
||||
{
|
||||
ErrorMessage = "No uncategorized transactions found.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
// Generate proposals
|
||||
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized);
|
||||
|
||||
// Store proposals in session for review
|
||||
HttpContext.Session.SetString("AIProposals", System.Text.Json.JsonSerializer.Serialize(proposals));
|
||||
|
||||
SuccessMessage = $"Generated {proposals.Count} AI suggestions. Review them below.";
|
||||
return RedirectToPage("ReviewAISuggestionsWithProposals");
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule)
|
||||
{
|
||||
var proposal = new AICategoryProposal
|
||||
{
|
||||
TransactionId = transactionId,
|
||||
Category = category,
|
||||
CanonicalMerchant = merchant,
|
||||
Pattern = pattern,
|
||||
Confidence = confidence,
|
||||
CreateRule = createRule
|
||||
};
|
||||
|
||||
var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SuccessMessage = result.RuleCreated
|
||||
? "Transaction categorized and rule created!"
|
||||
: "Transaction categorized!";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion.";
|
||||
}
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public IActionResult OnPostRejectProposalAsync(long transactionId)
|
||||
{
|
||||
// Just refresh the page, removing this transaction from view
|
||||
SuccessMessage = "Suggestion rejected.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public class TransactionWithProposal
|
||||
{
|
||||
public Transaction Transaction { get; set; } = null!;
|
||||
public AICategoryProposal? Proposal { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.ReviewAISuggestionsWithProposalsModel
|
||||
@{
|
||||
ViewData["Title"] = "Review AI Suggestions";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Review AI Suggestions</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" asp-page-handler="ApplyAll" class="d-inline" onsubmit="return confirm('Apply all high-confidence suggestions (≥80%)?');">
|
||||
<button type="submit" class="btn btn-success">
|
||||
Apply All High Confidence
|
||||
</button>
|
||||
</form>
|
||||
<a asp-page="/ReviewAISuggestions" class="btn btn-outline-secondary">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@Model.SuccessMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@Model.ErrorMessage
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Model.Proposals.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>No suggestions remaining</h5>
|
||||
<p class="mb-0">
|
||||
All AI suggestions have been processed.
|
||||
<a asp-page="/ReviewAISuggestions" class="alert-link">Generate more suggestions</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Review each suggestion below.</strong> You can accept the AI's proposal, reject it, or modify it before applying.
|
||||
High confidence suggestions (≥80%) are generally very reliable.
|
||||
</div>
|
||||
|
||||
@foreach (var item in Model.Proposals)
|
||||
{
|
||||
var confidenceClass = item.Proposal.Confidence >= 0.8m ? "success" :
|
||||
item.Proposal.Confidence >= 0.6m ? "warning" : "danger";
|
||||
var confidencePercent = (item.Proposal.Confidence * 100).ToString("F0");
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@item.Transaction.Name</strong>
|
||||
<span class="text-muted ms-2">@item.Transaction.Date.ToString("yyyy-MM-dd")</span>
|
||||
<span class="badge bg-@confidenceClass ms-2">@confidencePercent% Confidence</span>
|
||||
</div>
|
||||
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success") fw-bold">
|
||||
@item.Transaction.Amount.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Transaction Details</h6>
|
||||
<dl class="row small">
|
||||
<dt class="col-sm-3">Memo:</dt>
|
||||
<dd class="col-sm-9">@item.Transaction.Memo</dd>
|
||||
|
||||
<dt class="col-sm-3">Amount:</dt>
|
||||
<dd class="col-sm-9">@item.Transaction.Amount.ToString("C")</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>AI Suggestion</h6>
|
||||
<dl class="row small">
|
||||
<dt class="col-sm-4">Category:</dt>
|
||||
<dd class="col-sm-8"><span class="badge bg-primary">@item.Proposal.Category</span></dd>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(item.Proposal.CanonicalMerchant))
|
||||
{
|
||||
<dt class="col-sm-4">Merchant:</dt>
|
||||
<dd class="col-sm-8">@item.Proposal.CanonicalMerchant</dd>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(item.Proposal.Pattern))
|
||||
{
|
||||
<dt class="col-sm-4">Pattern:</dt>
|
||||
<dd class="col-sm-8"><code>@item.Proposal.Pattern</code></dd>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(item.Proposal.Reasoning))
|
||||
{
|
||||
<dt class="col-sm-4">Reasoning:</dt>
|
||||
<dd class="col-sm-8"><em>@item.Proposal.Reasoning</em></dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<form method="post" asp-page-handler="RejectProposal" class="d-inline">
|
||||
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
Reject
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
|
||||
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
|
||||
<input type="hidden" name="category" value="@item.Proposal.Category" />
|
||||
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
|
||||
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
|
||||
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
|
||||
<input type="hidden" name="createRule" value="false" />
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
Apply (No Rule)
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
|
||||
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
|
||||
<input type="hidden" name="category" value="@item.Proposal.Category" />
|
||||
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
|
||||
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
|
||||
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
|
||||
<input type="hidden" name="createRule" value="true" />
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Apply + Create Rule
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-outline-secondary btn-sm">
|
||||
Edit Manually
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header">
|
||||
<strong>Understanding Confidence Scores</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li><span class="badge bg-success">≥80%</span> - High confidence, very reliable</li>
|
||||
<li><span class="badge bg-warning text-dark">60-79%</span> - Medium confidence, review recommended</li>
|
||||
<li><span class="badge bg-danger"><60%</span> - Low confidence, manual review strongly recommended</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,157 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoneyMap.Pages;
|
||||
|
||||
public class ReviewAISuggestionsWithProposalsModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly ITransactionAICategorizer _aiCategorizer;
|
||||
|
||||
public ReviewAISuggestionsWithProposalsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer)
|
||||
{
|
||||
_db = db;
|
||||
_aiCategorizer = aiCategorizer;
|
||||
}
|
||||
|
||||
public List<TransactionWithProposal> Proposals { get; set; } = new();
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
// Load proposals from session
|
||||
var proposalsJson = HttpContext.Session.GetString("AIProposals");
|
||||
if (string.IsNullOrWhiteSpace(proposalsJson))
|
||||
{
|
||||
ErrorMessage = "No AI suggestions found. Please generate suggestions first.";
|
||||
return RedirectToPage("ReviewAISuggestions");
|
||||
}
|
||||
|
||||
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
|
||||
if (proposals == null || !proposals.Any())
|
||||
{
|
||||
ErrorMessage = "Failed to load AI suggestions.";
|
||||
return RedirectToPage("ReviewAISuggestions");
|
||||
}
|
||||
|
||||
// Load transactions for these proposals
|
||||
var transactionIds = proposals.Select(p => p.TransactionId).ToList();
|
||||
var transactions = await _db.Transactions
|
||||
.Include(t => t.Merchant)
|
||||
.Where(t => transactionIds.Contains(t.Id))
|
||||
.ToListAsync();
|
||||
|
||||
Proposals = proposals.Select(p => new TransactionWithProposal
|
||||
{
|
||||
Transaction = transactions.FirstOrDefault(t => t.Id == p.TransactionId)!,
|
||||
Proposal = p
|
||||
}).Where(x => x.Transaction != null).ToList();
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule)
|
||||
{
|
||||
var proposal = new AICategoryProposal
|
||||
{
|
||||
TransactionId = transactionId,
|
||||
Category = category,
|
||||
CanonicalMerchant = merchant,
|
||||
Pattern = pattern,
|
||||
Confidence = confidence,
|
||||
CreateRule = createRule
|
||||
};
|
||||
|
||||
var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Remove this proposal from session
|
||||
var proposalsJson = HttpContext.Session.GetString("AIProposals");
|
||||
if (!string.IsNullOrWhiteSpace(proposalsJson))
|
||||
{
|
||||
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
|
||||
if (proposals != null)
|
||||
{
|
||||
proposals.RemoveAll(p => p.TransactionId == transactionId);
|
||||
HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals));
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage = result.RuleCreated
|
||||
? "Transaction categorized and rule created!"
|
||||
: "Transaction categorized!";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion.";
|
||||
}
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public IActionResult OnPostRejectProposalAsync(long transactionId)
|
||||
{
|
||||
// Remove this proposal from session
|
||||
var proposalsJson = HttpContext.Session.GetString("AIProposals");
|
||||
if (!string.IsNullOrWhiteSpace(proposalsJson))
|
||||
{
|
||||
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
|
||||
if (proposals != null)
|
||||
{
|
||||
proposals.RemoveAll(p => p.TransactionId == transactionId);
|
||||
HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals));
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage = "Suggestion rejected.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostApplyAllAsync()
|
||||
{
|
||||
var proposalsJson = HttpContext.Session.GetString("AIProposals");
|
||||
if (string.IsNullOrWhiteSpace(proposalsJson))
|
||||
{
|
||||
ErrorMessage = "No AI suggestions found.";
|
||||
return RedirectToPage("ReviewAISuggestions");
|
||||
}
|
||||
|
||||
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
|
||||
if (proposals == null || !proposals.Any())
|
||||
{
|
||||
ErrorMessage = "Failed to load AI suggestions.";
|
||||
return RedirectToPage("ReviewAISuggestions");
|
||||
}
|
||||
|
||||
int applied = 0;
|
||||
foreach (var proposal in proposals.Where(p => p.Confidence >= 0.8m))
|
||||
{
|
||||
var result = await _aiCategorizer.ApplyProposalAsync(proposal.TransactionId, proposal, proposal.CreateRule);
|
||||
if (result.Success)
|
||||
applied++;
|
||||
}
|
||||
|
||||
// Clear session
|
||||
HttpContext.Session.Remove("AIProposals");
|
||||
|
||||
SuccessMessage = $"Applied {applied} high-confidence suggestions (≥80%).";
|
||||
return RedirectToPage("ReviewAISuggestions");
|
||||
}
|
||||
|
||||
public class TransactionWithProposal
|
||||
{
|
||||
public Transaction Transaction { get; set; } = null!;
|
||||
public AICategoryProposal Proposal { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.ReviewReceiptsModel
|
||||
@{
|
||||
ViewData["Title"] = "Review Receipts";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Receipts", Url.Page("/Receipts")),
|
||||
("Review Mappings", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,7 +10,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-dark bg-dark border-bottom box-shadow mb-3">
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm border-bottom box-shadow mb-3">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" asp-page="/Index">MoneyMap</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
|
||||
@@ -22,23 +22,62 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Index">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Transactions">Transactions</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Transactions
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" asp-page="/Transactions">All Transactions</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/Recategorize">Recategorize</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/AICategorizePreview">AI Review</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Receipts
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" asp-page="/Receipts">All Receipts</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/ReceiptQueue">Parse Queue</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/ReviewReceipts">Review Mappings</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Accounts
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" asp-page="/Accounts">Bank Accounts</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/Cards">Cards</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Receipts">Receipts</a>
|
||||
<a class="nav-link" asp-page="/Budgets">Budgets</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Accounts">Accounts</a>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
<li class="nav-item me-2">
|
||||
<a class="btn btn-sm btn-primary" asp-page="/Upload">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
|
||||
</svg>
|
||||
Upload
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/CategoryMappings">Categories</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Merchants">Merchants</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Recategorize">Recategorize</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492M5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0"/>
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.892 3.434-.901 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.892-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52zm-2.658.06a1.873 1.873 0 0 1 3.724 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16a1.873 1.873 0 0 1 2.693 2.693l-.16.291a1.873 1.873 0 0 0 1.116 2.693l.318.094a1.873 1.873 0 0 1 0 3.724l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291a1.873 1.873 0 0 1-2.693 2.693l-.292-.16a1.873 1.873 0 0 0-2.693 1.116l-.094.318a1.873 1.873 0 0 1-3.724 0l-.094-.319a1.873 1.873 0 0 0-2.693-1.115l-.291.16a1.873 1.873 0 0 1-2.693-2.693l.16-.291a1.873 1.873 0 0 0-1.116-2.693l-.318-.094a1.873 1.873 0 0 1 0-3.724l.319-.094a1.873 1.873 0 0 0 1.115-2.693l-.16-.291a1.873 1.873 0 0 1 2.693-2.693l.292.16a1.873 1.873 0 0 0 2.693-1.116z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" asp-page="/CategoryMappings">Category Rules</a></li>
|
||||
<li><a class="dropdown-item" asp-page="/Merchants">Merchants</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" asp-page="/Settings">AI Settings</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -47,18 +86,37 @@
|
||||
</header>
|
||||
<div class="@(ViewData["FullWidth"] is true ? "container-fluid px-3" : "container")">
|
||||
<main role="main" class="pb-3">
|
||||
@if (ViewData["Breadcrumbs"] is List<(string Label, string? Url)> breadcrumbs && breadcrumbs.Count > 0)
|
||||
{
|
||||
<nav aria-label="breadcrumb" class="breadcrumb-nav">
|
||||
<ol class="breadcrumb">
|
||||
@for (var i = 0; i < breadcrumbs.Count; i++)
|
||||
{
|
||||
var crumb = breadcrumbs[i];
|
||||
if (i == breadcrumbs.Count - 1)
|
||||
{
|
||||
<li class="breadcrumb-item active" aria-current="page">@crumb.Label</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="breadcrumb-item"><a href="@crumb.Url" class="text-decoration-none">@crumb.Label</a></li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
}
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="border-top footer text-body-secondary">
|
||||
<div class="container">
|
||||
© 2025 - MoneyMap
|
||||
© 2026 - MoneyMap
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="~/lib/jquery/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
|
||||
@@ -169,17 +169,15 @@
|
||||
<span class="text-muted">- @Model.Stats.Count total</span>
|
||||
}
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)">
|
||||
<label class="form-check-label small" for="selectAllCheckbox">Select all on page</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)" title="Select all on page">
|
||||
</th>
|
||||
<th style="width: 70px;">ID</th>
|
||||
<th style="width: 110px;">Date</th>
|
||||
<th>Name</th>
|
||||
@@ -359,11 +357,11 @@ else
|
||||
labels: categoryLabels,
|
||||
datasets: [{
|
||||
data: categoryValues,
|
||||
backgroundColor: ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab']
|
||||
backgroundColor: ['#6366f1','#f59e0b','#ef4444','#10b981','#06b6d4','#8b5cf6','#f97316','#ec4899','#84cc16','#a78bfa']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#adb5bd' } } },
|
||||
plugins: { legend: { position: 'bottom', labels: { color: '#64748b', font: { size: 12 } } } },
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,6 +54,13 @@ namespace MoneyMap.Pages
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
// Default to last 30 days if no date filters provided
|
||||
if (!StartDate.HasValue && !EndDate.HasValue)
|
||||
{
|
||||
StartDate = DateTime.Today.AddDays(-30);
|
||||
EndDate = DateTime.Today;
|
||||
}
|
||||
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Card)
|
||||
.ThenInclude(c => c!.Account)
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@{
|
||||
ViewData["Title"] = "Upload Transactions";
|
||||
ViewData["FullWidth"] = Model.PreviewTransactions.Any();
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Transactions", Url.Page("/Transactions")),
|
||||
("Upload", null)
|
||||
};
|
||||
}
|
||||
|
||||
<h2>Upload Transactions</h2>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.ViewReceiptModel
|
||||
@{
|
||||
ViewData["Title"] = "View Receipt";
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Transactions", Url.Page("/Transactions")),
|
||||
($"Transaction #{Model.Receipt.TransactionId}", Url.Page("/EditTransaction", new { id = Model.Receipt.TransactionId })),
|
||||
("Receipt", null)
|
||||
};
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -156,28 +162,20 @@
|
||||
<strong>Parse Receipt</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model.AvailableParsers.Any())
|
||||
{
|
||||
<form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id">
|
||||
<input type="hidden" name="parser" value="@Model.AvailableParsers.First().FullName" />
|
||||
<p class="text-muted small mb-2">
|
||||
Using: <strong>@Model.SelectedModel</strong>
|
||||
<a href="/Settings" class="ms-2 small">Change</a>
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label for="ParsingNotes" class="form-label small text-muted mb-1">Notes for AI</label>
|
||||
<textarea asp-for="ParsingNotes" class="form-control form-control-sm" rows="3"
|
||||
placeholder="Optional hints for parsing (e.g., 'This is a restaurant receipt', 'Ignore the voided items')"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
Parse Receipt
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-0">No parsers available</p>
|
||||
}
|
||||
<form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id">
|
||||
<p class="text-muted small mb-2">
|
||||
Using: <strong>@Model.SelectedModel</strong>
|
||||
<a href="/Settings" class="ms-2 small">Change</a>
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label for="ParsingNotes" class="form-label small text-muted mb-1">Notes for AI</label>
|
||||
<textarea asp-for="ParsingNotes" class="form-control form-control-sm" rows="3"
|
||||
placeholder="Optional hints for parsing (e.g., 'This is a restaurant receipt', 'Ignore the voided items')"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
Parse Receipt
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +212,15 @@
|
||||
{
|
||||
<div class="small text-danger mt-1">@log.Error</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(log.RawProviderPayloadJson) && log.RawProviderPayloadJson != "{}")
|
||||
{
|
||||
<a class="small" data-bs-toggle="collapse" href="#rawPayload@(log.Id)" role="button" aria-expanded="false">
|
||||
Show LLM Response
|
||||
</a>
|
||||
<div class="collapse mt-1" id="rawPayload@(log.Id)">
|
||||
<pre class="bg-body-secondary p-2 rounded small" style="max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@log.RawProviderPayloadJson</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user