feat: add receipt upload API and target net10
Build MoneyMap image / build-and-push (push) Failing after 10s
Build MoneyMap image / build-and-push (push) Failing after 10s
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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,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,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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user