diff --git a/AGENTS.md b/AGENTS.md index b822d16..a14b92c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Quick Overview -MoneyMap is an ASP.NET Core 8.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API. +MoneyMap is an ASP.NET Core 10.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API. ## Architecture Documentation diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8c52c82..a4ccf99 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,11 +2,11 @@ ## Project Overview -MoneyMap is an ASP.NET Core 8.0 Razor Pages application designed for personal finance tracking. It allows users to import bank transaction CSV files, categorize expenses, attach receipt images/PDFs, and parse receipts using AI (OpenAI GPT-4o-mini Vision API). +MoneyMap is an ASP.NET Core 10.0 Razor Pages application designed for personal finance tracking. It allows users to import bank transaction CSV files, categorize expenses, attach receipt images/PDFs, and parse receipts using AI (OpenAI GPT-4o-mini Vision API). ## Technology Stack -- **Framework**: ASP.NET Core 8.0 (Razor Pages) +- **Framework**: ASP.NET Core 10.0 (Razor Pages) - **Database**: SQL Server with Entity Framework Core 9.0 - **Libraries**: - CsvHelper (33.1.0) - CSV parsing @@ -1214,6 +1214,19 @@ EF Core DbContext managing all database entities. ## API Endpoints +### Receipt Upload API +**Route:** `POST /api/receipts?transactionId={id}` + +**Purpose:** Upload one receipt image or PDF, store it through `IReceiptManager`, and queue it for asynchronous parsing. Omit `transactionId` to upload an unmapped receipt; provide it to attach the receipt to an existing transaction. + +**Request:** `multipart/form-data` with one required `file` field. The shared receipt service accepts JPG/JPEG, PNG, GIF, HEIC, and PDF files up to 10 MB; it validates their file signatures, calculates a SHA-256 hash, and returns duplicate warnings when applicable. + +**Responses:** +- `200 OK`: uploaded receipt metadata (`id`, file metadata, `parseStatus`, optional `transactionId`, and `duplicateWarnings`). +- `400 Bad Request`: missing/invalid file, unsupported content, over-size upload, duplicate mapped receipt, or unknown transaction. + +**Example:** `curl -X POST http://localhost:5010/api/receipts -F 'file=@receipt.png'` + ### Financial Audit API **Route:** `GET /api/audit` @@ -1818,7 +1831,7 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep **Last Updated:** 2026-02-11 **Version:** 1.5 -**Framework:** ASP.NET Core 8.0 / EF Core 9.0 +**Framework:** ASP.NET Core 10.0 / EF Core 9.0 ## Recent Changes (v1.5) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 109bbe9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,134 +0,0 @@ -# MoneyMap - Claude Code Context - -## Quick Overview - -MoneyMap is an ASP.NET Core 8.0 Razor Pages application for personal finance tracking. Users can import bank transaction CSVs, categorize expenses, attach receipt images/PDFs, and parse receipts using OpenAI's Vision API. - -## Architecture Documentation - -**For detailed technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)** - -The architecture document contains: -- Complete technology stack -- Core domain models (Transaction, Receipt, Card, Account, Merchant, etc.) -- Service layer details (TransactionImporter, CardResolver, TransactionCategorizer, etc.) -- Database schema and relationships -- Key workflows and design patterns -- Security and performance considerations - -## Project Structure - -``` -MoneyMap/ -├── Data/ -│ └── MoneyMapContext.cs # EF Core DbContext -├── Models/ -│ ├── Account.cs # Bank accounts -│ ├── Card.cs # Payment cards -│ ├── Merchant.cs # Merchants/vendors -│ ├── Transaction.cs # Core transaction entity -│ ├── Receipt.cs # Receipt files -│ ├── ReceiptLineItem.cs # Parsed line items -│ └── ReceiptParseLog.cs # Parse attempt logs -├── Services/ -│ ├── TransactionCategorizer.cs # Auto-categorization logic -│ ├── ReceiptManager.cs # Receipt upload/storage -│ └── OpenAIReceiptParser.cs # AI-powered receipt parsing -├── Pages/ -│ ├── Index.cshtml[.cs] # Dashboard -│ ├── Upload.cshtml[.cs] # CSV import -│ ├── Transactions.cshtml[.cs] # Transaction list -│ ├── EditTransaction.cshtml[.cs] # Edit transaction -│ ├── ViewReceipt.cshtml[.cs] # Receipt details -│ ├── CategoryMappings.cshtml[.cs]# Category rules -│ ├── Merchants.cshtml[.cs] # Merchant management -│ └── Recategorize.cshtml[.cs] # Bulk recategorization -└── Program.cs # DI configuration -``` - -## Common Development Tasks - -### Adding a New Page -1. Create `.cshtml` and `.cshtml.cs` files in `Pages/` -2. Inherit from `PageModel` -3. Add route via `@page` directive -4. Register dependencies in constructor via DI - -### Adding a New Service -1. Create interface in appropriate namespace -2. Create implementation class -3. Register in `Program.cs`: - ```csharp - builder.Services.AddScoped(); - ``` - -### Adding a Database Migration -```bash -dotnet ef migrations add MigrationName -dotnet ef database update -``` - -### Modifying Domain Models -1. Update model class in `Models/` -2. Update `MoneyMapContext.OnModelCreating()` if needed (relationships, indexes) -3. Create and apply migration - -## Key Design Principles - -1. **Service Layer Pattern**: Business logic lives in services, not pages -2. **Result Pattern**: Services return result objects (not exceptions) -3. **Dependency Injection**: All services injected via interfaces -4. **Single Responsibility**: Each service has one clear purpose -5. **Clean Architecture**: UI → Services → Data Access - -## Important Notes - -- **Duplicate Prevention**: Transactions have a unique constraint on (Date, Amount, Name, Memo, AccountId, CardId) -- **Cascade Deletes**: Deleting a transaction cascades to receipts, parse logs, and line items -- **Merchant Assignment**: Category mappings can auto-assign merchants to transactions -- **Transfer Detection**: Transactions with `TransferToAccountId` are identified as transfers -- **Receipt Parsing**: OpenAI API key required for receipt parsing (env var `OPENAI_API_KEY`) - -## Development Workflow - -1. Read [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details -2. Make changes to models, services, or pages -3. Test locally -4. Create database migration if schema changed -5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) if architecture changes** (new models, services, workflows, etc.) -6. Commit with descriptive message - -## Important: Keep Documentation Updated - -**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):** -- Adding/removing domain models -- Adding/removing services or changing their responsibilities -- Modifying database schema or relationships -- Adding new workflows or processes -- Changing design patterns or conventions -- Adding new pages or major features - -This ensures both Claude Code and Codex CLI have accurate, up-to-date context. - -## Configuration - -See `appsettings.json`: -- `ConnectionStrings:MoneyMapDb` - SQL Server connection -- `OpenAI:ApiKey` - OpenAI API key (optional, use env var instead) -- `Receipts:StoragePath` - Receipt storage location (relative to wwwroot) - -## Testing - -- All services use interfaces for mockability -- Use in-memory database for integration tests -- Mock `IReceiptParser` to avoid OpenAI API calls in tests - -## Questions? - -Refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for comprehensive technical documentation including: -- Detailed service descriptions -- Database schema -- Key workflows -- Security considerations -- Performance optimizations -- Troubleshooting guide diff --git a/MoneyMap.Core/MoneyMap.Core.csproj b/MoneyMap.Core/MoneyMap.Core.csproj index 518cae6..2a6e1bf 100644 --- a/MoneyMap.Core/MoneyMap.Core.csproj +++ b/MoneyMap.Core/MoneyMap.Core.csproj @@ -1,6 +1,6 @@ - net8.0 + net10.0 enable enable diff --git a/MoneyMap.Mcp/MoneyMap.Mcp.csproj b/MoneyMap.Mcp/MoneyMap.Mcp.csproj index f1bce23..df799db 100644 --- a/MoneyMap.Mcp/MoneyMap.Mcp.csproj +++ b/MoneyMap.Mcp/MoneyMap.Mcp.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 enable enable diff --git a/MoneyMap.Tests/Controllers/ReceiptsControllerTests.cs b/MoneyMap.Tests/Controllers/ReceiptsControllerTests.cs new file mode 100644 index 0000000..8a16c1e --- /dev/null +++ b/MoneyMap.Tests/Controllers/ReceiptsControllerTests.cs @@ -0,0 +1,92 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using MoneyMap.Controllers; +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Controllers; + +public class ReceiptsControllerTests +{ + [Fact] + public async Task Upload_NoFile_ReturnsBadRequest() + { + using var context = DbContextHelper.CreateInMemoryContext(); + var manager = new Mock(); + var controller = CreateController(context, manager.Object); + + var result = await controller.Upload(null); + + Assert.IsType(result); + manager.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Upload_UnmappedFile_QueuesReceiptAndReturnsMetadata() + { + using var context = DbContextHelper.CreateInMemoryContext(); + var manager = new Mock(); + var receipt = new Receipt + { + Id = 42, + FileName = "receipt.png", + ContentType = "image/png", + FileSizeBytes = 3, + UploadedAtUtc = new DateTime(2026, 9, 11, 12, 0, 0, DateTimeKind.Utc), + ParseStatus = ReceiptParseStatus.Queued + }; + manager.Setup(m => m.UploadUnmappedReceiptAsync(It.IsAny())) + .ReturnsAsync(ReceiptUploadResult.Success(receipt)); + var controller = CreateController(context, manager.Object); + + var result = await controller.Upload(CreateFormFile("receipt.png")); + + var ok = Assert.IsType(result); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.Equal(42, json.RootElement.GetProperty("Id").GetInt64()); + Assert.Equal("Queued", json.RootElement.GetProperty("ParseStatus").GetString()); + Assert.Equal(JsonValueKind.Null, json.RootElement.GetProperty("TransactionId").ValueKind); + manager.Verify(m => m.UploadUnmappedReceiptAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Upload_WithTransactionId_DelegatesToMappedUpload() + { + using var context = DbContextHelper.CreateInMemoryContext(); + var manager = new Mock(); + manager.Setup(m => m.UploadReceiptAsync(99, It.IsAny())) + .ReturnsAsync(ReceiptUploadResult.Failure("Transaction not found.")); + var controller = CreateController(context, manager.Object); + + var result = await controller.Upload(CreateFormFile("receipt.png"), 99); + + var badRequest = Assert.IsType(result); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(badRequest.Value)); + Assert.Equal("Transaction not found.", json.RootElement.GetProperty("message").GetString()); + manager.Verify(m => m.UploadReceiptAsync(99, It.IsAny()), Times.Once); + } + + private static ReceiptsController CreateController( + MoneyMap.Data.MoneyMapContext context, + IReceiptManager receiptManager) => + new(context, new TestReceiptStorageOptions(), receiptManager); + + private static IFormFile CreateFormFile(string fileName) + { + var bytes = Encoding.UTF8.GetBytes("png"); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "image/png" + }; + } + + private sealed class TestReceiptStorageOptions : IReceiptStorageOptions + { + public string ReceiptsBasePath => Path.GetTempPath(); + } +} diff --git a/MoneyMap.Tests/MoneyMap.Tests.csproj b/MoneyMap.Tests/MoneyMap.Tests.csproj index 656e9e6..131b4a3 100644 --- a/MoneyMap.Tests/MoneyMap.Tests.csproj +++ b/MoneyMap.Tests/MoneyMap.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/MoneyMap/Controllers/ReceiptsController.cs b/MoneyMap/Controllers/ReceiptsController.cs index 73d64b8..5a02826 100644 --- a/MoneyMap/Controllers/ReceiptsController.cs +++ b/MoneyMap/Controllers/ReceiptsController.cs @@ -13,11 +13,44 @@ public class ReceiptsController : ControllerBase { private readonly MoneyMapContext _db; private readonly IReceiptStorageOptions _storageOptions; + private readonly IReceiptManager _receiptManager; - public ReceiptsController(MoneyMapContext db, IReceiptStorageOptions storageOptions) + public ReceiptsController( + MoneyMapContext db, + IReceiptStorageOptions storageOptions, + IReceiptManager receiptManager) { _db = db; _storageOptions = storageOptions; + _receiptManager = receiptManager; + } + + [HttpPost] + [Consumes("multipart/form-data")] + public async Task Upload(IFormFile? file, [FromQuery] long? transactionId = null) + { + if (file == null || file.Length == 0) + return BadRequest(new { message = "No file selected." }); + + var result = transactionId.HasValue + ? await _receiptManager.UploadReceiptAsync(transactionId.Value, file) + : await _receiptManager.UploadUnmappedReceiptAsync(file); + + if (!result.IsSuccess) + return BadRequest(new { message = result.ErrorMessage }); + + var receipt = result.Receipt!; + return Ok(new + { + receipt.Id, + receipt.FileName, + receipt.ContentType, + receipt.FileSizeBytes, + receipt.UploadedAtUtc, + ParseStatus = receipt.ParseStatus.ToString(), + receipt.TransactionId, + DuplicateWarnings = result.DuplicateWarnings + }); } [HttpGet] diff --git a/MoneyMap/MoneyMap.csproj b/MoneyMap/MoneyMap.csproj index bab1ef7..f72ba92 100644 --- a/MoneyMap/MoneyMap.csproj +++ b/MoneyMap/MoneyMap.csproj @@ -1,20 +1,25 @@ - net8.0 + net10.0 + + false enable enable - - - - - - - - + + + + + + + + + + +