feat: add receipt upload API and target net10
Build MoneyMap image / build-and-push (push) Failing after 10s

This commit is contained in:
aj
2026-09-11 20:31:51 -04:00
parent 1cc0a355f2
commit 08e54a4d31
9 changed files with 160 additions and 151 deletions
+1 -1
View File
@@ -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
+16 -3
View File
@@ -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)
-134
View File
@@ -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<IMyService, MyService>();
```
### 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
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
@@ -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<IReceiptManager>();
var controller = CreateController(context, manager.Object);
var result = await controller.Upload(null);
Assert.IsType<BadRequestObjectResult>(result);
manager.VerifyNoOtherCalls();
}
[Fact]
public async Task Upload_UnmappedFile_QueuesReceiptAndReturnsMetadata()
{
using var context = DbContextHelper.CreateInMemoryContext();
var manager = new Mock<IReceiptManager>();
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<IFormFile>()))
.ReturnsAsync(ReceiptUploadResult.Success(receipt));
var controller = CreateController(context, manager.Object);
var result = await controller.Upload(CreateFormFile("receipt.png"));
var ok = Assert.IsType<OkObjectResult>(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<IFormFile>()), Times.Once);
}
[Fact]
public async Task Upload_WithTransactionId_DelegatesToMappedUpload()
{
using var context = DbContextHelper.CreateInMemoryContext();
var manager = new Mock<IReceiptManager>();
manager.Setup(m => m.UploadReceiptAsync(99, It.IsAny<IFormFile>()))
.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<BadRequestObjectResult>(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<IFormFile>()), 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();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
+34 -1
View File
@@ -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<IActionResult> 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]
+14 -9
View File
@@ -1,20 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<!-- .NET SDK 10.0.112 cannot resolve the Web SDK's wwwroot glob on Linux. -->
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Utility\**" />
<Compile Remove="wwwroot\receipts\**" />
<Content Remove="Utility\**" />
<Content Remove="wwwroot\receipts\**" />
<EmbeddedResource Remove="Utility\**" />
<EmbeddedResource Remove="wwwroot\receipts\**" />
<None Remove="Utility\**" />
<None Remove="wwwroot\receipts\**" />
<Content Include="wwwroot/favicon.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/css/site.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/category-mappings.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/edit-transaction.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/merchants.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/site.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/transactions.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/js/upload.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/lib/bootstrap/css/bootstrap.min.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="wwwroot/lib/jquery/jquery.min.js" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>