Compare commits

..

46 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:09:05 -05:00
aj f4ab4c4e7d Config: change Kestrel listen port to 5010 to avoid conflicts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:48:58 -05:00
aj a7c304ccb5 Config: Switch receipt parsing to Qwen3-VL-Thinking model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:06:18 -05:00
aj f622912f2e Improve: Support reasoning models in AI vision pipeline
Strip <think>...</think> blocks from reasoning model output (e.g.
Qwen3-VL-Thinking) and increase max_tokens from 4096 to 16384 to
accommodate thinking token overhead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:04:29 -05:00
aj 299ea3d4fe Cleanup: Remove unused Privacy page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:41:01 -05:00
aj b5f46a7646 Cleanup: Remove redundant AI categorization pages
ReviewAISuggestions and ReviewAISuggestionsWithProposals were a two-page
workflow superseded by AICategorizePreview, which handles batch approval,
tabs, and TempData storage in a single page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:02:50 -05:00
aj 4be5658d32 Improve: Overhaul navigation with grouped dropdowns, breadcrumbs, and quick-actions
Restructure the flat 7-item navbar into logical dropdown groups (Transactions,
Receipts, Accounts), add a prominent Upload button, settings gear icon, breadcrumb
navigation on 11 deep pages, and dashboard quick-action cards with hover effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:41:56 -05:00
aj 324ab2c627 Fix: ViewReceipt parse button now uses parse queue instead of direct parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:26:40 -05:00
aj e6512f9b7f Docs: Update ARCHITECTURE.md for tool-use, parse queue, and bulk upload
Document AI tool-use framework, receipt parse queue system, background
worker, bulk upload, ParseStatus enum, and updated AIReceiptParser flow
with tool-aware vs enriched-prompt paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:35 -05:00
aj 516546b345 Config: Update storage path, port, and AI model defaults
Change receipt storage to \TRUENAS\receipts, Kestrel port to 5005,
and add AI section with ModelsEndpoint and default ReceiptParsingModel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:31 -05:00
aj 2be9990dbc Improve: Default transaction date filter and LLM response viewer
Transactions page now defaults to last 30 days when no date filters are
set. ViewReceipt page adds collapsible raw LLM response payload on
parse logs for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:27 -05:00
aj c3e88df43c Improve: Recategorize page error display and provider detection
Add error message alert for failed proposal applications. Derive AI
provider name from model prefix instead of separate config key.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:23 -05:00
aj 444035fd72 Refactor: AICategorizePreview with tabbed proposals and rule status
Split proposals into High Confidence / Needs Review tabs. Extract
proposal table into _ProposalTable partial view. Show rule status
(Create/Update/Exists) based on existing category mappings. Persist
proposals in hidden form field to survive app restarts. Add per-tab
select-all and improved error reporting on apply.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:19 -05:00
aj 6e3589f7da Improve: AI categorizer with rule matching and unified model routing
Categorizer now pre-fetches existing rules and includes matching rules
in prompts so the AI respects established mappings. Unified model
routing via CallModelAsync replaces separate provider branching.
Improved pattern instructions require exact transaction name substrings.
Add rule update support (RuleUpdated) when a pattern exists with a
different category.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:14 -05:00
aj 5eb27319e1 Feature: Receipt queue dashboard and multi-file upload UI
Add ReceiptQueue page with tabbed dashboard (queued/completed/failed),
AJAX polling for live status updates, and per-receipt retry. Update
Receipts page with multi-file upload modal, file preview, upload
spinner, and bulk retry for failed parses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:09 -05:00
aj 705f4ea201 Feature: Receipt parse queue with background worker
Add ReceiptParseQueue (Channel-based singleton) and
ReceiptParseWorkerService (BackgroundService) for sequential receipt
parsing. Replaces fire-and-forget Task.Run with a proper queue.
ReceiptManager now enqueues uploaded receipts and supports bulk upload
via UploadManyUnmappedReceiptsAsync. Worker recovers pending items on
startup. Register IAIToolExecutor and IAIVisionClientResolver in DI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:05 -05:00
aj 16c8d121d4 Feature: Add ReceiptParseStatus enum and migration
Add ParseStatus field to Receipt model with states: NotRequested,
Queued, Parsing, Completed, Failed. Includes indexed column and EF Core
migration for tracking receipt parse queue progress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:00 -05:00
aj 396d5cfc1d Feature: Database-aware receipt parsing with tool-use and enriched prompts
AIReceiptParser now routes to tool-aware or standard vision clients.
Tool-capable models (OpenAI, Claude, LlamaCpp) call search_categories,
search_transactions, and search_merchants during parsing. Ollama gets
pre-fetched DB context injected into the prompt. Adds suggestedCategory
and suggestedTransactionId fields with AI-driven transaction mapping.
Includes NullableLongConverter for resilient JSON deserialization and
restructured receipt prompt with strict field types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:56 -05:00
aj 167b7c2ec1 Feature: Add tool-use support to AI vision clients
Add IAIToolAwareVisionClient interface and OpenAIToolUseHelper for
function-calling via /v1/chat/completions. OpenAI and LlamaCpp clients
now support multi-round tool calls, letting the AI query the database
during receipt image analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:51 -05:00
aj 5c0f0f3fca Feature: Add AI tool-use framework for database-aware receipt parsing
Introduce provider-agnostic tool definitions (AIToolRegistry) and an
executor (AIToolExecutor) that lets AI models query MoneyMap's database
during receipt parsing via search_categories, search_transactions, and
search_merchants tools. Includes an enriched-context fallback for
providers that don't support function calling (Ollama).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:47 -05:00
110 changed files with 6348 additions and 1569 deletions
+9
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Http;
@@ -1,4 +1,3 @@
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations;
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>();
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="PdfPig" Version="0.1.11" />
</ItemGroup>
<ItemGroup>
<None Update="Prompts\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,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);
}
}
}
@@ -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; }
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+241
View File
@@ -0,0 +1,241 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace MoneyMap.Mcp;
public class MoneyMapApiClient
{
private readonly HttpClient _http;
public MoneyMapApiClient(HttpClient http) => _http = http;
public async Task<string> HealthCheckAsync()
{
return await GetAsync("/api/health");
}
// --- Transactions ---
public async Task<string> SearchTransactionsAsync(
string? query, string? startDate, string? endDate, string? category,
string? merchantName, decimal? minAmount, decimal? maxAmount,
int? accountId, int? cardId, string? type, bool? uncategorizedOnly, int? limit)
{
var qs = BuildQueryString(
("query", query), ("startDate", startDate), ("endDate", endDate),
("category", category), ("merchantName", merchantName),
("minAmount", minAmount?.ToString()), ("maxAmount", maxAmount?.ToString()),
("accountId", accountId?.ToString()), ("cardId", cardId?.ToString()),
("type", type), ("uncategorizedOnly", uncategorizedOnly?.ToString()),
("limit", limit?.ToString()));
return await GetAsync($"/api/transactions{qs}");
}
public async Task<string> GetTransactionAsync(long transactionId)
{
return await GetAsync($"/api/transactions/{transactionId}");
}
public async Task<string> UpdateTransactionCategoryAsync(long[] transactionIds, string category, string? merchantName)
{
var body = new { TransactionIds = transactionIds, Category = category, MerchantName = merchantName };
return await PutAsync($"/api/transactions/{transactionIds[0]}/category", body);
}
public async Task<string> BulkRecategorizeAsync(string namePattern, string toCategory, string? fromCategory, string? merchantName, bool dryRun)
{
var body = new { NamePattern = namePattern, ToCategory = toCategory, FromCategory = fromCategory, MerchantName = merchantName, DryRun = dryRun };
return await PostAsync("/api/transactions/bulk-recategorize", body);
}
public async Task<string> GetSpendingSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/spending-summary{qs}");
}
public async Task<string> GetIncomeSummaryAsync(string startDate, string endDate, int? accountId)
{
var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString()));
return await GetAsync($"/api/transactions/income-summary{qs}");
}
// --- Budgets ---
public async Task<string> GetBudgetStatusAsync(string? asOfDate)
{
var qs = BuildQueryString(("asOfDate", asOfDate));
return await GetAsync($"/api/budgets/status{qs}");
}
public async Task<string> CreateBudgetAsync(string? category, decimal amount, string period, string startDate)
{
var body = new { Category = category, Amount = amount, Period = period, StartDate = startDate };
return await PostAsync("/api/budgets", body);
}
public async Task<string> UpdateBudgetAsync(int budgetId, decimal? amount, string? period, bool? isActive)
{
var body = new { Amount = amount, Period = period, IsActive = isActive };
return await PutAsync($"/api/budgets/{budgetId}", body);
}
// --- Categories ---
public async Task<string> ListCategoriesAsync()
{
return await GetAsync("/api/categories");
}
public async Task<string> GetCategoryMappingsAsync(string? category)
{
var qs = BuildQueryString(("category", category));
return await GetAsync($"/api/categories/mappings{qs}");
}
public async Task<string> AddCategoryMappingAsync(string pattern, string category, string? merchantName, int priority)
{
var body = new { Pattern = pattern, Category = category, MerchantName = merchantName, Priority = priority };
return await PostAsync("/api/categories/mappings", body);
}
// --- Receipts ---
public async Task<string> ListReceiptsAsync(long? transactionId, string? parseStatus, int? limit)
{
var qs = BuildQueryString(("transactionId", transactionId?.ToString()), ("parseStatus", parseStatus), ("limit", limit?.ToString()));
return await GetAsync($"/api/receipts{qs}");
}
public async Task<string> GetReceiptDetailsAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}");
}
public async Task<string> GetReceiptImageAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/image");
}
public async Task<string> GetReceiptTextAsync(long receiptId)
{
return await GetAsync($"/api/receipts/{receiptId}/text");
}
// --- Merchants ---
public async Task<string> ListMerchantsAsync(string? query)
{
var qs = BuildQueryString(("query", query));
return await GetAsync($"/api/merchants{qs}");
}
public async Task<string> MergeMerchantsAsync(int sourceMerchantId, int targetMerchantId)
{
var body = new { SourceMerchantId = sourceMerchantId, TargetMerchantId = targetMerchantId };
return await PostAsync("/api/merchants/merge", body);
}
// --- Accounts ---
public async Task<string> ListAccountsAsync()
{
return await GetAsync("/api/accounts");
}
public async Task<string> ListCardsAsync(int? accountId)
{
var qs = BuildQueryString(("accountId", accountId?.ToString()));
return await GetAsync($"/api/accounts/cards{qs}");
}
// --- Dashboard ---
public async Task<string> GetDashboardAsync(int? topCategoriesCount, int? recentTransactionsCount)
{
var qs = BuildQueryString(("topCategoriesCount", topCategoriesCount?.ToString()), ("recentTransactionsCount", recentTransactionsCount?.ToString()));
return await GetAsync($"/api/dashboard{qs}");
}
public async Task<string> GetMonthlyTrendAsync(int? months, string? category)
{
var qs = BuildQueryString(("months", months?.ToString()), ("category", category));
return await GetAsync($"/api/dashboard/monthly-trend{qs}");
}
// --- HTTP Helpers ---
private async Task<string> GetAsync(string path)
{
try
{
var response = await _http.GetAsync(path);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var body = await response.Content.ReadAsStringAsync();
return body.Length > 0 ? body : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PostAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PostAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private async Task<string> PutAsync(string path, object body)
{
try
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _http.PutAsync(path, content);
if (response.IsSuccessStatusCode)
return await response.Content.ReadAsStringAsync();
if (response.StatusCode == HttpStatusCode.NotFound)
{
var responseBody = await response.Content.ReadAsStringAsync();
return responseBody.Length > 0 ? responseBody : "Not found";
}
return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}";
}
catch (HttpRequestException ex)
{
return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}";
}
}
private static string BuildQueryString(params (string key, string? value)[] parameters)
{
var pairs = parameters
.Where(p => !string.IsNullOrWhiteSpace(p.value))
.Select(p => $"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}");
var qs = string.Join("&", pairs);
return qs.Length > 0 ? $"?{qs}" : "";
}
}
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoneyMap.Mcp;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddHttpClient<MoneyMapApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["MoneyMapApi:BaseUrl"]!);
});
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(typeof(Program).Assembly);
var app = builder.Build();
await app.RunAsync();
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class AccountTools
{
[McpServerTool(Name = "list_accounts"), Description("List all accounts with transaction counts.")]
public static async Task<string> ListAccounts(
MoneyMapApiClient api = default!)
{
return await api.ListAccountsAsync();
}
[McpServerTool(Name = "list_cards"), Description("List all cards with account info and transaction counts.")]
public static async Task<string> ListCards(
[Description("Filter cards by account ID")] int? accountId = null,
MoneyMapApiClient api = default!)
{
return await api.ListCardsAsync(accountId);
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class BudgetTools
{
[McpServerTool(Name = "get_budget_status"), Description("Get all active budgets with current period spending vs. limit.")]
public static async Task<string> GetBudgetStatus(
[Description("Date to calculate status for (defaults to today)")] string? asOfDate = null,
MoneyMapApiClient api = default!)
{
return await api.GetBudgetStatusAsync(asOfDate);
}
[McpServerTool(Name = "create_budget"), Description("Create a new budget for a category or total spending.")]
public static async Task<string> CreateBudget(
[Description("Budget amount limit")] decimal amount,
[Description("Period: Weekly, Monthly, or Yearly")] string period,
[Description("Start date for period calculation, e.g. 2026-01-01")] string startDate,
[Description("Category name (omit for total spending budget)")] string? category = null,
MoneyMapApiClient api = default!)
{
return await api.CreateBudgetAsync(category, amount, period, startDate);
}
[McpServerTool(Name = "update_budget"), Description("Update an existing budget's amount, period, or active status.")]
public static async Task<string> UpdateBudget(
[Description("Budget ID to update")] int budgetId,
[Description("New budget amount")] decimal? amount = null,
[Description("New period: Weekly, Monthly, or Yearly")] string? period = null,
[Description("Set active/inactive")] bool? isActive = null,
MoneyMapApiClient api = default!)
{
return await api.UpdateBudgetAsync(budgetId, amount, period, isActive);
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class CategoryTools
{
[McpServerTool(Name = "list_categories"), Description("List all categories with transaction counts.")]
public static async Task<string> ListCategories(
MoneyMapApiClient api = default!)
{
return await api.ListCategoriesAsync();
}
[McpServerTool(Name = "get_category_mappings"), Description("Get auto-categorization pattern rules (CategoryMappings).")]
public static async Task<string> GetCategoryMappings(
[Description("Filter mappings to a specific category")] string? category = null,
MoneyMapApiClient api = default!)
{
return await api.GetCategoryMappingsAsync(category);
}
[McpServerTool(Name = "add_category_mapping"), Description("Add a new auto-categorization rule that maps transaction name patterns to categories.")]
public static async Task<string> AddCategoryMapping(
[Description("Pattern to match in transaction name (case-insensitive)")] string pattern,
[Description("Category to assign when pattern matches")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("Priority (higher = checked first, default 0)")] int priority = 0,
MoneyMapApiClient api = default!)
{
return await api.AddCategoryMappingAsync(pattern, category, merchantName, priority);
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class DashboardTools
{
[McpServerTool(Name = "get_dashboard"), Description("Get dashboard overview: top spending categories, recent transactions, and aggregate stats.")]
public static async Task<string> GetDashboard(
[Description("Number of top categories to show (default 8)")] int? topCategoriesCount = null,
[Description("Number of recent transactions to show (default 20)")] int? recentTransactionsCount = null,
MoneyMapApiClient api = default!)
{
return await api.GetDashboardAsync(topCategoriesCount, recentTransactionsCount);
}
[McpServerTool(Name = "get_monthly_trend"), Description("Get month-over-month spending totals for trend analysis.")]
public static async Task<string> GetMonthlyTrend(
[Description("Number of months to include (default 6)")] int? months = null,
[Description("Filter to a specific category")] string? category = null,
MoneyMapApiClient api = default!)
{
return await api.GetMonthlyTrendAsync(months, category);
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class MerchantTools
{
[McpServerTool(Name = "list_merchants"), Description("List all merchants with transaction counts and category mapping info.")]
public static async Task<string> ListMerchants(
[Description("Filter merchants by name (contains)")] string? query = null,
MoneyMapApiClient api = default!)
{
return await api.ListMerchantsAsync(query);
}
[McpServerTool(Name = "merge_merchants"), Description("Merge duplicate merchants. Reassigns all transactions and category mappings from source to target, then deletes source.")]
public static async Task<string> MergeMerchants(
[Description("Merchant ID to merge FROM (will be deleted)")] int sourceMerchantId,
[Description("Merchant ID to merge INTO (will be kept)")] int targetMerchantId,
MoneyMapApiClient api = default!)
{
return await api.MergeMerchantsAsync(sourceMerchantId, targetMerchantId);
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class ReceiptTools
{
[McpServerTool(Name = "get_receipt_image"), Description("Get a receipt image for visual inspection. Returns the image as base64-encoded data. Useful for verifying transaction categories.")]
public static async Task<string> GetReceiptImage(
[Description("Receipt ID")] long receiptId,
MoneyMapApiClient api = default!)
{
return await api.GetReceiptImageAsync(receiptId);
}
[McpServerTool(Name = "get_receipt_text"), Description("Get already-parsed receipt data as structured text. Avoids re-analyzing the image when parse data exists.")]
public static async Task<string> GetReceiptText(
[Description("Receipt ID")] long receiptId,
MoneyMapApiClient api = default!)
{
return await api.GetReceiptTextAsync(receiptId);
}
[McpServerTool(Name = "list_receipts"), Description("List receipts with their parse status and basic info.")]
public static async Task<string> ListReceipts(
[Description("Filter by transaction ID")] long? transactionId = null,
[Description("Filter by parse status: NotRequested, Queued, Parsing, Completed, Failed")] string? parseStatus = null,
[Description("Max results (default 50)")] int? limit = null,
MoneyMapApiClient api = default!)
{
return await api.ListReceiptsAsync(transactionId, parseStatus, limit);
}
[McpServerTool(Name = "get_receipt_details"), Description("Get full receipt details including parsed data and all line items.")]
public static async Task<string> GetReceiptDetails(
[Description("Receipt ID")] long receiptId,
MoneyMapApiClient api = default!)
{
return await api.GetReceiptDetailsAsync(receiptId);
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace MoneyMap.Mcp.Tools;
[McpServerToolType]
public static class TransactionTools
{
[McpServerTool(Name = "search_transactions"), Description("Search and filter transactions. Returns matching transactions with details.")]
public static async Task<string> SearchTransactions(
[Description("Full-text search across name, memo, and category")] string? query = null,
[Description("Start date (inclusive), e.g. 2026-01-01")] string? startDate = null,
[Description("End date (inclusive), e.g. 2026-01-31")] string? endDate = null,
[Description("Filter by category name (exact match)")] string? category = null,
[Description("Filter by merchant name (contains)")] string? merchantName = null,
[Description("Minimum amount (absolute value)")] decimal? minAmount = null,
[Description("Maximum amount (absolute value)")] decimal? maxAmount = null,
[Description("Filter by account ID")] int? accountId = null,
[Description("Filter by card ID")] int? cardId = null,
[Description("Filter by type: 'debit' or 'credit'")] string? type = null,
[Description("Only show uncategorized transactions")] bool? uncategorizedOnly = null,
[Description("Max results to return (default 50)")] int? limit = null,
MoneyMapApiClient api = default!)
{
return await api.SearchTransactionsAsync(query, startDate, endDate, category, merchantName, minAmount, maxAmount, accountId, cardId, type, uncategorizedOnly, limit);
}
[McpServerTool(Name = "get_transaction"), Description("Get a single transaction with all details including receipts.")]
public static async Task<string> GetTransaction(
[Description("Transaction ID")] long transactionId,
MoneyMapApiClient api = default!)
{
return await api.GetTransactionAsync(transactionId);
}
[McpServerTool(Name = "get_spending_summary"), Description("Get spending totals grouped by category for a date range. Excludes transfers.")]
public static async Task<string> GetSpendingSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapApiClient api = default!)
{
return await api.GetSpendingSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "get_income_summary"), Description("Get income (credits) grouped by source/name for a date range.")]
public static async Task<string> GetIncomeSummary(
[Description("Start date (inclusive), e.g. 2026-01-01")] string startDate,
[Description("End date (inclusive), e.g. 2026-01-31")] string endDate,
[Description("Filter to specific account ID")] int? accountId = null,
MoneyMapApiClient api = default!)
{
return await api.GetIncomeSummaryAsync(startDate, endDate, accountId);
}
[McpServerTool(Name = "update_transaction_category"), Description("Update the category (and optionally merchant) on one or more transactions.")]
public static async Task<string> UpdateTransactionCategory(
[Description("Array of transaction IDs to update")] long[] transactionIds,
[Description("New category to assign")] string category,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
MoneyMapApiClient api = default!)
{
return await api.UpdateTransactionCategoryAsync(transactionIds, category, merchantName);
}
[McpServerTool(Name = "bulk_recategorize"), Description("Recategorize all transactions matching a name pattern. Use dryRun=true (default) to preview changes first.")]
public static async Task<string> BulkRecategorize(
[Description("Pattern to match in transaction name (case-insensitive contains)")] string namePattern,
[Description("New category to assign")] string toCategory,
[Description("Only recategorize transactions currently in this category")] string? fromCategory = null,
[Description("Merchant name to assign (creates if new)")] string? merchantName = null,
[Description("If true (default), only shows what would change without applying")] bool dryRun = true,
MoneyMapApiClient api = default!)
{
return await api.BulkRecategorizeAsync(namePattern, toCategory, fromCategory, merchantName, dryRun);
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"MoneyMapApi": {
"BaseUrl": "http://barge.lan:5010/"
}
}
+1
View File
@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\MoneyMap\MoneyMap.csproj" />
<ProjectReference Include="..\MoneyMap.Core\MoneyMap.Core.csproj" />
</ItemGroup>
</Project>
+48
View File
@@ -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);
}
}
+26
View File
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuditController : ControllerBase
{
private readonly IFinancialAuditService _auditService;
public AuditController(IFinancialAuditService auditService) => _auditService = auditService;
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] bool includeTransactions = false)
{
var end = endDate ?? DateTime.Today;
var start = startDate ?? end.AddDays(-90);
var result = await _auditService.GenerateAuditAsync(start, end, includeTransactions);
return Ok(result);
}
}
+103
View File
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BudgetsController : ControllerBase
{
private readonly IBudgetService _budgetService;
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
[HttpGet("status")]
public async Task<IActionResult> GetStatus([FromQuery] string? asOfDate = null)
{
DateTime? date = null;
if (!string.IsNullOrWhiteSpace(asOfDate) && DateTime.TryParse(asOfDate, out var parsed))
date = parsed;
var statuses = await _budgetService.GetAllBudgetStatusesAsync(date);
var result = statuses.Select(s => new
{
s.Budget.Id,
Category = s.Budget.DisplayName,
s.Budget.Amount,
Period = s.Budget.Period.ToString(),
s.PeriodStart,
s.PeriodEnd,
s.Spent,
s.Remaining,
PercentUsed = Math.Round(s.PercentUsed, 1),
s.IsOverBudget
}).ToList();
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateBudgetRequest request)
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
if (!DateTime.TryParse(request.StartDate, out var startDate))
return BadRequest(new { message = "Invalid start date format" });
var budget = new Budget
{
Category = request.Category,
Amount = request.Amount,
Period = budgetPeriod,
StartDate = startDate,
IsActive = true
};
var result = await _budgetService.CreateBudgetAsync(budget);
return Ok(new { result.Success, result.Message, BudgetId = budget.Id });
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetRequest request)
{
var budget = await _budgetService.GetBudgetByIdAsync(id);
if (budget == null)
return NotFound(new { message = "Budget not found" });
if (request.Amount.HasValue)
budget.Amount = request.Amount.Value;
if (!string.IsNullOrWhiteSpace(request.Period))
{
if (!Enum.TryParse<BudgetPeriod>(request.Period, true, out var budgetPeriod))
return BadRequest(new { message = $"Invalid period '{request.Period}'. Must be Weekly, Monthly, or Yearly." });
budget.Period = budgetPeriod;
}
if (request.IsActive.HasValue)
budget.IsActive = request.IsActive.Value;
var result = await _budgetService.UpdateBudgetAsync(budget);
return Ok(new { result.Success, result.Message });
}
}
public class CreateBudgetRequest
{
public string? Category { get; set; }
public decimal Amount { get; set; }
public string Period { get; set; } = "";
public string StartDate { get; set; } = "";
}
public class UpdateBudgetRequest
{
public decimal? Amount { get; set; }
public string? Period { get; set; }
public bool? IsActive { get; set; }
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoriesController(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{
_db = db;
_categorizer = categorizer;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> List()
{
var categories = await _db.Transactions
.Where(t => t.Category != null && t.Category != "")
.GroupBy(t => t.Category!)
.Select(g => new { Category = g.Key, Count = g.Count(), TotalSpent = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)) })
.OrderByDescending(x => x.Count)
.ToListAsync();
var uncategorized = await _db.Transactions
.CountAsync(t => t.Category == null || t.Category == "");
return Ok(new { Categories = categories, UncategorizedCount = uncategorized });
}
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings([FromQuery] string? category = null)
{
var mappings = await _categorizer.GetAllMappingsAsync();
if (!string.IsNullOrWhiteSpace(category))
mappings = mappings.Where(m => m.Category.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList();
var result = mappings.Select(m => new
{
m.Id,
m.Pattern,
m.Category,
m.MerchantId,
m.Priority
}).OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList();
return Ok(result);
}
[HttpPost("mappings")]
public async Task<IActionResult> AddMapping([FromBody] CreateCategoryMappingRequest request)
{
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
var mapping = new CategoryMapping
{
Pattern = request.Pattern,
Category = request.Category,
MerchantId = merchantId,
Priority = request.Priority
};
_db.CategoryMappings.Add(mapping);
await _db.SaveChangesAsync();
return Ok(new { Created = true, mapping.Id, mapping.Pattern, mapping.Category, Merchant = request.MerchantName, mapping.Priority });
}
}
public class CreateCategoryMappingRequest
{
public string Pattern { get; set; } = "";
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
public int Priority { get; set; }
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboardService;
private readonly MoneyMapContext _db;
public DashboardController(IDashboardService dashboardService, MoneyMapContext db)
{
_dashboardService = dashboardService;
_db = db;
}
[HttpGet]
public async Task<IActionResult> Get(
[FromQuery] int? topCategoriesCount = null,
[FromQuery] int? recentTransactionsCount = null)
{
var data = await _dashboardService.GetDashboardDataAsync(
topCategoriesCount ?? 8,
recentTransactionsCount ?? 20);
return Ok(data);
}
[HttpGet("monthly-trend")]
public async Task<IActionResult> MonthlyTrend(
[FromQuery] int? months = null,
[FromQuery] string? category = null)
{
var monthCount = months ?? 6;
var endDate = DateTime.Today;
var startDate = new DateTime(endDate.Year, endDate.Month, 1).AddMonths(-(monthCount - 1));
var q = _db.Transactions
.Where(t => t.Date >= startDate && t.Date <= endDate)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
var monthly = await q
.GroupBy(t => new { t.Date.Year, t.Date.Month })
.Select(g => new
{
Year = g.Key.Year,
Month = g.Key.Month,
Total = g.Sum(t => Math.Abs(t.Amount)),
Count = g.Count()
})
.OrderBy(x => x.Year).ThenBy(x => x.Month)
.ToListAsync();
var result = monthly.Select(m => new
{
Period = $"{m.Year}-{m.Month:D2}",
m.Total,
m.Count
}).ToList();
return Ok(new { Category = category ?? "All Spending", Months = result });
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
private readonly MoneyMapContext _db;
public HealthController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get()
{
var canConnect = await _db.Database.CanConnectAsync();
if (!canConnect)
return StatusCode(503, new { status = "unhealthy", reason = "database unreachable" });
return Ok(new { status = "healthy" });
}
}
@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MerchantsController : ControllerBase
{
private readonly MoneyMapContext _db;
public MerchantsController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? query = null)
{
var q = _db.Merchants
.Include(m => m.Transactions)
.Include(m => m.CategoryMappings)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(m => m.Name.Contains(query));
var merchants = await q
.OrderBy(m => m.Name)
.Select(m => new
{
m.Id,
m.Name,
TransactionCount = m.Transactions.Count,
MappingCount = m.CategoryMappings.Count,
Categories = m.CategoryMappings.Select(cm => cm.Category).Distinct().ToList()
})
.ToListAsync();
return Ok(new { Count = merchants.Count, Merchants = merchants });
}
[HttpPost("merge")]
public async Task<IActionResult> Merge([FromBody] MergeMerchantsRequest request)
{
if (request.SourceMerchantId == request.TargetMerchantId)
return BadRequest(new { message = "Source and target merchant cannot be the same" });
var source = await _db.Merchants.FindAsync(request.SourceMerchantId);
var target = await _db.Merchants.FindAsync(request.TargetMerchantId);
if (source == null)
return NotFound(new { message = $"Source merchant {request.SourceMerchantId} not found" });
if (target == null)
return NotFound(new { message = $"Target merchant {request.TargetMerchantId} not found" });
var transactions = await _db.Transactions
.Where(t => t.MerchantId == request.SourceMerchantId)
.ToListAsync();
foreach (var t in transactions)
t.MerchantId = request.TargetMerchantId;
var sourceMappings = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.SourceMerchantId)
.ToListAsync();
var targetMappingPatterns = await _db.CategoryMappings
.Where(cm => cm.MerchantId == request.TargetMerchantId)
.Select(cm => cm.Pattern)
.ToListAsync();
foreach (var mapping in sourceMappings)
{
if (targetMappingPatterns.Contains(mapping.Pattern))
_db.CategoryMappings.Remove(mapping);
else
mapping.MerchantId = request.TargetMerchantId;
}
_db.Merchants.Remove(source);
await _db.SaveChangesAsync();
return Ok(new
{
Merged = true,
Source = new { source.Id, source.Name },
Target = new { target.Id, target.Name },
TransactionsReassigned = transactions.Count,
MappingsReassigned = sourceMappings.Count
});
}
}
public class MergeMerchantsRequest
{
public int SourceMerchantId { get; set; }
public int TargetMerchantId { get; set; }
}
+197
View File
@@ -0,0 +1,197 @@
using ImageMagick;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReceiptsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IReceiptStorageOptions _storageOptions;
public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions)
{
_db = db;
_storageOptions = storageOptions;
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] long? transactionId = null,
[FromQuery] string? parseStatus = null,
[FromQuery] int? limit = null)
{
var q = _db.Receipts
.Include(r => r.Transaction)
.AsQueryable();
if (transactionId.HasValue)
q = q.Where(r => r.TransactionId == transactionId.Value);
if (!string.IsNullOrWhiteSpace(parseStatus) && Enum.TryParse<ReceiptParseStatus>(parseStatus, true, out var status))
q = q.Where(r => r.ParseStatus == status);
var results = await q
.OrderByDescending(r => r.UploadedAtUtc)
.Take(limit ?? 50)
.Select(r => new
{
r.Id,
r.FileName,
ParseStatus = r.ParseStatus.ToString(),
r.Merchant,
r.Total,
r.ReceiptDate,
r.UploadedAtUtc,
TransactionId = r.TransactionId,
TransactionName = r.Transaction != null ? r.Transaction.Name : null
})
.ToListAsync();
return Ok(new { Count = results.Count, Receipts = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetDetails(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.Include(r => r.ParseLogs)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
var result = new
{
receipt.Id,
receipt.FileName,
receipt.ContentType,
receipt.FileSizeBytes,
receipt.UploadedAtUtc,
ParseStatus = receipt.ParseStatus.ToString(),
ParsedData = new
{
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency
},
LinkedTransaction = receipt.Transaction != null ? new
{
receipt.Transaction.Id,
receipt.Transaction.Name,
receipt.Transaction.Date,
receipt.Transaction.Amount,
receipt.Transaction.Category
} : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList(),
ParseHistory = receipt.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc).Select(pl => new
{
pl.Provider,
pl.Model,
pl.Success,
pl.Confidence,
pl.Error,
pl.StartedAtUtc
}).ToList()
};
return Ok(result);
}
[HttpGet("{id}/image")]
public async Task<IActionResult> GetImage(long id)
{
var receipt = await _db.Receipts.FindAsync(id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
var basePath = Path.GetFullPath(_storageOptions.ReceiptsBasePath);
var fullPath = Path.GetFullPath(Path.Combine(basePath, receipt.StoragePath));
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { message = "Invalid receipt path" });
if (!System.IO.File.Exists(fullPath))
return NotFound(new { message = "Receipt file not found on disk" });
byte[] imageBytes;
string mimeType;
if (receipt.ContentType == "application/pdf")
{
var settings = new MagickReadSettings { Density = new Density(220) };
using var image = new MagickImage(fullPath + "[0]", settings);
image.Format = MagickFormat.Png;
image.BackgroundColor = MagickColors.White;
image.Alpha(AlphaOption.Remove);
imageBytes = image.ToByteArray();
mimeType = "image/png";
}
else
{
imageBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
mimeType = receipt.ContentType;
}
var base64 = Convert.ToBase64String(imageBytes);
return Ok(new { MimeType = mimeType, Data = base64, SizeBytes = imageBytes.Length });
}
[HttpGet("{id}/text")]
public async Task<IActionResult> GetText(long id)
{
var receipt = await _db.Receipts
.Include(r => r.LineItems)
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == id);
if (receipt == null)
return NotFound(new { message = "Receipt not found" });
if (receipt.ParseStatus != ReceiptParseStatus.Completed)
return Ok(new { Message = "Receipt has not been parsed yet", ParseStatus = receipt.ParseStatus.ToString() });
var result = new
{
receipt.Id,
receipt.Merchant,
receipt.ReceiptDate,
receipt.DueDate,
receipt.Subtotal,
receipt.Tax,
receipt.Total,
receipt.Currency,
LinkedTransaction = receipt.Transaction != null ? new { receipt.Transaction.Id, receipt.Transaction.Name, receipt.Transaction.Category, receipt.Transaction.Amount } : null,
LineItems = receipt.LineItems.OrderBy(li => li.LineNumber).Select(li => new
{
li.LineNumber,
li.Description,
li.Quantity,
li.UnitPrice,
li.LineTotal,
li.Category
}).ToList()
};
return Ok(result);
}
}
@@ -0,0 +1,271 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly MoneyMapContext _db;
private readonly IMerchantService _merchantService;
public TransactionsController(MoneyMapContext db, IMerchantService merchantService)
{
_db = db;
_merchantService = merchantService;
}
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? query = null,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] string? category = null,
[FromQuery] string? merchantName = null,
[FromQuery] decimal? minAmount = null,
[FromQuery] decimal? maxAmount = null,
[FromQuery] int? accountId = null,
[FromQuery] int? cardId = null,
[FromQuery] string? type = null,
[FromQuery] bool? uncategorizedOnly = null,
[FromQuery] int? limit = null)
{
var q = _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(t => t.Name.Contains(query) || (t.Memo != null && t.Memo.Contains(query)) || (t.Category != null && t.Category.Contains(query)));
if (!string.IsNullOrWhiteSpace(startDate) && DateTime.TryParse(startDate, out var start))
q = q.Where(t => t.Date >= start);
if (!string.IsNullOrWhiteSpace(endDate) && DateTime.TryParse(endDate, out var end))
q = q.Where(t => t.Date <= end);
if (!string.IsNullOrWhiteSpace(category))
q = q.Where(t => t.Category == category);
if (!string.IsNullOrWhiteSpace(merchantName))
q = q.Where(t => t.Merchant != null && t.Merchant.Name.Contains(merchantName));
if (minAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) >= minAmount.Value);
if (maxAmount.HasValue)
q = q.Where(t => Math.Abs(t.Amount) <= maxAmount.Value);
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
if (cardId.HasValue)
q = q.Where(t => t.CardId == cardId.Value);
if (type?.ToLower() == "debit")
q = q.Where(t => t.Amount < 0);
else if (type?.ToLower() == "credit")
q = q.Where(t => t.Amount > 0);
if (uncategorizedOnly == true)
q = q.Where(t => t.Category == null || t.Category == "");
var results = await q
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
.Take(limit ?? 50)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
Account = t.Account!.Institution + " " + t.Account.Last4,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
ReceiptCount = t.Receipts.Count,
t.TransferToAccountId
})
.ToListAsync();
return Ok(new { Count = results.Count, Transactions = results });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(long id)
{
var t = await _db.Transactions
.Include(t => t.Merchant)
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Receipts)
.Where(t => t.Id == id)
.Select(t => new
{
t.Id,
t.Date,
t.Name,
t.Memo,
t.Amount,
t.TransactionType,
t.Category,
Merchant = t.Merchant != null ? t.Merchant.Name : null,
MerchantId = t.MerchantId,
Account = t.Account!.Institution + " " + t.Account.Last4,
AccountId = t.AccountId,
Card = t.Card != null ? t.Card.Issuer + " " + t.Card.Last4 : null,
CardId = t.CardId,
t.Notes,
t.TransferToAccountId,
Receipts = t.Receipts.Select(r => new { r.Id, r.FileName, r.ParseStatus, r.Merchant, r.Total }).ToList()
})
.FirstOrDefaultAsync();
if (t == null)
return NotFound(new { message = "Transaction not found" });
return Ok(t);
}
[HttpPut("{id}/category")]
public async Task<IActionResult> UpdateCategory(long id, [FromBody] UpdateCategoryRequest request)
{
var transactions = await _db.Transactions
.Where(t => request.TransactionIds.Contains(t.Id))
.ToListAsync();
if (!transactions.Any())
return NotFound(new { message = "No transactions found with the provided IDs" });
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.Category;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Updated = transactions.Count, request.Category, Merchant = request.MerchantName });
}
[HttpPost("bulk-recategorize")]
public async Task<IActionResult> BulkRecategorize([FromBody] BulkRecategorizeRequest request)
{
var q = _db.Transactions
.Where(t => t.Name.Contains(request.NamePattern));
if (!string.IsNullOrWhiteSpace(request.FromCategory))
q = q.Where(t => t.Category == request.FromCategory);
var transactions = await q.ToListAsync();
if (!transactions.Any())
return Ok(new { Message = "No transactions match the pattern", request.NamePattern, request.FromCategory });
if (request.DryRun)
{
var preview = transactions.Take(20).Select(t => new { t.Id, t.Date, t.Name, t.Amount, CurrentCategory = t.Category }).ToList();
return Ok(new { DryRun = true, TotalMatches = transactions.Count, Preview = preview, request.ToCategory });
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(request.MerchantName))
merchantId = await _merchantService.GetOrCreateIdAsync(request.MerchantName);
foreach (var t in transactions)
{
t.Category = request.ToCategory;
if (merchantId.HasValue)
t.MerchantId = merchantId;
}
await _db.SaveChangesAsync();
return Ok(new { Applied = true, Updated = transactions.Count, request.ToCategory, Merchant = request.MerchantName });
}
[HttpGet("spending-summary")]
public async Task<IActionResult> SpendingSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount < 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Category ?? "Uncategorized")
.Select(g => new { Category = g.Key, Total = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Categories = summary });
}
[HttpGet("income-summary")]
public async Task<IActionResult> IncomeSummary(
[FromQuery] string startDate,
[FromQuery] string endDate,
[FromQuery] int? accountId = null)
{
if (!DateTime.TryParse(startDate, out var start) || !DateTime.TryParse(endDate, out var end))
return BadRequest(new { message = "Invalid date format" });
var q = _db.Transactions
.Where(t => t.Date >= start && t.Date <= end)
.Where(t => t.Amount > 0)
.Where(t => t.TransferToAccountId == null)
.ExcludeTransfers();
if (accountId.HasValue)
q = q.Where(t => t.AccountId == accountId.Value);
var summary = await q
.GroupBy(t => t.Name)
.Select(g => new { Source = g.Key, Total = g.Sum(t => t.Amount), Count = g.Count() })
.OrderByDescending(x => x.Total)
.ToListAsync();
var grandTotal = summary.Sum(x => x.Total);
return Ok(new { Period = $"{startDate} to {endDate}", GrandTotal = grandTotal, Sources = summary });
}
}
public class UpdateCategoryRequest
{
public long[] TransactionIds { get; set; } = [];
public string Category { get; set; } = "";
public string? MerchantName { get; set; }
}
public class BulkRecategorizeRequest
{
public string NamePattern { get; set; } = "";
public string ToCategory { get; set; } = "";
public string? FromCategory { get; set; }
public string? MerchantName { get; set; }
public bool DryRun { get; set; } = true;
}
+11 -3
View File
@@ -2,12 +2,20 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
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");
+9 -10
View File
@@ -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>
+81 -87
View File
@@ -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>
}
+67 -12
View File
@@ -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
+5
View File
@@ -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">
+5
View File
@@ -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>
+6
View File
@@ -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">
+5
View File
@@ -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
View File
@@ -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: {
-8
View File
@@ -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>
-19
View File
@@ -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()
{
}
}
}
+7
View File
@@ -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">
+14 -1
View File
@@ -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();
+398
View File
@@ -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>
}
+220
View File
@@ -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; }
}
}
}
+68 -16
View File
@@ -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>
+54 -1
View File
@@ -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);
-121
View File
@@ -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">&lt;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!;
}
}
+5
View File
@@ -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">
+75 -17
View File
@@ -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">
&copy; 2025 - MoneyMap
&copy; 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)
+5 -7
View File
@@ -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
}
});
+7
View File
@@ -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)
+5
View File
@@ -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>
+29 -22
View File
@@ -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