From 1cc0a355f25138bbe07eba18a03b423a3caec6c6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 17 Aug 2026 06:42:08 -0400 Subject: [PATCH] feat: add recurring places and transaction pagination --- .claude/settings.local.json | 6 +- .env.example | 26 +- .gitea/workflows/build-moneymap.yml | 4 +- .gitignore | 96 +- AGENTS.md | 218 +- ARCHITECTURE.md | 3822 ++++++++--------- CLAUDE.md | 268 +- MoneyMap.Core/Data/MoneyMapContext.cs | 503 ++- MoneyMap.Core/GlobalUsings.cs | 10 +- MoneyMap.Core/Models/Account.cs | 90 +- .../Models/Api/FinancialAuditModels.cs | 274 +- MoneyMap.Core/Models/Budget.cs | 124 +- MoneyMap.Core/Models/Card.cs | 75 +- MoneyMap.Core/Models/Category.cs | 33 + MoneyMap.Core/Models/CategoryMapping.cs | 94 +- .../Models/Dashboard/DashboardModels.cs | 126 +- MoneyMap.Core/Models/Import/ImportContext.cs | 56 +- MoneyMap.Core/Models/Import/ImportResults.cs | 138 +- .../Models/Import/PaymentResolutionResult.cs | 64 +- .../Models/Import/TransactionCsvRow.cs | 22 +- .../Models/Import/TransactionCsvRowMap.cs | 62 +- MoneyMap.Core/Models/Merchant.cs | 33 +- MoneyMap.Core/Models/Receipt.cs | 146 +- MoneyMap.Core/Models/ReceiptLineItem.cs | 86 +- MoneyMap.Core/Models/ReceiptParseLog.cs | 86 +- MoneyMap.Core/Models/RecurringPlace.cs | 40 + MoneyMap.Core/Models/Transaction.cs | 172 +- MoneyMap.Core/Models/Transfer.cs | 98 +- MoneyMap.Core/MoneyMap.Core.csproj | 42 +- MoneyMap.Core/Prompts/ReceiptParserPrompt.txt | 122 +- MoneyMap.Core/ServiceCollectionExtensions.cs | 106 +- MoneyMap.Core/Services/AIReceiptParser.cs | 932 ++-- .../Services/AITools/AIToolDefinitions.cs | 318 +- .../Services/AITools/AIToolExecutor.cs | 560 +-- MoneyMap.Core/Services/AIVisionClient.cs | 2048 ++++----- MoneyMap.Core/Services/AccountService.cs | 404 +- MoneyMap.Core/Services/BudgetService.cs | 694 +-- MoneyMap.Core/Services/CardResolver.cs | 316 +- MoneyMap.Core/Services/CardService.cs | 246 +- MoneyMap.Core/Services/DashboardService.cs | 536 +-- .../Services/FinancialAuditService.cs | 982 ++--- .../Services/IReceiptStorageOptions.cs | 12 +- MoneyMap.Core/Services/MerchantService.cs | 368 +- MoneyMap.Core/Services/PdfToImageConverter.cs | 122 +- MoneyMap.Core/Services/ReceiptAutoMapper.cs | 990 ++--- MoneyMap.Core/Services/ReceiptManager.cs | 810 ++-- .../Services/ReceiptMatchingService.cs | 474 +- MoneyMap.Core/Services/ReceiptParseQueue.cs | 112 +- .../Services/ReferenceDataService.cs | 162 +- .../Services/TransactionAICategorizer.cs | 934 ++-- .../Services/TransactionCategorizer.cs | 524 +-- MoneyMap.Core/Services/TransactionFilters.cs | 74 +- MoneyMap.Core/Services/TransactionImporter.cs | 334 +- MoneyMap.Core/Services/TransactionService.cs | 158 +- .../Services/TransactionStatisticsService.cs | 210 +- MoneyMap.Tests/MoneyMap.Tests.csproj | 56 +- .../Services/AccountServiceTests.cs | 472 +- MoneyMap.Tests/Services/CardServiceTests.cs | 462 +- .../Services/MerchantServiceTests.cs | 376 +- .../Services/ReceiptMatchingServiceTests.cs | 658 +-- .../Services/ReferenceDataServiceTests.cs | 440 +- .../Services/TransactionServiceTests.cs | 456 +- .../TransactionStatisticsServiceTests.cs | 374 +- MoneyMap.Tests/TestHelpers/DbContextHelper.cs | 40 +- MoneyMap.sln | 158 +- MoneyMap/.dockerignore | 14 +- .../Controllers/RecurringPlacesController.cs | 163 + .../Controllers/TransactionsController.cs | 22 +- .../20251004000603_InitialCreate.Designer.cs | 704 +-- .../20251004000603_InitialCreate.cs | 418 +- ...1004023633_AddCategoryMappings.Designer.cs | 746 ++-- .../20251004023633_AddCategoryMappings.cs | 92 +- ...4034919_AddNotesToTransactions.Designer.cs | 754 ++-- .../20251004034919_AddNotesToTransactions.cs | 58 +- ...10003334_SplitCardsAndAccounts.Designer.cs | 1014 ++--- .../20251010003334_SplitCardsAndAccounts.cs | 368 +- ...1010025545_LinkCardsToAccounts.Designer.cs | 1056 ++--- .../20251010025545_LinkCardsToAccounts.cs | 120 +- ...keAccountRequiredOnTransaction.Designer.cs | 1056 ++--- ...231349_MakeAccountRequiredOnTransaction.cs | 178 +- ...51011014001_AddTransferSupport.Designer.cs | 1078 ++--- .../20251011014001_AddTransferSupport.cs | 96 +- ...1124_AddMerchantToTransactions.Designer.cs | 1086 ++--- ...0251012071124_AddMerchantToTransactions.cs | 58 +- ..._AddMerchantToCategoryMappings.Designer.cs | 1092 ++--- ...012071155_AddMerchantToCategoryMappings.cs | 56 +- ...075038_ConvertMerchantToEntity.Designer.cs | 1192 ++--- .../20251012075038_ConvertMerchantToEntity.cs | 318 +- ...1012145117_AddAICategorization.Designer.cs | 1212 +++--- .../20251012145117_AddAICategorization.cs | 98 +- ...keReceiptTransactionIdNullable.Designer.cs | 1212 +++--- ...152604_MakeReceiptTransactionIdNullable.cs | 44 +- ...tIndexForNullableTransactionId.Designer.cs | 1212 +++--- ...ateReceiptIndexForNullableTransactionId.cs | 114 +- ...MakeReceiptTransactionOptional.Designer.cs | 1212 +++--- ...12163144_MakeReceiptTransactionOptional.cs | 114 +- ...251012185017_AddReceiptDueDate.Designer.cs | 1218 +++--- .../20251012185017_AddReceiptDueDate.cs | 56 +- ...640_AddVoidedToReceiptLineItem.Designer.cs | 1224 +++--- ...251019194640_AddVoidedToReceiptLineItem.cs | 58 +- ...1124231350_AddCompositeIndexes.Designer.cs | 1240 +++--- .../20251124231350_AddCompositeIndexes.cs | 180 +- .../20251211020503_AddBudgets.Designer.cs | 1314 +++--- .../Migrations/20251211020503_AddBudgets.cs | 94 +- ...9005858_AddReceiptParsingNotes.Designer.cs | 1322 +++--- .../20260119005858_AddReceiptParsingNotes.cs | 58 +- ...15030558_AddReceiptParseStatus.Designer.cs | 1336 +++--- .../20260215030558_AddReceiptParseStatus.cs | 76 +- ...0707000000_AddCategoryToRecurringPlaces.cs | 65 + .../MoneyMapContextModelSnapshot.cs | 1330 +++--- MoneyMap/MoneyMap.csproj | 82 +- MoneyMap/MoneyMap.sln | 48 +- MoneyMap/Pages/AICategorizePreview.cshtml | 390 +- MoneyMap/Pages/AICategorizePreview.cshtml.cs | 732 ++-- MoneyMap/Pages/AccountDetails.cshtml | 282 +- MoneyMap/Pages/AccountDetails.cshtml.cs | 124 +- MoneyMap/Pages/Accounts.cshtml | 146 +- MoneyMap/Pages/Accounts.cshtml.cs | 134 +- MoneyMap/Pages/Budgets.cshtml | 632 +-- MoneyMap/Pages/Budgets.cshtml.cs | 440 +- MoneyMap/Pages/Cards.cshtml | 220 +- MoneyMap/Pages/Cards.cshtml.cs | 88 +- MoneyMap/Pages/CategoryMappings.cshtml | 616 +-- MoneyMap/Pages/CategoryMappings.cshtml.cs | 632 +-- MoneyMap/Pages/EditAccount.cshtml | 128 +- MoneyMap/Pages/EditAccount.cshtml.cs | 246 +- MoneyMap/Pages/EditCard.cshtml | 230 +- MoneyMap/Pages/EditCard.cshtml.cs | 298 +- MoneyMap/Pages/EditTransaction.cshtml | 556 +-- MoneyMap/Pages/EditTransaction.cshtml.cs | 560 +-- MoneyMap/Pages/Error.cshtml | 52 +- MoneyMap/Pages/Error.cshtml.cs | 56 +- MoneyMap/Pages/Index.cshtml | 768 ++-- MoneyMap/Pages/Index.cshtml.cs | 78 +- MoneyMap/Pages/Merchants.cshtml | 312 +- MoneyMap/Pages/Merchants.cshtml.cs | 272 +- MoneyMap/Pages/Recategorize.cshtml | 308 +- MoneyMap/Pages/Recategorize.cshtml.cs | 296 +- MoneyMap/Pages/ReceiptQueue.cshtml | 796 ++-- MoneyMap/Pages/ReceiptQueue.cshtml.cs | 440 +- MoneyMap/Pages/Receipts.cshtml | 1116 ++--- MoneyMap/Pages/Receipts.cshtml.cs | 806 ++-- MoneyMap/Pages/ReviewReceipts.cshtml | 576 +-- MoneyMap/Pages/ReviewReceipts.cshtml.cs | 390 +- MoneyMap/Pages/Settings.cshtml | 186 +- MoneyMap/Pages/Settings.cshtml.cs | 228 +- MoneyMap/Pages/Shared/_Layout.cshtml | 248 +- MoneyMap/Pages/Shared/_Layout.cshtml.css | 96 +- .../Shared/_ValidationScriptsPartial.cshtml | 4 +- MoneyMap/Pages/Transactions.cshtml | 844 ++-- MoneyMap/Pages/Transactions.cshtml.cs | 424 +- MoneyMap/Pages/Upload.cshtml | 488 +-- MoneyMap/Pages/Upload.cshtml.cs | 432 +- MoneyMap/Pages/ViewReceipt.cshtml | 458 +- MoneyMap/Pages/ViewReceipt.cshtml.cs | 220 +- MoneyMap/Pages/_ProposalTable.cshtml | 202 +- MoneyMap/Pages/_ViewImports.cshtml | 6 +- MoneyMap/Pages/_ViewStart.cshtml | 6 +- MoneyMap/Program.cs | 172 +- MoneyMap/Properties/launchSettings.json | 76 +- MoneyMap/Services/ModelWarmupService.cs | 126 +- .../Services/ReceiptParseWorkerService.cs | 258 +- MoneyMap/Services/WebReceiptStorageOptions.cs | 34 +- MoneyMap/appsettings.Development.json | 18 +- MoneyMap/appsettings.json | 52 +- MoneyMap/libman.json | 26 +- MoneyMap/wwwroot/css/site.css | 862 ++-- MoneyMap/wwwroot/js/category-mappings.js | 138 +- MoneyMap/wwwroot/js/edit-transaction.js | 146 +- MoneyMap/wwwroot/js/merchants.js | 62 +- MoneyMap/wwwroot/js/site.js | 38 +- MoneyMap/wwwroot/js/transactions.js | 88 +- MoneyMap/wwwroot/js/upload.js | 252 +- docker-compose.yml | 26 +- docker-publish.cmd | 2 +- 175 files changed, 34411 insertions(+), 34039 deletions(-) create mode 100644 MoneyMap.Core/Models/Category.cs create mode 100644 MoneyMap.Core/Models/RecurringPlace.cs create mode 100644 MoneyMap/Controllers/RecurringPlacesController.cs create mode 100644 MoneyMap/Migrations/20260707000000_AddCategoryToRecurringPlaces.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 764dcd8..cea85a4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,11 @@ "Bash(dotnet build:*)", "Bash(dotnet new:*)", "Bash(dotnet add:*)", - "Bash(dotnet test:*)" + "Bash(dotnet test:*)", + "mcp__moneymap__search_transactions", + "mcp__moneymap__update_transaction_category", + "mcp__moneymap__get_spending_summary", + "mcp__moneymap__get_income_summary" ], "deny": [], "ask": [] diff --git a/.env.example b/.env.example index afaf64d..d9587b7 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ -# Docker image -DOCKER_IMAGE=yourusername/moneymap:latest - -# Database connection -DB_SERVER=your-server -DB_USER=your-username -DB_PASSWORD=your-password - -# OpenAI API key for receipt parsing -OPENAI_API_KEY=your-openai-key - -# Host path for receipt storage -RECEIPTS_HOST_PATH=/mnt/docker-data/moneymap/receipts +# Docker image +DOCKER_IMAGE=yourusername/moneymap:latest + +# Database connection +DB_SERVER=your-server +DB_USER=your-username +DB_PASSWORD=your-password + +# OpenAI API key for receipt parsing +OPENAI_API_KEY=your-openai-key + +# Host path for receipt storage +RECEIPTS_HOST_PATH=/mnt/docker-data/moneymap/receipts diff --git a/.gitea/workflows/build-moneymap.yml b/.gitea/workflows/build-moneymap.yml index 11dfc49..4b5c35a 100644 --- a/.gitea/workflows/build-moneymap.yml +++ b/.gitea/workflows/build-moneymap.yml @@ -7,7 +7,7 @@ on: - "MoneyMap/**" - "MoneyMap.Core/**" - "MoneyMap.Mcp/**" - - "MoneyMap/Dockerfile" + - "Dockerfile" - ".gitea/workflows/build-moneymap.yml" workflow_dispatch: {} @@ -21,7 +21,7 @@ jobs: run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin - name: Build image - run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} . + run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} MoneyMap/ - name: Push image run: | diff --git a/.gitignore b/.gitignore index c7be5ce..7754353 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,48 @@ - -#Ignore thumbnails created by Windows -Thumbs.db -#Ignore files built by Visual Studio -*.obj -*.exe -*.pdb -*.user -*.aps -*.pch -*.vspscc -*_i.c -*_p.c -*.ncb -*.suo -*.tlb -*.tlh -*.bak -*.cache -*.ilk -*.log -[Bb]in -[Dd]ebug*/ -*.lib -*.sbr -obj/ -[Rr]elease*/ -_ReSharper*/ -[Tt]est[Rr]esult* -.vs/ -.idea/ -#Nuget packages folder -packages/ -/MoneyMap/wwwroot/lib/ -/MoneyMap/wwwroot/receipts/ -**/publish/ - -# Environment files with secrets -.env - -# Local settings -settings.local.json - -# Superpowers plans/specs -docs/superpowers/ - -# Playwright MCP artifacts -.playwright-mcp/ + +#Ignore thumbnails created by Windows +Thumbs.db +#Ignore files built by Visual Studio +*.obj +*.exe +*.pdb +*.user +*.aps +*.pch +*.vspscc +*_i.c +*_p.c +*.ncb +*.suo +*.tlb +*.tlh +*.bak +*.cache +*.ilk +*.log +[Bb]in +[Dd]ebug*/ +*.lib +*.sbr +obj/ +[Rr]elease*/ +_ReSharper*/ +[Tt]est[Rr]esult* +.vs/ +.idea/ +#Nuget packages folder +packages/ +/MoneyMap/wwwroot/lib/ +/MoneyMap/wwwroot/receipts/ +**/publish/ + +# Environment files with secrets +.env + +# Local settings +settings.local.json + +# Superpowers plans/specs +docs/superpowers/ + +# Playwright MCP artifacts +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index d4e7732..b822d16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,109 +1,109 @@ -# MoneyMap - Codex Agent 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 complete technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)** - -The shared architecture document provides: -- Complete technology stack and dependencies -- Core domain models and relationships -- Service layer implementations -- Database schema with all tables and relationships -- Key workflows (CSV import, categorization, receipt parsing, etc.) -- Design patterns and best practices -- Security considerations -- Performance optimizations -- Troubleshooting guide - -## Agent Configuration - -This file is used by Codex CLI for agent context. Claude Code users should reference [CLAUDE.md](./CLAUDE.md) instead. - -## Key Features for Agents - -### Transaction Management -- CSV import with duplicate detection -- Auto-categorization based on merchant patterns -- Manual category and merchant assignment -- Transfer detection between accounts - -### Receipt Processing -- File upload with SHA256 deduplication -- OpenAI Vision API integration for parsing -- Line item extraction -- Parse logging and confidence tracking - -### Data Organization -- Account and Card hierarchy -- Merchant normalization -- Category mapping rules with priority -- Relationship tracking (transactions ↔ receipts ↔ line items) - -## Common Agent Tasks - -### Code Analysis -When analyzing code, refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for: -- Service interfaces and their responsibilities -- Domain model relationships -- Database constraints and cascade rules -- Performance considerations (indexes, AsNoTracking) - -### Feature Implementation -1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for existing patterns -2. Follow Service Layer Pattern (business logic in services) -3. Use Result Pattern for error handling -4. Register new services in Program.cs -5. Create migrations for schema changes - -### Bug Investigation -1. Review relevant service in [ARCHITECTURE.md](./ARCHITECTURE.md) -2. Check database constraints and relationships -3. Review cascade delete rules -4. Check unique indexes for duplicate constraints - -### Refactoring -1. Maintain service layer separation -2. Keep interfaces for testability -3. Follow existing patterns (DI, Result Pattern, DTOs) -4. Update [ARCHITECTURE.md](./ARCHITECTURE.md) if making significant changes - -## Important Constraints - -- **Unique Transactions**: (Date, Amount, Name, Memo, AccountId, CardId) -- **Cascade Deletes**: Transaction → Receipts → ParseLogs/LineItems -- **Restrict Deletes**: Can't delete Account/Card with existing transactions -- **File Limits**: Receipts max 10MB, whitelist extensions only -- **API Requirements**: OpenAI API key required for receipt parsing - -## Development Guidelines - -1. **Always** check [ARCHITECTURE.md](./ARCHITECTURE.md) before making changes -2. **Use** existing service patterns and interfaces -3. **Follow** Single Responsibility Principle -4. **Test** with mockable interfaces -5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) when making architectural changes** - -## Important: Keep Documentation Updated - -**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):** -- Adding/removing domain models (Account, Transaction, Receipt, etc.) -- Adding/removing services or changing their responsibilities -- Modifying database schema, relationships, or constraints -- Adding new workflows or processes -- Changing design patterns or conventions -- Adding new pages or major features -- Modifying security or performance considerations - -This shared documentation ensures both Claude Code and Codex CLI have accurate context for future work. - -## Reference Links - -- Technical Details: [ARCHITECTURE.md](./ARCHITECTURE.md) -- Claude Code Context: [CLAUDE.md](./CLAUDE.md) -- Service Layer: See ARCHITECTURE.md § Service Layer -- Database Schema: See ARCHITECTURE.md § Database Schema -- Workflows: See ARCHITECTURE.md § Key Workflows +# MoneyMap - Codex Agent 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 complete technical documentation, see [ARCHITECTURE.md](./ARCHITECTURE.md)** + +The shared architecture document provides: +- Complete technology stack and dependencies +- Core domain models and relationships +- Service layer implementations +- Database schema with all tables and relationships +- Key workflows (CSV import, categorization, receipt parsing, etc.) +- Design patterns and best practices +- Security considerations +- Performance optimizations +- Troubleshooting guide + +## Agent Configuration + +This file is used by Codex CLI for agent context. Claude Code users should reference [CLAUDE.md](./CLAUDE.md) instead. + +## Key Features for Agents + +### Transaction Management +- CSV import with duplicate detection +- Auto-categorization based on merchant patterns +- Manual category and merchant assignment +- Transfer detection between accounts + +### Receipt Processing +- File upload with SHA256 deduplication +- OpenAI Vision API integration for parsing +- Line item extraction +- Parse logging and confidence tracking + +### Data Organization +- Account and Card hierarchy +- Merchant normalization +- Category mapping rules with priority +- Relationship tracking (transactions ↔ receipts ↔ line items) + +## Common Agent Tasks + +### Code Analysis +When analyzing code, refer to [ARCHITECTURE.md](./ARCHITECTURE.md) for: +- Service interfaces and their responsibilities +- Domain model relationships +- Database constraints and cascade rules +- Performance considerations (indexes, AsNoTracking) + +### Feature Implementation +1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for existing patterns +2. Follow Service Layer Pattern (business logic in services) +3. Use Result Pattern for error handling +4. Register new services in Program.cs +5. Create migrations for schema changes + +### Bug Investigation +1. Review relevant service in [ARCHITECTURE.md](./ARCHITECTURE.md) +2. Check database constraints and relationships +3. Review cascade delete rules +4. Check unique indexes for duplicate constraints + +### Refactoring +1. Maintain service layer separation +2. Keep interfaces for testability +3. Follow existing patterns (DI, Result Pattern, DTOs) +4. Update [ARCHITECTURE.md](./ARCHITECTURE.md) if making significant changes + +## Important Constraints + +- **Unique Transactions**: (Date, Amount, Name, Memo, AccountId, CardId) +- **Cascade Deletes**: Transaction → Receipts → ParseLogs/LineItems +- **Restrict Deletes**: Can't delete Account/Card with existing transactions +- **File Limits**: Receipts max 10MB, whitelist extensions only +- **API Requirements**: OpenAI API key required for receipt parsing + +## Development Guidelines + +1. **Always** check [ARCHITECTURE.md](./ARCHITECTURE.md) before making changes +2. **Use** existing service patterns and interfaces +3. **Follow** Single Responsibility Principle +4. **Test** with mockable interfaces +5. **Update [ARCHITECTURE.md](./ARCHITECTURE.md) when making architectural changes** + +## Important: Keep Documentation Updated + +**When making architectural changes, always update [ARCHITECTURE.md](./ARCHITECTURE.md):** +- Adding/removing domain models (Account, Transaction, Receipt, etc.) +- Adding/removing services or changing their responsibilities +- Modifying database schema, relationships, or constraints +- Adding new workflows or processes +- Changing design patterns or conventions +- Adding new pages or major features +- Modifying security or performance considerations + +This shared documentation ensures both Claude Code and Codex CLI have accurate context for future work. + +## Reference Links + +- Technical Details: [ARCHITECTURE.md](./ARCHITECTURE.md) +- Claude Code Context: [CLAUDE.md](./CLAUDE.md) +- Service Layer: See ARCHITECTURE.md § Service Layer +- Database Schema: See ARCHITECTURE.md § Database Schema +- Workflows: See ARCHITECTURE.md § Key Workflows diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2992343..8c52c82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,1911 +1,1911 @@ -# MoneyMap Architecture Documentation - -## 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). - -## Technology Stack - -- **Framework**: ASP.NET Core 8.0 (Razor Pages) -- **Database**: SQL Server with Entity Framework Core 9.0 -- **Libraries**: - - CsvHelper (33.1.0) - CSV parsing - - Magick.NET (14.8.2) - PDF to image conversion - - PdfPig (0.1.11) - PDF processing - - OpenAI API - Receipt OCR and parsing - -## Architecture - -MoneyMap follows a clean, service-oriented architecture: - -``` -┌─────────────────────────────────────────────┐ -│ Razor Pages (UI Layer) │ -│ Index, Upload, Transactions, ViewReceipt │ -│ EditTransaction, CategoryMappings, etc. │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────┴────────────────────────────┐ -│ Service Layer (Business Logic) │ -│ Core Transaction Services: │ -│ - TransactionImporter │ -│ - CardResolver │ -│ - TransactionCategorizer │ -│ - TransactionService (NEW) │ -│ - TransactionStatisticsService (NEW) │ -│ Entity Management Services: │ -│ - AccountService (NEW) │ -│ - CardService (NEW) │ -│ - MerchantService (EXPANDED) │ -│ Receipt Services: │ -│ - ReceiptMatchingService (NEW) │ -│ - ReceiptManager │ -│ - AIReceiptParser │ -│ - ReceiptParseQueue (singleton) │ -│ - ReceiptParseWorkerService (hosted) │ -│ AI Tool Use: │ -│ - AIToolExecutor (DB query tools) │ -│ - AIToolRegistry (tool definitions) │ -│ Reference & Dashboard: │ -│ - ReferenceDataService (NEW) │ -│ - DashboardService │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────┴────────────────────────────┐ -│ Data Access Layer (EF Core) │ -│ MoneyMapContext │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────┴────────────────────────────┐ -│ SQL Server Database │ -│ Cards, Accounts, Transactions, Receipts, │ -│ ReceiptLineItems, ReceiptParseLogs, │ -│ CategoryMappings, Merchants, Budgets │ -└─────────────────────────────────────────────┘ -``` - -## Core Domain Models - -### Account (Models/Account.cs) -Represents a bank account (checking, savings, credit card account). - -**Properties:** -- `Id` (int) - Primary key -- `Issuer` (string, 100) - Institution name -- `Last4` (string, 4) - Last 4 digits of account number -- `Owner` (string, 100) - Account owner name (optional) -- `DisplayLabel` (string, computed) - Formatted label for display -- `Cards` - Collection of cards linked to this account -- `Transactions` - Collection of transactions for this account - -### Card (Models/Card.cs) -Represents a payment card (credit/debit) linked to an account. - -**Properties:** -- `Id` (int) - Primary key -- `AccountId` (int) - Foreign key to Account -- `Issuer` (string, 100) - Card issuer (VISA, MC, etc.) -- `Last4` (string, 4) - Last 4 digits of card number -- `Owner` (string, 100) - Cardholder name (optional) -- `DisplayLabel` (string, computed) - Formatted label for display -- `Transactions` - Navigation property to transactions - -### Merchant (Models/Merchant.cs) -Represents a merchant/vendor where transactions occur. - -**Properties:** -- `Id` (int) - Primary key -- `Name` (string, 100) - Merchant name -- `Transactions` - Collection of transactions with this merchant -- `CategoryMappings` - Collection of category mapping rules - -### Transaction (Models/Transaction.cs) -Core entity representing a bank transaction. - -**Properties:** -- `Id` (long) - Primary key -- `Date` (DateTime) - Transaction date -- `TransactionType` (string, 20) - "DEBIT"/"CREDIT" -- `Name` (string, 200) - Transaction name from CSV -- `Memo` (string, 500) - Transaction description -- `Amount` (decimal) - Amount (negative = debit, positive = credit) -- `Category` (string, 100) - Expense category -- `MerchantId` (int?) - Foreign key to Merchant -- `Notes` (string) - User notes -- `AccountId` (int) - Foreign key to Account (required) -- `CardId` (int?) - Foreign key to Card (optional) -- `TransferToAccountId` (int?) - Foreign key for transfers -- `Last4` (string, 4) - Cached last 4 digits from memo -- `Receipts` - Collection of attached receipts - -**Unique Index:** Date + Amount + Name + Memo + AccountId + CardId (prevents duplicates) - -**Computed Properties:** -- `IsCredit` - Returns true if Amount > 0 -- `IsDebit` - Returns true if Amount < 0 -- `IsTransfer` - Returns true if TransferToAccountId is set -- `PaymentMethodLabel` - Formatted payment method string - -### Receipt (Models/Receipt.cs) -Stores uploaded receipt files (images/PDFs) linked to transactions. - -**Properties:** -- `Id` (long) - Primary key -- `TransactionId` (long?) - Foreign key to Transaction (nullable to support unmapped receipts) -- `FileName` (string, 260) - Original file name (sanitized) -- `ContentType` (string, 100) - MIME type -- `StoragePath` (string, 1024) - Relative path in wwwroot -- `FileSizeBytes` (long) - File size -- `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 -- `DueDate` (DateTime?) - Payment due date (for bills only) -- `Subtotal`, `Tax`, `Total` (decimal?) - Monetary amounts -- `Currency` (string, 8) - Currency code - -**Navigation Properties:** -- `ParseLogs` - Collection of parse attempts -- `LineItems` - Collection of receipt line items - -**Unique Index:** TransactionId + FileHashSha256 (prevents duplicate uploads, filtered WHERE TransactionId IS NOT NULL) - -### ReceiptLineItem (Models/ReceiptLineItem.cs) -Individual line items extracted from receipts. - -**Properties:** -- `Id` (long) - Primary key -- `ReceiptId` (long) - Foreign key to Receipt -- `LineNumber` (int) - Sequential line number -- `Description` (string, 300) - Item description -- `Quantity` (decimal?) - Quantity purchased (null for services/fees) -- `UnitPrice` (decimal?) - Price per unit -- `LineTotal` (decimal) - Total for this line - -### ReceiptParseLog (Models/ReceiptParseLog.cs) -Logs each receipt parsing attempt for auditing. - -**Properties:** -- `Id` (long) - Primary key -- `ReceiptId` (long) - Foreign key to Receipt -- `Provider` (string, 50) - "OpenAI" -- `Model` (string, 100) - Model used (e.g., "gpt-4o-mini") -- `StartedAtUtc`, `CompletedAtUtc` (DateTime) -- `Success` (bool) - Parse success status -- `Confidence` (decimal?) - AI confidence score -- `Error` (string?) - Error message if failed -- `RawProviderPayloadJson` (string?) - Full API response - -### CategoryMapping (Services/TransactionCategorizer.cs) -Pattern-based rules for auto-categorization with merchant linking. - -**Properties:** -- `Id` (int) - Primary key -- `Category` (string) - Category name -- `Pattern` (string) - Merchant name pattern to match -- `MerchantId` (int?) - Foreign key to Merchant (optional) -- `Priority` (int) - Higher priority checked first (default 0) - -### Budget (Models/Budget.cs) -Represents a spending budget for a category or total spending. - -**Properties:** -- `Id` (int) - Primary key -- `Category` (string?, 100) - Category name (null = total spending budget) -- `Amount` (decimal) - Budget limit amount -- `Period` (BudgetPeriod) - Weekly, Monthly, or Yearly -- `StartDate` (DateTime) - When budget becomes effective (used for period boundaries) -- `IsActive` (bool) - Whether budget is currently active -- `Notes` (string?, 500) - Optional notes - -**Computed Properties:** -- `IsTotalBudget` - Returns true if Category is null -- `DisplayName` - Returns Category or "Total Spending" - -**Unique Index:** Category + Period (filtered WHERE IsActive = 1, prevents duplicate active budgets) - -**BudgetPeriod Enum:** -- `Weekly` (0) - 7-day periods from StartDate -- `Monthly` (1) - Monthly periods based on StartDate day-of-month -- `Yearly` (2) - Annual periods from StartDate anniversary - -## Service Layer - -### TransactionImporter (Pages/Upload.cshtml.cs) -**Interface:** `ITransactionImporter` - -**Responsibility:** Import transactions from CSV files. - -**Key Methods:** -- `ImportAsync(Stream csvStream, ImportContext context)` - - Parses CSV using CsvHelper - - Resolves card for each transaction (auto or manual) - - Deduplicates against database and current batch - - Returns `ImportOperationResult` with stats - -**Workflow:** -1. Read CSV with flexible header mapping -2. For each row: - - Resolve card using CardResolver - - Map CSV row to Transaction entity - - Check for duplicates (database + in-memory batch) - - Add non-duplicates to batch -3. Bulk save to database -4. Return import statistics - -**Location:** Pages/Upload.cshtml.cs:92-180 - -### CardResolver (Pages/Upload.cshtml.cs) -**Interface:** `ICardResolver` - -**Responsibility:** Determine which card a transaction belongs to. - -**Key Methods:** -- `ResolveCardAsync(string? memo, ImportContext context)` - - Auto mode: Extract last 4 digits from memo or filename - - Manual mode: Use user-selected card - - Auto-creates cards if not found - - Returns `CardResolutionResult` - -**Card Extraction Logic:** -- From memo: Regex pattern `\b(?:\.|\s)(\d{4,6})\b` extracts last 4 digits -- From filename: Split by `-`, `_`, or space; find 4-digit numeric segment -- Auto-create: If card doesn't exist, creates new Card with Owner="Unknown" - -**Location:** Pages/Upload.cshtml.cs:190-248 - -### TransactionCategorizer (Services/TransactionCategorizer.cs) -**Interface:** `ITransactionCategorizer` - -**Responsibility:** Auto-categorize transactions based on merchant name patterns. - -**Key Methods:** -- `CategorizeAsync(string merchantName, decimal? amount = null)` - - Matches merchant name against CategoryMapping patterns (case-insensitive) - - Special logic: Gas stations with small purchases (<-$20) → "Convenience Store" - - Assigns merchant to transaction if mapping has MerchantId - - Returns category string or empty if no match -- `GetAllMappingsAsync()` - Retrieve all mappings for management UI -- `SeedDefaultMappingsAsync()` - One-time seed of default category rules - -**Default Categories (200+ patterns):** -- Online shopping (Amazon, Sephora, Kohls, etc.) -- Walmart (separate: Online, Pickup/Grocery) -- Pizza chains (Domino's, Papa John's, etc.) -- Brick/mortar stores (Dollar General, Kroger, Target, etc.) -- Restaurants (McDonald's, Starbucks, Olive Garden, etc.) -- School expenses -- Health (pharmacies, clinics, dental) -- Gas & Auto (high priority, 100) -- Utilities/Services (Verizon, Comcast, etc.) -- Entertainment (Netflix, Hulu, Steam, etc.) -- Banking fees (highest priority, 200) -- Mortgage, Car Payment, Insurance -- Credit Card payments -- Ice Cream/Treats -- Government/DMV -- Home Improvement -- Software/Subscriptions - -**Location:** Services/TransactionCategorizer.cs:31-231 - -### TransactionService (Services/TransactionService.cs) -**Interface:** `ITransactionService` - -**Responsibility:** Core transaction operations including duplicate detection, retrieval, and deletion. - -**Key Methods:** -- `IsDuplicateAsync(Transaction transaction)` - - Checks if a transaction already exists in the database - - Matches on: Date, Amount, Name, Memo, AccountId, and CardId - - Used during CSV import to prevent duplicate entries - - Returns boolean indicating if duplicate exists - -- `GetTransactionByIdAsync(long id, bool includeRelated = false)` - - Retrieves a transaction by ID - - Optional parameter to include related entities (Card, Account, Merchant, Receipts, etc.) - - Returns Transaction entity or null if not found - -- `DeleteTransactionAsync(long id)` - - Deletes a transaction and all related data - - Cascades to Receipts, ParseLogs, and LineItems - - Returns boolean indicating success - -**Design Pattern:** -- Consolidates duplicate detection logic previously scattered across multiple PageModels -- Single source of truth for transaction CRUD operations -- Improves testability by separating business logic from UI layer - -**Location:** Services/TransactionService.cs - -### ReceiptMatchingService (Services/ReceiptMatchingService.cs) -**Interface:** `IReceiptMatchingService` - -**Responsibility:** Match receipts to transactions using intelligent scoring based on date, merchant, and amount. - -**Key Methods:** -- `FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)` - - Finds candidate transactions that match receipt criteria - - Uses multi-stage filtering and scoring algorithm - - Returns sorted list of `TransactionMatch` objects with relevance scores - -- `GetTransactionIdsWithReceiptsAsync()` - - Returns set of transaction IDs that already have receipts - - Used to exclude already-mapped transactions from matching - -**Matching Algorithm:** -1. **Date Filtering:** - - Regular receipts: ±3 days from receipt date - - Bills with due dates: From receipt date to due date + 5 days (for auto-pay delays) - - No date: Skip date filter - -2. **Merchant Matching (Word-Based Scoring):** - - Exact match: Score = 1000 - - Word overlap: Count matching words between receipt merchant and transaction name/merchant - - Splits on spaces, hyphens, underscores, periods - - Uses case-insensitive comparison - - Prioritizes merchant name over transaction description - -3. **Amount Tolerance:** - - ±10% tolerance from receipt total - - Marks exact matches and close matches separately - - Filters out transactions outside tolerance range - -4. **Fallback:** - - If no matches found and no receipt date: Returns 50 most recent unmapped transactions - -**DTOs:** -- `ReceiptMatchCriteria` - Input criteria (receipt date, due date, total, merchant, exclusions) -- `TransactionMatch` - Output with transaction details and match quality flags (IsExactAmount, IsCloseAmount) - -**Design Pattern:** -- Extracts complex 140+ line matching algorithm from Receipts PageModel -- Enables unit testing of matching logic -- Centralizes receipt-to-transaction correlation rules - -**Location:** Services/ReceiptMatchingService.cs - -### AccountService (Services/AccountService.cs) -**Interface:** `IAccountService` - -**Responsibility:** Account management including retrieval, validation, and deletion. - -**Key Methods:** -- `GetAccountByIdAsync(int id, bool includeRelated = false)` - - Retrieves account by ID with optional related data (cards, transactions) - - Returns Account entity or null - -- `GetAllAccountsWithStatsAsync()` - - Gets all accounts with transaction counts - - Returns list of `AccountWithStats` DTOs - - Sorted by owner, institution, last4 - -- `GetAccountDetailsAsync(int id)` - - Gets comprehensive account details including linked cards and stats - - Returns `AccountDetails` DTO with cards and transaction counts - -- `CanDeleteAccountAsync(int id)` - - Validates if account can be deleted (no transactions) - - Returns `DeleteValidationResult` - -- `DeleteAccountAsync(int id)` - - Deletes account after validation - - Returns `DeleteResult` with success/failure message - -**DTOs:** -- `AccountWithStats` - Account with transaction count -- `AccountDetails` - Full account details with cards and statistics -- `DeleteValidationResult` - Validation result with reason if cannot delete -- `DeleteResult` - Operation result with success status and message - -**Design Pattern:** -- Consolidates account deletion validation logic from multiple PageModels -- Provides single source of truth for account operations -- Improves testability - -**Location:** Services/AccountService.cs - -### CardService (Services/CardService.cs) -**Interface:** `ICardService` - -**Responsibility:** Card management including retrieval, validation, and deletion. - -**Key Methods:** -- `GetCardByIdAsync(int id, bool includeRelated = false)` - - Retrieves card by ID with optional related data (account, transactions) - - Returns Card entity or null - -- `GetAllCardsWithStatsAsync()` - - Gets all cards with transaction counts - - Returns list of `CardWithStats` DTOs - - Sorted by owner, last4 - -- `CanDeleteCardAsync(int id)` - - Validates if card can be deleted (no transactions) - - Returns `DeleteValidationResult` - -- `DeleteCardAsync(int id)` - - Deletes card after validation - - Returns `DeleteResult` with success/failure message - -**DTOs:** -- `CardWithStats` - Card with transaction count - -**Design Pattern:** -- Consolidates card deletion validation logic from Cards and AccountDetails pages -- Mirrors AccountService for consistency -- Enables reusable card management operations - -**Location:** Services/CardService.cs - -### MerchantService (Services/MerchantService.cs) - EXPANDED -**Interface:** `IMerchantService` - -**Responsibility:** Merchant management including creation, retrieval, update, and deletion. - -**Existing Methods:** -- `FindByNameAsync(string name)` - Find merchant by exact name match -- `GetOrCreateAsync(string name)` - Get existing or create new merchant -- `GetOrCreateIdAsync(string? name)` - Get or create merchant and return ID - -**New Methods:** -- `GetMerchantByIdAsync(int id, bool includeRelated = false)` - - Retrieves merchant by ID with optional related data (transactions, category mappings) - -- `GetAllMerchantsWithStatsAsync()` - - Gets all merchants with transaction and mapping counts - - Returns list of `MerchantWithStats` DTOs - - Sorted alphabetically by name - -- `UpdateMerchantAsync(int id, string newName)` - - Updates merchant name with duplicate checking - - Returns `MerchantUpdateResult` - -- `DeleteMerchantAsync(int id)` - - Deletes merchant and reports unlinked transactions/mappings - - Returns `MerchantDeleteResult` with counts - -**DTOs:** -- `MerchantWithStats` - Merchant with transaction and mapping counts -- `MerchantUpdateResult` - Update operation result -- `MerchantDeleteResult` - Delete operation result with impact statistics - -**Design Pattern:** -- Expands existing service with full CRUD operations -- Used by EditTransaction for merchant creation -- Used by Merchants page for management UI - -**Location:** Services/MerchantService.cs - -### BudgetService (Services/BudgetService.cs) -**Interface:** `IBudgetService` - -**Responsibility:** Budget management including CRUD operations and spending calculations. - -**Key Methods:** -- `GetAllBudgetsAsync(bool activeOnly = true)` - Get all budgets, optionally filtered to active only -- `GetBudgetByIdAsync(int id)` - Get budget by ID -- `CreateBudgetAsync(Budget budget)` - Create new budget with duplicate validation -- `UpdateBudgetAsync(Budget budget)` - Update existing budget -- `DeleteBudgetAsync(int id)` - Delete budget - -- `GetAllBudgetStatusesAsync(DateTime? asOfDate = null)` - Get spending status for all active budgets -- `GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)` - Get spending status for specific budget - -- `GetAvailableCategoriesAsync()` - Get list of categories for budget creation -- `GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)` - Calculate period start/end dates - -**Period Boundary Logic:** -- **Weekly:** 7-day periods starting from StartDate -- **Monthly:** Periods run from StartDate's day-of-month (e.g., 15th to 14th) -- **Yearly:** Annual periods from StartDate anniversary - -**DTOs:** -- `BudgetOperationResult` - CRUD operation result with success/message -- `BudgetStatus` - Budget with calculated spending data: - - `Budget` - The budget entity - - `PeriodStart`, `PeriodEnd` - Current period boundaries - - `Spent` - Total spending in period - - `Remaining` - Budget amount minus spent - - `PercentUsed` - Percentage of budget consumed - - `IsOverBudget` - Whether spending exceeds budget - - `StatusClass` - CSS class (success/warning/danger) - -**Design Pattern:** -- Follows service layer pattern with interface -- Uses DTOs for complex return types -- Calculates spending by querying transactions within period boundaries -- Excludes transfers from spending calculations - -**Location:** Services/BudgetService.cs - -### TransactionStatisticsService (Services/TransactionStatisticsService.cs) -**Interface:** `ITransactionStatisticsService` - -**Responsibility:** Calculate transaction statistics and aggregates. - -**Key Methods:** -- `CalculateStatsAsync(IQueryable query)` - - Calculates statistics for any filtered set of transactions - - Returns `TransactionStats` DTO with counts and totals - - Computes: total count, total debits, total credits, net amount - -- `GetCategorizationStatsAsync()` - - Gets categorization statistics for entire database - - Returns `CategorizationStats` with categorized/uncategorized counts - -- `GetCardStatsForAccountAsync(int accountId)` - - Gets transaction counts for all cards linked to an account - - Returns list of `CardStats` DTOs - -**DTOs:** -- `TransactionStats` - Aggregate statistics (count, debits, credits, net) -- `CategorizationStats` - Categorization summary -- `CardStats` - Card with transaction count - -**Design Pattern:** -- Consolidates statistics calculation from Transactions, Recategorize, and AccountDetails pages -- Enables consistent statistics across the application -- Improves performance by centralizing query logic - -**Location:** Services/TransactionStatisticsService.cs - -### ReferenceDataService (Services/ReferenceDataService.cs) -**Interface:** `IReferenceDataService` - -**Responsibility:** Provide reference/lookup data for dropdowns and filters. - -**Key Methods:** -- `GetAvailableCategoriesAsync()` - - Gets all distinct categories from transactions - - Returns sorted list of category names - - Excludes empty/whitespace categories - -- `GetAvailableMerchantsAsync()` - - Gets all merchants sorted by name - - Returns list of Merchant entities - -- `GetAvailableCardsAsync(bool includeAccount = true)` - - Gets all cards with optional account information - - Returns sorted list of Card entities - - Sorted by owner, last4 - -- `GetAvailableAccountsAsync()` - - Gets all accounts sorted by institution and last4 - - Returns list of Account entities - -**Design Pattern:** -- Centralizes repetitive reference data queries used across multiple pages -- Reduces code duplication in Transactions, EditTransaction, Upload pages -- Provides consistent sorting and filtering - -**Location:** Services/ReferenceDataService.cs - -### DashboardService (Pages/Index.cshtml.cs) -**Interface:** `IDashboardService` - -**Responsibility:** Aggregate dashboard statistics and data. - -**Key Methods:** -- `GetDashboardDataAsync(int topCategoriesCount, int recentTransactionsCount)` - - Orchestrates calls to specialized providers - - Returns `DashboardData` DTO - -**Sub-Services:** - -#### IDashboardStatsCalculator -- `CalculateAsync()` - Compute aggregate stats: - - Total transactions, credits, debits, uncategorized count - - Total receipts, total cards - -#### ITopCategoriesProvider -- `GetTopCategoriesAsync(int count, int lastDays)` - - Aggregates spending by category for last N days (default 90) - - Returns top N categories by total spend - - Includes transaction count and average per transaction - - Excludes transfers - -#### IRecentTransactionsProvider -- `GetRecentTransactionsAsync(int count)` - - Fetches most recent N transactions (default 20) - - Includes card label, receipt count - - Sorted by date descending, then ID descending - -**Location:** Pages/Index.cshtml.cs:63-250 - -### ReceiptManager (Services/ReceiptManager.cs) -**Interface:** `IReceiptManager` - -**Responsibility:** Handle receipt file uploads, storage, and transaction mapping. - -**Key Methods:** -- `UploadReceiptAsync(long transactionId, IFormFile file)` - - Validates file (10MB max, allowed extensions: jpg, jpeg, png, pdf, gif, heic) - - Computes SHA256 hash for deduplication - - Checks for duplicates (same hash or same filename + size) - - Saves file to `wwwroot/receipts/{transactionId}_{guid}{ext}` - - Sanitizes filename (removes non-ASCII like ®, ™, ©) - - Creates Receipt entity in database - - Returns `ReceiptUploadResult` with duplicate warnings - -- `UploadUnmappedReceiptAsync(IFormFile file)` - - Same as UploadReceiptAsync but for receipts without initial transaction mapping - - TransactionId is null until later mapped - -- `UploadManyUnmappedReceiptsAsync(IReadOnlyList 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 - -- `DeleteReceiptAsync(long receiptId)` - - Deletes physical file from disk - - Removes database record (cascades to ParseLogs and LineItems) - -- `GetReceiptPhysicalPath(Receipt receipt)` - Resolves storage path -- `GetReceiptAsync(long receiptId)` - Retrieves receipt with transaction - -**Duplicate Detection:** -- SHA256 hash matching (identical files) -- Filename + size matching (possible duplicates) -- Returns `DuplicateWarning` list with existing receipts -- User can confirm upload anyway via modal dialog - -**Security Features:** -- File size validation (10MB limit) -- Extension whitelist -- Filename sanitization -- SHA256 deduplication - -**Location:** Services/ReceiptManager.cs:23-199 - -### AIReceiptParser (Services/AIReceiptParser.cs) -**Interface:** `IReceiptParser` - -**Responsibility:** Parse receipts using AI vision APIs with tool-use support for database-aware parsing. - -**Key Methods:** -- `ParseReceiptAsync(long receiptId, string? model, string? notes)` - - Loads receipt file from disk - - Converts PDFs to PNG images using ImageMagick (220 DPI) - - 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 - - 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 - - Returns `ReceiptParseResult` - -**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 -``` - -**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 -- Flattens alpha channel (white background) -- TrueColor 8-bit RGB output - -**Auto-Mapping Integration:** -- 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/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` - -**Responsibility:** Automatically map parsed receipts to matching transactions using smart matching rules. - -**Key Methods:** -- `AutoMapReceiptAsync(long receiptId)` - - Attempts to match a single receipt to a transaction - - Uses date range, merchant name, and amount matching - - Returns `ReceiptAutoMapResult` with status (Success, NoMatch, MultipleMatches, AlreadyMapped, NotParsed, Failed) - -- `AutoMapUnmappedReceiptsAsync()` - - Bulk processes all unmapped parsed receipts - - Returns `BulkAutoMapResult` with counts (total, mapped, no match, multiple matches) - -**Matching Algorithm:** -1. **Date Range Filtering:** - - For bills with due dates: `billDate` to `dueDate + 5 days` (accounts for auto-pay delays, weekends, bank processing) - - For regular receipts: `receiptDate ± 3 days` (accounts for transaction posting delays) - - No date = no match (can't narrow down effectively) - -2. **Merchant Filtering (optional):** - - Matches if merchant name or transaction name contains receipt merchant - - Case-insensitive contains matching - -3. **Amount Matching (optional):** - - If receipt has total: matches transactions within ±$0.10 tolerance - - Accounts for rounding differences and tips - -4. **Receipt Exclusion:** - - Excludes transactions that already have receipts attached - - Uses HashSet for performance - -**Auto-Mapping Triggers:** -- Automatically triggered after successful receipt parsing (if receipt is unmapped) -- Can be manually triggered via "Auto-Map Unmapped Receipts" button on Receipts page - -**Result Status Types:** -- `Success` - Single match found, receipt mapped automatically -- `NoMatch` - No matching transactions found -- `MultipleMatches` - Multiple potential matches found (requires manual selection) -- `AlreadyMapped` - Receipt already linked to a transaction -- `NotParsed` - Receipt has no parsed data (merchant, date, or total missing) -- `Failed` - Error during mapping process - -**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 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` - -**Responsibility:** Generate comprehensive financial audit data for AI analysis via the `/api/audit` endpoint. - -**Key Methods:** -- `GenerateAuditAsync(DateTime startDate, DateTime endDate, bool includeTransactions = false)` - - Aggregates all financial data for the specified period - - Returns `FinancialAuditResponse` with summary, budgets, categories, merchants, trends, accounts, and flags - -**Response Structure:** -- `Summary` - High-level stats (income, expenses, net, average daily spend, uncategorized count) -- `Budgets` - All active budget statuses with period info and over-budget flags -- `SpendingByCategory` - Category breakdown with budget correlation -- `TopMerchants` - Top 20 merchants by spending -- `MonthlyTrends` - Month-by-month income/expense/net with top categories per month -- `Accounts` - Per-account transaction summaries -- `Flags` - AI-friendly alerts (over-budget, spending increases, uncategorized, etc.) -- `Transactions` - Optional detailed transaction list - -**Audit Flags Generated:** -- `OverBudget` - Budgets exceeded (Alert) -- `HighBudgetUtilization` - Budgets at 80%+ (Warning) -- `Uncategorized` - Transactions without categories (Warning) -- `NegativeCashFlow` - Spending exceeds income (Alert) -- `HighCategoryConcentration` - Category >30% of total spending (Info) -- `SpendingIncrease` - Month-over-month increase >20% (Warning) -- `NoBudget` - Top spending categories without budgets (Info) - -**Design Pattern:** -- Orchestrates existing services (IBudgetService) and new queries -- Single comprehensive endpoint for AI consumption -- Excludes transfers from spending calculations - -**Location:** Services/FinancialAuditService.cs - -### TransactionAICategorizer (Services/TransactionAICategorizer.cs) -**Interface:** `ITransactionAICategorizer` - -**Responsibility:** AI-powered categorization for uncategorized transactions. - -**Key Methods:** -- `ProposeCategorizationAsync(Transaction transaction)` - - Analyzes transaction details (name, memo, amount, date) - - Calls OpenAI GPT-4o-mini with categorization prompt - - Returns `AICategoryProposal` with category, merchant, pattern, and confidence - - Auto-suggests rule creation for high confidence (≥70%) - -- `ProposeBatchCategorizationAsync(List transactions)` - - Processes transactions in batches of 5 to avoid rate limits - - Returns list of proposals for review - -- `ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule)` - - Updates transaction category and merchant - - Optionally creates CategoryMapping rule for future auto-categorization - - Returns `ApplyProposalResult` with success status - -**API Configuration:** -- Model: `gpt-4o-mini` -- Temperature: 0.1 (deterministic) -- Max tokens: 300 -- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey` -- Cost: ~$0.00015 per transaction (~$0.015 per 100 transactions) - -**Prompt Strategy:** -- Provides transaction details (name, memo, amount, date) -- Requests JSON response with category, canonical_merchant, pattern, confidence, reasoning -- Includes common category examples for context -- High confidence threshold (≥70%) suggests automatic rule creation - -**CategoryMapping Enhancements:** -- `Confidence` (decimal?) - AI confidence score (0.0-1.0) -- `CreatedBy` (string?) - "AI" or "User" -- `CreatedAt` (DateTime?) - Rule creation timestamp - -**Location:** Services/TransactionAICategorizer.cs - -## Data Access Layer - -### MoneyMapContext (Data/MoneyMapContext.cs) -EF Core DbContext managing all database entities. - -**DbSets:** -- `Accounts` - Bank accounts -- `Cards` - Payment cards -- `Merchants` - Merchants/vendors -- `Transactions` - Bank transactions -- `Receipts` - Receipt files -- `ReceiptParseLogs` - Parse attempt logs -- `ReceiptLineItems` - Receipt line items -- `CategoryMappings` - Categorization rules - -**Key Configuration:** -- **Transactions ↔ Cards**: Restrict delete (can't delete card with transactions) -- **Transactions ↔ Accounts**: Restrict delete (can't delete account with transactions) -- **Receipts ↔ Transactions**: Cascade delete (deleting transaction removes receipts) -- **ReceiptParseLogs ↔ Receipts**: Cascade delete -- **ReceiptLineItems ↔ Receipts**: Cascade delete - -**Performance Indexes:** -- Transaction.Date, Transaction.Amount, Transaction.Category (filtering) -- Card: (Issuer, Last4, Owner) composite -- Transaction: (Date, Amount, Name, Memo, AccountId, CardId) unique constraint -- Receipt: (TransactionId, FileHashSha256) unique constraint - -**Location:** Data/MoneyMapContext.cs:9-107 - -## Razor Pages (UI Layer) - -### Index.cshtml / IndexModel (Dashboard) -**Route:** `/` - -**Purpose:** Main dashboard showing financial overview. - -**Displays:** -- Aggregate stats (total transactions, credits, debits, uncategorized, receipts, cards) -- Top 8 spending categories (last 90 days) with average per transaction -- Recent 20 transactions with card labels and receipt counts - -**Dependencies:** `IDashboardService` - -**Location:** Pages/Index.cshtml.cs:12-250 - -### Upload.cshtml / UploadModel (CSV Import) -**Route:** `/Upload` - -**Purpose:** Import bank transactions from CSV files. - -**Features:** -- Card selection mode: Auto (extract from memo/filename) or Manual (user selects) -- Duplicate detection (unique constraint on Date+Amount+Name+Memo+AccountId+CardId) -- Auto-creates cards if not found in Auto mode -- Displays import stats (total, inserted, skipped) - -**Form Inputs:** -- CSV file -- Card mode (radio: Auto/Manual) -- Card dropdown (if Manual mode) - -**Dependencies:** `ITransactionImporter` - -**Location:** Pages/Upload.cshtml.cs:17-333 - -### Transactions.cshtml / TransactionsModel -**Route:** `/Transactions` - -**Purpose:** View and search all transactions with advanced filtering. - -**Features:** -- Search by name, memo, category, notes, or merchant -- Filter by card, category, merchant, date range -- Quick date range selectors (30/60/90 days, last year, this month, last month) -- Sort by date, amount, name, category -- Pagination with preserved filters -- Transaction ID display for easy reference -- Receipt count indicator with badge -- Upload CSV button in header -- Edit transaction link - -**Stats Display:** -- Total transaction count -- Current page showing count -- Total spent (debits sum) -- Total income (credits sum) -- Net amount with color coding - -**Location:** Pages/Transactions.cshtml.cs - -### Receipts.cshtml / ReceiptsModel -**Route:** `/Receipts` - -**Purpose:** Manage receipts with upload, mapping, and auto-mapping capabilities. - -**Features:** -- Upload receipts without initial transaction mapping (unmapped receipts) -- Duplicate detection with blocking modal dialog - - SHA256 hash matching (identical files) - - Filename + size matching (possible duplicates) - - User confirmation required to proceed -- View all receipts (mapped and unmapped) with status badges -- Manual receipt-to-transaction mapping via modal selector -- Auto-map unmapped receipts button (bulk operation) -- Smart transaction matching with filters -- Receipt info display: merchant, date, due date (for bills), total -- Delete receipts -- View receipt details - -**Transaction Selector Modal:** -- Displays up to 50 matching transactions in scrollable table -- Date range filtering: - - Bills with due dates: bill date to due date + 5 days - - Regular receipts: receipt date ± 3 days -- Merchant-based relevance sorting (exact match > contains > no match) -- Amount matching with green highlighting (±$0.10 tolerance) -- Auto-scroll to first amount match when modal opens -- Manual transaction ID entry option -- Link to open Transactions page for searching - -**Receipt List Columns:** -- Uploaded timestamp -- File name with icon (image/PDF) -- Receipt info (merchant, date, due date, total) -- Mapped transaction details or "Unmapped" badge -- Actions: View, Map (if unmapped), Delete - -**Dependencies:** `IReceiptManager`, `IReceiptAutoMapper` - -**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 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}` - -**Purpose:** Edit transaction details. - -**Features:** -- Update category (select existing or create new) -- Update merchant (select existing or create new) -- Update notes -- Upload/delete receipts -- View receipt thumbnails with parsed data -- Trigger AI receipt parsing - -**Location:** Pages/EditTransaction.cshtml.cs:27-202 - -### ViewReceipt.cshtml / ViewReceiptModel -**Route:** `/ViewReceipt/{id}` - -**Purpose:** View receipt details and parsed data. - -**Displays:** -- Receipt image/PDF preview -- File metadata (name, size, upload date) -- Parsed merchant, date, amounts -- Line items table (description, quantity, unit price, total) -- Parse logs (provider, model, confidence, timestamp, errors) -- Actions: Re-parse, Delete - -**Dependencies:** `IReceiptManager`, `IReceiptParser` - -**Location:** Pages/ViewReceipt.cshtml.cs:16-126 - -### CategoryMappings.cshtml / CategoryMappingsModel -**Route:** `/CategoryMappings` - -**Purpose:** Manage category mapping rules. - -**Features:** -- View all category patterns grouped by category -- Add/edit/delete mappings -- Assign merchant to mapping (creates normalized merchant name) -- Priority management (higher = checked first) -- Seed default mappings -- Export/import mappings as JSON - -**Dependencies:** `ITransactionCategorizer` - -**Location:** Pages/CategoryMappings.cshtml.cs:16-91 - -### Merchants.cshtml / MerchantsModel -**Route:** `/Merchants` - -**Purpose:** Manage merchant entities. - -**Features:** -- View all merchants -- Edit merchant names -- Delete merchants (with confirmation) -- See transaction count per merchant - -**Location:** Pages/Merchants.cshtml.cs - -### Budgets.cshtml / BudgetsModel -**Route:** `/Budgets` - -**Purpose:** Manage spending budgets and track budget status. - -**Features:** -- Create budgets for specific categories or total spending -- Support for Weekly, Monthly, and Yearly periods -- Real-time spending vs budget tracking with progress bars -- Color-coded status (green/yellow/red based on % used) -- Pause/resume budgets without deleting -- Edit budget amounts and periods -- View active and inactive budgets separately - -**Budget Types:** -- **Category Budget:** Track spending for a specific category (e.g., "Dining Out") -- **Total Budget:** Track all spending combined (Category = null) - -**Period Boundaries:** -- Periods are calculated relative to the budget's StartDate -- Monthly budgets run from the StartDate's day (e.g., if StartDate is Jan 15, periods run 15th to 14th) -- Weekly budgets run in 7-day increments from StartDate -- Yearly budgets run from the StartDate anniversary - -**Dependencies:** `IBudgetService` - -**Location:** Pages/Budgets.cshtml.cs - -### Recategorize.cshtml / RecategorizeModel -**Route:** `/Recategorize` - -**Purpose:** Bulk recategorize uncategorized transactions. - -**Features:** -- Lists all transactions without a category -- Applies TransactionCategorizer rules -- Updates matching transactions -- Reports counts (total, categorized, still uncategorized) - -**Dependencies:** `ITransactionCategorizer` - -**Location:** Pages/Recategorize.cshtml.cs:16-87 - -### ReviewAISuggestions.cshtml / ReviewAISuggestionsModel -**Route:** `/ReviewAISuggestions` - -**Purpose:** AI-powered categorization suggestions for uncategorized transactions. - -**Features:** -- Lists up to 50 most recent uncategorized transactions -- Generate AI suggestions button (processes up to 20 at a time) -- Cost transparency (~$0.00015 per transaction) -- Link to view uncategorized transactions - -**Dependencies:** `ITransactionAICategorizer` - -**Location:** Pages/ReviewAISuggestions.cshtml.cs - -### ReviewAISuggestionsWithProposals.cshtml / ReviewAISuggestionsWithProposalsModel -**Route:** `/ReviewAISuggestionsWithProposals` - -**Purpose:** Review and apply AI categorization proposals. - -**Features:** -- Display AI proposals with confidence scores -- Color-coded confidence indicators (green ≥80%, yellow 60-79%, red <60%) -- Individual actions: Accept (with/without rule), Reject, Edit Manually -- Bulk action: Apply all high-confidence suggestions (≥80%) -- Shows AI reasoning for each suggestion -- Stores proposals in session for review workflow - -**Dependencies:** `ITransactionAICategorizer` - -**Location:** Pages/ReviewAISuggestionsWithProposals.cshtml.cs - -## API Endpoints - -### Financial Audit API -**Route:** `GET /api/audit` - -**Purpose:** Provide comprehensive financial data for AI analysis and auditing. - -**Query Parameters:** -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `startDate` | DateTime? | Today - 90 days | Start of audit period | -| `endDate` | DateTime? | Today | End of audit period | -| `includeTransactions` | bool | false | Include detailed transaction list | - -**Example Requests:** -``` -GET /api/audit -GET /api/audit?startDate=2025-01-01&endDate=2025-12-31 -GET /api/audit?startDate=2025-11-01&includeTransactions=true -``` - -**Response:** JSON `FinancialAuditResponse` object containing: -- `generatedAt` - Timestamp of audit generation -- `periodStart`, `periodEnd` - Audit date range -- `summary` - High-level financial statistics -- `budgets` - Active budget statuses -- `spendingByCategory` - Category breakdown with budget correlation -- `topMerchants` - Top 20 merchants by spending -- `monthlyTrends` - Month-by-month trends -- `accounts` - Per-account summaries -- `flags` - AI-friendly alerts and observations -- `transactions` - Detailed transaction list (optional) - -**Authentication:** None (localhost/personal use only) - -**Use Cases:** -- AI-powered financial analysis and recommendations -- Spending pattern identification -- Budget compliance monitoring -- External tool integration - -**Implementation:** Minimal API endpoint in Program.cs, backed by `IFinancialAuditService` - -**Location:** Program.cs:99-112 - -## Configuration - -### appsettings.json -```json -{ - "ConnectionStrings": { - "MoneyMapDb": "Server=...;Database=MoneyMap;..." - }, - "OpenAI": { - "ApiKey": "sk-..." // Optional, can use OPENAI_API_KEY env var - }, - "Receipts": { - "StoragePath": "receipts" // Relative to wwwroot - } -} -``` - -### Program.cs Dependency Injection -```csharp -// Database -builder.Services.AddDbContext(options => - options.UseSqlServer(...)); - -// Transaction Services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Dashboard Services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Receipt Services -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// AI Vision Clients and Tool Use -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -``` - -**Location:** Program.cs:1-44 - -## Database Schema (SQL Server) - -### Accounts Table -```sql -Id (int, PK) -Issuer (nvarchar(100), NOT NULL) -Last4 (nvarchar(4), NOT NULL) -Owner (nvarchar(100)) -INDEX: (Issuer, Last4, Owner) -``` - -### Cards Table -```sql -Id (int, PK) -AccountId (int, FK → Accounts.Id, RESTRICT) -Issuer (nvarchar(100), NOT NULL) -Last4 (nvarchar(4), NOT NULL) -Owner (nvarchar(100)) -INDEX: (Issuer, Last4, Owner) -``` - -### Merchants Table -```sql -Id (int, PK) -Name (nvarchar(100), NOT NULL) -``` - -### Transactions Table -```sql -Id (bigint, PK) -Date (datetime2, NOT NULL) -TransactionType (nvarchar(20)) -Name (nvarchar(200), NOT NULL) -Memo (nvarchar(500)) -Amount (decimal(18,2), NOT NULL) -Category (nvarchar(100)) -MerchantId (int, FK → Merchants.Id, SET NULL) -Notes (nvarchar(max)) -AccountId (int, FK → Accounts.Id, RESTRICT) -CardId (int, FK → Cards.Id, RESTRICT) -TransferToAccountId (int, FK → Accounts.Id, RESTRICT) -Last4 (nvarchar(4)) -UNIQUE INDEX: (Date, Amount, Name, Memo, AccountId, CardId) -INDEX: Date, Amount, Category -``` - -### Receipts Table -```sql -Id (bigint, PK) -TransactionId (bigint, FK → Transactions.Id, CASCADE, NULLABLE) -FileName (nvarchar(260), NOT NULL) -ContentType (nvarchar(100), DEFAULT 'application/octet-stream') -StoragePath (nvarchar(1024), NOT NULL) -FileSizeBytes (bigint, NOT NULL) -FileHashSha256 (nvarchar(64), NOT NULL) -UploadedAtUtc (datetime2, NOT NULL) -Merchant (nvarchar(200)) -ReceiptDate (datetime2) -DueDate (datetime2) -- For bills: payment due date -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 -```sql -Id (bigint, PK) -ReceiptId (bigint, FK → Receipts.Id, CASCADE) -Provider (nvarchar(50), NOT NULL) -Model (nvarchar(100), NOT NULL) -StartedAtUtc (datetime2, NOT NULL) -CompletedAtUtc (datetime2) -Success (bit, NOT NULL) -Confidence (decimal(18,2)) -Error (nvarchar(max)) -ProviderJobId (nvarchar(100)) -ExtractedTextPath (nvarchar(1024)) -RawProviderPayloadJson (nvarchar(max)) -``` - -### ReceiptLineItems Table -```sql -Id (bigint, PK) -ReceiptId (bigint, FK → Receipts.Id, CASCADE) -LineNumber (int, NOT NULL) -Description (nvarchar(300), NOT NULL) -Quantity (decimal(18,4)) -UnitPrice (decimal(18,4)) -LineTotal (decimal(18,2), NOT NULL) -Unit (nvarchar(16)) -Sku (nvarchar(64)) -Category (nvarchar(100)) -``` - -### CategoryMappings Table -```sql -Id (int, PK) -Category (nvarchar(max), NOT NULL) -Pattern (nvarchar(max), NOT NULL) -MerchantId (int, FK → Merchants.Id, SET NULL) -Priority (int, NOT NULL, DEFAULT 0) -Confidence (decimal(18,2), NULL) -- AI confidence score -CreatedBy (nvarchar(max), NULL) -- "AI" or "User" -CreatedAt (datetime2, NULL) -- Rule creation timestamp -``` - -### Budgets Table -```sql -Id (int, PK) -Category (nvarchar(100), NULL) -- NULL = total spending budget -Amount (decimal(18,2), NOT NULL) -Period (int, NOT NULL) -- 0=Weekly, 1=Monthly, 2=Yearly -StartDate (datetime2, NOT NULL) -IsActive (bit, NOT NULL) -Notes (nvarchar(500), NULL) -UNIQUE INDEX: (Category, Period) WHERE IsActive = 1 -``` - -## Key Workflows - -### 1. Import Transactions from CSV - -``` -User uploads CSV file - ↓ -UploadModel.OnPostAsync() - ↓ -TransactionImporter.ImportAsync() - ↓ -For each CSV row: - CardResolver.ResolveCardAsync() → Get/Create Card - Check for duplicates (DB + batch) - Add to batch if unique - ↓ -DbContext.SaveChangesAsync() - ↓ -Display import stats (inserted, skipped) -``` - -### 2. Auto-Categorize Transaction - -``` -Transaction inserted with Name="KROGER #123" - ↓ -TransactionCategorizer.CategorizeAsync("KROGER #123") - ↓ -Load CategoryMappings (ordered by Priority DESC) - ↓ -Check special case: Gas station with small purchase? - ↓ -Iterate mappings: - If "KROGER".Contains(pattern.ToUpper()) → Match! - Return "Brick/mortar store" - If mapping has MerchantId → Assign to transaction - ↓ -Update Transaction.Category and Transaction.MerchantId -``` - -### 3. Upload and Parse Receipt (Traditional Flow - From Transaction) - -``` -User attaches receipt to transaction - ↓ -EditTransactionModel.OnPostUploadReceiptAsync() - ↓ -ReceiptManager.UploadReceiptAsync() - - Validate file (size, extension) - - Compute SHA256 hash - - Check for duplicate (TransactionId + Hash) - - Save to wwwroot/receipts/ - - Create Receipt entity - ↓ -User clicks "Parse Receipt" - ↓ -ViewReceiptModel.OnPostParseAsync() - ↓ -OpenAIReceiptParser.ParseReceiptAsync() - - Load receipt file - - If PDF: Convert to PNG with ImageMagick - - Encode as base64 - - Call OpenAI Vision API with structured prompt - - Parse JSON response - - Update Receipt (merchant, date, due date, amounts) - - Replace ReceiptLineItems - - Create ReceiptParseLog - ↓ -Display parsed data -``` - -### 3a. Upload, Parse, and Auto-Map Receipt (New Unmapped Flow) - -``` -User visits /Receipts page - ↓ -User uploads receipt without transaction - ↓ -ReceiptsModel.OnPostUploadAsync() - ↓ -ReceiptManager.UploadUnmappedReceiptAsync() - - Validate file (size, extension) - - Compute SHA256 hash - - Check for duplicates (hash, filename+size) - ↓ -IF duplicates found: - - Delete uploaded file temporarily - - Store duplicate warnings in TempData - - Redirect to GET with modal - ↓ - User sees blocking modal: - Option A: Cancel (don't upload) - Option B: Upload Anyway (re-upload with confirmation) - ↓ -IF no duplicates OR confirmed: - - Save to wwwroot/receipts/ - - Create Receipt entity (TransactionId = null) - ↓ -User clicks "Parse Receipt" - ↓ -OpenAIReceiptParser.ParseReceiptAsync() - - Parse receipt (merchant, date, due date, total, line items) - - Update Receipt entity - - Trigger ReceiptAutoMapper.AutoMapReceiptAsync() - ↓ -ReceiptAutoMapper.AutoMapReceiptAsync() - - Find matching transactions: - * Date filter (bill date to due date+5 OR receipt date ±3) - * Merchant filter (contains, case-insensitive) - * Amount filter (±$0.10 tolerance) - * Exclude transactions with existing receipts - ↓ - IF single match found: - - Auto-map receipt to transaction - - Return Success - ELSE IF multiple matches: - - Return MultipleMatches (requires manual selection) - ELSE: - - Return NoMatch - ↓ -IF auto-mapping failed (no match or multiple matches): - User clicks "Map" button on receipt row - ↓ - ReceiptsModel.FindMatchingTransactionsForReceipt() - - Apply same filtering logic - - Sort by merchant relevance (exact > contains > none) - - Return up to 50 matches - ↓ - Modal displays transactions in table: - - Green highlighting for amount matches - - Auto-scroll to first amount match - - Radio button selection - - Manual ID entry option - ↓ - User selects transaction - ↓ - ReceiptsModel.OnPostMapToTransactionAsync() - - Links receipt to transaction - - Redirect to receipts page -``` - -### 4. Dashboard Aggregation - -``` -User visits homepage (/) - ↓ -IndexModel.OnGet() - ↓ -DashboardService.GetDashboardDataAsync() - ↓ -Parallel data fetching: - - DashboardStatsCalculator → Total txns, credits, debits, uncategorized, receipts, cards - - TopCategoriesProvider → Top 8 categories by spend (last 90 days) with average per transaction - - RecentTransactionsProvider → Latest 20 transactions - ↓ -Return DashboardData DTO - ↓ -Render dashboard view -``` - -### 5. AI-Powered Categorization (Phase 1 - Manual Review) - -``` -User visits /ReviewAISuggestions - ↓ -ReviewAISuggestionsModel.OnGetAsync() - - Loads up to 50 recent uncategorized transactions - ↓ -User clicks "Generate AI Suggestions" - ↓ -ReviewAISuggestionsModel.OnPostGenerateSuggestionsAsync() - - Fetches up to 20 uncategorized transactions - - Calls TransactionAICategorizer.ProposeBatchCategorizationAsync() - ↓ -For each transaction (batches of 5): - TransactionAICategorizer.ProposeCategorizationAsync() - - Builds prompt with transaction details - - Calls OpenAI GPT-4o-mini API - - Parses JSON response - - Returns AICategoryProposal - ↓ -Store proposals in session -Redirect to /ReviewAISuggestionsWithProposals - ↓ -User reviews proposals with confidence scores - ↓ -User actions: - Option A: Apply + Create Rule - - Updates transaction category and merchant - - Creates CategoryMapping rule (CreatedBy="AI") - - Future similar transactions auto-categorized - Option B: Apply (No Rule) - - Updates transaction only - - No rule created - Option C: Reject - - Removes proposal from session - Option D: Edit Manually - - Redirects to EditTransaction page - ↓ -Proposal applied -Remove from session -Display success message -``` - -## Design Patterns - -### 1. Service Layer Pattern -All business logic isolated in service interfaces/implementations: -- Testable (mock services in unit tests) -- Reusable across pages -- Single Responsibility Principle - -### 2. Repository Pattern (via EF Core DbContext) -- MoneyMapContext encapsulates data access -- LINQ queries in service layer -- Change tracking and unit of work handled by EF Core - -### 3. Result Pattern -Services return result objects instead of throwing exceptions: -- `ImportOperationResult` (success/failure with data or error message) -- `CardResolutionResult` -- `ReceiptUploadResult` -- `ReceiptParseResult` - -Benefits: Explicit error handling, no try-catch clutter, clear success/failure paths - -### 4. Dependency Injection -All services registered in Program.cs: -- Scoped lifetime for per-request services -- HttpClient factory for IReceiptParser -- Testability via interface injection - -### 5. Data Transfer Objects (DTOs) -Separate DTOs for data transfer between layers: -- `DashboardData` (aggregates stats + categories + recent transactions) -- `ImportContext` (encapsulates import parameters) -- `TransactionKey` (value object for deduplication) -- Page model record types (TopCategoryRow, RecentTxnRow, etc.) - -### 6. Strategy Pattern -CardResolver uses strategy based on CardSelectMode: -- Auto strategy: Extract from memo/filename -- Manual strategy: Use user selection - -## Security Considerations - -### 1. File Upload Security -- File size limit: 10MB -- Extension whitelist: jpg, jpeg, png, pdf, gif, heic -- Filename sanitization: Remove non-ASCII characters (®, ™, ©) -- SHA256 hash validation for deduplication -- Files stored outside database (wwwroot/receipts/) - -### 2. SQL Injection Prevention -- Parameterized queries via EF Core -- LINQ expressions compile to safe SQL - -### 3. API Key Management -- OpenAI API key from environment variable (preferred) or config -- Not hardcoded in source - -### 4. Delete Constraints -- Restrict delete on Cards (can't delete if transactions exist) -- Restrict delete on Accounts (can't delete if transactions exist) -- Cascade delete on Receipts (deleting transaction removes receipts) - -## Performance Optimizations - -### 1. Database Indexes -- Transactions: Date, Amount, Category (for filtering/sorting) -- Unique indexes on duplicate detection columns -- Composite index on Cards (Issuer, Last4, Owner) - -### 2. Batch Processing -- CSV import: Bulk insert with single SaveChangesAsync() -- In-memory duplicate checking (HashSet) avoids N+1 queries - -### 3. Pagination -- Transactions list uses pagination to limit result sets -- Dashboard limits top categories (8) and recent transactions (20) - -### 4. AsNoTracking -- Read-only queries use `.AsNoTracking()` to reduce EF overhead -- TopCategoriesProvider, RecentTransactionsProvider - -### 5. PDF Conversion -- ImageMagick runs on ThreadPool (Task.Run) to avoid blocking -- First page only for multi-page PDFs - -## Testing Considerations - -### Unit Testing Strategy -All service interfaces are mockable: -```csharp -// Example: Test TransactionImporter -var mockCardResolver = new Mock(); -var mockDb = new Mock(); -var importer = new TransactionImporter(mockDb.Object, mockCardResolver.Object); -``` - -### Integration Testing -- In-memory database for DbContext tests -- Test CSV import end-to-end -- Validate categorization rules - -### External Dependencies -- OpenAI API: Mock IReceiptParser for tests -- File system: Mock IWebHostEnvironment - -## Future Enhancements - -### Potential Features -1. **Multi-user support**: Add authentication/authorization -2. **Recurring transactions**: Auto-detect and predict recurring expenses -3. **Data export**: Export transactions to Excel/CSV -4. **Charts/graphs**: Spending trends over time -5. **Mobile app**: React Native or .NET MAUI -6. **Bank API integration**: Direct import from bank APIs (Plaid, Yodlee) -7. **Receipt search**: Full-text search on parsed line items -8. **Split transactions**: Divide single transaction across categories -9. **Tags**: Tag transactions with multiple labels - -### Technical Debt -1. Move inline service implementations to separate files (Upload.cshtml.cs is 333 lines) -2. Add logging (ILogger) to all services -3. Add retry logic for OpenAI API calls (Polly) -4. Implement background job processing for receipt parsing (Hangfire) -5. Add integration tests for critical workflows -6. Implement caching for CategoryMappings (IMemoryCache) - -## Troubleshooting - -### Common Issues - -**1. Duplicate Transaction Errors** -- **Cause**: Unique constraint violation (Date + Amount + Name + Memo + AccountId + CardId) -- **Solution**: Transactions are skipped automatically during import - -**2. Receipt Parsing Failures** -- **Cause**: OpenAI API key missing or invalid -- **Solution**: Set `OPENAI_API_KEY` environment variable or `OpenAI:ApiKey` in appsettings.json -- **Cause**: PDF conversion error -- **Solution**: Ensure Magick.NET is properly installed - -**3. Card Not Found** -- **Cause**: Auto mode can't extract last 4 digits from memo or filename -- **Solution**: Use Manual mode and select card from dropdown, or rename CSV file to include card digits (e.g., `transactions-1234.csv`) - -**4. Slow Dashboard Load** -- **Cause**: Large transaction dataset without indexes -- **Solution**: Ensure migrations have run (indexes on Date, Amount, Category) - -**5. File Upload Fails** -- **Cause**: File exceeds 10MB or unsupported format -- **Solution**: Resize/compress image, or use supported format - -## Deployment - -### Database Migration -```bash -# Create migration -dotnet ef migrations add MigrationName - -# Update database -dotnet ef database update -``` - -### Production Configuration -1. Update connection string in appsettings.json (Azure SQL, AWS RDS, etc.) -2. Set `OPENAI_API_KEY` environment variable -3. Configure `Receipts:StoragePath` (consider Azure Blob Storage) -4. Enable HTTPS and HSTS -5. Set `ASPNETCORE_ENVIRONMENT=Production` - -### File Storage Considerations -- **Local**: wwwroot/receipts (current implementation) -- **Production**: Azure Blob Storage, AWS S3, or network share - - Modify ReceiptManager to use cloud storage SDK - - Update StoragePath format (blob URL instead of relative path) - -## Conclusion - -MoneyMap demonstrates a well-architected ASP.NET Core application with clear separation of concerns, testable services, and modern AI integration. The service layer pattern allows for easy maintenance and extension, while the Result pattern provides explicit error handling. The OpenAI receipt parsing feature showcases practical LLM integration for automating tedious data entry tasks. - ---- - -**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 -- **New Endpoint**: `GET /api/audit` - Comprehensive financial data for AI analysis -- **FinancialAuditService**: New service orchestrating financial data aggregation -- **Audit Response**: Summary stats, budgets, categories, merchants, trends, accounts -- **AI Flags**: Pre-computed observations (over-budget, spending increases, uncategorized) -- **Configurable Period**: Query parameters for date range and transaction inclusion - -### Technical Details -- Implemented using Minimal API pattern (no controllers needed) -- DTOs in `Models/Api/FinancialAuditModels.cs` -- Excludes transfers from spending calculations -- Correlates category spending with budget limits - -## Recent Changes (v1.3) - -### Budget Tracking Feature -- **Budget Model**: New `Budget` entity with support for Weekly, Monthly, and Yearly periods -- **Category & Total Budgets**: Create budgets for specific categories or track total spending -- **Period Boundaries**: Smart calculation of period start/end dates based on budget start date -- **Budget Service**: Full CRUD operations plus spending calculations via `IBudgetService` -- **Budgets Page**: New `/Budgets` page for managing budgets with progress bars -- **Dashboard Integration**: Budget status displayed on dashboard with color-coded progress -- **Pause/Resume**: Deactivate budgets without deleting them - -### Technical Details -- Added `Budgets` table with filtered unique index on (Category, Period) WHERE IsActive = 1 -- `BudgetPeriod` enum: Weekly (0), Monthly (1), Yearly (2) -- Period boundary calculations handle edge cases (month boundaries, leap years) -- Spending calculated by summing debits within period, excluding transfers - -## Recent Changes (v1.2) - -### Receipt Management Enhancements -- **Unmapped Receipts**: Receipts can now be uploaded without initial transaction mapping -- **Duplicate Detection**: SHA256 hash and filename+size matching with blocking modal confirmation -- **Auto-Mapping**: Smart automatic receipt-to-transaction matching using date, merchant, and amount -- **Due Date Support**: Bills (utility, credit card) can have due dates for expanded matching windows -- **Manual Mapping UI**: Enhanced modal with transaction table, relevance sorting, amount highlighting, and auto-scroll -- **Date Range Intelligence**: - - Bills: bill date to due date + 5 days (accounts for auto-pay processing) - - Regular receipts: receipt date ± 3 days - -### UI Improvements -- **Transactions Page**: Added search, quick date filters, transaction ID column, Upload CSV button in header -- **Navigation**: Moved Upload CSV from menu to Transactions page header -- **Receipts Page**: New dedicated page for receipt management with mapping workflow -- **Transaction Selector**: Scrollable table with green amount match highlighting and auto-scroll to best match - -### Technical Improvements -- Made `Receipt.TransactionId` nullable with filtered unique index -- Added `Receipt.DueDate` column for bill payment tracking -- Enhanced OpenAI prompt to extract due dates from bills -- 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`-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 +# MoneyMap Architecture Documentation + +## 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). + +## Technology Stack + +- **Framework**: ASP.NET Core 8.0 (Razor Pages) +- **Database**: SQL Server with Entity Framework Core 9.0 +- **Libraries**: + - CsvHelper (33.1.0) - CSV parsing + - Magick.NET (14.8.2) - PDF to image conversion + - PdfPig (0.1.11) - PDF processing + - OpenAI API - Receipt OCR and parsing + +## Architecture + +MoneyMap follows a clean, service-oriented architecture: + +``` +┌─────────────────────────────────────────────┐ +│ Razor Pages (UI Layer) │ +│ Index, Upload, Transactions, ViewReceipt │ +│ EditTransaction, CategoryMappings, etc. │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────┴────────────────────────────┐ +│ Service Layer (Business Logic) │ +│ Core Transaction Services: │ +│ - TransactionImporter │ +│ - CardResolver │ +│ - TransactionCategorizer │ +│ - TransactionService (NEW) │ +│ - TransactionStatisticsService (NEW) │ +│ Entity Management Services: │ +│ - AccountService (NEW) │ +│ - CardService (NEW) │ +│ - MerchantService (EXPANDED) │ +│ Receipt Services: │ +│ - ReceiptMatchingService (NEW) │ +│ - ReceiptManager │ +│ - AIReceiptParser │ +│ - ReceiptParseQueue (singleton) │ +│ - ReceiptParseWorkerService (hosted) │ +│ AI Tool Use: │ +│ - AIToolExecutor (DB query tools) │ +│ - AIToolRegistry (tool definitions) │ +│ Reference & Dashboard: │ +│ - ReferenceDataService (NEW) │ +│ - DashboardService │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────┴────────────────────────────┐ +│ Data Access Layer (EF Core) │ +│ MoneyMapContext │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────┴────────────────────────────┐ +│ SQL Server Database │ +│ Cards, Accounts, Transactions, Receipts, │ +│ ReceiptLineItems, ReceiptParseLogs, │ +│ CategoryMappings, Merchants, Budgets │ +└─────────────────────────────────────────────┘ +``` + +## Core Domain Models + +### Account (Models/Account.cs) +Represents a bank account (checking, savings, credit card account). + +**Properties:** +- `Id` (int) - Primary key +- `Issuer` (string, 100) - Institution name +- `Last4` (string, 4) - Last 4 digits of account number +- `Owner` (string, 100) - Account owner name (optional) +- `DisplayLabel` (string, computed) - Formatted label for display +- `Cards` - Collection of cards linked to this account +- `Transactions` - Collection of transactions for this account + +### Card (Models/Card.cs) +Represents a payment card (credit/debit) linked to an account. + +**Properties:** +- `Id` (int) - Primary key +- `AccountId` (int) - Foreign key to Account +- `Issuer` (string, 100) - Card issuer (VISA, MC, etc.) +- `Last4` (string, 4) - Last 4 digits of card number +- `Owner` (string, 100) - Cardholder name (optional) +- `DisplayLabel` (string, computed) - Formatted label for display +- `Transactions` - Navigation property to transactions + +### Merchant (Models/Merchant.cs) +Represents a merchant/vendor where transactions occur. + +**Properties:** +- `Id` (int) - Primary key +- `Name` (string, 100) - Merchant name +- `Transactions` - Collection of transactions with this merchant +- `CategoryMappings` - Collection of category mapping rules + +### Transaction (Models/Transaction.cs) +Core entity representing a bank transaction. + +**Properties:** +- `Id` (long) - Primary key +- `Date` (DateTime) - Transaction date +- `TransactionType` (string, 20) - "DEBIT"/"CREDIT" +- `Name` (string, 200) - Transaction name from CSV +- `Memo` (string, 500) - Transaction description +- `Amount` (decimal) - Amount (negative = debit, positive = credit) +- `Category` (string, 100) - Expense category +- `MerchantId` (int?) - Foreign key to Merchant +- `Notes` (string) - User notes +- `AccountId` (int) - Foreign key to Account (required) +- `CardId` (int?) - Foreign key to Card (optional) +- `TransferToAccountId` (int?) - Foreign key for transfers +- `Last4` (string, 4) - Cached last 4 digits from memo +- `Receipts` - Collection of attached receipts + +**Unique Index:** Date + Amount + Name + Memo + AccountId + CardId (prevents duplicates) + +**Computed Properties:** +- `IsCredit` - Returns true if Amount > 0 +- `IsDebit` - Returns true if Amount < 0 +- `IsTransfer` - Returns true if TransferToAccountId is set +- `PaymentMethodLabel` - Formatted payment method string + +### Receipt (Models/Receipt.cs) +Stores uploaded receipt files (images/PDFs) linked to transactions. + +**Properties:** +- `Id` (long) - Primary key +- `TransactionId` (long?) - Foreign key to Transaction (nullable to support unmapped receipts) +- `FileName` (string, 260) - Original file name (sanitized) +- `ContentType` (string, 100) - MIME type +- `StoragePath` (string, 1024) - Relative path in wwwroot +- `FileSizeBytes` (long) - File size +- `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 +- `DueDate` (DateTime?) - Payment due date (for bills only) +- `Subtotal`, `Tax`, `Total` (decimal?) - Monetary amounts +- `Currency` (string, 8) - Currency code + +**Navigation Properties:** +- `ParseLogs` - Collection of parse attempts +- `LineItems` - Collection of receipt line items + +**Unique Index:** TransactionId + FileHashSha256 (prevents duplicate uploads, filtered WHERE TransactionId IS NOT NULL) + +### ReceiptLineItem (Models/ReceiptLineItem.cs) +Individual line items extracted from receipts. + +**Properties:** +- `Id` (long) - Primary key +- `ReceiptId` (long) - Foreign key to Receipt +- `LineNumber` (int) - Sequential line number +- `Description` (string, 300) - Item description +- `Quantity` (decimal?) - Quantity purchased (null for services/fees) +- `UnitPrice` (decimal?) - Price per unit +- `LineTotal` (decimal) - Total for this line + +### ReceiptParseLog (Models/ReceiptParseLog.cs) +Logs each receipt parsing attempt for auditing. + +**Properties:** +- `Id` (long) - Primary key +- `ReceiptId` (long) - Foreign key to Receipt +- `Provider` (string, 50) - "OpenAI" +- `Model` (string, 100) - Model used (e.g., "gpt-4o-mini") +- `StartedAtUtc`, `CompletedAtUtc` (DateTime) +- `Success` (bool) - Parse success status +- `Confidence` (decimal?) - AI confidence score +- `Error` (string?) - Error message if failed +- `RawProviderPayloadJson` (string?) - Full API response + +### CategoryMapping (Services/TransactionCategorizer.cs) +Pattern-based rules for auto-categorization with merchant linking. + +**Properties:** +- `Id` (int) - Primary key +- `Category` (string) - Category name +- `Pattern` (string) - Merchant name pattern to match +- `MerchantId` (int?) - Foreign key to Merchant (optional) +- `Priority` (int) - Higher priority checked first (default 0) + +### Budget (Models/Budget.cs) +Represents a spending budget for a category or total spending. + +**Properties:** +- `Id` (int) - Primary key +- `Category` (string?, 100) - Category name (null = total spending budget) +- `Amount` (decimal) - Budget limit amount +- `Period` (BudgetPeriod) - Weekly, Monthly, or Yearly +- `StartDate` (DateTime) - When budget becomes effective (used for period boundaries) +- `IsActive` (bool) - Whether budget is currently active +- `Notes` (string?, 500) - Optional notes + +**Computed Properties:** +- `IsTotalBudget` - Returns true if Category is null +- `DisplayName` - Returns Category or "Total Spending" + +**Unique Index:** Category + Period (filtered WHERE IsActive = 1, prevents duplicate active budgets) + +**BudgetPeriod Enum:** +- `Weekly` (0) - 7-day periods from StartDate +- `Monthly` (1) - Monthly periods based on StartDate day-of-month +- `Yearly` (2) - Annual periods from StartDate anniversary + +## Service Layer + +### TransactionImporter (Pages/Upload.cshtml.cs) +**Interface:** `ITransactionImporter` + +**Responsibility:** Import transactions from CSV files. + +**Key Methods:** +- `ImportAsync(Stream csvStream, ImportContext context)` + - Parses CSV using CsvHelper + - Resolves card for each transaction (auto or manual) + - Deduplicates against database and current batch + - Returns `ImportOperationResult` with stats + +**Workflow:** +1. Read CSV with flexible header mapping +2. For each row: + - Resolve card using CardResolver + - Map CSV row to Transaction entity + - Check for duplicates (database + in-memory batch) + - Add non-duplicates to batch +3. Bulk save to database +4. Return import statistics + +**Location:** Pages/Upload.cshtml.cs:92-180 + +### CardResolver (Pages/Upload.cshtml.cs) +**Interface:** `ICardResolver` + +**Responsibility:** Determine which card a transaction belongs to. + +**Key Methods:** +- `ResolveCardAsync(string? memo, ImportContext context)` + - Auto mode: Extract last 4 digits from memo or filename + - Manual mode: Use user-selected card + - Auto-creates cards if not found + - Returns `CardResolutionResult` + +**Card Extraction Logic:** +- From memo: Regex pattern `\b(?:\.|\s)(\d{4,6})\b` extracts last 4 digits +- From filename: Split by `-`, `_`, or space; find 4-digit numeric segment +- Auto-create: If card doesn't exist, creates new Card with Owner="Unknown" + +**Location:** Pages/Upload.cshtml.cs:190-248 + +### TransactionCategorizer (Services/TransactionCategorizer.cs) +**Interface:** `ITransactionCategorizer` + +**Responsibility:** Auto-categorize transactions based on merchant name patterns. + +**Key Methods:** +- `CategorizeAsync(string merchantName, decimal? amount = null)` + - Matches merchant name against CategoryMapping patterns (case-insensitive) + - Special logic: Gas stations with small purchases (<-$20) → "Convenience Store" + - Assigns merchant to transaction if mapping has MerchantId + - Returns category string or empty if no match +- `GetAllMappingsAsync()` - Retrieve all mappings for management UI +- `SeedDefaultMappingsAsync()` - One-time seed of default category rules + +**Default Categories (200+ patterns):** +- Online shopping (Amazon, Sephora, Kohls, etc.) +- Walmart (separate: Online, Pickup/Grocery) +- Pizza chains (Domino's, Papa John's, etc.) +- Brick/mortar stores (Dollar General, Kroger, Target, etc.) +- Restaurants (McDonald's, Starbucks, Olive Garden, etc.) +- School expenses +- Health (pharmacies, clinics, dental) +- Gas & Auto (high priority, 100) +- Utilities/Services (Verizon, Comcast, etc.) +- Entertainment (Netflix, Hulu, Steam, etc.) +- Banking fees (highest priority, 200) +- Mortgage, Car Payment, Insurance +- Credit Card payments +- Ice Cream/Treats +- Government/DMV +- Home Improvement +- Software/Subscriptions + +**Location:** Services/TransactionCategorizer.cs:31-231 + +### TransactionService (Services/TransactionService.cs) +**Interface:** `ITransactionService` + +**Responsibility:** Core transaction operations including duplicate detection, retrieval, and deletion. + +**Key Methods:** +- `IsDuplicateAsync(Transaction transaction)` + - Checks if a transaction already exists in the database + - Matches on: Date, Amount, Name, Memo, AccountId, and CardId + - Used during CSV import to prevent duplicate entries + - Returns boolean indicating if duplicate exists + +- `GetTransactionByIdAsync(long id, bool includeRelated = false)` + - Retrieves a transaction by ID + - Optional parameter to include related entities (Card, Account, Merchant, Receipts, etc.) + - Returns Transaction entity or null if not found + +- `DeleteTransactionAsync(long id)` + - Deletes a transaction and all related data + - Cascades to Receipts, ParseLogs, and LineItems + - Returns boolean indicating success + +**Design Pattern:** +- Consolidates duplicate detection logic previously scattered across multiple PageModels +- Single source of truth for transaction CRUD operations +- Improves testability by separating business logic from UI layer + +**Location:** Services/TransactionService.cs + +### ReceiptMatchingService (Services/ReceiptMatchingService.cs) +**Interface:** `IReceiptMatchingService` + +**Responsibility:** Match receipts to transactions using intelligent scoring based on date, merchant, and amount. + +**Key Methods:** +- `FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria)` + - Finds candidate transactions that match receipt criteria + - Uses multi-stage filtering and scoring algorithm + - Returns sorted list of `TransactionMatch` objects with relevance scores + +- `GetTransactionIdsWithReceiptsAsync()` + - Returns set of transaction IDs that already have receipts + - Used to exclude already-mapped transactions from matching + +**Matching Algorithm:** +1. **Date Filtering:** + - Regular receipts: ±3 days from receipt date + - Bills with due dates: From receipt date to due date + 5 days (for auto-pay delays) + - No date: Skip date filter + +2. **Merchant Matching (Word-Based Scoring):** + - Exact match: Score = 1000 + - Word overlap: Count matching words between receipt merchant and transaction name/merchant + - Splits on spaces, hyphens, underscores, periods + - Uses case-insensitive comparison + - Prioritizes merchant name over transaction description + +3. **Amount Tolerance:** + - ±10% tolerance from receipt total + - Marks exact matches and close matches separately + - Filters out transactions outside tolerance range + +4. **Fallback:** + - If no matches found and no receipt date: Returns 50 most recent unmapped transactions + +**DTOs:** +- `ReceiptMatchCriteria` - Input criteria (receipt date, due date, total, merchant, exclusions) +- `TransactionMatch` - Output with transaction details and match quality flags (IsExactAmount, IsCloseAmount) + +**Design Pattern:** +- Extracts complex 140+ line matching algorithm from Receipts PageModel +- Enables unit testing of matching logic +- Centralizes receipt-to-transaction correlation rules + +**Location:** Services/ReceiptMatchingService.cs + +### AccountService (Services/AccountService.cs) +**Interface:** `IAccountService` + +**Responsibility:** Account management including retrieval, validation, and deletion. + +**Key Methods:** +- `GetAccountByIdAsync(int id, bool includeRelated = false)` + - Retrieves account by ID with optional related data (cards, transactions) + - Returns Account entity or null + +- `GetAllAccountsWithStatsAsync()` + - Gets all accounts with transaction counts + - Returns list of `AccountWithStats` DTOs + - Sorted by owner, institution, last4 + +- `GetAccountDetailsAsync(int id)` + - Gets comprehensive account details including linked cards and stats + - Returns `AccountDetails` DTO with cards and transaction counts + +- `CanDeleteAccountAsync(int id)` + - Validates if account can be deleted (no transactions) + - Returns `DeleteValidationResult` + +- `DeleteAccountAsync(int id)` + - Deletes account after validation + - Returns `DeleteResult` with success/failure message + +**DTOs:** +- `AccountWithStats` - Account with transaction count +- `AccountDetails` - Full account details with cards and statistics +- `DeleteValidationResult` - Validation result with reason if cannot delete +- `DeleteResult` - Operation result with success status and message + +**Design Pattern:** +- Consolidates account deletion validation logic from multiple PageModels +- Provides single source of truth for account operations +- Improves testability + +**Location:** Services/AccountService.cs + +### CardService (Services/CardService.cs) +**Interface:** `ICardService` + +**Responsibility:** Card management including retrieval, validation, and deletion. + +**Key Methods:** +- `GetCardByIdAsync(int id, bool includeRelated = false)` + - Retrieves card by ID with optional related data (account, transactions) + - Returns Card entity or null + +- `GetAllCardsWithStatsAsync()` + - Gets all cards with transaction counts + - Returns list of `CardWithStats` DTOs + - Sorted by owner, last4 + +- `CanDeleteCardAsync(int id)` + - Validates if card can be deleted (no transactions) + - Returns `DeleteValidationResult` + +- `DeleteCardAsync(int id)` + - Deletes card after validation + - Returns `DeleteResult` with success/failure message + +**DTOs:** +- `CardWithStats` - Card with transaction count + +**Design Pattern:** +- Consolidates card deletion validation logic from Cards and AccountDetails pages +- Mirrors AccountService for consistency +- Enables reusable card management operations + +**Location:** Services/CardService.cs + +### MerchantService (Services/MerchantService.cs) - EXPANDED +**Interface:** `IMerchantService` + +**Responsibility:** Merchant management including creation, retrieval, update, and deletion. + +**Existing Methods:** +- `FindByNameAsync(string name)` - Find merchant by exact name match +- `GetOrCreateAsync(string name)` - Get existing or create new merchant +- `GetOrCreateIdAsync(string? name)` - Get or create merchant and return ID + +**New Methods:** +- `GetMerchantByIdAsync(int id, bool includeRelated = false)` + - Retrieves merchant by ID with optional related data (transactions, category mappings) + +- `GetAllMerchantsWithStatsAsync()` + - Gets all merchants with transaction and mapping counts + - Returns list of `MerchantWithStats` DTOs + - Sorted alphabetically by name + +- `UpdateMerchantAsync(int id, string newName)` + - Updates merchant name with duplicate checking + - Returns `MerchantUpdateResult` + +- `DeleteMerchantAsync(int id)` + - Deletes merchant and reports unlinked transactions/mappings + - Returns `MerchantDeleteResult` with counts + +**DTOs:** +- `MerchantWithStats` - Merchant with transaction and mapping counts +- `MerchantUpdateResult` - Update operation result +- `MerchantDeleteResult` - Delete operation result with impact statistics + +**Design Pattern:** +- Expands existing service with full CRUD operations +- Used by EditTransaction for merchant creation +- Used by Merchants page for management UI + +**Location:** Services/MerchantService.cs + +### BudgetService (Services/BudgetService.cs) +**Interface:** `IBudgetService` + +**Responsibility:** Budget management including CRUD operations and spending calculations. + +**Key Methods:** +- `GetAllBudgetsAsync(bool activeOnly = true)` - Get all budgets, optionally filtered to active only +- `GetBudgetByIdAsync(int id)` - Get budget by ID +- `CreateBudgetAsync(Budget budget)` - Create new budget with duplicate validation +- `UpdateBudgetAsync(Budget budget)` - Update existing budget +- `DeleteBudgetAsync(int id)` - Delete budget + +- `GetAllBudgetStatusesAsync(DateTime? asOfDate = null)` - Get spending status for all active budgets +- `GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)` - Get spending status for specific budget + +- `GetAvailableCategoriesAsync()` - Get list of categories for budget creation +- `GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)` - Calculate period start/end dates + +**Period Boundary Logic:** +- **Weekly:** 7-day periods starting from StartDate +- **Monthly:** Periods run from StartDate's day-of-month (e.g., 15th to 14th) +- **Yearly:** Annual periods from StartDate anniversary + +**DTOs:** +- `BudgetOperationResult` - CRUD operation result with success/message +- `BudgetStatus` - Budget with calculated spending data: + - `Budget` - The budget entity + - `PeriodStart`, `PeriodEnd` - Current period boundaries + - `Spent` - Total spending in period + - `Remaining` - Budget amount minus spent + - `PercentUsed` - Percentage of budget consumed + - `IsOverBudget` - Whether spending exceeds budget + - `StatusClass` - CSS class (success/warning/danger) + +**Design Pattern:** +- Follows service layer pattern with interface +- Uses DTOs for complex return types +- Calculates spending by querying transactions within period boundaries +- Excludes transfers from spending calculations + +**Location:** Services/BudgetService.cs + +### TransactionStatisticsService (Services/TransactionStatisticsService.cs) +**Interface:** `ITransactionStatisticsService` + +**Responsibility:** Calculate transaction statistics and aggregates. + +**Key Methods:** +- `CalculateStatsAsync(IQueryable query)` + - Calculates statistics for any filtered set of transactions + - Returns `TransactionStats` DTO with counts and totals + - Computes: total count, total debits, total credits, net amount + +- `GetCategorizationStatsAsync()` + - Gets categorization statistics for entire database + - Returns `CategorizationStats` with categorized/uncategorized counts + +- `GetCardStatsForAccountAsync(int accountId)` + - Gets transaction counts for all cards linked to an account + - Returns list of `CardStats` DTOs + +**DTOs:** +- `TransactionStats` - Aggregate statistics (count, debits, credits, net) +- `CategorizationStats` - Categorization summary +- `CardStats` - Card with transaction count + +**Design Pattern:** +- Consolidates statistics calculation from Transactions, Recategorize, and AccountDetails pages +- Enables consistent statistics across the application +- Improves performance by centralizing query logic + +**Location:** Services/TransactionStatisticsService.cs + +### ReferenceDataService (Services/ReferenceDataService.cs) +**Interface:** `IReferenceDataService` + +**Responsibility:** Provide reference/lookup data for dropdowns and filters. + +**Key Methods:** +- `GetAvailableCategoriesAsync()` + - Gets all distinct categories from transactions + - Returns sorted list of category names + - Excludes empty/whitespace categories + +- `GetAvailableMerchantsAsync()` + - Gets all merchants sorted by name + - Returns list of Merchant entities + +- `GetAvailableCardsAsync(bool includeAccount = true)` + - Gets all cards with optional account information + - Returns sorted list of Card entities + - Sorted by owner, last4 + +- `GetAvailableAccountsAsync()` + - Gets all accounts sorted by institution and last4 + - Returns list of Account entities + +**Design Pattern:** +- Centralizes repetitive reference data queries used across multiple pages +- Reduces code duplication in Transactions, EditTransaction, Upload pages +- Provides consistent sorting and filtering + +**Location:** Services/ReferenceDataService.cs + +### DashboardService (Pages/Index.cshtml.cs) +**Interface:** `IDashboardService` + +**Responsibility:** Aggregate dashboard statistics and data. + +**Key Methods:** +- `GetDashboardDataAsync(int topCategoriesCount, int recentTransactionsCount)` + - Orchestrates calls to specialized providers + - Returns `DashboardData` DTO + +**Sub-Services:** + +#### IDashboardStatsCalculator +- `CalculateAsync()` - Compute aggregate stats: + - Total transactions, credits, debits, uncategorized count + - Total receipts, total cards + +#### ITopCategoriesProvider +- `GetTopCategoriesAsync(int count, int lastDays)` + - Aggregates spending by category for last N days (default 90) + - Returns top N categories by total spend + - Includes transaction count and average per transaction + - Excludes transfers + +#### IRecentTransactionsProvider +- `GetRecentTransactionsAsync(int count)` + - Fetches most recent N transactions (default 20) + - Includes card label, receipt count + - Sorted by date descending, then ID descending + +**Location:** Pages/Index.cshtml.cs:63-250 + +### ReceiptManager (Services/ReceiptManager.cs) +**Interface:** `IReceiptManager` + +**Responsibility:** Handle receipt file uploads, storage, and transaction mapping. + +**Key Methods:** +- `UploadReceiptAsync(long transactionId, IFormFile file)` + - Validates file (10MB max, allowed extensions: jpg, jpeg, png, pdf, gif, heic) + - Computes SHA256 hash for deduplication + - Checks for duplicates (same hash or same filename + size) + - Saves file to `wwwroot/receipts/{transactionId}_{guid}{ext}` + - Sanitizes filename (removes non-ASCII like ®, ™, ©) + - Creates Receipt entity in database + - Returns `ReceiptUploadResult` with duplicate warnings + +- `UploadUnmappedReceiptAsync(IFormFile file)` + - Same as UploadReceiptAsync but for receipts without initial transaction mapping + - TransactionId is null until later mapped + +- `UploadManyUnmappedReceiptsAsync(IReadOnlyList 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 + +- `DeleteReceiptAsync(long receiptId)` + - Deletes physical file from disk + - Removes database record (cascades to ParseLogs and LineItems) + +- `GetReceiptPhysicalPath(Receipt receipt)` - Resolves storage path +- `GetReceiptAsync(long receiptId)` - Retrieves receipt with transaction + +**Duplicate Detection:** +- SHA256 hash matching (identical files) +- Filename + size matching (possible duplicates) +- Returns `DuplicateWarning` list with existing receipts +- User can confirm upload anyway via modal dialog + +**Security Features:** +- File size validation (10MB limit) +- Extension whitelist +- Filename sanitization +- SHA256 deduplication + +**Location:** Services/ReceiptManager.cs:23-199 + +### AIReceiptParser (Services/AIReceiptParser.cs) +**Interface:** `IReceiptParser` + +**Responsibility:** Parse receipts using AI vision APIs with tool-use support for database-aware parsing. + +**Key Methods:** +- `ParseReceiptAsync(long receiptId, string? model, string? notes)` + - Loads receipt file from disk + - Converts PDFs to PNG images using ImageMagick (220 DPI) + - 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 + - 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 + - Returns `ReceiptParseResult` + +**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 +``` + +**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 +- Flattens alpha channel (white background) +- TrueColor 8-bit RGB output + +**Auto-Mapping Integration:** +- 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/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` + +**Responsibility:** Automatically map parsed receipts to matching transactions using smart matching rules. + +**Key Methods:** +- `AutoMapReceiptAsync(long receiptId)` + - Attempts to match a single receipt to a transaction + - Uses date range, merchant name, and amount matching + - Returns `ReceiptAutoMapResult` with status (Success, NoMatch, MultipleMatches, AlreadyMapped, NotParsed, Failed) + +- `AutoMapUnmappedReceiptsAsync()` + - Bulk processes all unmapped parsed receipts + - Returns `BulkAutoMapResult` with counts (total, mapped, no match, multiple matches) + +**Matching Algorithm:** +1. **Date Range Filtering:** + - For bills with due dates: `billDate` to `dueDate + 5 days` (accounts for auto-pay delays, weekends, bank processing) + - For regular receipts: `receiptDate ± 3 days` (accounts for transaction posting delays) + - No date = no match (can't narrow down effectively) + +2. **Merchant Filtering (optional):** + - Matches if merchant name or transaction name contains receipt merchant + - Case-insensitive contains matching + +3. **Amount Matching (optional):** + - If receipt has total: matches transactions within ±$0.10 tolerance + - Accounts for rounding differences and tips + +4. **Receipt Exclusion:** + - Excludes transactions that already have receipts attached + - Uses HashSet for performance + +**Auto-Mapping Triggers:** +- Automatically triggered after successful receipt parsing (if receipt is unmapped) +- Can be manually triggered via "Auto-Map Unmapped Receipts" button on Receipts page + +**Result Status Types:** +- `Success` - Single match found, receipt mapped automatically +- `NoMatch` - No matching transactions found +- `MultipleMatches` - Multiple potential matches found (requires manual selection) +- `AlreadyMapped` - Receipt already linked to a transaction +- `NotParsed` - Receipt has no parsed data (merchant, date, or total missing) +- `Failed` - Error during mapping process + +**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 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` + +**Responsibility:** Generate comprehensive financial audit data for AI analysis via the `/api/audit` endpoint. + +**Key Methods:** +- `GenerateAuditAsync(DateTime startDate, DateTime endDate, bool includeTransactions = false)` + - Aggregates all financial data for the specified period + - Returns `FinancialAuditResponse` with summary, budgets, categories, merchants, trends, accounts, and flags + +**Response Structure:** +- `Summary` - High-level stats (income, expenses, net, average daily spend, uncategorized count) +- `Budgets` - All active budget statuses with period info and over-budget flags +- `SpendingByCategory` - Category breakdown with budget correlation +- `TopMerchants` - Top 20 merchants by spending +- `MonthlyTrends` - Month-by-month income/expense/net with top categories per month +- `Accounts` - Per-account transaction summaries +- `Flags` - AI-friendly alerts (over-budget, spending increases, uncategorized, etc.) +- `Transactions` - Optional detailed transaction list + +**Audit Flags Generated:** +- `OverBudget` - Budgets exceeded (Alert) +- `HighBudgetUtilization` - Budgets at 80%+ (Warning) +- `Uncategorized` - Transactions without categories (Warning) +- `NegativeCashFlow` - Spending exceeds income (Alert) +- `HighCategoryConcentration` - Category >30% of total spending (Info) +- `SpendingIncrease` - Month-over-month increase >20% (Warning) +- `NoBudget` - Top spending categories without budgets (Info) + +**Design Pattern:** +- Orchestrates existing services (IBudgetService) and new queries +- Single comprehensive endpoint for AI consumption +- Excludes transfers from spending calculations + +**Location:** Services/FinancialAuditService.cs + +### TransactionAICategorizer (Services/TransactionAICategorizer.cs) +**Interface:** `ITransactionAICategorizer` + +**Responsibility:** AI-powered categorization for uncategorized transactions. + +**Key Methods:** +- `ProposeCategorizationAsync(Transaction transaction)` + - Analyzes transaction details (name, memo, amount, date) + - Calls OpenAI GPT-4o-mini with categorization prompt + - Returns `AICategoryProposal` with category, merchant, pattern, and confidence + - Auto-suggests rule creation for high confidence (≥70%) + +- `ProposeBatchCategorizationAsync(List transactions)` + - Processes transactions in batches of 5 to avoid rate limits + - Returns list of proposals for review + +- `ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule)` + - Updates transaction category and merchant + - Optionally creates CategoryMapping rule for future auto-categorization + - Returns `ApplyProposalResult` with success status + +**API Configuration:** +- Model: `gpt-4o-mini` +- Temperature: 0.1 (deterministic) +- Max tokens: 300 +- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey` +- Cost: ~$0.00015 per transaction (~$0.015 per 100 transactions) + +**Prompt Strategy:** +- Provides transaction details (name, memo, amount, date) +- Requests JSON response with category, canonical_merchant, pattern, confidence, reasoning +- Includes common category examples for context +- High confidence threshold (≥70%) suggests automatic rule creation + +**CategoryMapping Enhancements:** +- `Confidence` (decimal?) - AI confidence score (0.0-1.0) +- `CreatedBy` (string?) - "AI" or "User" +- `CreatedAt` (DateTime?) - Rule creation timestamp + +**Location:** Services/TransactionAICategorizer.cs + +## Data Access Layer + +### MoneyMapContext (Data/MoneyMapContext.cs) +EF Core DbContext managing all database entities. + +**DbSets:** +- `Accounts` - Bank accounts +- `Cards` - Payment cards +- `Merchants` - Merchants/vendors +- `Transactions` - Bank transactions +- `Receipts` - Receipt files +- `ReceiptParseLogs` - Parse attempt logs +- `ReceiptLineItems` - Receipt line items +- `CategoryMappings` - Categorization rules + +**Key Configuration:** +- **Transactions ↔ Cards**: Restrict delete (can't delete card with transactions) +- **Transactions ↔ Accounts**: Restrict delete (can't delete account with transactions) +- **Receipts ↔ Transactions**: Cascade delete (deleting transaction removes receipts) +- **ReceiptParseLogs ↔ Receipts**: Cascade delete +- **ReceiptLineItems ↔ Receipts**: Cascade delete + +**Performance Indexes:** +- Transaction.Date, Transaction.Amount, Transaction.Category (filtering) +- Card: (Issuer, Last4, Owner) composite +- Transaction: (Date, Amount, Name, Memo, AccountId, CardId) unique constraint +- Receipt: (TransactionId, FileHashSha256) unique constraint + +**Location:** Data/MoneyMapContext.cs:9-107 + +## Razor Pages (UI Layer) + +### Index.cshtml / IndexModel (Dashboard) +**Route:** `/` + +**Purpose:** Main dashboard showing financial overview. + +**Displays:** +- Aggregate stats (total transactions, credits, debits, uncategorized, receipts, cards) +- Top 8 spending categories (last 90 days) with average per transaction +- Recent 20 transactions with card labels and receipt counts + +**Dependencies:** `IDashboardService` + +**Location:** Pages/Index.cshtml.cs:12-250 + +### Upload.cshtml / UploadModel (CSV Import) +**Route:** `/Upload` + +**Purpose:** Import bank transactions from CSV files. + +**Features:** +- Card selection mode: Auto (extract from memo/filename) or Manual (user selects) +- Duplicate detection (unique constraint on Date+Amount+Name+Memo+AccountId+CardId) +- Auto-creates cards if not found in Auto mode +- Displays import stats (total, inserted, skipped) + +**Form Inputs:** +- CSV file +- Card mode (radio: Auto/Manual) +- Card dropdown (if Manual mode) + +**Dependencies:** `ITransactionImporter` + +**Location:** Pages/Upload.cshtml.cs:17-333 + +### Transactions.cshtml / TransactionsModel +**Route:** `/Transactions` + +**Purpose:** View and search all transactions with advanced filtering. + +**Features:** +- Search by name, memo, category, notes, or merchant +- Filter by card, category, merchant, date range +- Quick date range selectors (30/60/90 days, last year, this month, last month) +- Sort by date, amount, name, category +- Pagination with preserved filters +- Transaction ID display for easy reference +- Receipt count indicator with badge +- Upload CSV button in header +- Edit transaction link + +**Stats Display:** +- Total transaction count +- Current page showing count +- Total spent (debits sum) +- Total income (credits sum) +- Net amount with color coding + +**Location:** Pages/Transactions.cshtml.cs + +### Receipts.cshtml / ReceiptsModel +**Route:** `/Receipts` + +**Purpose:** Manage receipts with upload, mapping, and auto-mapping capabilities. + +**Features:** +- Upload receipts without initial transaction mapping (unmapped receipts) +- Duplicate detection with blocking modal dialog + - SHA256 hash matching (identical files) + - Filename + size matching (possible duplicates) + - User confirmation required to proceed +- View all receipts (mapped and unmapped) with status badges +- Manual receipt-to-transaction mapping via modal selector +- Auto-map unmapped receipts button (bulk operation) +- Smart transaction matching with filters +- Receipt info display: merchant, date, due date (for bills), total +- Delete receipts +- View receipt details + +**Transaction Selector Modal:** +- Displays up to 50 matching transactions in scrollable table +- Date range filtering: + - Bills with due dates: bill date to due date + 5 days + - Regular receipts: receipt date ± 3 days +- Merchant-based relevance sorting (exact match > contains > no match) +- Amount matching with green highlighting (±$0.10 tolerance) +- Auto-scroll to first amount match when modal opens +- Manual transaction ID entry option +- Link to open Transactions page for searching + +**Receipt List Columns:** +- Uploaded timestamp +- File name with icon (image/PDF) +- Receipt info (merchant, date, due date, total) +- Mapped transaction details or "Unmapped" badge +- Actions: View, Map (if unmapped), Delete + +**Dependencies:** `IReceiptManager`, `IReceiptAutoMapper` + +**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 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}` + +**Purpose:** Edit transaction details. + +**Features:** +- Update category (select existing or create new) +- Update merchant (select existing or create new) +- Update notes +- Upload/delete receipts +- View receipt thumbnails with parsed data +- Trigger AI receipt parsing + +**Location:** Pages/EditTransaction.cshtml.cs:27-202 + +### ViewReceipt.cshtml / ViewReceiptModel +**Route:** `/ViewReceipt/{id}` + +**Purpose:** View receipt details and parsed data. + +**Displays:** +- Receipt image/PDF preview +- File metadata (name, size, upload date) +- Parsed merchant, date, amounts +- Line items table (description, quantity, unit price, total) +- Parse logs (provider, model, confidence, timestamp, errors) +- Actions: Re-parse, Delete + +**Dependencies:** `IReceiptManager`, `IReceiptParser` + +**Location:** Pages/ViewReceipt.cshtml.cs:16-126 + +### CategoryMappings.cshtml / CategoryMappingsModel +**Route:** `/CategoryMappings` + +**Purpose:** Manage category mapping rules. + +**Features:** +- View all category patterns grouped by category +- Add/edit/delete mappings +- Assign merchant to mapping (creates normalized merchant name) +- Priority management (higher = checked first) +- Seed default mappings +- Export/import mappings as JSON + +**Dependencies:** `ITransactionCategorizer` + +**Location:** Pages/CategoryMappings.cshtml.cs:16-91 + +### Merchants.cshtml / MerchantsModel +**Route:** `/Merchants` + +**Purpose:** Manage merchant entities. + +**Features:** +- View all merchants +- Edit merchant names +- Delete merchants (with confirmation) +- See transaction count per merchant + +**Location:** Pages/Merchants.cshtml.cs + +### Budgets.cshtml / BudgetsModel +**Route:** `/Budgets` + +**Purpose:** Manage spending budgets and track budget status. + +**Features:** +- Create budgets for specific categories or total spending +- Support for Weekly, Monthly, and Yearly periods +- Real-time spending vs budget tracking with progress bars +- Color-coded status (green/yellow/red based on % used) +- Pause/resume budgets without deleting +- Edit budget amounts and periods +- View active and inactive budgets separately + +**Budget Types:** +- **Category Budget:** Track spending for a specific category (e.g., "Dining Out") +- **Total Budget:** Track all spending combined (Category = null) + +**Period Boundaries:** +- Periods are calculated relative to the budget's StartDate +- Monthly budgets run from the StartDate's day (e.g., if StartDate is Jan 15, periods run 15th to 14th) +- Weekly budgets run in 7-day increments from StartDate +- Yearly budgets run from the StartDate anniversary + +**Dependencies:** `IBudgetService` + +**Location:** Pages/Budgets.cshtml.cs + +### Recategorize.cshtml / RecategorizeModel +**Route:** `/Recategorize` + +**Purpose:** Bulk recategorize uncategorized transactions. + +**Features:** +- Lists all transactions without a category +- Applies TransactionCategorizer rules +- Updates matching transactions +- Reports counts (total, categorized, still uncategorized) + +**Dependencies:** `ITransactionCategorizer` + +**Location:** Pages/Recategorize.cshtml.cs:16-87 + +### ReviewAISuggestions.cshtml / ReviewAISuggestionsModel +**Route:** `/ReviewAISuggestions` + +**Purpose:** AI-powered categorization suggestions for uncategorized transactions. + +**Features:** +- Lists up to 50 most recent uncategorized transactions +- Generate AI suggestions button (processes up to 20 at a time) +- Cost transparency (~$0.00015 per transaction) +- Link to view uncategorized transactions + +**Dependencies:** `ITransactionAICategorizer` + +**Location:** Pages/ReviewAISuggestions.cshtml.cs + +### ReviewAISuggestionsWithProposals.cshtml / ReviewAISuggestionsWithProposalsModel +**Route:** `/ReviewAISuggestionsWithProposals` + +**Purpose:** Review and apply AI categorization proposals. + +**Features:** +- Display AI proposals with confidence scores +- Color-coded confidence indicators (green ≥80%, yellow 60-79%, red <60%) +- Individual actions: Accept (with/without rule), Reject, Edit Manually +- Bulk action: Apply all high-confidence suggestions (≥80%) +- Shows AI reasoning for each suggestion +- Stores proposals in session for review workflow + +**Dependencies:** `ITransactionAICategorizer` + +**Location:** Pages/ReviewAISuggestionsWithProposals.cshtml.cs + +## API Endpoints + +### Financial Audit API +**Route:** `GET /api/audit` + +**Purpose:** Provide comprehensive financial data for AI analysis and auditing. + +**Query Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `startDate` | DateTime? | Today - 90 days | Start of audit period | +| `endDate` | DateTime? | Today | End of audit period | +| `includeTransactions` | bool | false | Include detailed transaction list | + +**Example Requests:** +``` +GET /api/audit +GET /api/audit?startDate=2025-01-01&endDate=2025-12-31 +GET /api/audit?startDate=2025-11-01&includeTransactions=true +``` + +**Response:** JSON `FinancialAuditResponse` object containing: +- `generatedAt` - Timestamp of audit generation +- `periodStart`, `periodEnd` - Audit date range +- `summary` - High-level financial statistics +- `budgets` - Active budget statuses +- `spendingByCategory` - Category breakdown with budget correlation +- `topMerchants` - Top 20 merchants by spending +- `monthlyTrends` - Month-by-month trends +- `accounts` - Per-account summaries +- `flags` - AI-friendly alerts and observations +- `transactions` - Detailed transaction list (optional) + +**Authentication:** None (localhost/personal use only) + +**Use Cases:** +- AI-powered financial analysis and recommendations +- Spending pattern identification +- Budget compliance monitoring +- External tool integration + +**Implementation:** Minimal API endpoint in Program.cs, backed by `IFinancialAuditService` + +**Location:** Program.cs:99-112 + +## Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "MoneyMapDb": "Server=...;Database=MoneyMap;..." + }, + "OpenAI": { + "ApiKey": "sk-..." // Optional, can use OPENAI_API_KEY env var + }, + "Receipts": { + "StoragePath": "receipts" // Relative to wwwroot + } +} +``` + +### Program.cs Dependency Injection +```csharp +// Database +builder.Services.AddDbContext(options => + options.UseSqlServer(...)); + +// Transaction Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Dashboard Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Receipt Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// AI Vision Clients and Tool Use +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +**Location:** Program.cs:1-44 + +## Database Schema (SQL Server) + +### Accounts Table +```sql +Id (int, PK) +Issuer (nvarchar(100), NOT NULL) +Last4 (nvarchar(4), NOT NULL) +Owner (nvarchar(100)) +INDEX: (Issuer, Last4, Owner) +``` + +### Cards Table +```sql +Id (int, PK) +AccountId (int, FK → Accounts.Id, RESTRICT) +Issuer (nvarchar(100), NOT NULL) +Last4 (nvarchar(4), NOT NULL) +Owner (nvarchar(100)) +INDEX: (Issuer, Last4, Owner) +``` + +### Merchants Table +```sql +Id (int, PK) +Name (nvarchar(100), NOT NULL) +``` + +### Transactions Table +```sql +Id (bigint, PK) +Date (datetime2, NOT NULL) +TransactionType (nvarchar(20)) +Name (nvarchar(200), NOT NULL) +Memo (nvarchar(500)) +Amount (decimal(18,2), NOT NULL) +Category (nvarchar(100)) +MerchantId (int, FK → Merchants.Id, SET NULL) +Notes (nvarchar(max)) +AccountId (int, FK → Accounts.Id, RESTRICT) +CardId (int, FK → Cards.Id, RESTRICT) +TransferToAccountId (int, FK → Accounts.Id, RESTRICT) +Last4 (nvarchar(4)) +UNIQUE INDEX: (Date, Amount, Name, Memo, AccountId, CardId) +INDEX: Date, Amount, Category +``` + +### Receipts Table +```sql +Id (bigint, PK) +TransactionId (bigint, FK → Transactions.Id, CASCADE, NULLABLE) +FileName (nvarchar(260), NOT NULL) +ContentType (nvarchar(100), DEFAULT 'application/octet-stream') +StoragePath (nvarchar(1024), NOT NULL) +FileSizeBytes (bigint, NOT NULL) +FileHashSha256 (nvarchar(64), NOT NULL) +UploadedAtUtc (datetime2, NOT NULL) +Merchant (nvarchar(200)) +ReceiptDate (datetime2) +DueDate (datetime2) -- For bills: payment due date +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 +```sql +Id (bigint, PK) +ReceiptId (bigint, FK → Receipts.Id, CASCADE) +Provider (nvarchar(50), NOT NULL) +Model (nvarchar(100), NOT NULL) +StartedAtUtc (datetime2, NOT NULL) +CompletedAtUtc (datetime2) +Success (bit, NOT NULL) +Confidence (decimal(18,2)) +Error (nvarchar(max)) +ProviderJobId (nvarchar(100)) +ExtractedTextPath (nvarchar(1024)) +RawProviderPayloadJson (nvarchar(max)) +``` + +### ReceiptLineItems Table +```sql +Id (bigint, PK) +ReceiptId (bigint, FK → Receipts.Id, CASCADE) +LineNumber (int, NOT NULL) +Description (nvarchar(300), NOT NULL) +Quantity (decimal(18,4)) +UnitPrice (decimal(18,4)) +LineTotal (decimal(18,2), NOT NULL) +Unit (nvarchar(16)) +Sku (nvarchar(64)) +Category (nvarchar(100)) +``` + +### CategoryMappings Table +```sql +Id (int, PK) +Category (nvarchar(max), NOT NULL) +Pattern (nvarchar(max), NOT NULL) +MerchantId (int, FK → Merchants.Id, SET NULL) +Priority (int, NOT NULL, DEFAULT 0) +Confidence (decimal(18,2), NULL) -- AI confidence score +CreatedBy (nvarchar(max), NULL) -- "AI" or "User" +CreatedAt (datetime2, NULL) -- Rule creation timestamp +``` + +### Budgets Table +```sql +Id (int, PK) +Category (nvarchar(100), NULL) -- NULL = total spending budget +Amount (decimal(18,2), NOT NULL) +Period (int, NOT NULL) -- 0=Weekly, 1=Monthly, 2=Yearly +StartDate (datetime2, NOT NULL) +IsActive (bit, NOT NULL) +Notes (nvarchar(500), NULL) +UNIQUE INDEX: (Category, Period) WHERE IsActive = 1 +``` + +## Key Workflows + +### 1. Import Transactions from CSV + +``` +User uploads CSV file + ↓ +UploadModel.OnPostAsync() + ↓ +TransactionImporter.ImportAsync() + ↓ +For each CSV row: + CardResolver.ResolveCardAsync() → Get/Create Card + Check for duplicates (DB + batch) + Add to batch if unique + ↓ +DbContext.SaveChangesAsync() + ↓ +Display import stats (inserted, skipped) +``` + +### 2. Auto-Categorize Transaction + +``` +Transaction inserted with Name="KROGER #123" + ↓ +TransactionCategorizer.CategorizeAsync("KROGER #123") + ↓ +Load CategoryMappings (ordered by Priority DESC) + ↓ +Check special case: Gas station with small purchase? + ↓ +Iterate mappings: + If "KROGER".Contains(pattern.ToUpper()) → Match! + Return "Brick/mortar store" + If mapping has MerchantId → Assign to transaction + ↓ +Update Transaction.Category and Transaction.MerchantId +``` + +### 3. Upload and Parse Receipt (Traditional Flow - From Transaction) + +``` +User attaches receipt to transaction + ↓ +EditTransactionModel.OnPostUploadReceiptAsync() + ↓ +ReceiptManager.UploadReceiptAsync() + - Validate file (size, extension) + - Compute SHA256 hash + - Check for duplicate (TransactionId + Hash) + - Save to wwwroot/receipts/ + - Create Receipt entity + ↓ +User clicks "Parse Receipt" + ↓ +ViewReceiptModel.OnPostParseAsync() + ↓ +OpenAIReceiptParser.ParseReceiptAsync() + - Load receipt file + - If PDF: Convert to PNG with ImageMagick + - Encode as base64 + - Call OpenAI Vision API with structured prompt + - Parse JSON response + - Update Receipt (merchant, date, due date, amounts) + - Replace ReceiptLineItems + - Create ReceiptParseLog + ↓ +Display parsed data +``` + +### 3a. Upload, Parse, and Auto-Map Receipt (New Unmapped Flow) + +``` +User visits /Receipts page + ↓ +User uploads receipt without transaction + ↓ +ReceiptsModel.OnPostUploadAsync() + ↓ +ReceiptManager.UploadUnmappedReceiptAsync() + - Validate file (size, extension) + - Compute SHA256 hash + - Check for duplicates (hash, filename+size) + ↓ +IF duplicates found: + - Delete uploaded file temporarily + - Store duplicate warnings in TempData + - Redirect to GET with modal + ↓ + User sees blocking modal: + Option A: Cancel (don't upload) + Option B: Upload Anyway (re-upload with confirmation) + ↓ +IF no duplicates OR confirmed: + - Save to wwwroot/receipts/ + - Create Receipt entity (TransactionId = null) + ↓ +User clicks "Parse Receipt" + ↓ +OpenAIReceiptParser.ParseReceiptAsync() + - Parse receipt (merchant, date, due date, total, line items) + - Update Receipt entity + - Trigger ReceiptAutoMapper.AutoMapReceiptAsync() + ↓ +ReceiptAutoMapper.AutoMapReceiptAsync() + - Find matching transactions: + * Date filter (bill date to due date+5 OR receipt date ±3) + * Merchant filter (contains, case-insensitive) + * Amount filter (±$0.10 tolerance) + * Exclude transactions with existing receipts + ↓ + IF single match found: + - Auto-map receipt to transaction + - Return Success + ELSE IF multiple matches: + - Return MultipleMatches (requires manual selection) + ELSE: + - Return NoMatch + ↓ +IF auto-mapping failed (no match or multiple matches): + User clicks "Map" button on receipt row + ↓ + ReceiptsModel.FindMatchingTransactionsForReceipt() + - Apply same filtering logic + - Sort by merchant relevance (exact > contains > none) + - Return up to 50 matches + ↓ + Modal displays transactions in table: + - Green highlighting for amount matches + - Auto-scroll to first amount match + - Radio button selection + - Manual ID entry option + ↓ + User selects transaction + ↓ + ReceiptsModel.OnPostMapToTransactionAsync() + - Links receipt to transaction + - Redirect to receipts page +``` + +### 4. Dashboard Aggregation + +``` +User visits homepage (/) + ↓ +IndexModel.OnGet() + ↓ +DashboardService.GetDashboardDataAsync() + ↓ +Parallel data fetching: + - DashboardStatsCalculator → Total txns, credits, debits, uncategorized, receipts, cards + - TopCategoriesProvider → Top 8 categories by spend (last 90 days) with average per transaction + - RecentTransactionsProvider → Latest 20 transactions + ↓ +Return DashboardData DTO + ↓ +Render dashboard view +``` + +### 5. AI-Powered Categorization (Phase 1 - Manual Review) + +``` +User visits /ReviewAISuggestions + ↓ +ReviewAISuggestionsModel.OnGetAsync() + - Loads up to 50 recent uncategorized transactions + ↓ +User clicks "Generate AI Suggestions" + ↓ +ReviewAISuggestionsModel.OnPostGenerateSuggestionsAsync() + - Fetches up to 20 uncategorized transactions + - Calls TransactionAICategorizer.ProposeBatchCategorizationAsync() + ↓ +For each transaction (batches of 5): + TransactionAICategorizer.ProposeCategorizationAsync() + - Builds prompt with transaction details + - Calls OpenAI GPT-4o-mini API + - Parses JSON response + - Returns AICategoryProposal + ↓ +Store proposals in session +Redirect to /ReviewAISuggestionsWithProposals + ↓ +User reviews proposals with confidence scores + ↓ +User actions: + Option A: Apply + Create Rule + - Updates transaction category and merchant + - Creates CategoryMapping rule (CreatedBy="AI") + - Future similar transactions auto-categorized + Option B: Apply (No Rule) + - Updates transaction only + - No rule created + Option C: Reject + - Removes proposal from session + Option D: Edit Manually + - Redirects to EditTransaction page + ↓ +Proposal applied +Remove from session +Display success message +``` + +## Design Patterns + +### 1. Service Layer Pattern +All business logic isolated in service interfaces/implementations: +- Testable (mock services in unit tests) +- Reusable across pages +- Single Responsibility Principle + +### 2. Repository Pattern (via EF Core DbContext) +- MoneyMapContext encapsulates data access +- LINQ queries in service layer +- Change tracking and unit of work handled by EF Core + +### 3. Result Pattern +Services return result objects instead of throwing exceptions: +- `ImportOperationResult` (success/failure with data or error message) +- `CardResolutionResult` +- `ReceiptUploadResult` +- `ReceiptParseResult` + +Benefits: Explicit error handling, no try-catch clutter, clear success/failure paths + +### 4. Dependency Injection +All services registered in Program.cs: +- Scoped lifetime for per-request services +- HttpClient factory for IReceiptParser +- Testability via interface injection + +### 5. Data Transfer Objects (DTOs) +Separate DTOs for data transfer between layers: +- `DashboardData` (aggregates stats + categories + recent transactions) +- `ImportContext` (encapsulates import parameters) +- `TransactionKey` (value object for deduplication) +- Page model record types (TopCategoryRow, RecentTxnRow, etc.) + +### 6. Strategy Pattern +CardResolver uses strategy based on CardSelectMode: +- Auto strategy: Extract from memo/filename +- Manual strategy: Use user selection + +## Security Considerations + +### 1. File Upload Security +- File size limit: 10MB +- Extension whitelist: jpg, jpeg, png, pdf, gif, heic +- Filename sanitization: Remove non-ASCII characters (®, ™, ©) +- SHA256 hash validation for deduplication +- Files stored outside database (wwwroot/receipts/) + +### 2. SQL Injection Prevention +- Parameterized queries via EF Core +- LINQ expressions compile to safe SQL + +### 3. API Key Management +- OpenAI API key from environment variable (preferred) or config +- Not hardcoded in source + +### 4. Delete Constraints +- Restrict delete on Cards (can't delete if transactions exist) +- Restrict delete on Accounts (can't delete if transactions exist) +- Cascade delete on Receipts (deleting transaction removes receipts) + +## Performance Optimizations + +### 1. Database Indexes +- Transactions: Date, Amount, Category (for filtering/sorting) +- Unique indexes on duplicate detection columns +- Composite index on Cards (Issuer, Last4, Owner) + +### 2. Batch Processing +- CSV import: Bulk insert with single SaveChangesAsync() +- In-memory duplicate checking (HashSet) avoids N+1 queries + +### 3. Pagination +- Transactions list uses pagination to limit result sets +- Dashboard limits top categories (8) and recent transactions (20) + +### 4. AsNoTracking +- Read-only queries use `.AsNoTracking()` to reduce EF overhead +- TopCategoriesProvider, RecentTransactionsProvider + +### 5. PDF Conversion +- ImageMagick runs on ThreadPool (Task.Run) to avoid blocking +- First page only for multi-page PDFs + +## Testing Considerations + +### Unit Testing Strategy +All service interfaces are mockable: +```csharp +// Example: Test TransactionImporter +var mockCardResolver = new Mock(); +var mockDb = new Mock(); +var importer = new TransactionImporter(mockDb.Object, mockCardResolver.Object); +``` + +### Integration Testing +- In-memory database for DbContext tests +- Test CSV import end-to-end +- Validate categorization rules + +### External Dependencies +- OpenAI API: Mock IReceiptParser for tests +- File system: Mock IWebHostEnvironment + +## Future Enhancements + +### Potential Features +1. **Multi-user support**: Add authentication/authorization +2. **Recurring transactions**: Auto-detect and predict recurring expenses +3. **Data export**: Export transactions to Excel/CSV +4. **Charts/graphs**: Spending trends over time +5. **Mobile app**: React Native or .NET MAUI +6. **Bank API integration**: Direct import from bank APIs (Plaid, Yodlee) +7. **Receipt search**: Full-text search on parsed line items +8. **Split transactions**: Divide single transaction across categories +9. **Tags**: Tag transactions with multiple labels + +### Technical Debt +1. Move inline service implementations to separate files (Upload.cshtml.cs is 333 lines) +2. Add logging (ILogger) to all services +3. Add retry logic for OpenAI API calls (Polly) +4. Implement background job processing for receipt parsing (Hangfire) +5. Add integration tests for critical workflows +6. Implement caching for CategoryMappings (IMemoryCache) + +## Troubleshooting + +### Common Issues + +**1. Duplicate Transaction Errors** +- **Cause**: Unique constraint violation (Date + Amount + Name + Memo + AccountId + CardId) +- **Solution**: Transactions are skipped automatically during import + +**2. Receipt Parsing Failures** +- **Cause**: OpenAI API key missing or invalid +- **Solution**: Set `OPENAI_API_KEY` environment variable or `OpenAI:ApiKey` in appsettings.json +- **Cause**: PDF conversion error +- **Solution**: Ensure Magick.NET is properly installed + +**3. Card Not Found** +- **Cause**: Auto mode can't extract last 4 digits from memo or filename +- **Solution**: Use Manual mode and select card from dropdown, or rename CSV file to include card digits (e.g., `transactions-1234.csv`) + +**4. Slow Dashboard Load** +- **Cause**: Large transaction dataset without indexes +- **Solution**: Ensure migrations have run (indexes on Date, Amount, Category) + +**5. File Upload Fails** +- **Cause**: File exceeds 10MB or unsupported format +- **Solution**: Resize/compress image, or use supported format + +## Deployment + +### Database Migration +```bash +# Create migration +dotnet ef migrations add MigrationName + +# Update database +dotnet ef database update +``` + +### Production Configuration +1. Update connection string in appsettings.json (Azure SQL, AWS RDS, etc.) +2. Set `OPENAI_API_KEY` environment variable +3. Configure `Receipts:StoragePath` (consider Azure Blob Storage) +4. Enable HTTPS and HSTS +5. Set `ASPNETCORE_ENVIRONMENT=Production` + +### File Storage Considerations +- **Local**: wwwroot/receipts (current implementation) +- **Production**: Azure Blob Storage, AWS S3, or network share + - Modify ReceiptManager to use cloud storage SDK + - Update StoragePath format (blob URL instead of relative path) + +## Conclusion + +MoneyMap demonstrates a well-architected ASP.NET Core application with clear separation of concerns, testable services, and modern AI integration. The service layer pattern allows for easy maintenance and extension, while the Result pattern provides explicit error handling. The OpenAI receipt parsing feature showcases practical LLM integration for automating tedious data entry tasks. + +--- + +**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 +- **New Endpoint**: `GET /api/audit` - Comprehensive financial data for AI analysis +- **FinancialAuditService**: New service orchestrating financial data aggregation +- **Audit Response**: Summary stats, budgets, categories, merchants, trends, accounts +- **AI Flags**: Pre-computed observations (over-budget, spending increases, uncategorized) +- **Configurable Period**: Query parameters for date range and transaction inclusion + +### Technical Details +- Implemented using Minimal API pattern (no controllers needed) +- DTOs in `Models/Api/FinancialAuditModels.cs` +- Excludes transfers from spending calculations +- Correlates category spending with budget limits + +## Recent Changes (v1.3) + +### Budget Tracking Feature +- **Budget Model**: New `Budget` entity with support for Weekly, Monthly, and Yearly periods +- **Category & Total Budgets**: Create budgets for specific categories or track total spending +- **Period Boundaries**: Smart calculation of period start/end dates based on budget start date +- **Budget Service**: Full CRUD operations plus spending calculations via `IBudgetService` +- **Budgets Page**: New `/Budgets` page for managing budgets with progress bars +- **Dashboard Integration**: Budget status displayed on dashboard with color-coded progress +- **Pause/Resume**: Deactivate budgets without deleting them + +### Technical Details +- Added `Budgets` table with filtered unique index on (Category, Period) WHERE IsActive = 1 +- `BudgetPeriod` enum: Weekly (0), Monthly (1), Yearly (2) +- Period boundary calculations handle edge cases (month boundaries, leap years) +- Spending calculated by summing debits within period, excluding transfers + +## Recent Changes (v1.2) + +### Receipt Management Enhancements +- **Unmapped Receipts**: Receipts can now be uploaded without initial transaction mapping +- **Duplicate Detection**: SHA256 hash and filename+size matching with blocking modal confirmation +- **Auto-Mapping**: Smart automatic receipt-to-transaction matching using date, merchant, and amount +- **Due Date Support**: Bills (utility, credit card) can have due dates for expanded matching windows +- **Manual Mapping UI**: Enhanced modal with transaction table, relevance sorting, amount highlighting, and auto-scroll +- **Date Range Intelligence**: + - Bills: bill date to due date + 5 days (accounts for auto-pay processing) + - Regular receipts: receipt date ± 3 days + +### UI Improvements +- **Transactions Page**: Added search, quick date filters, transaction ID column, Upload CSV button in header +- **Navigation**: Moved Upload CSV from menu to Transactions page header +- **Receipts Page**: New dedicated page for receipt management with mapping workflow +- **Transaction Selector**: Scrollable table with green amount match highlighting and auto-scroll to best match + +### Technical Improvements +- Made `Receipt.TransactionId` nullable with filtered unique index +- Added `Receipt.DueDate` column for bill payment tracking +- Enhanced OpenAI prompt to extract due dates from bills +- 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`-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 diff --git a/CLAUDE.md b/CLAUDE.md index 4082c5d..109bbe9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,134 +1,134 @@ -# 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 +# 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/Data/MoneyMapContext.cs b/MoneyMap.Core/Data/MoneyMapContext.cs index 09a3e9c..b098efb 100644 --- a/MoneyMap.Core/Data/MoneyMapContext.cs +++ b/MoneyMap.Core/Data/MoneyMapContext.cs @@ -1,227 +1,276 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Models; - -namespace MoneyMap.Data -{ - public class MoneyMapContext : DbContext - { - public MoneyMapContext(DbContextOptions options) : base(options) { } - - public DbSet Cards => Set(); - public DbSet Accounts => Set(); - public DbSet Transactions => Set(); - public DbSet Transfers => Set(); - public DbSet Receipts => Set(); - public DbSet ReceiptParseLogs => Set(); - public DbSet ReceiptLineItems => Set(); - - public DbSet CategoryMappings => Set(); - public DbSet Merchants => Set(); - public DbSet Budgets => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - // ---------- CARD ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Issuer).HasMaxLength(100).IsRequired(); - e.Property(x => x.Last4).HasMaxLength(4).IsRequired(); - e.Property(x => x.Owner).HasMaxLength(100).IsRequired(); - e.Property(x => x.Nickname).HasMaxLength(50); - - // Card can be linked to an account (optional - for credit cards without linked account) - e.HasOne(x => x.Account) - .WithMany(a => a.Cards) - .HasForeignKey(x => x.AccountId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(false); - - e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner }); - e.HasIndex(x => x.AccountId); - }); - - // ---------- ACCOUNT ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Institution).HasMaxLength(100).IsRequired(); - e.Property(x => x.Last4).HasMaxLength(4).IsRequired(); - e.Property(x => x.Owner).HasMaxLength(100).IsRequired(); - e.Property(x => x.Nickname).HasMaxLength(50); - e.HasIndex(x => new { x.Institution, x.Last4, x.Owner }); - }); - - // ---------- TRANSACTION ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.TransactionType).HasMaxLength(20); - e.Property(x => x.Name).HasMaxLength(200).IsRequired(); - e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty); - e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); - e.Property(x => x.Category).HasMaxLength(100); - e.Property(x => x.Last4).HasMaxLength(4); - - // Card (optional). If a card is deleted, block delete when txns exist. - e.HasOne(x => x.Card) - .WithMany(c => c.Transactions) - .HasForeignKey(x => x.CardId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(false); - - // Account (optional). If an account is deleted, block delete when txns exist. - e.HasOne(x => x.Account) - .WithMany(a => a.Transactions) - .HasForeignKey(x => x.AccountId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(false); - - // Merchant (optional). If a merchant is deleted, set to null. - e.HasOne(x => x.Merchant) - .WithMany(m => m.Transactions) - .HasForeignKey(x => x.MerchantId) - .OnDelete(DeleteBehavior.SetNull) - .IsRequired(false); - }); - - // ---------- TRANSFER ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); - e.Property(x => x.Description).HasMaxLength(500); - - // Source account (optional - can be "Unknown") - e.HasOne(x => x.SourceAccount) - .WithMany(a => a.SourceTransfers) - .HasForeignKey(x => x.SourceAccountId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(false); - - // Destination account (optional - can be "Unknown") - e.HasOne(x => x.DestinationAccount) - .WithMany(a => a.DestinationTransfers) - .HasForeignKey(x => x.DestinationAccountId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(false); - - // Original transaction link (optional) - e.HasOne(x => x.OriginalTransaction) - .WithMany() - .HasForeignKey(x => x.OriginalTransactionId) - .OnDelete(DeleteBehavior.SetNull) - .IsRequired(false); - - e.HasIndex(x => x.Date); - e.HasIndex(x => x.SourceAccountId); - e.HasIndex(x => x.DestinationAccountId); - }); - - // ---------- RECEIPT ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.FileName).HasMaxLength(260).IsRequired(); - e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream"); - e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired(); - e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired(); - - e.Property(x => x.Merchant).HasMaxLength(200); - e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)"); - e.Property(x => x.Tax).HasColumnType("decimal(18,2)"); - 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) - .HasForeignKey(x => x.TransactionId) - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(false); - }); - - // ---------- RECEIPT PARSE LOG ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Provider).HasMaxLength(50).IsRequired(); - e.Property(x => x.Model).HasMaxLength(100).IsRequired(); - e.Property(x => x.ProviderJobId).HasMaxLength(100); - e.Property(x => x.ExtractedTextPath).HasMaxLength(1024); - - e.HasOne(x => x.Receipt) - .WithMany(r => r.ParseLogs) - .HasForeignKey(x => x.ReceiptId) - .OnDelete(DeleteBehavior.Cascade); - }); - - // ---------- RECEIPT LINE ITEM ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Description).HasMaxLength(300).IsRequired(); - e.Property(x => x.Unit).HasMaxLength(16); - e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)"); - e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)"); - e.Property(x => x.Sku).HasMaxLength(64); - e.Property(x => x.Category).HasMaxLength(100); - - e.HasOne(x => x.Receipt) - .WithMany(r => r.LineItems) - .HasForeignKey(x => x.ReceiptId) - .OnDelete(DeleteBehavior.Cascade); - }); - - // ---------- MERCHANT ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Name).HasMaxLength(100).IsRequired(); - e.HasIndex(x => x.Name).IsUnique(); - }); - - // ---------- CATEGORY MAPPING ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Category).HasMaxLength(100).IsRequired(); - e.Property(x => x.Pattern).HasMaxLength(200).IsRequired(); - e.Property(x => x.Confidence).HasColumnType("decimal(5,4)"); // 0.0000 to 1.0000 - e.Property(x => x.CreatedBy).HasMaxLength(50); - - // Merchant (optional). If a merchant is deleted, set to null. - e.HasOne(x => x.Merchant) - .WithMany(m => m.CategoryMappings) - .HasForeignKey(x => x.MerchantId) - .OnDelete(DeleteBehavior.SetNull) - .IsRequired(false); - }); - - // ---------- Extra SQL Server–friendly indexes ---------- - // Fast filtering by date/amount/category - modelBuilder.Entity().HasIndex(x => x.Date); - modelBuilder.Entity().HasIndex(x => x.Amount); - modelBuilder.Entity().HasIndex(x => x.Category); - modelBuilder.Entity().HasIndex(x => x.MerchantId); - - // Composite indexes for common query patterns - modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Category }); - modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Date }); - modelBuilder.Entity().HasIndex(x => new { x.MerchantId, x.Date }); - modelBuilder.Entity().HasIndex(x => new { x.CardId, x.Date }); - - // Receipt duplicate detection and lookup - modelBuilder.Entity().HasIndex(x => x.FileHashSha256); - modelBuilder.Entity().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); - - // ---------- BUDGET ---------- - modelBuilder.Entity(e => - { - e.Property(x => x.Category).HasMaxLength(100); - e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); - e.Property(x => x.Notes).HasMaxLength(500); - - // Only one active budget per category per period - // Null category = total budget, so we use a filtered unique index - e.HasIndex(x => new { x.Category, x.Period }) - .HasFilter("[IsActive] = 1") - .IsUnique(); - }); - } - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Models; + +namespace MoneyMap.Data +{ + public class MoneyMapContext : DbContext + { + public MoneyMapContext(DbContextOptions options) : base(options) { } + + public DbSet Cards => Set(); + public DbSet Accounts => Set(); + public DbSet Transactions => Set(); + public DbSet Transfers => Set(); + public DbSet Receipts => Set(); + public DbSet ReceiptParseLogs => Set(); + public DbSet ReceiptLineItems => Set(); + + public DbSet CategoryMappings => Set(); + public DbSet Merchants => Set(); + public DbSet Budgets => Set(); + public DbSet RecurringPlaces => Set(); + public DbSet Categories => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // ---------- CARD ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Issuer).HasMaxLength(100).IsRequired(); + e.Property(x => x.Last4).HasMaxLength(4).IsRequired(); + e.Property(x => x.Owner).HasMaxLength(100).IsRequired(); + e.Property(x => x.Nickname).HasMaxLength(50); + + // Card can be linked to an account (optional - for credit cards without linked account) + e.HasOne(x => x.Account) + .WithMany(a => a.Cards) + .HasForeignKey(x => x.AccountId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner }); + e.HasIndex(x => x.AccountId); + }); + + // ---------- ACCOUNT ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Institution).HasMaxLength(100).IsRequired(); + e.Property(x => x.Last4).HasMaxLength(4).IsRequired(); + e.Property(x => x.Owner).HasMaxLength(100).IsRequired(); + e.Property(x => x.Nickname).HasMaxLength(50); + e.HasIndex(x => new { x.Institution, x.Last4, x.Owner }); + }); + + // ---------- TRANSACTION ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.TransactionType).HasMaxLength(20); + e.Property(x => x.Name).HasMaxLength(200).IsRequired(); + e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty); + e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); + e.Property(x => x.Category).HasMaxLength(100); + e.Property(x => x.Last4).HasMaxLength(4); + + // Card (optional). If a card is deleted, block delete when txns exist. + e.HasOne(x => x.Card) + .WithMany(c => c.Transactions) + .HasForeignKey(x => x.CardId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // Account (optional). If an account is deleted, block delete when txns exist. + e.HasOne(x => x.Account) + .WithMany(a => a.Transactions) + .HasForeignKey(x => x.AccountId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // Merchant (optional). If a merchant is deleted, set to null. + e.HasOne(x => x.Merchant) + .WithMany(m => m.Transactions) + .HasForeignKey(x => x.MerchantId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + }); + + // ---------- TRANSFER ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); + e.Property(x => x.Description).HasMaxLength(500); + + // Source account (optional - can be "Unknown") + e.HasOne(x => x.SourceAccount) + .WithMany(a => a.SourceTransfers) + .HasForeignKey(x => x.SourceAccountId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // Destination account (optional - can be "Unknown") + e.HasOne(x => x.DestinationAccount) + .WithMany(a => a.DestinationTransfers) + .HasForeignKey(x => x.DestinationAccountId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // Original transaction link (optional) + e.HasOne(x => x.OriginalTransaction) + .WithMany() + .HasForeignKey(x => x.OriginalTransactionId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasIndex(x => x.Date); + e.HasIndex(x => x.SourceAccountId); + e.HasIndex(x => x.DestinationAccountId); + }); + + // ---------- RECEIPT ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.FileName).HasMaxLength(260).IsRequired(); + e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream"); + e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired(); + e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired(); + + e.Property(x => x.Merchant).HasMaxLength(200); + e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)"); + e.Property(x => x.Tax).HasColumnType("decimal(18,2)"); + 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) + .HasForeignKey(x => x.TransactionId) + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(false); + }); + + // ---------- RECEIPT PARSE LOG ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Provider).HasMaxLength(50).IsRequired(); + e.Property(x => x.Model).HasMaxLength(100).IsRequired(); + e.Property(x => x.ProviderJobId).HasMaxLength(100); + e.Property(x => x.ExtractedTextPath).HasMaxLength(1024); + + e.HasOne(x => x.Receipt) + .WithMany(r => r.ParseLogs) + .HasForeignKey(x => x.ReceiptId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ---------- RECEIPT LINE ITEM ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Description).HasMaxLength(300).IsRequired(); + e.Property(x => x.Unit).HasMaxLength(16); + e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)"); + e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)"); + e.Property(x => x.Sku).HasMaxLength(64); + e.Property(x => x.Category).HasMaxLength(100); + + e.HasOne(x => x.Receipt) + .WithMany(r => r.LineItems) + .HasForeignKey(x => x.ReceiptId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ---------- MERCHANT ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Name).HasMaxLength(100).IsRequired(); + e.HasIndex(x => x.Name).IsUnique(); + }); + + // ---------- CATEGORY MAPPING ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Category).HasMaxLength(100).IsRequired(); + e.Property(x => x.Pattern).HasMaxLength(200).IsRequired(); + e.Property(x => x.Confidence).HasColumnType("decimal(5,4)"); // 0.0000 to 1.0000 + e.Property(x => x.CreatedBy).HasMaxLength(50); + + // Merchant (optional). If a merchant is deleted, set to null. + e.HasOne(x => x.Merchant) + .WithMany(m => m.CategoryMappings) + .HasForeignKey(x => x.MerchantId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + }); + + // ---------- Extra SQL Server–friendly indexes ---------- + // Fast filtering by date/amount/category + modelBuilder.Entity().HasIndex(x => x.Date); + modelBuilder.Entity().HasIndex(x => x.Amount); + modelBuilder.Entity().HasIndex(x => x.Category); + modelBuilder.Entity().HasIndex(x => x.MerchantId); + + // Composite indexes for common query patterns + modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Category }); + modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Date }); + modelBuilder.Entity().HasIndex(x => new { x.MerchantId, x.Date }); + modelBuilder.Entity().HasIndex(x => new { x.CardId, x.Date }); + + // Receipt duplicate detection and lookup + modelBuilder.Entity().HasIndex(x => x.FileHashSha256); + modelBuilder.Entity().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); + + // ---------- BUDGET ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Category).HasMaxLength(100); + e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); + e.Property(x => x.Notes).HasMaxLength(500); + + // Only one active budget per category per period + // Null category = total budget, so we use a filtered unique index + e.HasIndex(x => new { x.Category, x.Period }) + .HasFilter("[IsActive] = 1") + .IsUnique(); + }); + + // ---------- RECURRING PLACE ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Frequency).HasMaxLength(50).IsRequired(); + e.Property(x => x.Notes).HasMaxLength(500); + + // Relationships + e.HasOne(x => x.Merchant) + .WithMany(m => m.RecurringPlaces) + .HasForeignKey(x => x.MerchantId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + e.HasOne(x => x.Card) + .WithMany(c => c.RecurringPlaces) + .HasForeignKey(x => x.CardId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + e.HasOne(x => x.Category) + .WithMany(c => c.RecurringPlaces) + .HasForeignKey(x => x.CategoryId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + e.HasIndex(x => new { x.MerchantId, x.CardId, x.Frequency }); + e.HasIndex(x => x.NextDueDate); + }); + + // ---------- CATEGORY ---------- + modelBuilder.Entity(e => + { + e.Property(x => x.Name).HasMaxLength(100).IsRequired(); + e.Property(x => x.Description).HasMaxLength(500); + e.Property(x => x.Type).HasMaxLength(50); + + // Parent-child relationship for hierarchical categories + e.HasOne(x => x.ParentCategory) + .WithMany(c => c.Children) + .HasForeignKey(x => x.ParentCategoryId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + e.HasIndex(x => new { x.Name, x.Type }); + e.HasIndex(x => x.ParentCategoryId); + }); + } + } +} diff --git a/MoneyMap.Core/GlobalUsings.cs b/MoneyMap.Core/GlobalUsings.cs index fece00b..4fc9118 100644 --- a/MoneyMap.Core/GlobalUsings.cs +++ b/MoneyMap.Core/GlobalUsings.cs @@ -1,5 +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; +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; diff --git a/MoneyMap.Core/Models/Account.cs b/MoneyMap.Core/Models/Account.cs index afd19db..870d933 100644 --- a/MoneyMap.Core/Models/Account.cs +++ b/MoneyMap.Core/Models/Account.cs @@ -1,45 +1,45 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -public enum AccountType -{ - Checking, - Savings, - Other -} - -public class Account -{ - [Key] - public int Id { get; set; } - - [Required] - [MaxLength(100)] - public string Institution { get; set; } = string.Empty; // e.g., "Chase", "Wells Fargo" - - [Required] - [MaxLength(4)] - public string Last4 { get; set; } = string.Empty; // Last 4 digits of account number - - [Required] - [MaxLength(100)] - public string Owner { get; set; } = string.Empty; // Account holder name - - public AccountType AccountType { get; set; } = AccountType.Checking; - - [MaxLength(50)] - public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund" - - // Navigation properties - public ICollection Cards { get; set; } = new List(); // Cards linked to this account - public ICollection Transactions { get; set; } = new List(); - public ICollection SourceTransfers { get; set; } = new List(); - public ICollection DestinationTransfers { get; set; } = new List(); - - [NotMapped] - public string DisplayLabel => string.IsNullOrEmpty(Nickname) - ? $"{Institution} {Last4} ({AccountType})" - : $"{Nickname} ({Institution} {Last4})"; -} +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +public enum AccountType +{ + Checking, + Savings, + Other +} + +public class Account +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Institution { get; set; } = string.Empty; // e.g., "Chase", "Wells Fargo" + + [Required] + [MaxLength(4)] + public string Last4 { get; set; } = string.Empty; // Last 4 digits of account number + + [Required] + [MaxLength(100)] + public string Owner { get; set; } = string.Empty; // Account holder name + + public AccountType AccountType { get; set; } = AccountType.Checking; + + [MaxLength(50)] + public string? Nickname { get; set; } // Optional friendly name like "Emergency Fund" + + // Navigation properties + public ICollection Cards { get; set; } = new List(); // Cards linked to this account + public ICollection Transactions { get; set; } = new List(); + public ICollection SourceTransfers { get; set; } = new List(); + public ICollection DestinationTransfers { get; set; } = new List(); + + [NotMapped] + public string DisplayLabel => string.IsNullOrEmpty(Nickname) + ? $"{Institution} {Last4} ({AccountType})" + : $"{Nickname} ({Institution} {Last4})"; +} diff --git a/MoneyMap.Core/Models/Api/FinancialAuditModels.cs b/MoneyMap.Core/Models/Api/FinancialAuditModels.cs index 4f7123e..fe5253f 100644 --- a/MoneyMap.Core/Models/Api/FinancialAuditModels.cs +++ b/MoneyMap.Core/Models/Api/FinancialAuditModels.cs @@ -1,137 +1,137 @@ -namespace MoneyMap.Models.Api; - -/// -/// Complete financial audit response for AI analysis. -/// -public class FinancialAuditResponse -{ - public DateTime GeneratedAt { get; set; } - public DateTime PeriodStart { get; set; } - public DateTime PeriodEnd { get; set; } - - public AuditSummary Summary { get; set; } = new(); - public List Budgets { get; set; } = new(); - public List SpendingByCategory { get; set; } = new(); - public List TopMerchants { get; set; } = new(); - public List MonthlyTrends { get; set; } = new(); - public List Accounts { get; set; } = new(); - public List Flags { get; set; } = new(); - public List? Transactions { get; set; } -} - -/// -/// High-level financial statistics for the audit period. -/// -public class AuditSummary -{ - public int TotalTransactions { get; set; } - public decimal TotalIncome { get; set; } - public decimal TotalExpenses { get; set; } - public decimal NetCashFlow { get; set; } - public decimal AverageDailySpend { get; set; } - public int DaysInPeriod { get; set; } - public int UncategorizedTransactions { get; set; } - public decimal UncategorizedAmount { get; set; } -} - -/// -/// Budget status with period information. -/// -public class BudgetStatusDto -{ - public int BudgetId { get; set; } - public string Category { get; set; } = ""; - public string Period { get; set; } = ""; - public decimal Limit { get; set; } - public decimal Spent { get; set; } - public decimal Remaining { get; set; } - public decimal PercentUsed { get; set; } - public bool IsOverBudget { get; set; } - public string PeriodRange { get; set; } = ""; -} - -/// -/// Spending breakdown by category with optional budget correlation. -/// -public class CategorySpendingDto -{ - public string Category { get; set; } = ""; - public decimal TotalSpent { get; set; } - public int TransactionCount { get; set; } - public decimal PercentOfTotal { get; set; } - public decimal AverageTransaction { get; set; } - public decimal? BudgetLimit { get; set; } - public decimal? BudgetRemaining { get; set; } - public bool? IsOverBudget { get; set; } -} - -/// -/// Spending patterns by merchant. -/// -public class MerchantSpendingDto -{ - public string MerchantName { get; set; } = ""; - public string? Category { get; set; } - public decimal TotalSpent { get; set; } - public int TransactionCount { get; set; } - public decimal AverageTransaction { get; set; } - public DateTime FirstTransaction { get; set; } - public DateTime LastTransaction { get; set; } -} - -/// -/// Monthly income/expense/net trends. -/// -public class MonthlyTrendDto -{ - public string Month { get; set; } = ""; - public int Year { get; set; } - public decimal Income { get; set; } - public decimal Expenses { get; set; } - public decimal NetCashFlow { get; set; } - public int TransactionCount { get; set; } - public Dictionary TopCategories { get; set; } = new(); -} - -/// -/// Per-account transaction summary. -/// -public class AccountSummaryDto -{ - public int AccountId { get; set; } - public string AccountName { get; set; } = ""; - public string Institution { get; set; } = ""; - public string AccountType { get; set; } = ""; - public int TransactionCount { get; set; } - public decimal TotalDebits { get; set; } - public decimal TotalCredits { get; set; } - public decimal NetFlow { get; set; } -} - -/// -/// AI-friendly flag highlighting potential issues or observations. -/// -public class AuditFlagDto -{ - public string Type { get; set; } = ""; - public string Severity { get; set; } = ""; - public string Message { get; set; } = ""; - public object? Details { get; set; } -} - -/// -/// Simplified transaction for export. -/// -public class TransactionDto -{ - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public string? Memo { get; set; } - public decimal Amount { get; set; } - public string? Category { get; set; } - public string? MerchantName { get; set; } - public string AccountName { get; set; } = ""; - public string? CardLabel { get; set; } - public bool IsTransfer { get; set; } -} +namespace MoneyMap.Models.Api; + +/// +/// Complete financial audit response for AI analysis. +/// +public class FinancialAuditResponse +{ + public DateTime GeneratedAt { get; set; } + public DateTime PeriodStart { get; set; } + public DateTime PeriodEnd { get; set; } + + public AuditSummary Summary { get; set; } = new(); + public List Budgets { get; set; } = new(); + public List SpendingByCategory { get; set; } = new(); + public List TopMerchants { get; set; } = new(); + public List MonthlyTrends { get; set; } = new(); + public List Accounts { get; set; } = new(); + public List Flags { get; set; } = new(); + public List? Transactions { get; set; } +} + +/// +/// High-level financial statistics for the audit period. +/// +public class AuditSummary +{ + public int TotalTransactions { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalExpenses { get; set; } + public decimal NetCashFlow { get; set; } + public decimal AverageDailySpend { get; set; } + public int DaysInPeriod { get; set; } + public int UncategorizedTransactions { get; set; } + public decimal UncategorizedAmount { get; set; } +} + +/// +/// Budget status with period information. +/// +public class BudgetStatusDto +{ + public int BudgetId { get; set; } + public string Category { get; set; } = ""; + public string Period { get; set; } = ""; + public decimal Limit { get; set; } + public decimal Spent { get; set; } + public decimal Remaining { get; set; } + public decimal PercentUsed { get; set; } + public bool IsOverBudget { get; set; } + public string PeriodRange { get; set; } = ""; +} + +/// +/// Spending breakdown by category with optional budget correlation. +/// +public class CategorySpendingDto +{ + public string Category { get; set; } = ""; + public decimal TotalSpent { get; set; } + public int TransactionCount { get; set; } + public decimal PercentOfTotal { get; set; } + public decimal AverageTransaction { get; set; } + public decimal? BudgetLimit { get; set; } + public decimal? BudgetRemaining { get; set; } + public bool? IsOverBudget { get; set; } +} + +/// +/// Spending patterns by merchant. +/// +public class MerchantSpendingDto +{ + public string MerchantName { get; set; } = ""; + public string? Category { get; set; } + public decimal TotalSpent { get; set; } + public int TransactionCount { get; set; } + public decimal AverageTransaction { get; set; } + public DateTime FirstTransaction { get; set; } + public DateTime LastTransaction { get; set; } +} + +/// +/// Monthly income/expense/net trends. +/// +public class MonthlyTrendDto +{ + public string Month { get; set; } = ""; + public int Year { get; set; } + public decimal Income { get; set; } + public decimal Expenses { get; set; } + public decimal NetCashFlow { get; set; } + public int TransactionCount { get; set; } + public Dictionary TopCategories { get; set; } = new(); +} + +/// +/// Per-account transaction summary. +/// +public class AccountSummaryDto +{ + public int AccountId { get; set; } + public string AccountName { get; set; } = ""; + public string Institution { get; set; } = ""; + public string AccountType { get; set; } = ""; + public int TransactionCount { get; set; } + public decimal TotalDebits { get; set; } + public decimal TotalCredits { get; set; } + public decimal NetFlow { get; set; } +} + +/// +/// AI-friendly flag highlighting potential issues or observations. +/// +public class AuditFlagDto +{ + public string Type { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Message { get; set; } = ""; + public object? Details { get; set; } +} + +/// +/// Simplified transaction for export. +/// +public class TransactionDto +{ + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public string? Memo { get; set; } + public decimal Amount { get; set; } + public string? Category { get; set; } + public string? MerchantName { get; set; } + public string AccountName { get; set; } = ""; + public string? CardLabel { get; set; } + public bool IsTransfer { get; set; } +} diff --git a/MoneyMap.Core/Models/Budget.cs b/MoneyMap.Core/Models/Budget.cs index e132a0a..4027e0f 100644 --- a/MoneyMap.Core/Models/Budget.cs +++ b/MoneyMap.Core/Models/Budget.cs @@ -1,62 +1,62 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -public enum BudgetPeriod -{ - Weekly = 0, - Monthly = 1, - Yearly = 2 -} - -/// -/// Represents a spending budget for a category or total spending. -/// When Category is null, this is a "Total" budget that tracks all spending. -/// -public class Budget -{ - [Key] - public int Id { get; set; } - - /// - /// The category this budget applies to. - /// Null means this is a total spending budget (all categories combined). - /// - [MaxLength(100)] - public string? Category { get; set; } - - /// - /// The budget limit amount (positive value). - /// - [Column(TypeName = "decimal(18,2)")] - public decimal Amount { get; set; } - - /// - /// The time period for this budget (Weekly, Monthly, Yearly). - /// - public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly; - - /// - /// When the budget becomes effective. Used to calculate period boundaries. - /// - public DateTime StartDate { get; set; } = DateTime.Today; - - /// - /// Whether this budget is currently active. - /// - public bool IsActive { get; set; } = true; - - /// - /// Optional notes about this budget. - /// - [MaxLength(500)] - public string? Notes { get; set; } - - // Computed properties - [NotMapped] - public bool IsTotalBudget => Category == null; - - [NotMapped] - public string DisplayName => Category ?? "Total Spending"; -} +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +public enum BudgetPeriod +{ + Weekly = 0, + Monthly = 1, + Yearly = 2 +} + +/// +/// Represents a spending budget for a category or total spending. +/// When Category is null, this is a "Total" budget that tracks all spending. +/// +public class Budget +{ + [Key] + public int Id { get; set; } + + /// + /// The category this budget applies to. + /// Null means this is a total spending budget (all categories combined). + /// + [MaxLength(100)] + public string? Category { get; set; } + + /// + /// The budget limit amount (positive value). + /// + [Column(TypeName = "decimal(18,2)")] + public decimal Amount { get; set; } + + /// + /// The time period for this budget (Weekly, Monthly, Yearly). + /// + public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly; + + /// + /// When the budget becomes effective. Used to calculate period boundaries. + /// + public DateTime StartDate { get; set; } = DateTime.Today; + + /// + /// Whether this budget is currently active. + /// + public bool IsActive { get; set; } = true; + + /// + /// Optional notes about this budget. + /// + [MaxLength(500)] + public string? Notes { get; set; } + + // Computed properties + [NotMapped] + public bool IsTotalBudget => Category == null; + + [NotMapped] + public string DisplayName => Category ?? "Total Spending"; +} diff --git a/MoneyMap.Core/Models/Card.cs b/MoneyMap.Core/Models/Card.cs index c0ac0aa..bdc6a18 100644 --- a/MoneyMap.Core/Models/Card.cs +++ b/MoneyMap.Core/Models/Card.cs @@ -1,37 +1,38 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -public class Card -{ - [Key] - public int Id { get; set; } - - [Required] - [MaxLength(100)] - public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover - - [Required] - [MaxLength(4)] - public string Last4 { get; set; } = string.Empty; // "1234" - - [Required] - [MaxLength(100)] - public string Owner { get; set; } = string.Empty; - - // Link to the account this card draws from/pays to - [ForeignKey(nameof(Account))] - public int? AccountId { get; set; } - public Account? Account { get; set; } - - [MaxLength(50)] - public string? Nickname { get; set; } // Optional friendly name - - public ICollection Transactions { get; set; } = new List(); - - [NotMapped] - public string DisplayLabel => string.IsNullOrEmpty(Nickname) - ? $"{Issuer} {Last4}" - : $"{Nickname} ({Issuer} {Last4})"; -} +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +public class Card +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC, Discover + + [Required] + [MaxLength(4)] + public string Last4 { get; set; } = string.Empty; // "1234" + + [Required] + [MaxLength(100)] + public string Owner { get; set; } = string.Empty; + + // Link to the account this card draws from/pays to + [ForeignKey(nameof(Account))] + public int? AccountId { get; set; } + public Account? Account { get; set; } + + [MaxLength(50)] + public string? Nickname { get; set; } // Optional friendly name + + public ICollection Transactions { get; set; } = new List(); + public ICollection RecurringPlaces { get; set; } = new List(); + + [NotMapped] + public string DisplayLabel => string.IsNullOrEmpty(Nickname) + ? $"{Issuer} {Last4}" + : $"{Nickname} ({Issuer} {Last4})"; +} diff --git a/MoneyMap.Core/Models/Category.cs b/MoneyMap.Core/Models/Category.cs new file mode 100644 index 0000000..96e17f0 --- /dev/null +++ b/MoneyMap.Core/Models/Category.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +public class Category +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string Description { get; set; } = string.Empty; + + [MaxLength(50)] + public string Type { get; set; } = string.Empty; // e.g., "Expense", "Income", "Transfer" + + public bool IsDefault { get; set; } = false; + + public int? ParentCategoryId { get; set; } + public Category? ParentCategory { get; set; } + public ICollection Children { get; set; } = new List(); + + public ICollection RecurringPlaces { get; set; } = new List(); + public ICollection CategoryMappings { get; set; } = new List(); + public ICollection Transactions { get; set; } = new List(); + + [NotMapped] + public string DisplayLabel => Name; +} \ No newline at end of file diff --git a/MoneyMap.Core/Models/CategoryMapping.cs b/MoneyMap.Core/Models/CategoryMapping.cs index 0d362f0..e2ee145 100644 --- a/MoneyMap.Core/Models/CategoryMapping.cs +++ b/MoneyMap.Core/Models/CategoryMapping.cs @@ -1,47 +1,47 @@ -namespace MoneyMap.Models -{ - /// - /// Represents a mapping rule that associates transaction name patterns with categories. - /// Used for automatic categorization of transactions during import. - /// - public class CategoryMapping - { - public int Id { get; set; } - - /// - /// The category to assign when a transaction matches the pattern. - /// - public required string Category { get; set; } - - /// - /// The pattern to match against transaction names (case-insensitive contains). - /// - public required string Pattern { get; set; } - - /// - /// Higher priority mappings are checked first. Default is 0. - /// - public int Priority { get; set; } = 0; - - /// - /// Optional merchant to auto-assign when this pattern matches. - /// - public int? MerchantId { get; set; } - public Merchant? Merchant { get; set; } - - /// - /// AI confidence score when this rule was created by AI (0.0 - 1.0). - /// - public decimal? Confidence { get; set; } - - /// - /// Who created this rule: "User" or "AI". - /// - public string? CreatedBy { get; set; } - - /// - /// When this rule was created. - /// - public DateTime? CreatedAt { get; set; } - } -} +namespace MoneyMap.Models +{ + /// + /// Represents a mapping rule that associates transaction name patterns with categories. + /// Used for automatic categorization of transactions during import. + /// + public class CategoryMapping + { + public int Id { get; set; } + + /// + /// The category to assign when a transaction matches the pattern. + /// + public required string Category { get; set; } + + /// + /// The pattern to match against transaction names (case-insensitive contains). + /// + public required string Pattern { get; set; } + + /// + /// Higher priority mappings are checked first. Default is 0. + /// + public int Priority { get; set; } = 0; + + /// + /// Optional merchant to auto-assign when this pattern matches. + /// + public int? MerchantId { get; set; } + public Merchant? Merchant { get; set; } + + /// + /// AI confidence score when this rule was created by AI (0.0 - 1.0). + /// + public decimal? Confidence { get; set; } + + /// + /// Who created this rule: "User" or "AI". + /// + public string? CreatedBy { get; set; } + + /// + /// When this rule was created. + /// + public DateTime? CreatedAt { get; set; } + } +} diff --git a/MoneyMap.Core/Models/Dashboard/DashboardModels.cs b/MoneyMap.Core/Models/Dashboard/DashboardModels.cs index b81845b..6f735eb 100644 --- a/MoneyMap.Core/Models/Dashboard/DashboardModels.cs +++ b/MoneyMap.Core/Models/Dashboard/DashboardModels.cs @@ -1,63 +1,63 @@ -namespace MoneyMap.Models.Dashboard -{ - /// - /// Statistics displayed on the dashboard. - /// - public record DashboardStats( - int TotalTransactions = 0, - int Credits = 0, - int Debits = 0, - int Uncategorized = 0, - int Receipts = 0, - int Cards = 0); - - /// - /// Row representing spending in a category. - /// - public class TopCategoryRow - { - public string Category { get; set; } = ""; - public decimal TotalSpend { get; set; } - public int Count { get; set; } - public decimal PercentageOfTotal { get; set; } - public decimal AveragePerTransaction { get; set; } - } - - /// - /// Row representing a recent transaction. - /// - public class RecentTransactionRow - { - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public string Memo { get; set; } = ""; - public decimal Amount { get; set; } - public string Category { get; set; } = ""; - public string CardLabel { get; set; } = ""; - public int ReceiptCount { get; set; } - } - - /// - /// Spending trends over time. - /// - public class SpendTrends - { - public List Labels { get; set; } = new(); - public List DebitsAbs { get; set; } = new(); - public List Credits { get; set; } = new(); - public List Net { get; set; } = new(); - public List RunningBalance { get; set; } = new(); - } - - /// - /// Complete dashboard data package. - /// - public class DashboardData - { - public required DashboardStats Stats { get; init; } - public required List TopCategories { get; init; } - public required List RecentTransactions { get; init; } - public required SpendTrends Trends { get; init; } - } -} +namespace MoneyMap.Models.Dashboard +{ + /// + /// Statistics displayed on the dashboard. + /// + public record DashboardStats( + int TotalTransactions = 0, + int Credits = 0, + int Debits = 0, + int Uncategorized = 0, + int Receipts = 0, + int Cards = 0); + + /// + /// Row representing spending in a category. + /// + public class TopCategoryRow + { + public string Category { get; set; } = ""; + public decimal TotalSpend { get; set; } + public int Count { get; set; } + public decimal PercentageOfTotal { get; set; } + public decimal AveragePerTransaction { get; set; } + } + + /// + /// Row representing a recent transaction. + /// + public class RecentTransactionRow + { + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public string Memo { get; set; } = ""; + public decimal Amount { get; set; } + public string Category { get; set; } = ""; + public string CardLabel { get; set; } = ""; + public int ReceiptCount { get; set; } + } + + /// + /// Spending trends over time. + /// + public class SpendTrends + { + public List Labels { get; set; } = new(); + public List DebitsAbs { get; set; } = new(); + public List Credits { get; set; } = new(); + public List Net { get; set; } = new(); + public List RunningBalance { get; set; } = new(); + } + + /// + /// Complete dashboard data package. + /// + public class DashboardData + { + public required DashboardStats Stats { get; init; } + public required List TopCategories { get; init; } + public required List RecentTransactions { get; init; } + public required SpendTrends Trends { get; init; } + } +} diff --git a/MoneyMap.Core/Models/Import/ImportContext.cs b/MoneyMap.Core/Models/Import/ImportContext.cs index 6fa0ee3..f0a8a3e 100644 --- a/MoneyMap.Core/Models/Import/ImportContext.cs +++ b/MoneyMap.Core/Models/Import/ImportContext.cs @@ -1,28 +1,28 @@ -namespace MoneyMap.Models.Import -{ - /// - /// Context for transaction import operations, containing payment selection mode and available options. - /// - public class ImportContext - { - public required PaymentSelectMode PaymentMode { get; init; } - public int? SelectedCardId { get; init; } - public int? SelectedAccountId { get; init; } - public required List AvailableCards { get; init; } - public required List AvailableAccounts { get; init; } - public required string FileName { get; init; } - } - - /// - /// Specifies how to determine the payment method for imported transactions. - /// - public enum PaymentSelectMode - { - /// Auto-detect from memo or filename. - Auto, - /// Use a specific card for all transactions. - Card, - /// Use a specific account for all transactions. - Account - } -} +namespace MoneyMap.Models.Import +{ + /// + /// Context for transaction import operations, containing payment selection mode and available options. + /// + public class ImportContext + { + public required PaymentSelectMode PaymentMode { get; init; } + public int? SelectedCardId { get; init; } + public int? SelectedAccountId { get; init; } + public required List AvailableCards { get; init; } + public required List AvailableAccounts { get; init; } + public required string FileName { get; init; } + } + + /// + /// Specifies how to determine the payment method for imported transactions. + /// + public enum PaymentSelectMode + { + /// Auto-detect from memo or filename. + Auto, + /// Use a specific card for all transactions. + Card, + /// Use a specific account for all transactions. + Account + } +} diff --git a/MoneyMap.Core/Models/Import/ImportResults.cs b/MoneyMap.Core/Models/Import/ImportResults.cs index 41c9514..9381a1b 100644 --- a/MoneyMap.Core/Models/Import/ImportResults.cs +++ b/MoneyMap.Core/Models/Import/ImportResults.cs @@ -1,69 +1,69 @@ -namespace MoneyMap.Models.Import -{ - /// - /// Result of an import operation, showing counts of processed transactions. - /// - public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile); - - /// - /// Wrapper for import operation result with success/failure state. - /// - public class ImportOperationResult - { - public bool IsSuccess { get; init; } - public ImportResult? Data { get; init; } - public string? ErrorMessage { get; init; } - - public static ImportOperationResult Success(ImportResult data) => - new() { IsSuccess = true, Data = data }; - - public static ImportOperationResult Failure(string error) => - new() { IsSuccess = false, ErrorMessage = error }; - } - - /// - /// Wrapper for preview operation result with success/failure state. - /// - public class PreviewOperationResult - { - public bool IsSuccess { get; init; } - public List? Data { get; init; } - public string? ErrorMessage { get; init; } - - public static PreviewOperationResult Success(List data) => - new() { IsSuccess = true, Data = data }; - - public static PreviewOperationResult Failure(string error) => - new() { IsSuccess = false, ErrorMessage = error }; - } - - /// - /// Preview of a transaction before import, with duplicate detection info. - /// - public class TransactionPreview - { - public required Transaction Transaction { get; init; } - public bool IsDuplicate { get; init; } - public required string PaymentMethodLabel { get; init; } - public string? SuggestedCategory { get; set; } - } - - /// - /// User's selection for payment method during import confirmation. - /// - public class PaymentSelection - { - public int? AccountId { get; set; } - public int? CardId { get; set; } - public string? Category { get; set; } - } - - /// - /// Key for detecting duplicate transactions. - /// - public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId) - { - public TransactionKey(Transaction txn) - : this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { } - } -} +namespace MoneyMap.Models.Import +{ + /// + /// Result of an import operation, showing counts of processed transactions. + /// + public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile); + + /// + /// Wrapper for import operation result with success/failure state. + /// + public class ImportOperationResult + { + public bool IsSuccess { get; init; } + public ImportResult? Data { get; init; } + public string? ErrorMessage { get; init; } + + public static ImportOperationResult Success(ImportResult data) => + new() { IsSuccess = true, Data = data }; + + public static ImportOperationResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + /// + /// Wrapper for preview operation result with success/failure state. + /// + public class PreviewOperationResult + { + public bool IsSuccess { get; init; } + public List? Data { get; init; } + public string? ErrorMessage { get; init; } + + public static PreviewOperationResult Success(List data) => + new() { IsSuccess = true, Data = data }; + + public static PreviewOperationResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + /// + /// Preview of a transaction before import, with duplicate detection info. + /// + public class TransactionPreview + { + public required Transaction Transaction { get; init; } + public bool IsDuplicate { get; init; } + public required string PaymentMethodLabel { get; init; } + public string? SuggestedCategory { get; set; } + } + + /// + /// User's selection for payment method during import confirmation. + /// + public class PaymentSelection + { + public int? AccountId { get; set; } + public int? CardId { get; set; } + public string? Category { get; set; } + } + + /// + /// Key for detecting duplicate transactions. + /// + public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int AccountId, int? CardId) + { + public TransactionKey(Transaction txn) + : this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.AccountId, txn.CardId) { } + } +} diff --git a/MoneyMap.Core/Models/Import/PaymentResolutionResult.cs b/MoneyMap.Core/Models/Import/PaymentResolutionResult.cs index bbfc2cd..9ce456c 100644 --- a/MoneyMap.Core/Models/Import/PaymentResolutionResult.cs +++ b/MoneyMap.Core/Models/Import/PaymentResolutionResult.cs @@ -1,32 +1,32 @@ -namespace MoneyMap.Models.Import -{ - /// - /// Result of resolving a payment method (card or account) for a transaction. - /// - public class PaymentResolutionResult - { - public bool IsSuccess { get; init; } - public int? CardId { get; init; } - public int? AccountId { get; init; } - public string? Last4 { get; init; } - public string? ErrorMessage { get; init; } - - /// - /// Creates a successful result when a card is used. - /// - public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) => - new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 }; - - /// - /// Creates a successful result when a direct account transaction (no card). - /// - public static PaymentResolutionResult SuccessAccount(int accountId, string last4) => - new() { IsSuccess = true, AccountId = accountId, Last4 = last4 }; - - /// - /// Creates a failure result with error message. - /// - public static PaymentResolutionResult Failure(string error) => - new() { IsSuccess = false, ErrorMessage = error }; - } -} +namespace MoneyMap.Models.Import +{ + /// + /// Result of resolving a payment method (card or account) for a transaction. + /// + public class PaymentResolutionResult + { + public bool IsSuccess { get; init; } + public int? CardId { get; init; } + public int? AccountId { get; init; } + public string? Last4 { get; init; } + public string? ErrorMessage { get; init; } + + /// + /// Creates a successful result when a card is used. + /// + public static PaymentResolutionResult SuccessCard(int cardId, int accountId, string last4) => + new() { IsSuccess = true, CardId = cardId, AccountId = accountId, Last4 = last4 }; + + /// + /// Creates a successful result when a direct account transaction (no card). + /// + public static PaymentResolutionResult SuccessAccount(int accountId, string last4) => + new() { IsSuccess = true, AccountId = accountId, Last4 = last4 }; + + /// + /// Creates a failure result with error message. + /// + public static PaymentResolutionResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } +} diff --git a/MoneyMap.Core/Models/Import/TransactionCsvRow.cs b/MoneyMap.Core/Models/Import/TransactionCsvRow.cs index 700487c..dccec79 100644 --- a/MoneyMap.Core/Models/Import/TransactionCsvRow.cs +++ b/MoneyMap.Core/Models/Import/TransactionCsvRow.cs @@ -1,11 +1,11 @@ -namespace MoneyMap.Models.Import; - -public class TransactionCsvRow -{ - public DateTime Date { get; set; } - public string Transaction { get; set; } = ""; - public string Name { get; set; } = ""; - public string Memo { get; set; } = ""; - public decimal Amount { get; set; } - public string Category { get; set; } = ""; -} +namespace MoneyMap.Models.Import; + +public class TransactionCsvRow +{ + public DateTime Date { get; set; } + public string Transaction { get; set; } = ""; + public string Name { get; set; } = ""; + public string Memo { get; set; } = ""; + public decimal Amount { get; set; } + public string Category { get; set; } = ""; +} diff --git a/MoneyMap.Core/Models/Import/TransactionCsvRowMap.cs b/MoneyMap.Core/Models/Import/TransactionCsvRowMap.cs index f5cbf42..9b2bcd3 100644 --- a/MoneyMap.Core/Models/Import/TransactionCsvRowMap.cs +++ b/MoneyMap.Core/Models/Import/TransactionCsvRowMap.cs @@ -1,32 +1,32 @@ -using CsvHelper.Configuration; - -namespace MoneyMap.Models.Import; - -public sealed class TransactionCsvRowMap : ClassMap -{ - public TransactionCsvRowMap(bool hasCategory) - { - Map(m => m.Date).Name("Date"); - Map(m => m.Transaction).Name("Transaction"); - Map(m => m.Name).Name("Name"); - Map(m => m.Memo).Name("Memo"); - Map(m => m.Amount).Name("Amount"); - - if (hasCategory) - { - Map(m => m.Category).Name("Category"); - } - else - { - if (hasCategory) - { - Map(m => m.Category).Name("Category"); - } - else - { - Map(m => m.Category).Constant(string.Empty).Optional(); - } - - } - } +using CsvHelper.Configuration; + +namespace MoneyMap.Models.Import; + +public sealed class TransactionCsvRowMap : ClassMap +{ + public TransactionCsvRowMap(bool hasCategory) + { + Map(m => m.Date).Name("Date"); + Map(m => m.Transaction).Name("Transaction"); + Map(m => m.Name).Name("Name"); + Map(m => m.Memo).Name("Memo"); + Map(m => m.Amount).Name("Amount"); + + if (hasCategory) + { + Map(m => m.Category).Name("Category"); + } + else + { + if (hasCategory) + { + Map(m => m.Category).Name("Category"); + } + else + { + Map(m => m.Category).Constant(string.Empty).Optional(); + } + + } + } } \ No newline at end of file diff --git a/MoneyMap.Core/Models/Merchant.cs b/MoneyMap.Core/Models/Merchant.cs index e93570f..9596bc8 100644 --- a/MoneyMap.Core/Models/Merchant.cs +++ b/MoneyMap.Core/Models/Merchant.cs @@ -1,16 +1,17 @@ -using System.ComponentModel.DataAnnotations; - -namespace MoneyMap.Models; - -public class Merchant -{ - [Key] - public int Id { get; set; } - - [Required] - [MaxLength(100)] - public string Name { get; set; } = string.Empty; - - public ICollection Transactions { get; set; } = new List(); - public ICollection CategoryMappings { get; set; } = new List(); -} +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Models; + +public class Merchant +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public ICollection Transactions { get; set; } = new List(); + public ICollection CategoryMappings { get; set; } = new List(); + public ICollection RecurringPlaces { get; set; } = new List(); +} diff --git a/MoneyMap.Core/Models/Receipt.cs b/MoneyMap.Core/Models/Receipt.cs index a387b73..b086b43 100644 --- a/MoneyMap.Core/Models/Receipt.cs +++ b/MoneyMap.Core/Models/Receipt.cs @@ -1,73 +1,73 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -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 -{ - [Key] - public long Id { get; set; } - - // Link to transaction (nullable to support unmapped receipts) - public long? TransactionId { get; set; } - public Transaction? Transaction { get; set; } - - // File metadata - [MaxLength(260)] - public string FileName { get; set; } = string.Empty; - - [MaxLength(100)] - public string ContentType { get; set; } = "application/octet-stream"; - - [MaxLength(1024)] - public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key - - public long FileSizeBytes { get; set; } - - [MaxLength(64)] - public string FileHashSha256 { get; set; } = string.Empty; // for dedupe - - public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow; - - // Parsed header fields (optional, set by parser job) - [MaxLength(200)] - public string? Merchant { get; set; } - - public DateTime? ReceiptDate { get; set; } - - public DateTime? DueDate { get; set; } // For bills - payment due date - - [Column(TypeName = "decimal(18,2)")] - public decimal? Subtotal { get; set; } - - [Column(TypeName = "decimal(18,2)")] - public decimal? Tax { get; set; } - - [Column(TypeName = "decimal(18,2)")] - public decimal? Total { get; set; } - - [MaxLength(8)] - public string? Currency { get; set; } - - // User notes provided to AI parser - [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 ParseLogs { get; set; } = new List(); - public ICollection LineItems { get; set; } = new List(); -} +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +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 +{ + [Key] + public long Id { get; set; } + + // Link to transaction (nullable to support unmapped receipts) + public long? TransactionId { get; set; } + public Transaction? Transaction { get; set; } + + // File metadata + [MaxLength(260)] + public string FileName { get; set; } = string.Empty; + + [MaxLength(100)] + public string ContentType { get; set; } = "application/octet-stream"; + + [MaxLength(1024)] + public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key + + public long FileSizeBytes { get; set; } + + [MaxLength(64)] + public string FileHashSha256 { get; set; } = string.Empty; // for dedupe + + public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow; + + // Parsed header fields (optional, set by parser job) + [MaxLength(200)] + public string? Merchant { get; set; } + + public DateTime? ReceiptDate { get; set; } + + public DateTime? DueDate { get; set; } // For bills - payment due date + + [Column(TypeName = "decimal(18,2)")] + public decimal? Subtotal { get; set; } + + [Column(TypeName = "decimal(18,2)")] + public decimal? Tax { get; set; } + + [Column(TypeName = "decimal(18,2)")] + public decimal? Total { get; set; } + + [MaxLength(8)] + public string? Currency { get; set; } + + // User notes provided to AI parser + [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 ParseLogs { get; set; } = new List(); + public ICollection LineItems { get; set; } = new List(); +} diff --git a/MoneyMap.Core/Models/ReceiptLineItem.cs b/MoneyMap.Core/Models/ReceiptLineItem.cs index 16338bb..2e2f0ed 100644 --- a/MoneyMap.Core/Models/ReceiptLineItem.cs +++ b/MoneyMap.Core/Models/ReceiptLineItem.cs @@ -1,44 +1,44 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -[Index(nameof(ReceiptId), nameof(LineNumber))] -public class ReceiptLineItem -{ - [Key] - public long Id { get; set; } - - public long ReceiptId { get; set; } - public Receipt Receipt { get; set; } = null!; - - public int LineNumber { get; set; } - - [MaxLength(300)] - public string Description { get; set; } = string.Empty; - - // ReceiptLineItem - [Column(TypeName = "decimal(18,4)")] - public decimal? Quantity { get; set; } - - /// - /// Unit of Measure (ea, lb, gal, etc.) - /// - [MaxLength(16)] - public string? Unit { get; set; } - - [Column(TypeName = "decimal(18,4)")] - public decimal? UnitPrice { get; set; } - - [Column(TypeName = "decimal(18,2)")] - public decimal? LineTotal { get; set; } - - [MaxLength(64)] - public string? Sku { get; set; } - - [MaxLength(100)] - public string? Category { get; set; } - - public bool Voided { get; set; } +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +[Index(nameof(ReceiptId), nameof(LineNumber))] +public class ReceiptLineItem +{ + [Key] + public long Id { get; set; } + + public long ReceiptId { get; set; } + public Receipt Receipt { get; set; } = null!; + + public int LineNumber { get; set; } + + [MaxLength(300)] + public string Description { get; set; } = string.Empty; + + // ReceiptLineItem + [Column(TypeName = "decimal(18,4)")] + public decimal? Quantity { get; set; } + + /// + /// Unit of Measure (ea, lb, gal, etc.) + /// + [MaxLength(16)] + public string? Unit { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? UnitPrice { get; set; } + + [Column(TypeName = "decimal(18,2)")] + public decimal? LineTotal { get; set; } + + [MaxLength(64)] + public string? Sku { get; set; } + + [MaxLength(100)] + public string? Category { get; set; } + + public bool Voided { get; set; } } \ No newline at end of file diff --git a/MoneyMap.Core/Models/ReceiptParseLog.cs b/MoneyMap.Core/Models/ReceiptParseLog.cs index 4d2c89c..25e0c68 100644 --- a/MoneyMap.Core/Models/ReceiptParseLog.cs +++ b/MoneyMap.Core/Models/ReceiptParseLog.cs @@ -1,43 +1,43 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -[Index(nameof(ReceiptId), nameof(StartedAtUtc))] -public class ReceiptParseLog -{ - [Key] - public long Id { get; set; } - - public long ReceiptId { get; set; } - public Receipt Receipt { get; set; } = null!; - - // Provider metadata as strings for flexibility - [MaxLength(50)] - public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract" - - [MaxLength(100)] - public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini" - - [MaxLength(100)] - public string? ProviderJobId { get; set; } - - public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; - public DateTime? CompletedAtUtc { get; set; } - public bool Success { get; set; } - - // ReceiptParseLog - - [Column(TypeName = "decimal(5,4)")] - public decimal? Confidence { get; set; } // 0.0000–0.9999 is plenty - - // Store full provider JSON payload for re-parsing/debug (keep out of hot paths) - public string RawProviderPayloadJson { get; set; } = "{}"; - - // Optional extracted text path if you persist a .txt alongside the image/PDF - [MaxLength(1024)] - public string? ExtractedTextPath { get; set; } - - public string? Error { get; set; } -} +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +[Index(nameof(ReceiptId), nameof(StartedAtUtc))] +public class ReceiptParseLog +{ + [Key] + public long Id { get; set; } + + public long ReceiptId { get; set; } + public Receipt Receipt { get; set; } = null!; + + // Provider metadata as strings for flexibility + [MaxLength(50)] + public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract" + + [MaxLength(100)] + public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini" + + [MaxLength(100)] + public string? ProviderJobId { get; set; } + + public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAtUtc { get; set; } + public bool Success { get; set; } + + // ReceiptParseLog + + [Column(TypeName = "decimal(5,4)")] + public decimal? Confidence { get; set; } // 0.0000–0.9999 is plenty + + // Store full provider JSON payload for re-parsing/debug (keep out of hot paths) + public string RawProviderPayloadJson { get; set; } = "{}"; + + // Optional extracted text path if you persist a .txt alongside the image/PDF + [MaxLength(1024)] + public string? ExtractedTextPath { get; set; } + + public string? Error { get; set; } +} diff --git a/MoneyMap.Core/Models/RecurringPlace.cs b/MoneyMap.Core/Models/RecurringPlace.cs new file mode 100644 index 0000000..6a1a1b4 --- /dev/null +++ b/MoneyMap.Core/Models/RecurringPlace.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +[Index(nameof(MerchantId), nameof(CardId), nameof(Frequency), IsUnique = true)] +public class RecurringPlace +{ + [Key] + public int Id { get; set; } + + [Required] + public int MerchantId { get; set; } + public Merchant Merchant { get; set; } = null!; + + [Required] + public int CardId { get; set; } + public Card Card { get; set; } = null!; + + [Required] + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; + + [Column(TypeName = "decimal(18,2)")] + public decimal Amount { get; set; } + + [Required] + [MaxLength(50)] + public string Frequency { get; set; } = string.Empty; // e.g., "Monthly", "Weekly", "Quarterly" + + [Required] + public DateTime NextDueDate { get; set; } + + [MaxLength(500)] + public string Notes { get; set; } = string.Empty; + + [NotMapped] + public string DisplayLabel => $"{Merchant.Name} - {Card.Last4} - {Frequency} - ${Amount:N2}"; +} \ No newline at end of file diff --git a/MoneyMap.Core/Models/Transaction.cs b/MoneyMap.Core/Models/Transaction.cs index eed3024..b272c15 100644 --- a/MoneyMap.Core/Models/Transaction.cs +++ b/MoneyMap.Core/Models/Transaction.cs @@ -1,86 +1,86 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(AccountId), nameof(CardId), IsUnique = true)] -public class Transaction -{ - [Key] - public long Id { get; set; } - - [Required] - public DateTime Date { get; set; } - - [MaxLength(20)] - public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present - - [MaxLength(200)] - public string Name { get; set; } = string.Empty; - - [MaxLength(500)] - public string Memo { get; set; } = string.Empty; - - [Column(TypeName = "decimal(18,2)")] - public decimal Amount { get; set; } // negative = debit, positive = credit - - [MaxLength(100)] - public string Category { get; set; } = string.Empty; - - // Merchant relationship - [ForeignKey(nameof(Merchant))] - public int? MerchantId { get; set; } - public Merchant? Merchant { get; set; } - - public string Notes { get; set; } = string.Empty; - - // Primary container: Every transaction belongs to an Account (the source of CSV) - [Required] - [ForeignKey(nameof(Account))] - public int AccountId { get; set; } - public Account Account { get; set; } = null!; - - // Optional: Card used for this transaction (if it was a card payment) - [ForeignKey(nameof(Card))] - public int? CardId { get; set; } - public Card? Card { get; set; } - - // Optional: For transfers between accounts, this links to the destination account - // This transaction is the "source" side of the transfer (debit) - // The matching transaction in the destination account has this AccountId as its TransferToAccountId - [ForeignKey(nameof(TransferToAccount))] - public int? TransferToAccountId { get; set; } - public Account? TransferToAccount { get; set; } - - [MaxLength(4)] - public string? Last4 { get; set; } // parsed from Memo if present - - public ICollection Receipts { get; set; } = new List(); - - [NotMapped] public bool IsCredit => Amount > 0; - [NotMapped] public bool IsDebit => Amount < 0; - [NotMapped] public bool IsTransfer => TransferToAccountId.HasValue; - - [NotMapped] - public string PaymentMethodLabel - { - get - { - // Handle transfers - if (IsTransfer) - { - var toLabel = TransferToAccount?.DisplayLabel ?? "Unknown"; - return $"Transfer → {toLabel}"; - } - - // If card was used, show just the card (since account is implied) - if (Card != null) - return Card.DisplayLabel; - - // Direct account transaction (no card) - return Account?.DisplayLabel ?? $"···· {Last4}"; - } - } -} - +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(AccountId), nameof(CardId), IsUnique = true)] +public class Transaction +{ + [Key] + public long Id { get; set; } + + [Required] + public DateTime Date { get; set; } + + [MaxLength(20)] + public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present + + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string Memo { get; set; } = string.Empty; + + [Column(TypeName = "decimal(18,2)")] + public decimal Amount { get; set; } // negative = debit, positive = credit + + [MaxLength(100)] + public string Category { get; set; } = string.Empty; + + // Merchant relationship + [ForeignKey(nameof(Merchant))] + public int? MerchantId { get; set; } + public Merchant? Merchant { get; set; } + + public string Notes { get; set; } = string.Empty; + + // Primary container: Every transaction belongs to an Account (the source of CSV) + [Required] + [ForeignKey(nameof(Account))] + public int AccountId { get; set; } + public Account Account { get; set; } = null!; + + // Optional: Card used for this transaction (if it was a card payment) + [ForeignKey(nameof(Card))] + public int? CardId { get; set; } + public Card? Card { get; set; } + + // Optional: For transfers between accounts, this links to the destination account + // This transaction is the "source" side of the transfer (debit) + // The matching transaction in the destination account has this AccountId as its TransferToAccountId + [ForeignKey(nameof(TransferToAccount))] + public int? TransferToAccountId { get; set; } + public Account? TransferToAccount { get; set; } + + [MaxLength(4)] + public string? Last4 { get; set; } // parsed from Memo if present + + public ICollection Receipts { get; set; } = new List(); + + [NotMapped] public bool IsCredit => Amount > 0; + [NotMapped] public bool IsDebit => Amount < 0; + [NotMapped] public bool IsTransfer => TransferToAccountId.HasValue; + + [NotMapped] + public string PaymentMethodLabel + { + get + { + // Handle transfers + if (IsTransfer) + { + var toLabel = TransferToAccount?.DisplayLabel ?? "Unknown"; + return $"Transfer → {toLabel}"; + } + + // If card was used, show just the card (since account is implied) + if (Card != null) + return Card.DisplayLabel; + + // Direct account transaction (no card) + return Account?.DisplayLabel ?? $"···· {Last4}"; + } + } +} + diff --git a/MoneyMap.Core/Models/Transfer.cs b/MoneyMap.Core/Models/Transfer.cs index 53b6d30..78c3ac3 100644 --- a/MoneyMap.Core/Models/Transfer.cs +++ b/MoneyMap.Core/Models/Transfer.cs @@ -1,49 +1,49 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace MoneyMap.Models; - -public class Transfer -{ - [Key] - public long Id { get; set; } - - [Required] - public DateTime Date { get; set; } - - [Required] - [Column(TypeName = "decimal(18,2)")] - public decimal Amount { get; set; } // Always positive - - [MaxLength(500)] - public string Description { get; set; } = string.Empty; - - public string Notes { get; set; } = string.Empty; - - // Source account (where money comes from) - nullable for "Unknown" - [ForeignKey(nameof(SourceAccount))] - public int? SourceAccountId { get; set; } - public Account? SourceAccount { get; set; } - - // Destination account (where money goes to) - nullable for "Unknown" - [ForeignKey(nameof(DestinationAccount))] - public int? DestinationAccountId { get; set; } - public Account? DestinationAccount { get; set; } - - // Optional link to original transaction if imported from CSV - [ForeignKey(nameof(OriginalTransaction))] - public long? OriginalTransactionId { get; set; } - public Transaction? OriginalTransaction { get; set; } - - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [NotMapped] - public string SourceLabel => SourceAccount != null - ? $"{SourceAccount.Institution} {SourceAccount.Last4}" - : "Unknown"; - - [NotMapped] - public string DestinationLabel => DestinationAccount != null - ? $"{DestinationAccount.Institution} {DestinationAccount.Last4}" - : "Unknown"; -} +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MoneyMap.Models; + +public class Transfer +{ + [Key] + public long Id { get; set; } + + [Required] + public DateTime Date { get; set; } + + [Required] + [Column(TypeName = "decimal(18,2)")] + public decimal Amount { get; set; } // Always positive + + [MaxLength(500)] + public string Description { get; set; } = string.Empty; + + public string Notes { get; set; } = string.Empty; + + // Source account (where money comes from) - nullable for "Unknown" + [ForeignKey(nameof(SourceAccount))] + public int? SourceAccountId { get; set; } + public Account? SourceAccount { get; set; } + + // Destination account (where money goes to) - nullable for "Unknown" + [ForeignKey(nameof(DestinationAccount))] + public int? DestinationAccountId { get; set; } + public Account? DestinationAccount { get; set; } + + // Optional link to original transaction if imported from CSV + [ForeignKey(nameof(OriginalTransaction))] + public long? OriginalTransactionId { get; set; } + public Transaction? OriginalTransaction { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [NotMapped] + public string SourceLabel => SourceAccount != null + ? $"{SourceAccount.Institution} {SourceAccount.Last4}" + : "Unknown"; + + [NotMapped] + public string DestinationLabel => DestinationAccount != null + ? $"{DestinationAccount.Institution} {DestinationAccount.Last4}" + : "Unknown"; +} diff --git a/MoneyMap.Core/MoneyMap.Core.csproj b/MoneyMap.Core/MoneyMap.Core.csproj index 7dc4dc2..518cae6 100644 --- a/MoneyMap.Core/MoneyMap.Core.csproj +++ b/MoneyMap.Core/MoneyMap.Core.csproj @@ -1,21 +1,21 @@ - - - net8.0 - enable - enable - - - - - - - - - - - - - PreserveNewest - - - + + + net8.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + + + diff --git a/MoneyMap.Core/Prompts/ReceiptParserPrompt.txt b/MoneyMap.Core/Prompts/ReceiptParserPrompt.txt index be0b829..82574b2 100644 --- a/MoneyMap.Core/Prompts/ReceiptParserPrompt.txt +++ b/MoneyMap.Core/Prompts/ReceiptParserPrompt.txt @@ -1,62 +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 +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 \ No newline at end of file diff --git a/MoneyMap.Core/ServiceCollectionExtensions.cs b/MoneyMap.Core/ServiceCollectionExtensions.cs index 29239c2..4d9490f 100644 --- a/MoneyMap.Core/ServiceCollectionExtensions.cs +++ b/MoneyMap.Core/ServiceCollectionExtensions.cs @@ -1,53 +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(options => - options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb"))); - - services.AddMemoryCache(); - - // Core transaction and import services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Entity management services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Receipt services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Reference data and dashboard - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // AI services - services.AddScoped(); - services.AddScoped(); - - return services; - } -} +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(options => + options.UseSqlServer(configuration.GetConnectionString("MoneyMapDb"))); + + services.AddMemoryCache(); + + // Core transaction and import services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Entity management services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Receipt services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Reference data and dashboard + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // AI services + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/MoneyMap.Core/Services/AIReceiptParser.cs b/MoneyMap.Core/Services/AIReceiptParser.cs index ae620bb..492d40b 100644 --- a/MoneyMap.Core/Services/AIReceiptParser.cs +++ b/MoneyMap.Core/Services/AIReceiptParser.cs @@ -1,466 +1,466 @@ -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 -{ - public interface IReceiptParser - { - Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null); - } - - public class AIReceiptParser : IReceiptParser - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - 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 _logger; - private string? _promptTemplate; - - public AIReceiptParser( - MoneyMapContext db, - IReceiptManager receiptManager, - IPdfToImageConverter pdfConverter, - IAIVisionClientResolver clientResolver, - IMerchantService merchantService, - IAIToolExecutor toolExecutor, - IServiceProvider serviceProvider, - IConfiguration configuration, - ILogger logger) - { - _db = db; - _receiptManager = receiptManager; - _pdfConverter = pdfConverter; - _clientResolver = clientResolver; - _merchantService = merchantService; - _toolExecutor = toolExecutor; - _serviceProvider = serviceProvider; - _configuration = configuration; - _logger = logger; - } - - public async Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null) - { - var receipt = await _db.Receipts - .Include(r => r.Transaction) - .FirstOrDefaultAsync(r => r.Id == receiptId); - - if (receipt == null) - return ReceiptParseResult.Failure("Receipt not found."); - - var filePath = _receiptManager.GetReceiptPhysicalPath(receipt); - 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, - Provider = provider, - Model = selectedModel, - StartedAtUtc = DateTime.UtcNow, - Success = false - }; - - try - { - var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath); - var promptText = await BuildPromptAsync(receipt, effectiveNotes, client); - var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel); - - if (!visionResult.IsSuccess) - { - await SaveParseLogAsync(parseLog, visionResult.ErrorMessage); - return ReceiptParseResult.Failure(visionResult.ErrorMessage!); - } - - var parseData = ParseResponse(visionResult.Content); - 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, parseData.SuggestedTransactionId); - - var lineCount = parseData.LineItems.Count; - return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt."); - } - catch (Exception ex) - { - await SaveParseLogAsync(parseLog, ex.Message); - _logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message); - return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}"); - } - } - - /// - /// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama. - /// - private async Task 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") - { - var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath); - return (base64, "image/png"); - } - - var fileBytes = await File.ReadAllBytesAsync(filePath); - return (Convert.ToBase64String(fileBytes), receipt.ContentType); - } - - private async Task BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client) - { - var promptText = await LoadPromptTemplateAsync(); - - var transactionName = receipt.Transaction?.Name; - if (!string.IsNullOrWhiteSpace(transactionName)) - { - promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear."; - } - - var parsingNotes = _configuration["AI:ReceiptParsingNotes"]; - if (!string.IsNullOrWhiteSpace(parsingNotes)) - { - promptText += $"\n\nAdditional notes: {parsingNotes}"; - } - - if (!string.IsNullOrWhiteSpace(userNotes)) - { - 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; - } - - private static ParsedReceiptData ParseResponse(string? content) - { - if (string.IsNullOrWhiteSpace(content)) - return new ParsedReceiptData(); - - return JsonSerializer.Deserialize(content, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }) ?? new ParsedReceiptData(); - } - - private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes) - { - // Update receipt fields - receipt.ParsingNotes = notes; - receipt.Merchant = parseData.Merchant; - receipt.Total = parseData.Total; - receipt.Subtotal = parseData.Subtotal; - receipt.Tax = parseData.Tax; - receipt.ReceiptDate = parseData.ReceiptDate; - receipt.DueDate = parseData.DueDate; - - // Update transaction merchant if needed - if (receipt.Transaction != null && - !string.IsNullOrWhiteSpace(parseData.Merchant) && - receipt.Transaction.MerchantId == null) - { - var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant); - 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) - .ToListAsync(); - _db.ReceiptLineItems.RemoveRange(existingItems); - - var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem - { - ReceiptId = receiptId, - LineNumber = index + 1, - Description = item.Description, - Sku = item.Upc, - Quantity = item.Quantity, - UnitPrice = item.UnitPrice, - LineTotal = item.LineTotal, - Category = item.Category, - Voided = item.Voided - }).ToList(); - - _db.ReceiptLineItems.AddRange(lineItems); - await _db.SaveChangesAsync(); - } - - private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null) - { - parseLog.Error = error; - parseLog.CompletedAtUtc = DateTime.UtcNow; - _db.ReceiptParseLogs.Add(parseLog); - await _db.SaveChangesAsync(); - } - - 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; - - try - { - using var scope = _serviceProvider.CreateScope(); - var autoMapper = scope.ServiceProvider.GetRequiredService(); - await autoMapper.AutoMapReceiptAsync(receiptId); - _logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message); - } - } - - private async Task LoadPromptTemplateAsync() - { - if (_promptTemplate != null) - return _promptTemplate; - - var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "ReceiptParserPrompt.txt"); - - if (!File.Exists(promptPath)) - throw new FileNotFoundException($"Receipt parser prompt template not found at: {promptPath}"); - - _promptTemplate = await File.ReadAllTextAsync(promptPath); - return _promptTemplate; - } - } - - /// - /// Resolves the appropriate AI vision client based on model name. - /// - public interface IAIVisionClientResolver - { - (IAIVisionClient Client, string Provider) Resolve(string model); - } - - public class AIVisionClientResolver : IAIVisionClientResolver - { - private readonly OpenAIVisionClient _openAIClient; - private readonly ClaudeVisionClient _claudeClient; - private readonly OllamaVisionClient _ollamaClient; - private readonly LlamaCppVisionClient _llamaCppClient; - - public AIVisionClientResolver( - OpenAIVisionClient openAIClient, - ClaudeVisionClient claudeClient, - OllamaVisionClient ollamaClient, - LlamaCppVisionClient llamaCppClient) - { - _openAIClient = openAIClient; - _claudeClient = claudeClient; - _ollamaClient = ollamaClient; - _llamaCppClient = llamaCppClient; - } - - public (IAIVisionClient Client, string Provider) Resolve(string model) - { - if (model.StartsWith("llamacpp:")) - return (_llamaCppClient, "LlamaCpp"); - - if (model.StartsWith("ollama:")) - return (_ollamaClient, "Ollama"); - - if (model.StartsWith("claude-")) - return (_claudeClient, "Anthropic"); - - return (_openAIClient, "OpenAI"); - } - } - - public class ParsedReceiptData - { - public string? Merchant { get; set; } - public DateTime? ReceiptDate { get; set; } - public DateTime? DueDate { get; set; } - public decimal? Subtotal { get; set; } - 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 LineItems { get; set; } = new(); - } - - public class ParsedLineItem - { - public string Description { get; set; } = ""; - public string? Upc { get; set; } - 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; } - } - - public class ReceiptParseResult - { - public bool IsSuccess { get; init; } - public string? Message { get; init; } - - public static ReceiptParseResult Success(string message) => - new() { IsSuccess = true, Message = message }; - - public static ReceiptParseResult Failure(string message) => - new() { IsSuccess = false, Message = message }; - } - - /// - /// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123") - /// instead of as a JSON number or null. - /// - public class NullableLongConverter : JsonConverter - { - 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(); - } - } -} +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 +{ + public interface IReceiptParser + { + Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null); + } + + public class AIReceiptParser : IReceiptParser + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + 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 _logger; + private string? _promptTemplate; + + public AIReceiptParser( + MoneyMapContext db, + IReceiptManager receiptManager, + IPdfToImageConverter pdfConverter, + IAIVisionClientResolver clientResolver, + IMerchantService merchantService, + IAIToolExecutor toolExecutor, + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger) + { + _db = db; + _receiptManager = receiptManager; + _pdfConverter = pdfConverter; + _clientResolver = clientResolver; + _merchantService = merchantService; + _toolExecutor = toolExecutor; + _serviceProvider = serviceProvider; + _configuration = configuration; + _logger = logger; + } + + public async Task ParseReceiptAsync(long receiptId, string? model = null, string? notes = null) + { + var receipt = await _db.Receipts + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return ReceiptParseResult.Failure("Receipt not found."); + + var filePath = _receiptManager.GetReceiptPhysicalPath(receipt); + 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, + Provider = provider, + Model = selectedModel, + StartedAtUtc = DateTime.UtcNow, + Success = false + }; + + try + { + var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath); + var promptText = await BuildPromptAsync(receipt, effectiveNotes, client); + var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel); + + if (!visionResult.IsSuccess) + { + await SaveParseLogAsync(parseLog, visionResult.ErrorMessage); + return ReceiptParseResult.Failure(visionResult.ErrorMessage!); + } + + var parseData = ParseResponse(visionResult.Content); + 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, parseData.SuggestedTransactionId); + + var lineCount = parseData.LineItems.Count; + return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt."); + } + catch (Exception ex) + { + await SaveParseLogAsync(parseLog, ex.Message); + _logger.LogError(ex, "Error parsing receipt {ReceiptId}: {Message}", receiptId, ex.Message); + return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}"); + } + } + + /// + /// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama. + /// + private async Task 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") + { + var base64 = await _pdfConverter.ConvertFirstPageToBase64Async(filePath); + return (base64, "image/png"); + } + + var fileBytes = await File.ReadAllBytesAsync(filePath); + return (Convert.ToBase64String(fileBytes), receipt.ContentType); + } + + private async Task BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client) + { + var promptText = await LoadPromptTemplateAsync(); + + var transactionName = receipt.Transaction?.Name; + if (!string.IsNullOrWhiteSpace(transactionName)) + { + promptText += $"\n\nNote: This transaction was recorded as \"{transactionName}\" in the bank statement, which may help identify the merchant if the receipt is unclear."; + } + + var parsingNotes = _configuration["AI:ReceiptParsingNotes"]; + if (!string.IsNullOrWhiteSpace(parsingNotes)) + { + promptText += $"\n\nAdditional notes: {parsingNotes}"; + } + + if (!string.IsNullOrWhiteSpace(userNotes)) + { + 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; + } + + private static ParsedReceiptData ParseResponse(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + return new ParsedReceiptData(); + + return JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new ParsedReceiptData(); + } + + private async Task ApplyParseResultAsync(Receipt receipt, long receiptId, ParsedReceiptData parseData, string? notes) + { + // Update receipt fields + receipt.ParsingNotes = notes; + receipt.Merchant = parseData.Merchant; + receipt.Total = parseData.Total; + receipt.Subtotal = parseData.Subtotal; + receipt.Tax = parseData.Tax; + receipt.ReceiptDate = parseData.ReceiptDate; + receipt.DueDate = parseData.DueDate; + + // Update transaction merchant if needed + if (receipt.Transaction != null && + !string.IsNullOrWhiteSpace(parseData.Merchant) && + receipt.Transaction.MerchantId == null) + { + var merchantId = await _merchantService.GetOrCreateIdAsync(parseData.Merchant); + 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) + .ToListAsync(); + _db.ReceiptLineItems.RemoveRange(existingItems); + + var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem + { + ReceiptId = receiptId, + LineNumber = index + 1, + Description = item.Description, + Sku = item.Upc, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice, + LineTotal = item.LineTotal, + Category = item.Category, + Voided = item.Voided + }).ToList(); + + _db.ReceiptLineItems.AddRange(lineItems); + await _db.SaveChangesAsync(); + } + + private async Task SaveParseLogAsync(ReceiptParseLog parseLog, string? error = null) + { + parseLog.Error = error; + parseLog.CompletedAtUtc = DateTime.UtcNow; + _db.ReceiptParseLogs.Add(parseLog); + await _db.SaveChangesAsync(); + } + + 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; + + try + { + using var scope = _serviceProvider.CreateScope(); + var autoMapper = scope.ServiceProvider.GetRequiredService(); + await autoMapper.AutoMapReceiptAsync(receiptId); + _logger.LogInformation("Auto-mapping completed for receipt {ReceiptId}", receiptId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auto-mapping failed for receipt {ReceiptId}: {Message}", receiptId, ex.Message); + } + } + + private async Task LoadPromptTemplateAsync() + { + if (_promptTemplate != null) + return _promptTemplate; + + var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "ReceiptParserPrompt.txt"); + + if (!File.Exists(promptPath)) + throw new FileNotFoundException($"Receipt parser prompt template not found at: {promptPath}"); + + _promptTemplate = await File.ReadAllTextAsync(promptPath); + return _promptTemplate; + } + } + + /// + /// Resolves the appropriate AI vision client based on model name. + /// + public interface IAIVisionClientResolver + { + (IAIVisionClient Client, string Provider) Resolve(string model); + } + + public class AIVisionClientResolver : IAIVisionClientResolver + { + private readonly OpenAIVisionClient _openAIClient; + private readonly ClaudeVisionClient _claudeClient; + private readonly OllamaVisionClient _ollamaClient; + private readonly LlamaCppVisionClient _llamaCppClient; + + public AIVisionClientResolver( + OpenAIVisionClient openAIClient, + ClaudeVisionClient claudeClient, + OllamaVisionClient ollamaClient, + LlamaCppVisionClient llamaCppClient) + { + _openAIClient = openAIClient; + _claudeClient = claudeClient; + _ollamaClient = ollamaClient; + _llamaCppClient = llamaCppClient; + } + + public (IAIVisionClient Client, string Provider) Resolve(string model) + { + if (model.StartsWith("llamacpp:")) + return (_llamaCppClient, "LlamaCpp"); + + if (model.StartsWith("ollama:")) + return (_ollamaClient, "Ollama"); + + if (model.StartsWith("claude-")) + return (_claudeClient, "Anthropic"); + + return (_openAIClient, "OpenAI"); + } + } + + public class ParsedReceiptData + { + public string? Merchant { get; set; } + public DateTime? ReceiptDate { get; set; } + public DateTime? DueDate { get; set; } + public decimal? Subtotal { get; set; } + 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 LineItems { get; set; } = new(); + } + + public class ParsedLineItem + { + public string Description { get; set; } = ""; + public string? Upc { get; set; } + 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; } + } + + public class ReceiptParseResult + { + public bool IsSuccess { get; init; } + public string? Message { get; init; } + + public static ReceiptParseResult Success(string message) => + new() { IsSuccess = true, Message = message }; + + public static ReceiptParseResult Failure(string message) => + new() { IsSuccess = false, Message = message }; + } + + /// + /// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123") + /// instead of as a JSON number or null. + /// + public class NullableLongConverter : JsonConverter + { + 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(); + } + } +} diff --git a/MoneyMap.Core/Services/AITools/AIToolDefinitions.cs b/MoneyMap.Core/Services/AITools/AIToolDefinitions.cs index a47e4a0..2888bef 100644 --- a/MoneyMap.Core/Services/AITools/AIToolDefinitions.cs +++ b/MoneyMap.Core/Services/AITools/AIToolDefinitions.cs @@ -1,159 +1,159 @@ -using System.Text.Json.Serialization; - -namespace MoneyMap.Services.AITools -{ - /// - /// Provider-agnostic tool definition for AI function calling. - /// - public class AIToolDefinition - { - public string Name { get; set; } = ""; - public string Description { get; set; } = ""; - public List 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; } - } - - /// - /// Represents a tool call from the AI model. - /// - public class AIToolCall - { - public string Id { get; set; } = ""; - public string Name { get; set; } = ""; - public Dictionary 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; - } - } - - /// - /// Result of executing a tool, returned to the AI. - /// - public class AIToolResult - { - public string ToolCallId { get; set; } = ""; - public string Content { get; set; } = ""; - public bool IsError { get; set; } - } - - /// - /// Static registry of all tools available to the receipt parsing AI. - /// - public static class AIToolRegistry - { - public static List 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 - } - } - } - }; - } -} +using System.Text.Json.Serialization; + +namespace MoneyMap.Services.AITools +{ + /// + /// Provider-agnostic tool definition for AI function calling. + /// + public class AIToolDefinition + { + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public List 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; } + } + + /// + /// Represents a tool call from the AI model. + /// + public class AIToolCall + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public Dictionary 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; + } + } + + /// + /// Result of executing a tool, returned to the AI. + /// + public class AIToolResult + { + public string ToolCallId { get; set; } = ""; + public string Content { get; set; } = ""; + public bool IsError { get; set; } + } + + /// + /// Static registry of all tools available to the receipt parsing AI. + /// + public static class AIToolRegistry + { + public static List 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 + } + } + } + }; + } +} diff --git a/MoneyMap.Core/Services/AITools/AIToolExecutor.cs b/MoneyMap.Core/Services/AITools/AIToolExecutor.cs index e0d0be1..4a66ef5 100644 --- a/MoneyMap.Core/Services/AITools/AIToolExecutor.cs +++ b/MoneyMap.Core/Services/AITools/AIToolExecutor.cs @@ -1,280 +1,280 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using System.Text; -using System.Text.Json; - -namespace MoneyMap.Services.AITools -{ - public interface IAIToolExecutor - { - /// - /// Execute a single tool call and return the result as JSON. - /// - Task ExecuteAsync(AIToolCall toolCall); - - /// - /// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama). - /// - Task GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null); - } - - public class AIToolExecutor : IAIToolExecutor - { - private readonly MoneyMapContext _db; - private readonly ILogger _logger; - private const int MaxResults = 20; - - public AIToolExecutor(MoneyMapContext db, ILogger logger) - { - _db = db; - _logger = logger; - } - - public async Task 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 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 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 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 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 }); - } - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using System.Text; +using System.Text.Json; + +namespace MoneyMap.Services.AITools +{ + public interface IAIToolExecutor + { + /// + /// Execute a single tool call and return the result as JSON. + /// + Task ExecuteAsync(AIToolCall toolCall); + + /// + /// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama). + /// + Task GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null); + } + + public class AIToolExecutor : IAIToolExecutor + { + private readonly MoneyMapContext _db; + private readonly ILogger _logger; + private const int MaxResults = 20; + + public AIToolExecutor(MoneyMapContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task 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 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 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 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 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 }); + } + } +} diff --git a/MoneyMap.Core/Services/AIVisionClient.cs b/MoneyMap.Core/Services/AIVisionClient.cs index 9290d7b..9ac390f 100644 --- a/MoneyMap.Core/Services/AIVisionClient.cs +++ b/MoneyMap.Core/Services/AIVisionClient.cs @@ -1,1024 +1,1024 @@ -using MoneyMap.Services.AITools; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MoneyMap.Services -{ - /// - /// Result of an AI vision API call. - /// - public class VisionApiResult - { - public bool IsSuccess { get; init; } - public string? Content { get; init; } - public string? ErrorMessage { get; init; } - - public static VisionApiResult Success(string content) => - new() { IsSuccess = true, Content = content }; - - public static VisionApiResult Failure(string error) => - new() { IsSuccess = false, ErrorMessage = error }; - } - - /// - /// Client for making vision API calls to AI providers. - /// - public interface IAIVisionClient - { - Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model); - } - - /// - /// Extended interface for vision clients that support tool use / function calling. - /// - public interface IAIToolAwareVisionClient : IAIVisionClient - { - bool SupportsToolUse { get; } - - Task AnalyzeImageWithToolsAsync( - string base64Image, - string mediaType, - string prompt, - string model, - List tools, - Func> toolExecutor, - int maxToolRounds = 5); - } - - /// - /// Shared helper for the OpenAI-compatible tool-use wire format. - /// Used by both OpenAIVisionClient and LlamaCppVisionClient since they share /v1/chat/completions. - /// - public static class OpenAIToolUseHelper - { - /// - /// Convert AIToolDefinitions to the OpenAI tools array format. - /// - public static List BuildToolsArray(List tools) - { - return tools.Select(t => (object)new - { - type = "function", - function = new - { - name = t.Name, - description = t.Description, - parameters = new - { - type = "object", - properties = t.Parameters.ToDictionary( - p => p.Name, - p => (object)new { type = p.Type, description = p.Description } - ), - required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() - } - } - }).ToList(); - } - - /// - /// Execute the tool-use loop for OpenAI-compatible /v1/chat/completions endpoints. - /// - public static async Task ExecuteWithToolsAsync( - HttpClient httpClient, - string apiUrl, - Action configureHeaders, - string model, - List initialMessages, - List toolsArray, - Func> toolExecutor, - int maxToolRounds, - int maxTokens, - ILogger logger) - { - // Build mutable message list - var messages = new List(initialMessages); - - for (int round = 0; round <= maxToolRounds; round++) - { - var requestBody = new Dictionary - { - ["model"] = model, - ["messages"] = messages, - ["max_tokens"] = maxTokens, - ["temperature"] = 0.1 - }; - - // Only include tools if we haven't exhausted rounds - if (round < maxToolRounds && toolsArray.Count > 0) - { - requestBody["tools"] = toolsArray; - requestBody["tool_choice"] = "auto"; - } - - configureHeaders(httpClient); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await httpClient.PostAsync(apiUrl, content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - logger.LogError("API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var choice = responseObj.GetProperty("choices")[0]; - var message = choice.GetProperty("message"); - var finishReason = choice.GetProperty("finish_reason").GetString(); - - // Check for tool calls - var hasToolCalls = message.TryGetProperty("tool_calls", out var toolCallsElement) && - toolCallsElement.ValueKind == JsonValueKind.Array && - toolCallsElement.GetArrayLength() > 0; - - if (hasToolCalls || finishReason == "tool_calls") - { - if (!hasToolCalls) - { - // finish_reason says tool_calls but no tool_calls array - treat as final response - var fallbackContent = message.TryGetProperty("content", out var fc) ? fc.GetString() : null; - return VisionApiResult.Success(CleanJsonResponse(fallbackContent)); - } - - logger.LogInformation("Tool-use round {Round}: model requested {Count} tool calls", - round + 1, toolCallsElement.GetArrayLength()); - - // Add the assistant message (with tool_calls) to conversation - messages.Add(JsonSerializer.Deserialize(message.GetRawText())!); - - // Execute each tool call and add results - foreach (var tc in toolCallsElement.EnumerateArray()) - { - var toolCall = new AIToolCall - { - Id = tc.GetProperty("id").GetString() ?? "", - Name = tc.GetProperty("function").GetProperty("name").GetString() ?? "", - Arguments = ParseArguments(tc.GetProperty("function").GetProperty("arguments").GetString()) - }; - - logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); - var result = await toolExecutor(toolCall); - - messages.Add(new - { - role = "tool", - tool_call_id = toolCall.Id, - content = result.Content - }); - } - - continue; // Send another request with tool results - } - - // No tool calls - extract final content - var messageContent = message.TryGetProperty("content", out var contentElement) - ? contentElement.GetString() - : null; - - return VisionApiResult.Success(CleanJsonResponse(messageContent)); - } - - return VisionApiResult.Failure("Exceeded maximum tool-use rounds without getting a final response."); - } - - private static Dictionary ParseArguments(string? argsJson) - { - if (string.IsNullOrWhiteSpace(argsJson)) - return new(); - - try - { - var element = JsonSerializer.Deserialize(argsJson); - var dict = new Dictionary(); - foreach (var prop in element.EnumerateObject()) - { - dict[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString(), - JsonValueKind.Number => prop.Value.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - JsonValueKind.Null => null, - _ => prop.Value.GetRawText() - }; - } - return dict; - } - catch - { - return new(); - } - } - - public static string CleanJsonResponse(string? content) - { - var trimmed = content?.Trim() ?? ""; - - // Strip ... blocks from reasoning models (e.g. Qwen3-VL-Thinking) - trimmed = System.Text.RegularExpressions.Regex.Replace( - trimmed, @"[\s\S]*?", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim(); - - // Strip markdown code fences - if (trimmed.StartsWith("```json")) - { - trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); - } - else if (trimmed.StartsWith("```")) - { - trimmed = trimmed.Replace("```", "").Trim(); - } - - // If the response doesn't start with '{', try to extract the JSON object. - // This handles HTML error pages, XML-wrapped responses, or other non-JSON wrapping. - if (!trimmed.StartsWith("{")) - { - var firstBrace = trimmed.IndexOf('{'); - var lastBrace = trimmed.LastIndexOf('}'); - if (firstBrace >= 0 && lastBrace > firstBrace) - { - trimmed = trimmed[firstBrace..(lastBrace + 1)]; - } - } - - return trimmed; - } - } - - /// - /// OpenAI Vision API client with tool-use support. - /// - public class OpenAIVisionClient : IAIToolAwareVisionClient - { - private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public OpenAIVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) - { - _httpClient = httpClient; - _configuration = configuration; - _logger = logger; - } - - public bool SupportsToolUse => true; - - public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) - { - var apiKey = GetApiKey(); - if (apiKey == null) - return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json"); - - var requestBody = new - { - model = model, - messages = new[] - { - new - { - role = "user", - content = new object[] - { - new { type = "text", text = prompt }, - new - { - type = "image_url", - image_url = new { url = $"data:{mediaType};base64,{base64Image}" } - } - } - } - }, - max_tokens = 2000, - temperature = 0.1 - }; - - try - { - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("https://api.openai.com/v1/chat/completions", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("OpenAI API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"OpenAI API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var messageContent = responseObj - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString(); - - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); - } - catch (Exception ex) - { - _logger.LogError(ex, "OpenAI Vision API call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"OpenAI API error: {ex.Message}"); - } - } - - public async Task AnalyzeImageWithToolsAsync( - string base64Image, string mediaType, string prompt, string model, - List tools, Func> toolExecutor, - int maxToolRounds = 5) - { - var apiKey = GetApiKey(); - if (apiKey == null) - return VisionApiResult.Failure("OpenAI API key not configured."); - - var initialMessages = new List - { - new - { - role = "user", - content = new object[] - { - new { type = "text", text = prompt }, - new - { - type = "image_url", - image_url = new { url = $"data:{mediaType};base64,{base64Image}" } - } - } - } - }; - - try - { - return await OpenAIToolUseHelper.ExecuteWithToolsAsync( - _httpClient, - "https://api.openai.com/v1/chat/completions", - client => - { - client.DefaultRequestHeaders.Clear(); - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - }, - model, - initialMessages, - OpenAIToolUseHelper.BuildToolsArray(tools), - toolExecutor, - maxToolRounds, - maxTokens: 4096, - _logger); - } - catch (Exception ex) - { - _logger.LogError(ex, "OpenAI tool-use call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"OpenAI API error: {ex.Message}"); - } - } - - private string? GetApiKey() => - Environment.GetEnvironmentVariable("OPENAI_API_KEY") - ?? _configuration["OpenAI:ApiKey"]; - } - - /// - /// Anthropic Claude Vision API client with tool-use support. - /// - public class ClaudeVisionClient : IAIToolAwareVisionClient - { - private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public ClaudeVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) - { - _httpClient = httpClient; - _configuration = configuration; - _logger = logger; - } - - public bool SupportsToolUse => true; - - public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) - { - var apiKey = GetApiKey(); - if (apiKey == null) - return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json"); - - var requestBody = new - { - model = model, - max_tokens = 2000, - messages = new[] - { - new - { - role = "user", - content = new object[] - { - new - { - type = "image", - source = new - { - type = "base64", - media_type = mediaType, - data = base64Image - } - }, - new { type = "text", text = prompt } - } - } - } - }; - - try - { - ConfigureHeaders(); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var messageContent = responseObj - .GetProperty("content")[0] - .GetProperty("text") - .GetString(); - - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Claude Vision API call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"Anthropic API error: {ex.Message}"); - } - } - - public async Task AnalyzeImageWithToolsAsync( - string base64Image, string mediaType, string prompt, string model, - List tools, Func> toolExecutor, - int maxToolRounds = 5) - { - var apiKey = GetApiKey(); - if (apiKey == null) - return VisionApiResult.Failure("Anthropic API key not configured."); - - // Build Anthropic-format tools array - var anthropicTools = tools.Select(t => new - { - name = t.Name, - description = t.Description, - input_schema = new - { - type = "object", - properties = t.Parameters.ToDictionary( - p => p.Name, - p => (object)new { type = p.Type, description = p.Description } - ), - required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() - } - }).ToList(); - - // Initial message with image - var messages = new List - { - new - { - role = "user", - content = new object[] - { - new - { - type = "image", - source = new - { - type = "base64", - media_type = mediaType, - data = base64Image - } - }, - new { type = "text", text = prompt } - } - } - }; - - try - { - for (int round = 0; round <= maxToolRounds; round++) - { - var requestBody = new Dictionary - { - ["model"] = model, - ["max_tokens"] = 4096, - ["messages"] = messages - }; - - if (round < maxToolRounds && anthropicTools.Count > 0) - requestBody["tools"] = anthropicTools; - - ConfigureHeaders(); - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var stopReason = responseObj.GetProperty("stop_reason").GetString(); - var contentBlocks = responseObj.GetProperty("content"); - - // Check for tool_use blocks - var toolUseBlocks = contentBlocks.EnumerateArray() - .Where(b => b.GetProperty("type").GetString() == "tool_use") - .ToList(); - - if (stopReason == "tool_use" && toolUseBlocks.Count > 0) - { - _logger.LogInformation("Claude tool-use round {Round}: {Count} tool calls", - round + 1, toolUseBlocks.Count); - - // Add assistant response to messages (contains tool_use blocks) - var assistantContent = JsonSerializer.Deserialize(contentBlocks.GetRawText())!; - messages.Add(new { role = "assistant", content = assistantContent }); - - // Build tool_result blocks - var toolResults = new List(); - foreach (var block in toolUseBlocks) - { - var toolCall = new AIToolCall - { - Id = block.GetProperty("id").GetString() ?? "", - Name = block.GetProperty("name").GetString() ?? "", - Arguments = ParseAnthropicInput(block.GetProperty("input")) - }; - - _logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); - var result = await toolExecutor(toolCall); - - toolResults.Add(new - { - type = "tool_result", - tool_use_id = toolCall.Id, - content = result.Content, - is_error = result.IsError - }); - } - - messages.Add(new { role = "user", content = toolResults }); - continue; - } - - // Extract final text content - var textBlock = contentBlocks.EnumerateArray() - .FirstOrDefault(b => b.GetProperty("type").GetString() == "text"); - - var text = textBlock.ValueKind != JsonValueKind.Undefined - ? textBlock.GetProperty("text").GetString() - : null; - - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(text)); - } - - return VisionApiResult.Failure("Exceeded maximum tool-use rounds."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Claude tool-use call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"Anthropic API error: {ex.Message}"); - } - } - - private static Dictionary ParseAnthropicInput(JsonElement input) - { - var dict = new Dictionary(); - if (input.ValueKind == JsonValueKind.Object) - { - foreach (var prop in input.EnumerateObject()) - { - dict[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString(), - JsonValueKind.Number => prop.Value.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - JsonValueKind.Null => null, - _ => prop.Value.GetRawText() - }; - } - } - return dict; - } - - private void ConfigureHeaders() - { - var apiKey = GetApiKey()!; - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); - _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - } - - private string? GetApiKey() => - Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") - ?? _configuration["Anthropic:ApiKey"]; - } - - /// - /// llama.cpp server client using OpenAI-compatible vision API with tool-use support. - /// - public class LlamaCppVisionClient : IAIToolAwareVisionClient - { - private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - // Model families whose Jinja chat templates support the OpenAI tool role format. - private static readonly string[] _toolCapableModelPrefixes = new[] - { - "qwen3", "qwen2.5", "hermes", "functionary", "mistral" - }; - - private string? _currentModel; - - public LlamaCppVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) - { - _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(5); - _configuration = configuration; - _logger = logger; - } - - /// - /// Whether the current model supports OpenAI-style tool/function calling. - /// Only certain model families (Qwen3, Hermes, etc.) have chat templates that handle the tool role. - /// - public bool SupportsToolUse => - _currentModel != null && - _toolCapableModelPrefixes.Any(p => - _currentModel.StartsWith(p, StringComparison.OrdinalIgnoreCase)); - - /// - /// Set the model name so SupportsToolUse can be evaluated per-model. - /// Called by AIReceiptParser before the tool-use check. - /// - public void SetCurrentModel(string model) - { - _currentModel = model.StartsWith("llamacpp:") ? model[9..] : model; - } - - /// - /// Get available models from the llama.cpp server. - /// - public async Task> GetAvailableModelsAsync() - { - var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - - try - { - var response = await _httpClient.GetAsync($"{baseUrl.TrimEnd('/')}/v1/models"); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to fetch models: {StatusCode}", response.StatusCode); - return new List(); - } - - var json = await response.Content.ReadAsStringAsync(); - var modelsResponse = JsonSerializer.Deserialize(json); - - return modelsResponse?.Data? - .Where(m => !m.Id.StartsWith("mmproj-")) - .Select(m => new LlamaCppModel - { - Id = m.Id, - IsLoaded = m.Status?.Value == "loaded" - }) - .OrderByDescending(m => m.IsLoaded) - .ThenBy(m => m.Id) - .ToList() ?? new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching models from llama.cpp"); - return new List(); - } - } - - /// - /// Send a text-only prompt to the LLM (no image). - /// - public async Task SendTextPromptAsync(string prompt, string? model = null) - { - var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - var llamaModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "Qwen3-8B-Q6_K"; - if (llamaModel.StartsWith("llamacpp:")) - llamaModel = llamaModel[9..]; - - _logger.LogInformation("LlamaCpp: Sending text prompt to {BaseUrl} with model {Model}", baseUrl, llamaModel); - - var requestBody = new - { - model = llamaModel, - messages = new[] - { - new - { - role = "user", - content = prompt - } - }, - max_tokens = 1024, - temperature = 0.1 - }; - - try - { - var json = JsonSerializer.Serialize(requestBody); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("llama.cpp API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"llama.cpp API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var messageContent = responseObj - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString(); - - _logger.LogInformation("LlamaCpp: Text prompt completed successfully"); - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError("llama.cpp request timed out"); - return VisionApiResult.Failure("llama.cpp request timed out."); - } - catch (Exception ex) - { - _logger.LogError(ex, "llama.cpp API call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); - } - } - - public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) - { - var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; - - _logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", - baseUrl, llamaModel, base64Image.Length); - - var requestBody = new - { - model = llamaModel, - messages = new[] - { - new - { - role = "user", - content = new object[] - { - new - { - type = "image_url", - image_url = new { url = $"data:{mediaType};base64,{base64Image}" } - }, - new { type = "text", text = prompt } - } - } - }, - max_tokens = 16384, - temperature = 0.1 - }; - - try - { - var json = JsonSerializer.Serialize(requestBody); - _logger.LogDebug("LlamaCpp: Request payload size: {Size} bytes", json.Length); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("llama.cpp API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"llama.cpp API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("LlamaCpp: Response received, size: {Size} bytes", responseJson.Length); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var messageContent = responseObj - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString(); - - _logger.LogInformation("LlamaCpp: Successfully parsed response"); - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError("llama.cpp request timed out after 5 minutes"); - return VisionApiResult.Failure("llama.cpp request timed out. The model may be too slow or not loaded."); - } - catch (Exception ex) - { - _logger.LogError(ex, "llama.cpp Vision API call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); - } - } - - public async Task AnalyzeImageWithToolsAsync( - string base64Image, string mediaType, string prompt, string model, - List tools, Func> toolExecutor, - int maxToolRounds = 5) - { - var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; - - _logger.LogInformation("LlamaCpp: Starting tool-use request with model {Model}", llamaModel); - - var initialMessages = new List - { - new - { - role = "user", - content = new object[] - { - new - { - type = "image_url", - image_url = new { url = $"data:{mediaType};base64,{base64Image}" } - }, - new { type = "text", text = prompt } - } - } - }; - - try - { - return await OpenAIToolUseHelper.ExecuteWithToolsAsync( - _httpClient, - $"{baseUrl.TrimEnd('/')}/v1/chat/completions", - _ => { }, // No auth headers needed for local llama.cpp - llamaModel, - initialMessages, - OpenAIToolUseHelper.BuildToolsArray(tools), - toolExecutor, - maxToolRounds, - maxTokens: 16384, - _logger); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError("llama.cpp tool-use request timed out"); - return VisionApiResult.Failure("llama.cpp request timed out."); - } - catch (Exception ex) - { - _logger.LogError(ex, "llama.cpp tool-use call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); - } - } - } - - /// - /// Ollama Vision API client for local LLM inference. - /// Does NOT support tool use (uses /api/generate endpoint). - /// Falls back to enriched prompt with pre-fetched context. - /// - public class OllamaVisionClient : IAIVisionClient - { - private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) - { - _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(5); - _configuration = configuration; - _logger = logger; - } - - public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) - { - var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model; - - _logger.LogInformation("Ollama: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", - baseUrl, ollamaModel, base64Image.Length); - - var requestBody = new - { - model = ollamaModel, - prompt = prompt, - images = new[] { base64Image }, - stream = false, - options = new - { - temperature = 0.1 - } - }; - - try - { - var json = JsonSerializer.Serialize(requestBody); - _logger.LogDebug("Ollama: Request payload size: {Size} bytes", json.Length); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/api/generate", content); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("Ollama API error ({StatusCode}): {Error}", response.StatusCode, errorContent); - return VisionApiResult.Failure($"Ollama API error ({response.StatusCode}): {errorContent}"); - } - - var responseJson = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("Ollama: Response received, size: {Size} bytes", responseJson.Length); - var responseObj = JsonSerializer.Deserialize(responseJson); - - var messageContent = responseObj.GetProperty("response").GetString(); - - _logger.LogInformation("Ollama: Successfully parsed response"); - return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogError("Ollama request timed out after 5 minutes"); - return VisionApiResult.Failure("Ollama request timed out. The model may be too slow or not loaded."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Ollama Vision API call failed: {Message}", ex.Message); - return VisionApiResult.Failure($"Ollama API error: {ex.Message}"); - } - } - } - - // Models for llama.cpp /v1/models endpoint - public class LlamaCppModel - { - public string Id { get; set; } = ""; - public bool IsLoaded { get; set; } - } - - public class LlamaCppModelsResponse - { - [JsonPropertyName("data")] - public List? Data { get; set; } - } - - public class LlamaCppModelData - { - [JsonPropertyName("id")] - public string Id { get; set; } = ""; - - [JsonPropertyName("status")] - public LlamaCppModelStatus? Status { get; set; } - } - - public class LlamaCppModelStatus - { - [JsonPropertyName("value")] - public string? Value { get; set; } - } -} +using MoneyMap.Services.AITools; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MoneyMap.Services +{ + /// + /// Result of an AI vision API call. + /// + public class VisionApiResult + { + public bool IsSuccess { get; init; } + public string? Content { get; init; } + public string? ErrorMessage { get; init; } + + public static VisionApiResult Success(string content) => + new() { IsSuccess = true, Content = content }; + + public static VisionApiResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + /// + /// Client for making vision API calls to AI providers. + /// + public interface IAIVisionClient + { + Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model); + } + + /// + /// Extended interface for vision clients that support tool use / function calling. + /// + public interface IAIToolAwareVisionClient : IAIVisionClient + { + bool SupportsToolUse { get; } + + Task AnalyzeImageWithToolsAsync( + string base64Image, + string mediaType, + string prompt, + string model, + List tools, + Func> toolExecutor, + int maxToolRounds = 5); + } + + /// + /// Shared helper for the OpenAI-compatible tool-use wire format. + /// Used by both OpenAIVisionClient and LlamaCppVisionClient since they share /v1/chat/completions. + /// + public static class OpenAIToolUseHelper + { + /// + /// Convert AIToolDefinitions to the OpenAI tools array format. + /// + public static List BuildToolsArray(List tools) + { + return tools.Select(t => (object)new + { + type = "function", + function = new + { + name = t.Name, + description = t.Description, + parameters = new + { + type = "object", + properties = t.Parameters.ToDictionary( + p => p.Name, + p => (object)new { type = p.Type, description = p.Description } + ), + required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() + } + } + }).ToList(); + } + + /// + /// Execute the tool-use loop for OpenAI-compatible /v1/chat/completions endpoints. + /// + public static async Task ExecuteWithToolsAsync( + HttpClient httpClient, + string apiUrl, + Action configureHeaders, + string model, + List initialMessages, + List toolsArray, + Func> toolExecutor, + int maxToolRounds, + int maxTokens, + ILogger logger) + { + // Build mutable message list + var messages = new List(initialMessages); + + for (int round = 0; round <= maxToolRounds; round++) + { + var requestBody = new Dictionary + { + ["model"] = model, + ["messages"] = messages, + ["max_tokens"] = maxTokens, + ["temperature"] = 0.1 + }; + + // Only include tools if we haven't exhausted rounds + if (round < maxToolRounds && toolsArray.Count > 0) + { + requestBody["tools"] = toolsArray; + requestBody["tool_choice"] = "auto"; + } + + configureHeaders(httpClient); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await httpClient.PostAsync(apiUrl, content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + logger.LogError("API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var choice = responseObj.GetProperty("choices")[0]; + var message = choice.GetProperty("message"); + var finishReason = choice.GetProperty("finish_reason").GetString(); + + // Check for tool calls + var hasToolCalls = message.TryGetProperty("tool_calls", out var toolCallsElement) && + toolCallsElement.ValueKind == JsonValueKind.Array && + toolCallsElement.GetArrayLength() > 0; + + if (hasToolCalls || finishReason == "tool_calls") + { + if (!hasToolCalls) + { + // finish_reason says tool_calls but no tool_calls array - treat as final response + var fallbackContent = message.TryGetProperty("content", out var fc) ? fc.GetString() : null; + return VisionApiResult.Success(CleanJsonResponse(fallbackContent)); + } + + logger.LogInformation("Tool-use round {Round}: model requested {Count} tool calls", + round + 1, toolCallsElement.GetArrayLength()); + + // Add the assistant message (with tool_calls) to conversation + messages.Add(JsonSerializer.Deserialize(message.GetRawText())!); + + // Execute each tool call and add results + foreach (var tc in toolCallsElement.EnumerateArray()) + { + var toolCall = new AIToolCall + { + Id = tc.GetProperty("id").GetString() ?? "", + Name = tc.GetProperty("function").GetProperty("name").GetString() ?? "", + Arguments = ParseArguments(tc.GetProperty("function").GetProperty("arguments").GetString()) + }; + + logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); + var result = await toolExecutor(toolCall); + + messages.Add(new + { + role = "tool", + tool_call_id = toolCall.Id, + content = result.Content + }); + } + + continue; // Send another request with tool results + } + + // No tool calls - extract final content + var messageContent = message.TryGetProperty("content", out var contentElement) + ? contentElement.GetString() + : null; + + return VisionApiResult.Success(CleanJsonResponse(messageContent)); + } + + return VisionApiResult.Failure("Exceeded maximum tool-use rounds without getting a final response."); + } + + private static Dictionary ParseArguments(string? argsJson) + { + if (string.IsNullOrWhiteSpace(argsJson)) + return new(); + + try + { + var element = JsonSerializer.Deserialize(argsJson); + var dict = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => prop.Value.GetRawText() + }; + } + return dict; + } + catch + { + return new(); + } + } + + public static string CleanJsonResponse(string? content) + { + var trimmed = content?.Trim() ?? ""; + + // Strip ... blocks from reasoning models (e.g. Qwen3-VL-Thinking) + trimmed = System.Text.RegularExpressions.Regex.Replace( + trimmed, @"[\s\S]*?", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim(); + + // Strip markdown code fences + if (trimmed.StartsWith("```json")) + { + trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); + } + else if (trimmed.StartsWith("```")) + { + trimmed = trimmed.Replace("```", "").Trim(); + } + + // If the response doesn't start with '{', try to extract the JSON object. + // This handles HTML error pages, XML-wrapped responses, or other non-JSON wrapping. + if (!trimmed.StartsWith("{")) + { + var firstBrace = trimmed.IndexOf('{'); + var lastBrace = trimmed.LastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) + { + trimmed = trimmed[firstBrace..(lastBrace + 1)]; + } + } + + return trimmed; + } + } + + /// + /// OpenAI Vision API client with tool-use support. + /// + public class OpenAIVisionClient : IAIToolAwareVisionClient + { + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public OpenAIVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + } + + public bool SupportsToolUse => true; + + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) + { + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json"); + + var requestBody = new + { + model = model, + messages = new[] + { + new + { + role = "user", + content = new object[] + { + new { type = "text", text = prompt }, + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + } + } + } + }, + max_tokens = 2000, + temperature = 0.1 + }; + + try + { + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); + + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("https://api.openai.com/v1/chat/completions", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("OpenAI API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"OpenAI API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var messageContent = responseObj + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); + } + catch (Exception ex) + { + _logger.LogError(ex, "OpenAI Vision API call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"OpenAI API error: {ex.Message}"); + } + } + + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) + { + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("OpenAI API key not configured."); + + var initialMessages = new List + { + new + { + role = "user", + content = new object[] + { + new { type = "text", text = prompt }, + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + } + } + } + }; + + try + { + return await OpenAIToolUseHelper.ExecuteWithToolsAsync( + _httpClient, + "https://api.openai.com/v1/chat/completions", + client => + { + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); + }, + model, + initialMessages, + OpenAIToolUseHelper.BuildToolsArray(tools), + toolExecutor, + maxToolRounds, + maxTokens: 4096, + _logger); + } + catch (Exception ex) + { + _logger.LogError(ex, "OpenAI tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"OpenAI API error: {ex.Message}"); + } + } + + private string? GetApiKey() => + Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? _configuration["OpenAI:ApiKey"]; + } + + /// + /// Anthropic Claude Vision API client with tool-use support. + /// + public class ClaudeVisionClient : IAIToolAwareVisionClient + { + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ClaudeVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + } + + public bool SupportsToolUse => true; + + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) + { + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json"); + + var requestBody = new + { + model = model, + max_tokens = 2000, + messages = new[] + { + new + { + role = "user", + content = new object[] + { + new + { + type = "image", + source = new + { + type = "base64", + media_type = mediaType, + data = base64Image + } + }, + new { type = "text", text = prompt } + } + } + } + }; + + try + { + ConfigureHeaders(); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var messageContent = responseObj + .GetProperty("content")[0] + .GetProperty("text") + .GetString(); + + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Claude Vision API call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"Anthropic API error: {ex.Message}"); + } + } + + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) + { + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("Anthropic API key not configured."); + + // Build Anthropic-format tools array + var anthropicTools = tools.Select(t => new + { + name = t.Name, + description = t.Description, + input_schema = new + { + type = "object", + properties = t.Parameters.ToDictionary( + p => p.Name, + p => (object)new { type = p.Type, description = p.Description } + ), + required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() + } + }).ToList(); + + // Initial message with image + var messages = new List + { + new + { + role = "user", + content = new object[] + { + new + { + type = "image", + source = new + { + type = "base64", + media_type = mediaType, + data = base64Image + } + }, + new { type = "text", text = prompt } + } + } + }; + + try + { + for (int round = 0; round <= maxToolRounds; round++) + { + var requestBody = new Dictionary + { + ["model"] = model, + ["max_tokens"] = 4096, + ["messages"] = messages + }; + + if (round < maxToolRounds && anthropicTools.Count > 0) + requestBody["tools"] = anthropicTools; + + ConfigureHeaders(); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var stopReason = responseObj.GetProperty("stop_reason").GetString(); + var contentBlocks = responseObj.GetProperty("content"); + + // Check for tool_use blocks + var toolUseBlocks = contentBlocks.EnumerateArray() + .Where(b => b.GetProperty("type").GetString() == "tool_use") + .ToList(); + + if (stopReason == "tool_use" && toolUseBlocks.Count > 0) + { + _logger.LogInformation("Claude tool-use round {Round}: {Count} tool calls", + round + 1, toolUseBlocks.Count); + + // Add assistant response to messages (contains tool_use blocks) + var assistantContent = JsonSerializer.Deserialize(contentBlocks.GetRawText())!; + messages.Add(new { role = "assistant", content = assistantContent }); + + // Build tool_result blocks + var toolResults = new List(); + foreach (var block in toolUseBlocks) + { + var toolCall = new AIToolCall + { + Id = block.GetProperty("id").GetString() ?? "", + Name = block.GetProperty("name").GetString() ?? "", + Arguments = ParseAnthropicInput(block.GetProperty("input")) + }; + + _logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); + var result = await toolExecutor(toolCall); + + toolResults.Add(new + { + type = "tool_result", + tool_use_id = toolCall.Id, + content = result.Content, + is_error = result.IsError + }); + } + + messages.Add(new { role = "user", content = toolResults }); + continue; + } + + // Extract final text content + var textBlock = contentBlocks.EnumerateArray() + .FirstOrDefault(b => b.GetProperty("type").GetString() == "text"); + + var text = textBlock.ValueKind != JsonValueKind.Undefined + ? textBlock.GetProperty("text").GetString() + : null; + + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(text)); + } + + return VisionApiResult.Failure("Exceeded maximum tool-use rounds."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Claude tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"Anthropic API error: {ex.Message}"); + } + } + + private static Dictionary ParseAnthropicInput(JsonElement input) + { + var dict = new Dictionary(); + if (input.ValueKind == JsonValueKind.Object) + { + foreach (var prop in input.EnumerateObject()) + { + dict[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => prop.Value.GetRawText() + }; + } + } + return dict; + } + + private void ConfigureHeaders() + { + var apiKey = GetApiKey()!; + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + } + + private string? GetApiKey() => + Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? _configuration["Anthropic:ApiKey"]; + } + + /// + /// llama.cpp server client using OpenAI-compatible vision API with tool-use support. + /// + public class LlamaCppVisionClient : IAIToolAwareVisionClient + { + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + // Model families whose Jinja chat templates support the OpenAI tool role format. + private static readonly string[] _toolCapableModelPrefixes = new[] + { + "qwen3", "qwen2.5", "hermes", "functionary", "mistral" + }; + + private string? _currentModel; + + public LlamaCppVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _httpClient.Timeout = TimeSpan.FromMinutes(5); + _configuration = configuration; + _logger = logger; + } + + /// + /// Whether the current model supports OpenAI-style tool/function calling. + /// Only certain model families (Qwen3, Hermes, etc.) have chat templates that handle the tool role. + /// + public bool SupportsToolUse => + _currentModel != null && + _toolCapableModelPrefixes.Any(p => + _currentModel.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + + /// + /// Set the model name so SupportsToolUse can be evaluated per-model. + /// Called by AIReceiptParser before the tool-use check. + /// + public void SetCurrentModel(string model) + { + _currentModel = model.StartsWith("llamacpp:") ? model[9..] : model; + } + + /// + /// Get available models from the llama.cpp server. + /// + public async Task> GetAvailableModelsAsync() + { + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + + try + { + var response = await _httpClient.GetAsync($"{baseUrl.TrimEnd('/')}/v1/models"); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to fetch models: {StatusCode}", response.StatusCode); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(); + var modelsResponse = JsonSerializer.Deserialize(json); + + return modelsResponse?.Data? + .Where(m => !m.Id.StartsWith("mmproj-")) + .Select(m => new LlamaCppModel + { + Id = m.Id, + IsLoaded = m.Status?.Value == "loaded" + }) + .OrderByDescending(m => m.IsLoaded) + .ThenBy(m => m.Id) + .ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching models from llama.cpp"); + return new List(); + } + } + + /// + /// Send a text-only prompt to the LLM (no image). + /// + public async Task SendTextPromptAsync(string prompt, string? model = null) + { + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + var llamaModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "Qwen3-8B-Q6_K"; + if (llamaModel.StartsWith("llamacpp:")) + llamaModel = llamaModel[9..]; + + _logger.LogInformation("LlamaCpp: Sending text prompt to {BaseUrl} with model {Model}", baseUrl, llamaModel); + + var requestBody = new + { + model = llamaModel, + messages = new[] + { + new + { + role = "user", + content = prompt + } + }, + max_tokens = 1024, + temperature = 0.1 + }; + + try + { + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("llama.cpp API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"llama.cpp API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var messageContent = responseObj + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + _logger.LogInformation("LlamaCpp: Text prompt completed successfully"); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("llama.cpp request timed out"); + return VisionApiResult.Failure("llama.cpp request timed out."); + } + catch (Exception ex) + { + _logger.LogError(ex, "llama.cpp API call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); + } + } + + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) + { + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; + + _logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", + baseUrl, llamaModel, base64Image.Length); + + var requestBody = new + { + model = llamaModel, + messages = new[] + { + new + { + role = "user", + content = new object[] + { + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + }, + new { type = "text", text = prompt } + } + } + }, + max_tokens = 16384, + temperature = 0.1 + }; + + try + { + var json = JsonSerializer.Serialize(requestBody); + _logger.LogDebug("LlamaCpp: Request payload size: {Size} bytes", json.Length); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("llama.cpp API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"llama.cpp API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("LlamaCpp: Response received, size: {Size} bytes", responseJson.Length); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var messageContent = responseObj + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + + _logger.LogInformation("LlamaCpp: Successfully parsed response"); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("llama.cpp request timed out after 5 minutes"); + return VisionApiResult.Failure("llama.cpp request timed out. The model may be too slow or not loaded."); + } + catch (Exception ex) + { + _logger.LogError(ex, "llama.cpp Vision API call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); + } + } + + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) + { + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; + + _logger.LogInformation("LlamaCpp: Starting tool-use request with model {Model}", llamaModel); + + var initialMessages = new List + { + new + { + role = "user", + content = new object[] + { + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + }, + new { type = "text", text = prompt } + } + } + }; + + try + { + return await OpenAIToolUseHelper.ExecuteWithToolsAsync( + _httpClient, + $"{baseUrl.TrimEnd('/')}/v1/chat/completions", + _ => { }, // No auth headers needed for local llama.cpp + llamaModel, + initialMessages, + OpenAIToolUseHelper.BuildToolsArray(tools), + toolExecutor, + maxToolRounds, + maxTokens: 16384, + _logger); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("llama.cpp tool-use request timed out"); + return VisionApiResult.Failure("llama.cpp request timed out."); + } + catch (Exception ex) + { + _logger.LogError(ex, "llama.cpp tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); + } + } + } + + /// + /// Ollama Vision API client for local LLM inference. + /// Does NOT support tool use (uses /api/generate endpoint). + /// Falls back to enriched prompt with pre-fetched context. + /// + public class OllamaVisionClient : IAIVisionClient + { + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _httpClient.Timeout = TimeSpan.FromMinutes(5); + _configuration = configuration; + _logger = logger; + } + + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) + { + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model; + + _logger.LogInformation("Ollama: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", + baseUrl, ollamaModel, base64Image.Length); + + var requestBody = new + { + model = ollamaModel, + prompt = prompt, + images = new[] { base64Image }, + stream = false, + options = new + { + temperature = 0.1 + } + }; + + try + { + var json = JsonSerializer.Serialize(requestBody); + _logger.LogDebug("Ollama: Request payload size: {Size} bytes", json.Length); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{baseUrl.TrimEnd('/')}/api/generate", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("Ollama API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"Ollama API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Ollama: Response received, size: {Size} bytes", responseJson.Length); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var messageContent = responseObj.GetProperty("response").GetString(); + + _logger.LogInformation("Ollama: Successfully parsed response"); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("Ollama request timed out after 5 minutes"); + return VisionApiResult.Failure("Ollama request timed out. The model may be too slow or not loaded."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ollama Vision API call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"Ollama API error: {ex.Message}"); + } + } + } + + // Models for llama.cpp /v1/models endpoint + public class LlamaCppModel + { + public string Id { get; set; } = ""; + public bool IsLoaded { get; set; } + } + + public class LlamaCppModelsResponse + { + [JsonPropertyName("data")] + public List? Data { get; set; } + } + + public class LlamaCppModelData + { + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("status")] + public LlamaCppModelStatus? Status { get; set; } + } + + public class LlamaCppModelStatus + { + [JsonPropertyName("value")] + public string? Value { get; set; } + } +} diff --git a/MoneyMap.Core/Services/AccountService.cs b/MoneyMap.Core/Services/AccountService.cs index c045f6c..771bab3 100644 --- a/MoneyMap.Core/Services/AccountService.cs +++ b/MoneyMap.Core/Services/AccountService.cs @@ -1,202 +1,202 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for account management including retrieval, validation, and deletion. -/// -public interface IAccountService -{ - /// - /// Gets an account by ID with optional related data. - /// - Task GetAccountByIdAsync(int id, bool includeRelated = false); - - /// - /// Gets all accounts with optional statistics. - /// - Task> GetAllAccountsWithStatsAsync(); - - /// - /// Gets account details with cards and transaction count. - /// - Task GetAccountDetailsAsync(int id); - - /// - /// Checks if an account can be deleted (no transactions exist). - /// - Task CanDeleteAccountAsync(int id); - - /// - /// Deletes an account if it has no associated transactions. - /// - Task DeleteAccountAsync(int id); -} - -public class AccountService : IAccountService -{ - private readonly MoneyMapContext _db; - - public AccountService(MoneyMapContext db) - { - _db = db; - } - - public async Task GetAccountByIdAsync(int id, bool includeRelated = false) - { - var query = _db.Accounts.AsQueryable(); - - if (includeRelated) - { - query = query - .Include(a => a.Cards) - .Include(a => a.Transactions); - } - - return await query.FirstOrDefaultAsync(a => a.Id == id); - } - - public async Task> GetAllAccountsWithStatsAsync() - { - var accounts = await _db.Accounts - .Include(a => a.Transactions) - .OrderBy(a => a.Owner) - .ThenBy(a => a.Institution) - .ThenBy(a => a.Last4) - .ToListAsync(); - - return accounts.Select(a => new AccountWithStats - { - Id = a.Id, - Institution = a.Institution, - AccountType = a.AccountType, - Last4 = a.Last4, - Owner = a.Owner, - Nickname = a.Nickname, - TransactionCount = a.Transactions.Count - }).ToList(); - } - - public async Task GetAccountDetailsAsync(int id) - { - var account = await _db.Accounts.FindAsync(id); - if (account == null) - return null; - - // Single query with projection to avoid N+1 - var cardStats = await _db.Cards - .Where(c => c.AccountId == id) - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .Select(c => new CardWithStats - { - Card = c, - TransactionCount = c.Transactions.Count - }) - .ToListAsync(); - - // Get transaction count for this account - var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id); - - return new AccountDetails - { - Account = account, - Cards = cardStats, - TransactionCount = accountTransactionCount - }; - } - - public async Task CanDeleteAccountAsync(int id) - { - var account = await _db.Accounts - .Include(a => a.Transactions) - .FirstOrDefaultAsync(a => a.Id == id); - - if (account == null) - return new DeleteValidationResult - { - CanDelete = false, - Reason = "Account not found." - }; - - if (account.Transactions.Any()) - return new DeleteValidationResult - { - CanDelete = false, - Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it." - }; - - return new DeleteValidationResult { CanDelete = true }; - } - - public async Task DeleteAccountAsync(int id) - { - var validation = await CanDeleteAccountAsync(id); - if (!validation.CanDelete) - { - return new DeleteResult - { - Success = false, - Message = validation.Reason ?? "Cannot delete account." - }; - } - - var account = await _db.Accounts.FindAsync(id); - if (account == null) - { - return new DeleteResult - { - Success = false, - Message = "Account not found." - }; - } - - _db.Accounts.Remove(account); - await _db.SaveChangesAsync(); - - return new DeleteResult - { - Success = true, - Message = $"Deleted account {account.Institution} {account.Last4}" - }; - } -} - -// DTOs -public class AccountWithStats -{ - public int Id { get; set; } - public string Institution { get; set; } = ""; - public AccountType AccountType { get; set; } - public string Last4 { get; set; } = ""; - public string Owner { get; set; } = ""; - public string? Nickname { get; set; } - public int TransactionCount { get; set; } -} - -public class AccountDetails -{ - public Account Account { get; set; } = null!; - public List Cards { get; set; } = new(); - public int TransactionCount { get; set; } -} - -public class CardWithStats -{ - public Card Card { get; set; } = null!; - public int TransactionCount { get; set; } -} - -public class DeleteValidationResult -{ - public bool CanDelete { get; set; } - public string? Reason { get; set; } -} - -public class DeleteResult -{ - public bool Success { get; set; } - public string Message { get; set; } = ""; -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for account management including retrieval, validation, and deletion. +/// +public interface IAccountService +{ + /// + /// Gets an account by ID with optional related data. + /// + Task GetAccountByIdAsync(int id, bool includeRelated = false); + + /// + /// Gets all accounts with optional statistics. + /// + Task> GetAllAccountsWithStatsAsync(); + + /// + /// Gets account details with cards and transaction count. + /// + Task GetAccountDetailsAsync(int id); + + /// + /// Checks if an account can be deleted (no transactions exist). + /// + Task CanDeleteAccountAsync(int id); + + /// + /// Deletes an account if it has no associated transactions. + /// + Task DeleteAccountAsync(int id); +} + +public class AccountService : IAccountService +{ + private readonly MoneyMapContext _db; + + public AccountService(MoneyMapContext db) + { + _db = db; + } + + public async Task GetAccountByIdAsync(int id, bool includeRelated = false) + { + var query = _db.Accounts.AsQueryable(); + + if (includeRelated) + { + query = query + .Include(a => a.Cards) + .Include(a => a.Transactions); + } + + return await query.FirstOrDefaultAsync(a => a.Id == id); + } + + public async Task> GetAllAccountsWithStatsAsync() + { + var accounts = await _db.Accounts + .Include(a => a.Transactions) + .OrderBy(a => a.Owner) + .ThenBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + + return accounts.Select(a => new AccountWithStats + { + Id = a.Id, + Institution = a.Institution, + AccountType = a.AccountType, + Last4 = a.Last4, + Owner = a.Owner, + Nickname = a.Nickname, + TransactionCount = a.Transactions.Count + }).ToList(); + } + + public async Task GetAccountDetailsAsync(int id) + { + var account = await _db.Accounts.FindAsync(id); + if (account == null) + return null; + + // Single query with projection to avoid N+1 + var cardStats = await _db.Cards + .Where(c => c.AccountId == id) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .Select(c => new CardWithStats + { + Card = c, + TransactionCount = c.Transactions.Count + }) + .ToListAsync(); + + // Get transaction count for this account + var accountTransactionCount = await _db.Transactions.CountAsync(t => t.AccountId == id); + + return new AccountDetails + { + Account = account, + Cards = cardStats, + TransactionCount = accountTransactionCount + }; + } + + public async Task CanDeleteAccountAsync(int id) + { + var account = await _db.Accounts + .Include(a => a.Transactions) + .FirstOrDefaultAsync(a => a.Id == id); + + if (account == null) + return new DeleteValidationResult + { + CanDelete = false, + Reason = "Account not found." + }; + + if (account.Transactions.Any()) + return new DeleteValidationResult + { + CanDelete = false, + Reason = $"Cannot delete account. It has {account.Transactions.Count} transaction(s) associated with it." + }; + + return new DeleteValidationResult { CanDelete = true }; + } + + public async Task DeleteAccountAsync(int id) + { + var validation = await CanDeleteAccountAsync(id); + if (!validation.CanDelete) + { + return new DeleteResult + { + Success = false, + Message = validation.Reason ?? "Cannot delete account." + }; + } + + var account = await _db.Accounts.FindAsync(id); + if (account == null) + { + return new DeleteResult + { + Success = false, + Message = "Account not found." + }; + } + + _db.Accounts.Remove(account); + await _db.SaveChangesAsync(); + + return new DeleteResult + { + Success = true, + Message = $"Deleted account {account.Institution} {account.Last4}" + }; + } +} + +// DTOs +public class AccountWithStats +{ + public int Id { get; set; } + public string Institution { get; set; } = ""; + public AccountType AccountType { get; set; } + public string Last4 { get; set; } = ""; + public string Owner { get; set; } = ""; + public string? Nickname { get; set; } + public int TransactionCount { get; set; } +} + +public class AccountDetails +{ + public Account Account { get; set; } = null!; + public List Cards { get; set; } = new(); + public int TransactionCount { get; set; } +} + +public class CardWithStats +{ + public Card Card { get; set; } = null!; + public int TransactionCount { get; set; } +} + +public class DeleteValidationResult +{ + public bool CanDelete { get; set; } + public string? Reason { get; set; } +} + +public class DeleteResult +{ + public bool Success { get; set; } + public string Message { get; set; } = ""; +} diff --git a/MoneyMap.Core/Services/BudgetService.cs b/MoneyMap.Core/Services/BudgetService.cs index 4be1324..77413c3 100644 --- a/MoneyMap.Core/Services/BudgetService.cs +++ b/MoneyMap.Core/Services/BudgetService.cs @@ -1,347 +1,347 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -public interface IBudgetService -{ - // CRUD operations - Task> GetAllBudgetsAsync(bool activeOnly = true); - Task GetBudgetByIdAsync(int id); - Task CreateBudgetAsync(Budget budget); - Task UpdateBudgetAsync(Budget budget); - Task DeleteBudgetAsync(int id); - - // Budget status calculations - Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null); - Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null); - - // Helper methods - Task> GetAvailableCategoriesAsync(); - (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate); -} - -public class BudgetService : IBudgetService -{ - private readonly MoneyMapContext _db; - - public BudgetService(MoneyMapContext db) - { - _db = db; - } - - #region CRUD Operations - - public async Task> GetAllBudgetsAsync(bool activeOnly = true) - { - var query = _db.Budgets.AsQueryable(); - - if (activeOnly) - query = query.Where(b => b.IsActive); - - return await query - .OrderBy(b => b.Category == null) // Total budget last - .ThenBy(b => b.Category) - .ThenBy(b => b.Period) - .ToListAsync(); - } - - public async Task GetBudgetByIdAsync(int id) - { - return await _db.Budgets.FindAsync(id); - } - - public async Task CreateBudgetAsync(Budget budget) - { - // Validate amount - if (budget.Amount <= 0) - { - return new BudgetOperationResult - { - Success = false, - Message = "Budget amount must be greater than zero." - }; - } - - // Check for duplicate active budget (same category + period) - var existing = await _db.Budgets - .Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period) - .FirstOrDefaultAsync(); - - if (existing != null) - { - var categoryName = budget.Category ?? "Total Spending"; - return new BudgetOperationResult - { - Success = false, - Message = $"An active {budget.Period} budget for '{categoryName}' already exists." - }; - } - - budget.IsActive = true; - _db.Budgets.Add(budget); - await _db.SaveChangesAsync(); - - return new BudgetOperationResult - { - Success = true, - Message = "Budget created successfully.", - BudgetId = budget.Id - }; - } - - public async Task UpdateBudgetAsync(Budget budget) - { - var existing = await _db.Budgets.FindAsync(budget.Id); - if (existing == null) - { - return new BudgetOperationResult - { - Success = false, - Message = "Budget not found." - }; - } - - // Validate amount - if (budget.Amount <= 0) - { - return new BudgetOperationResult - { - Success = false, - Message = "Budget amount must be greater than zero." - }; - } - - // Check for duplicate if category or period changed - if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period)) - { - var duplicate = await _db.Budgets - .Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period) - .FirstOrDefaultAsync(); - - if (duplicate != null) - { - var categoryName = budget.Category ?? "Total Spending"; - return new BudgetOperationResult - { - Success = false, - Message = $"An active {budget.Period} budget for '{categoryName}' already exists." - }; - } - } - - existing.Category = budget.Category; - existing.Amount = budget.Amount; - existing.Period = budget.Period; - existing.StartDate = budget.StartDate; - existing.IsActive = budget.IsActive; - existing.Notes = budget.Notes; - - await _db.SaveChangesAsync(); - - return new BudgetOperationResult - { - Success = true, - Message = "Budget updated successfully.", - BudgetId = existing.Id - }; - } - - public async Task DeleteBudgetAsync(int id) - { - var budget = await _db.Budgets.FindAsync(id); - if (budget == null) - { - return new BudgetOperationResult - { - Success = false, - Message = "Budget not found." - }; - } - - _db.Budgets.Remove(budget); - await _db.SaveChangesAsync(); - - return new BudgetOperationResult - { - Success = true, - Message = "Budget deleted successfully." - }; - } - - #endregion - - #region Budget Status Calculations - - public async Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null) - { - var date = asOfDate ?? DateTime.Today; - var budgets = await GetAllBudgetsAsync(activeOnly: true); - var statuses = new List(); - - foreach (var budget in budgets) - { - var status = await CalculateBudgetStatusAsync(budget, date); - statuses.Add(status); - } - - return statuses; - } - - public async Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null) - { - var budget = await GetBudgetByIdAsync(budgetId); - if (budget == null) - return null; - - var date = asOfDate ?? DateTime.Today; - return await CalculateBudgetStatusAsync(budget, date); - } - - private async Task CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate) - { - var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate); - - // Calculate spending for the period - var query = _db.Transactions - .Where(t => t.Date >= periodStart && t.Date <= periodEnd) - .Where(t => t.Amount < 0) // Only debits (spending) - .Where(t => t.TransferToAccountId == null); // Exclude transfers - - // For category-specific budgets, filter by category (case-insensitive) - if (budget.Category != null) - { - query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower()); - } - - var spent = await query.SumAsync(t => Math.Abs(t.Amount)); - var remaining = budget.Amount - spent; - var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0; - - return new BudgetStatus - { - Budget = budget, - PeriodStart = periodStart, - PeriodEnd = periodEnd, - Spent = spent, - Remaining = remaining, - PercentUsed = percentUsed, - IsOverBudget = spent > budget.Amount - }; - } - - public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate) - { - return period switch - { - BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate), - BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate), - BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate), - _ => throw new ArgumentOutOfRangeException(nameof(period)) - }; - } - - private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate) - { - // Find which week we're in relative to the start date - var daysSinceStart = (asOfDate - startDate.Date).Days; - - if (daysSinceStart < 0) - { - // Before start date - use the week containing start date - return (startDate.Date, startDate.Date.AddDays(6)); - } - - var weekNumber = daysSinceStart / 7; - var periodStart = startDate.Date.AddDays(weekNumber * 7); - var periodEnd = periodStart.AddDays(6); - - return (periodStart, periodEnd); - } - - private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate) - { - // Use the start date's day of month as the boundary - var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month)); - - DateTime periodStart; - if (asOfDate.Day >= dayOfMonth) - { - // We're in the current period - periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth); - } - else - { - // We're before this month's boundary, so use last month - var lastMonth = asOfDate.AddMonths(-1); - dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month)); - periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth); - } - - // End is the day before the next period starts - var nextPeriodStart = periodStart.AddMonths(1); - var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month)); - nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth); - var periodEnd = nextPeriodStart.AddDays(-1); - - return (periodStart, periodEnd); - } - - private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate) - { - // Find which year period we're in - var yearsSinceStart = asOfDate.Year - startDate.Year; - - // Check if we're before the anniversary this year - var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month, - Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month))); - - if (asOfDate < anniversaryThisYear) - yearsSinceStart--; - - var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart)); - var periodEnd = periodStart.AddYears(1).AddDays(-1); - - return (periodStart, periodEnd); - } - - #endregion - - #region Helper Methods - - public async Task> GetAvailableCategoriesAsync() - { - return await _db.Transactions - .Where(t => !string.IsNullOrEmpty(t.Category)) - .Select(t => t.Category) - .Distinct() - .OrderBy(c => c) - .ToListAsync(); - } - - #endregion -} - -// DTOs -public class BudgetOperationResult -{ - public bool Success { get; set; } - public string Message { get; set; } = ""; - public int? BudgetId { get; set; } -} - -public class BudgetStatus -{ - public Budget Budget { get; set; } = null!; - public DateTime PeriodStart { get; set; } - public DateTime PeriodEnd { get; set; } - public decimal Spent { get; set; } - public decimal Remaining { get; set; } - public decimal PercentUsed { get; set; } - public bool IsOverBudget { get; set; } - - // Helper for display - public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success"; - public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}"; -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +public interface IBudgetService +{ + // CRUD operations + Task> GetAllBudgetsAsync(bool activeOnly = true); + Task GetBudgetByIdAsync(int id); + Task CreateBudgetAsync(Budget budget); + Task UpdateBudgetAsync(Budget budget); + Task DeleteBudgetAsync(int id); + + // Budget status calculations + Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null); + Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null); + + // Helper methods + Task> GetAvailableCategoriesAsync(); + (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate); +} + +public class BudgetService : IBudgetService +{ + private readonly MoneyMapContext _db; + + public BudgetService(MoneyMapContext db) + { + _db = db; + } + + #region CRUD Operations + + public async Task> GetAllBudgetsAsync(bool activeOnly = true) + { + var query = _db.Budgets.AsQueryable(); + + if (activeOnly) + query = query.Where(b => b.IsActive); + + return await query + .OrderBy(b => b.Category == null) // Total budget last + .ThenBy(b => b.Category) + .ThenBy(b => b.Period) + .ToListAsync(); + } + + public async Task GetBudgetByIdAsync(int id) + { + return await _db.Budgets.FindAsync(id); + } + + public async Task CreateBudgetAsync(Budget budget) + { + // Validate amount + if (budget.Amount <= 0) + { + return new BudgetOperationResult + { + Success = false, + Message = "Budget amount must be greater than zero." + }; + } + + // Check for duplicate active budget (same category + period) + var existing = await _db.Budgets + .Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period) + .FirstOrDefaultAsync(); + + if (existing != null) + { + var categoryName = budget.Category ?? "Total Spending"; + return new BudgetOperationResult + { + Success = false, + Message = $"An active {budget.Period} budget for '{categoryName}' already exists." + }; + } + + budget.IsActive = true; + _db.Budgets.Add(budget); + await _db.SaveChangesAsync(); + + return new BudgetOperationResult + { + Success = true, + Message = "Budget created successfully.", + BudgetId = budget.Id + }; + } + + public async Task UpdateBudgetAsync(Budget budget) + { + var existing = await _db.Budgets.FindAsync(budget.Id); + if (existing == null) + { + return new BudgetOperationResult + { + Success = false, + Message = "Budget not found." + }; + } + + // Validate amount + if (budget.Amount <= 0) + { + return new BudgetOperationResult + { + Success = false, + Message = "Budget amount must be greater than zero." + }; + } + + // Check for duplicate if category or period changed + if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period)) + { + var duplicate = await _db.Budgets + .Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period) + .FirstOrDefaultAsync(); + + if (duplicate != null) + { + var categoryName = budget.Category ?? "Total Spending"; + return new BudgetOperationResult + { + Success = false, + Message = $"An active {budget.Period} budget for '{categoryName}' already exists." + }; + } + } + + existing.Category = budget.Category; + existing.Amount = budget.Amount; + existing.Period = budget.Period; + existing.StartDate = budget.StartDate; + existing.IsActive = budget.IsActive; + existing.Notes = budget.Notes; + + await _db.SaveChangesAsync(); + + return new BudgetOperationResult + { + Success = true, + Message = "Budget updated successfully.", + BudgetId = existing.Id + }; + } + + public async Task DeleteBudgetAsync(int id) + { + var budget = await _db.Budgets.FindAsync(id); + if (budget == null) + { + return new BudgetOperationResult + { + Success = false, + Message = "Budget not found." + }; + } + + _db.Budgets.Remove(budget); + await _db.SaveChangesAsync(); + + return new BudgetOperationResult + { + Success = true, + Message = "Budget deleted successfully." + }; + } + + #endregion + + #region Budget Status Calculations + + public async Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null) + { + var date = asOfDate ?? DateTime.Today; + var budgets = await GetAllBudgetsAsync(activeOnly: true); + var statuses = new List(); + + foreach (var budget in budgets) + { + var status = await CalculateBudgetStatusAsync(budget, date); + statuses.Add(status); + } + + return statuses; + } + + public async Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null) + { + var budget = await GetBudgetByIdAsync(budgetId); + if (budget == null) + return null; + + var date = asOfDate ?? DateTime.Today; + return await CalculateBudgetStatusAsync(budget, date); + } + + private async Task CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate) + { + var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate); + + // Calculate spending for the period + var query = _db.Transactions + .Where(t => t.Date >= periodStart && t.Date <= periodEnd) + .Where(t => t.Amount < 0) // Only debits (spending) + .Where(t => t.TransferToAccountId == null); // Exclude transfers + + // For category-specific budgets, filter by category (case-insensitive) + if (budget.Category != null) + { + query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower()); + } + + var spent = await query.SumAsync(t => Math.Abs(t.Amount)); + var remaining = budget.Amount - spent; + var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0; + + return new BudgetStatus + { + Budget = budget, + PeriodStart = periodStart, + PeriodEnd = periodEnd, + Spent = spent, + Remaining = remaining, + PercentUsed = percentUsed, + IsOverBudget = spent > budget.Amount + }; + } + + public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate) + { + return period switch + { + BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate), + BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate), + BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate), + _ => throw new ArgumentOutOfRangeException(nameof(period)) + }; + } + + private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate) + { + // Find which week we're in relative to the start date + var daysSinceStart = (asOfDate - startDate.Date).Days; + + if (daysSinceStart < 0) + { + // Before start date - use the week containing start date + return (startDate.Date, startDate.Date.AddDays(6)); + } + + var weekNumber = daysSinceStart / 7; + var periodStart = startDate.Date.AddDays(weekNumber * 7); + var periodEnd = periodStart.AddDays(6); + + return (periodStart, periodEnd); + } + + private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate) + { + // Use the start date's day of month as the boundary + var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month)); + + DateTime periodStart; + if (asOfDate.Day >= dayOfMonth) + { + // We're in the current period + periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth); + } + else + { + // We're before this month's boundary, so use last month + var lastMonth = asOfDate.AddMonths(-1); + dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month)); + periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth); + } + + // End is the day before the next period starts + var nextPeriodStart = periodStart.AddMonths(1); + var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month)); + nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth); + var periodEnd = nextPeriodStart.AddDays(-1); + + return (periodStart, periodEnd); + } + + private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate) + { + // Find which year period we're in + var yearsSinceStart = asOfDate.Year - startDate.Year; + + // Check if we're before the anniversary this year + var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month, + Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month))); + + if (asOfDate < anniversaryThisYear) + yearsSinceStart--; + + var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart)); + var periodEnd = periodStart.AddYears(1).AddDays(-1); + + return (periodStart, periodEnd); + } + + #endregion + + #region Helper Methods + + public async Task> GetAvailableCategoriesAsync() + { + return await _db.Transactions + .Where(t => !string.IsNullOrEmpty(t.Category)) + .Select(t => t.Category) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + } + + #endregion +} + +// DTOs +public class BudgetOperationResult +{ + public bool Success { get; set; } + public string Message { get; set; } = ""; + public int? BudgetId { get; set; } +} + +public class BudgetStatus +{ + public Budget Budget { get; set; } = null!; + public DateTime PeriodStart { get; set; } + public DateTime PeriodEnd { get; set; } + public decimal Spent { get; set; } + public decimal Remaining { get; set; } + public decimal PercentUsed { get; set; } + public bool IsOverBudget { get; set; } + + // Helper for display + public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success"; + public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}"; +} diff --git a/MoneyMap.Core/Services/CardResolver.cs b/MoneyMap.Core/Services/CardResolver.cs index d2ac36a..0771c2f 100644 --- a/MoneyMap.Core/Services/CardResolver.cs +++ b/MoneyMap.Core/Services/CardResolver.cs @@ -1,158 +1,158 @@ -using MoneyMap.Data; -using MoneyMap.Models.Import; -using System.Text.RegularExpressions; - -namespace MoneyMap.Services -{ - /// - /// Service for resolving payment methods (cards/accounts) for transactions. - /// - public interface ICardResolver - { - Task ResolvePaymentAsync(string? memo, ImportContext context); - } - - public class CardResolver : ICardResolver - { - private readonly MoneyMapContext _db; - - public CardResolver(MoneyMapContext db) - { - _db = db; - } - - public async Task ResolvePaymentAsync(string? memo, ImportContext context) - { - if (context.PaymentMode == PaymentSelectMode.Card) - return ResolveCard(context); - - if (context.PaymentMode == PaymentSelectMode.Account) - return ResolveAccount(context); - - return await ResolveAutomaticallyAsync(memo, context); - } - - private PaymentResolutionResult ResolveCard(ImportContext context) - { - if (context.SelectedCardId is null) - return PaymentResolutionResult.Failure("Pick a card or switch to Auto."); - - var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId); - if (card is null) - return PaymentResolutionResult.Failure("Selected card not found."); - - // Card must have a linked account - if (!card.AccountId.HasValue) - return PaymentResolutionResult.Failure($"Card {card.DisplayLabel} is not linked to an account. Please link it to an account first."); - - return PaymentResolutionResult.SuccessCard(card.Id, card.AccountId.Value, card.Last4); - } - - private PaymentResolutionResult ResolveAccount(ImportContext context) - { - if (context.SelectedAccountId is null) - return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode."); - - var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId); - if (account is null) - return PaymentResolutionResult.Failure("Selected account not found."); - - return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4); - } - - private Task ResolveAutomaticallyAsync(string? memo, ImportContext context) - { - // Extract last4 from both memo and filename - var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName); - var last4FromMemo = CardIdentifierExtractor.FromMemo(memo); - - // PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765") - if (!string.IsNullOrWhiteSpace(last4FromMemo)) - { - var result = TryResolveByLast4(last4FromMemo, context); - if (result != null) return Task.FromResult(result); - } - - // PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card) - if (!string.IsNullOrWhiteSpace(last4FromFile)) - { - var result = TryResolveByLast4(last4FromFile, context); - if (result != null) return Task.FromResult(result); - } - - // Nothing found - error - var searchedLast4 = last4FromMemo ?? last4FromFile; - if (string.IsNullOrWhiteSpace(searchedLast4)) - { - return Task.FromResult(PaymentResolutionResult.Failure( - "Couldn't determine card or account from memo or file name. Choose an account manually.")); - } - - return Task.FromResult(PaymentResolutionResult.Failure( - $"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually.")); - } - - private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context) - { - // Look for both card and account matches - var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4); - var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4); - - // Prioritize card matches (for credit card CSVs or memo-based card detection) - if (matchingCard != null) - { - // Card found - it must have an account - if (!matchingCard.AccountId.HasValue) - return PaymentResolutionResult.Failure($"Card {matchingCard.DisplayLabel} is not linked to an account. Please link it first or choose an account manually."); - - return PaymentResolutionResult.SuccessCard(matchingCard.Id, matchingCard.AccountId.Value, matchingCard.Last4); - } - - // Fall back to account match (for direct account transactions) - if (matchingAccount != null) - { - return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4); - } - - return null; // No match found - } - } - - /// - /// Utility class for extracting card/account identifiers from memos and filenames. - /// - public static class CardIdentifierExtractor - { - // Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats - private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled); - private const int Last4Length = 4; - - public static string? FromMemo(string? memo) - { - if (string.IsNullOrWhiteSpace(memo)) - return null; - - // Try to find all matches and return the last one (most likely to be card/account number) - var matches = MemoLast4Pattern.Matches(memo); - if (matches.Count == 0) - return null; - - // Return the last match (typically the card number at the end) - return matches[^1].Groups[1].Value; - } - - public static string? FromFileName(string fileName) - { - var name = Path.GetFileNameWithoutExtension(fileName); - var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var part in parts.Select(p => p.Trim())) - { - if (part.Length == Last4Length && int.TryParse(part, out _)) - return part; - } - - return null; - } - } -} +using MoneyMap.Data; +using MoneyMap.Models.Import; +using System.Text.RegularExpressions; + +namespace MoneyMap.Services +{ + /// + /// Service for resolving payment methods (cards/accounts) for transactions. + /// + public interface ICardResolver + { + Task ResolvePaymentAsync(string? memo, ImportContext context); + } + + public class CardResolver : ICardResolver + { + private readonly MoneyMapContext _db; + + public CardResolver(MoneyMapContext db) + { + _db = db; + } + + public async Task ResolvePaymentAsync(string? memo, ImportContext context) + { + if (context.PaymentMode == PaymentSelectMode.Card) + return ResolveCard(context); + + if (context.PaymentMode == PaymentSelectMode.Account) + return ResolveAccount(context); + + return await ResolveAutomaticallyAsync(memo, context); + } + + private PaymentResolutionResult ResolveCard(ImportContext context) + { + if (context.SelectedCardId is null) + return PaymentResolutionResult.Failure("Pick a card or switch to Auto."); + + var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId); + if (card is null) + return PaymentResolutionResult.Failure("Selected card not found."); + + // Card must have a linked account + if (!card.AccountId.HasValue) + return PaymentResolutionResult.Failure($"Card {card.DisplayLabel} is not linked to an account. Please link it to an account first."); + + return PaymentResolutionResult.SuccessCard(card.Id, card.AccountId.Value, card.Last4); + } + + private PaymentResolutionResult ResolveAccount(ImportContext context) + { + if (context.SelectedAccountId is null) + return PaymentResolutionResult.Failure("Pick an account or switch to Auto/Card mode."); + + var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == context.SelectedAccountId); + if (account is null) + return PaymentResolutionResult.Failure("Selected account not found."); + + return PaymentResolutionResult.SuccessAccount(account.Id, account.Last4); + } + + private Task ResolveAutomaticallyAsync(string? memo, ImportContext context) + { + // Extract last4 from both memo and filename + var last4FromFile = CardIdentifierExtractor.FromFileName(context.FileName); + var last4FromMemo = CardIdentifierExtractor.FromMemo(memo); + + // PRIORITY 1: Try memo first (for per-transaction card detection like "usbank.com.2765") + if (!string.IsNullOrWhiteSpace(last4FromMemo)) + { + var result = TryResolveByLast4(last4FromMemo, context); + if (result != null) return Task.FromResult(result); + } + + // PRIORITY 2: Fall back to filename (for account-level CSVs or when memo has no card) + if (!string.IsNullOrWhiteSpace(last4FromFile)) + { + var result = TryResolveByLast4(last4FromFile, context); + if (result != null) return Task.FromResult(result); + } + + // Nothing found - error + var searchedLast4 = last4FromMemo ?? last4FromFile; + if (string.IsNullOrWhiteSpace(searchedLast4)) + { + return Task.FromResult(PaymentResolutionResult.Failure( + "Couldn't determine card or account from memo or file name. Choose an account manually.")); + } + + return Task.FromResult(PaymentResolutionResult.Failure( + $"Couldn't find account or card with last4 '{searchedLast4}'. Choose an account manually.")); + } + + private PaymentResolutionResult? TryResolveByLast4(string last4, ImportContext context) + { + // Look for both card and account matches + var matchingCard = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4); + var matchingAccount = context.AvailableAccounts.FirstOrDefault(a => a.Last4 == last4); + + // Prioritize card matches (for credit card CSVs or memo-based card detection) + if (matchingCard != null) + { + // Card found - it must have an account + if (!matchingCard.AccountId.HasValue) + return PaymentResolutionResult.Failure($"Card {matchingCard.DisplayLabel} is not linked to an account. Please link it first or choose an account manually."); + + return PaymentResolutionResult.SuccessCard(matchingCard.Id, matchingCard.AccountId.Value, matchingCard.Last4); + } + + // Fall back to account match (for direct account transactions) + if (matchingAccount != null) + { + return PaymentResolutionResult.SuccessAccount(matchingAccount.Id, matchingAccount.Last4); + } + + return null; // No match found + } + } + + /// + /// Utility class for extracting card/account identifiers from memos and filenames. + /// + public static class CardIdentifierExtractor + { + // Match patterns: "usbank.com.2765" or "usbank.com.0479" or similar formats + private static readonly Regex MemoLast4Pattern = new(@"\.(\d{4})(?:\D|$)", RegexOptions.Compiled); + private const int Last4Length = 4; + + public static string? FromMemo(string? memo) + { + if (string.IsNullOrWhiteSpace(memo)) + return null; + + // Try to find all matches and return the last one (most likely to be card/account number) + var matches = MemoLast4Pattern.Matches(memo); + if (matches.Count == 0) + return null; + + // Return the last match (typically the card number at the end) + return matches[^1].Groups[1].Value; + } + + public static string? FromFileName(string fileName) + { + var name = Path.GetFileNameWithoutExtension(fileName); + var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts.Select(p => p.Trim())) + { + if (part.Length == Last4Length && int.TryParse(part, out _)) + return part; + } + + return null; + } + } +} diff --git a/MoneyMap.Core/Services/CardService.cs b/MoneyMap.Core/Services/CardService.cs index 76eea26..d427945 100644 --- a/MoneyMap.Core/Services/CardService.cs +++ b/MoneyMap.Core/Services/CardService.cs @@ -1,123 +1,123 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for card management including retrieval, validation, and deletion. -/// -public interface ICardService -{ - /// - /// Gets a card by ID with optional related data. - /// - Task GetCardByIdAsync(int id, bool includeRelated = false); - - /// - /// Gets all cards with transaction statistics. - /// - Task> GetAllCardsWithStatsAsync(); - - /// - /// Checks if a card can be deleted (no transactions exist). - /// - Task CanDeleteCardAsync(int id); - - /// - /// Deletes a card if it has no associated transactions. - /// - Task DeleteCardAsync(int id); -} - -public class CardService : ICardService -{ - private readonly MoneyMapContext _db; - - public CardService(MoneyMapContext db) - { - _db = db; - } - - public async Task GetCardByIdAsync(int id, bool includeRelated = false) - { - var query = _db.Cards.AsQueryable(); - - if (includeRelated) - { - query = query - .Include(c => c.Account) - .Include(c => c.Transactions); - } - - return await query.FirstOrDefaultAsync(c => c.Id == id); - } - - public async Task> GetAllCardsWithStatsAsync() - { - // Single query with projection to avoid N+1 - return await _db.Cards - .Include(c => c.Account) - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .Select(c => new CardWithStats - { - Card = c, - TransactionCount = c.Transactions.Count - }) - .ToListAsync(); - } - - public async Task CanDeleteCardAsync(int id) - { - var card = await _db.Cards.FindAsync(id); - if (card == null) - return new DeleteValidationResult - { - CanDelete = false, - Reason = "Card not found." - }; - - var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id); - if (transactionCount > 0) - return new DeleteValidationResult - { - CanDelete = false, - Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it." - }; - - return new DeleteValidationResult { CanDelete = true }; - } - - public async Task DeleteCardAsync(int id) - { - var validation = await CanDeleteCardAsync(id); - if (!validation.CanDelete) - { - return new DeleteResult - { - Success = false, - Message = validation.Reason ?? "Cannot delete card." - }; - } - - var card = await _db.Cards.FindAsync(id); - if (card == null) - { - return new DeleteResult - { - Success = false, - Message = "Card not found." - }; - } - - _db.Cards.Remove(card); - await _db.SaveChangesAsync(); - - return new DeleteResult - { - Success = true, - Message = "Card deleted successfully." - }; - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for card management including retrieval, validation, and deletion. +/// +public interface ICardService +{ + /// + /// Gets a card by ID with optional related data. + /// + Task GetCardByIdAsync(int id, bool includeRelated = false); + + /// + /// Gets all cards with transaction statistics. + /// + Task> GetAllCardsWithStatsAsync(); + + /// + /// Checks if a card can be deleted (no transactions exist). + /// + Task CanDeleteCardAsync(int id); + + /// + /// Deletes a card if it has no associated transactions. + /// + Task DeleteCardAsync(int id); +} + +public class CardService : ICardService +{ + private readonly MoneyMapContext _db; + + public CardService(MoneyMapContext db) + { + _db = db; + } + + public async Task GetCardByIdAsync(int id, bool includeRelated = false) + { + var query = _db.Cards.AsQueryable(); + + if (includeRelated) + { + query = query + .Include(c => c.Account) + .Include(c => c.Transactions); + } + + return await query.FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task> GetAllCardsWithStatsAsync() + { + // Single query with projection to avoid N+1 + return await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .Select(c => new CardWithStats + { + Card = c, + TransactionCount = c.Transactions.Count + }) + .ToListAsync(); + } + + public async Task CanDeleteCardAsync(int id) + { + var card = await _db.Cards.FindAsync(id); + if (card == null) + return new DeleteValidationResult + { + CanDelete = false, + Reason = "Card not found." + }; + + var transactionCount = await _db.Transactions.CountAsync(t => t.CardId == id); + if (transactionCount > 0) + return new DeleteValidationResult + { + CanDelete = false, + Reason = $"Cannot delete card. It has {transactionCount} transaction(s) associated with it." + }; + + return new DeleteValidationResult { CanDelete = true }; + } + + public async Task DeleteCardAsync(int id) + { + var validation = await CanDeleteCardAsync(id); + if (!validation.CanDelete) + { + return new DeleteResult + { + Success = false, + Message = validation.Reason ?? "Cannot delete card." + }; + } + + var card = await _db.Cards.FindAsync(id); + if (card == null) + { + return new DeleteResult + { + Success = false, + Message = "Card not found." + }; + } + + _db.Cards.Remove(card); + await _db.SaveChangesAsync(); + + return new DeleteResult + { + Success = true, + Message = "Card deleted successfully." + }; + } +} diff --git a/MoneyMap.Core/Services/DashboardService.cs b/MoneyMap.Core/Services/DashboardService.cs index 268ed08..387d026 100644 --- a/MoneyMap.Core/Services/DashboardService.cs +++ b/MoneyMap.Core/Services/DashboardService.cs @@ -1,268 +1,268 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models.Dashboard; - -namespace MoneyMap.Services -{ - /// - /// Service for retrieving dashboard data. - /// - public interface IDashboardService - { - Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20); - } - - public class DashboardService : IDashboardService - { - private readonly IDashboardStatsCalculator _statsCalculator; - private readonly ITopCategoriesProvider _topCategoriesProvider; - private readonly IRecentTransactionsProvider _recentTransactionsProvider; - private readonly ISpendTrendsProvider _spendTrendsProvider; - - public DashboardService( - IDashboardStatsCalculator statsCalculator, - ITopCategoriesProvider topCategoriesProvider, - IRecentTransactionsProvider recentTransactionsProvider, - ISpendTrendsProvider spendTrendsProvider) - { - _statsCalculator = statsCalculator; - _topCategoriesProvider = topCategoriesProvider; - _recentTransactionsProvider = recentTransactionsProvider; - _spendTrendsProvider = spendTrendsProvider; - } - - public async Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20) - { - var stats = await _statsCalculator.CalculateAsync(); - var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount); - var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount); - var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30); - - return new DashboardData - { - Stats = stats, - TopCategories = topCategories, - RecentTransactions = recent, - Trends = trends - }; - } - } - - /// - /// Calculates dashboard statistics. - /// - public interface IDashboardStatsCalculator - { - Task CalculateAsync(); - } - - public class DashboardStatsCalculator : IDashboardStatsCalculator - { - private readonly MoneyMapContext _db; - - public DashboardStatsCalculator(MoneyMapContext db) - { - _db = db; - } - - public async Task CalculateAsync() - { - var transactionStats = await GetTransactionStatsAsync(); - var receiptsCount = await _db.Receipts.CountAsync(); - var cardsCount = await _db.Cards.CountAsync(); - - return new DashboardStats( - transactionStats.Total, - transactionStats.Credits, - transactionStats.Debits, - transactionStats.Uncategorized, - receiptsCount, - cardsCount - ); - } - - private async Task GetTransactionStatsAsync() - { - var stats = await _db.Transactions - .GroupBy(_ => 1) - .Select(g => new TransactionStatsInternal - { - Total = g.Count(), - Credits = g.Count(t => t.Amount > 0), - Debits = g.Count(t => t.Amount < 0), - Uncategorized = g.Count(t => t.Category == null || t.Category == "") - }) - .FirstOrDefaultAsync(); - - return stats ?? new TransactionStatsInternal(); - } - - private class TransactionStatsInternal - { - public int Total { get; set; } - public int Credits { get; set; } - public int Debits { get; set; } - public int Uncategorized { get; set; } - } - } - - /// - /// Provides top spending categories. - /// - public interface ITopCategoriesProvider - { - Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90); - } - - public class TopCategoriesProvider : ITopCategoriesProvider - { - private readonly MoneyMapContext _db; - - public TopCategoriesProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90) - { - var since = DateTime.UtcNow.Date.AddDays(-lastDays); - - var expenseTransactions = await _db.Transactions - .Where(t => t.Date >= since && t.Amount < 0) - .ExcludeTransfers() - .ToListAsync(); - - var totalSpend = expenseTransactions.Sum(t => -t.Amount); - - var topCategories = expenseTransactions - .GroupBy(t => t.Category ?? "") - .Select(g => new TopCategoryRow - { - Category = g.Key, - TotalSpend = g.Sum(x => -x.Amount), - Count = g.Count(), - PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0, - AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0 - }) - .OrderByDescending(x => x.TotalSpend) - .Take(count) - .ToList(); - - return topCategories; - } - } - - /// - /// Provides recent transactions. - /// - public interface IRecentTransactionsProvider - { - Task> GetRecentTransactionsAsync(int count = 20); - } - - public class RecentTransactionsProvider : IRecentTransactionsProvider - { - private readonly MoneyMapContext _db; - - public RecentTransactionsProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task> GetRecentTransactionsAsync(int count = 20) - { - return await _db.Transactions - .Include(t => t.Card) - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .Select(t => new RecentTransactionRow - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Memo = t.Memo, - Amount = t.Amount, - Category = t.Category ?? "", - CardLabel = t.PaymentMethodLabel, - ReceiptCount = t.Receipts.Count() - }) - .Take(count) - .AsNoTracking() - .ToListAsync(); - } - } - - /// - /// Provides spending trends over time. - /// - public interface ISpendTrendsProvider - { - Task GetDailyTrendsAsync(int lastDays = 30); - } - - public class SpendTrendsProvider : ISpendTrendsProvider - { - private readonly MoneyMapContext _db; - - public SpendTrendsProvider(MoneyMapContext db) - { - _db = db; - } - - public async Task GetDailyTrendsAsync(int lastDays = 30) - { - var today = DateTime.UtcNow.Date; - var since = today.AddDays(-(lastDays - 1)); - - var raw = await _db.Transactions - .Where(t => t.Date >= since) - .ExcludeTransfers() - .GroupBy(t => t.Date.Date) - .Select(g => new - { - Date = g.Key, - Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount), - Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) - }) - .ToListAsync(); - - var dict = raw.ToDictionary(x => x.Date, x => x); - var labels = new List(); - var debitsAbs = new List(); - var credits = new List(); - var net = new List(); - var runningBalance = new List(); - decimal cumulative = 0; - - for (var d = since; d <= today; d = d.AddDays(1)) - { - labels.Add(d.ToString("MMM d")); - if (dict.TryGetValue(d, out var v)) - { - var debit = v.Debits; - var credit = v.Credits; - debitsAbs.Add(Math.Abs(debit)); - credits.Add(credit); - net.Add(credit + debit); - cumulative += credit + debit; - } - else - { - debitsAbs.Add(0); - credits.Add(0); - net.Add(0); - } - runningBalance.Add(cumulative); - } - - return new SpendTrends - { - Labels = labels, - DebitsAbs = debitsAbs, - Credits = credits, - Net = net, - RunningBalance = runningBalance - }; - } - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models.Dashboard; + +namespace MoneyMap.Services +{ + /// + /// Service for retrieving dashboard data. + /// + public interface IDashboardService + { + Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20); + } + + public class DashboardService : IDashboardService + { + private readonly IDashboardStatsCalculator _statsCalculator; + private readonly ITopCategoriesProvider _topCategoriesProvider; + private readonly IRecentTransactionsProvider _recentTransactionsProvider; + private readonly ISpendTrendsProvider _spendTrendsProvider; + + public DashboardService( + IDashboardStatsCalculator statsCalculator, + ITopCategoriesProvider topCategoriesProvider, + IRecentTransactionsProvider recentTransactionsProvider, + ISpendTrendsProvider spendTrendsProvider) + { + _statsCalculator = statsCalculator; + _topCategoriesProvider = topCategoriesProvider; + _recentTransactionsProvider = recentTransactionsProvider; + _spendTrendsProvider = spendTrendsProvider; + } + + public async Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20) + { + var stats = await _statsCalculator.CalculateAsync(); + var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount); + var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount); + var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30); + + return new DashboardData + { + Stats = stats, + TopCategories = topCategories, + RecentTransactions = recent, + Trends = trends + }; + } + } + + /// + /// Calculates dashboard statistics. + /// + public interface IDashboardStatsCalculator + { + Task CalculateAsync(); + } + + public class DashboardStatsCalculator : IDashboardStatsCalculator + { + private readonly MoneyMapContext _db; + + public DashboardStatsCalculator(MoneyMapContext db) + { + _db = db; + } + + public async Task CalculateAsync() + { + var transactionStats = await GetTransactionStatsAsync(); + var receiptsCount = await _db.Receipts.CountAsync(); + var cardsCount = await _db.Cards.CountAsync(); + + return new DashboardStats( + transactionStats.Total, + transactionStats.Credits, + transactionStats.Debits, + transactionStats.Uncategorized, + receiptsCount, + cardsCount + ); + } + + private async Task GetTransactionStatsAsync() + { + var stats = await _db.Transactions + .GroupBy(_ => 1) + .Select(g => new TransactionStatsInternal + { + Total = g.Count(), + Credits = g.Count(t => t.Amount > 0), + Debits = g.Count(t => t.Amount < 0), + Uncategorized = g.Count(t => t.Category == null || t.Category == "") + }) + .FirstOrDefaultAsync(); + + return stats ?? new TransactionStatsInternal(); + } + + private class TransactionStatsInternal + { + public int Total { get; set; } + public int Credits { get; set; } + public int Debits { get; set; } + public int Uncategorized { get; set; } + } + } + + /// + /// Provides top spending categories. + /// + public interface ITopCategoriesProvider + { + Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90); + } + + public class TopCategoriesProvider : ITopCategoriesProvider + { + private readonly MoneyMapContext _db; + + public TopCategoriesProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90) + { + var since = DateTime.UtcNow.Date.AddDays(-lastDays); + + var expenseTransactions = await _db.Transactions + .Where(t => t.Date >= since && t.Amount < 0) + .ExcludeTransfers() + .ToListAsync(); + + var totalSpend = expenseTransactions.Sum(t => -t.Amount); + + var topCategories = expenseTransactions + .GroupBy(t => t.Category ?? "") + .Select(g => new TopCategoryRow + { + Category = g.Key, + TotalSpend = g.Sum(x => -x.Amount), + Count = g.Count(), + PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0, + AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0 + }) + .OrderByDescending(x => x.TotalSpend) + .Take(count) + .ToList(); + + return topCategories; + } + } + + /// + /// Provides recent transactions. + /// + public interface IRecentTransactionsProvider + { + Task> GetRecentTransactionsAsync(int count = 20); + } + + public class RecentTransactionsProvider : IRecentTransactionsProvider + { + private readonly MoneyMapContext _db; + + public RecentTransactionsProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task> GetRecentTransactionsAsync(int count = 20) + { + return await _db.Transactions + .Include(t => t.Card) + .OrderByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .Select(t => new RecentTransactionRow + { + Id = t.Id, + Date = t.Date, + Name = t.Name, + Memo = t.Memo, + Amount = t.Amount, + Category = t.Category ?? "", + CardLabel = t.PaymentMethodLabel, + ReceiptCount = t.Receipts.Count() + }) + .Take(count) + .AsNoTracking() + .ToListAsync(); + } + } + + /// + /// Provides spending trends over time. + /// + public interface ISpendTrendsProvider + { + Task GetDailyTrendsAsync(int lastDays = 30); + } + + public class SpendTrendsProvider : ISpendTrendsProvider + { + private readonly MoneyMapContext _db; + + public SpendTrendsProvider(MoneyMapContext db) + { + _db = db; + } + + public async Task GetDailyTrendsAsync(int lastDays = 30) + { + var today = DateTime.UtcNow.Date; + var since = today.AddDays(-(lastDays - 1)); + + var raw = await _db.Transactions + .Where(t => t.Date >= since) + .ExcludeTransfers() + .GroupBy(t => t.Date.Date) + .Select(g => new + { + Date = g.Key, + Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount), + Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) + }) + .ToListAsync(); + + var dict = raw.ToDictionary(x => x.Date, x => x); + var labels = new List(); + var debitsAbs = new List(); + var credits = new List(); + var net = new List(); + var runningBalance = new List(); + decimal cumulative = 0; + + for (var d = since; d <= today; d = d.AddDays(1)) + { + labels.Add(d.ToString("MMM d")); + if (dict.TryGetValue(d, out var v)) + { + var debit = v.Debits; + var credit = v.Credits; + debitsAbs.Add(Math.Abs(debit)); + credits.Add(credit); + net.Add(credit + debit); + cumulative += credit + debit; + } + else + { + debitsAbs.Add(0); + credits.Add(0); + net.Add(0); + } + runningBalance.Add(cumulative); + } + + return new SpendTrends + { + Labels = labels, + DebitsAbs = debitsAbs, + Credits = credits, + Net = net, + RunningBalance = runningBalance + }; + } + } +} diff --git a/MoneyMap.Core/Services/FinancialAuditService.cs b/MoneyMap.Core/Services/FinancialAuditService.cs index 2cfa2f8..3ee4d98 100644 --- a/MoneyMap.Core/Services/FinancialAuditService.cs +++ b/MoneyMap.Core/Services/FinancialAuditService.cs @@ -1,491 +1,491 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using MoneyMap.Models.Api; - -namespace MoneyMap.Services; - -public interface IFinancialAuditService -{ - Task GenerateAuditAsync( - DateTime startDate, - DateTime endDate, - bool includeTransactions = false); -} - -public class FinancialAuditService : IFinancialAuditService -{ - private readonly MoneyMapContext _db; - private readonly IBudgetService _budgetService; - - public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService) - { - _db = db; - _budgetService = budgetService; - } - - public async Task GenerateAuditAsync( - DateTime startDate, - DateTime endDate, - bool includeTransactions = false) - { - var response = new FinancialAuditResponse - { - GeneratedAt = DateTime.UtcNow, - PeriodStart = startDate.Date, - PeriodEnd = endDate.Date - }; - - // Base query for the period - var periodTransactions = _db.Transactions - .Include(t => t.Account) - .Include(t => t.Card) - .Include(t => t.Merchant) - .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) - .AsNoTracking(); - - // Calculate all sections in parallel where possible - response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate); - response.Budgets = await GetBudgetStatusesAsync(); - response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets); - response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions); - response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate); - response.Accounts = await GetAccountSummariesAsync(periodTransactions); - response.Flags = GenerateAuditFlags(response); - - if (includeTransactions) - { - response.Transactions = await GetTransactionListAsync(periodTransactions); - } - - return response; - } - - private async Task CalculateSummaryAsync( - IQueryable transactions, - DateTime startDate, - DateTime endDate) - { - // Exclude transfers for spending calculations - var nonTransferTxns = transactions.ExcludeTransfers(); - - var stats = await nonTransferTxns - .GroupBy(_ => 1) - .Select(g => new - { - TotalCount = g.Count(), - TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount), - TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), - UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)), - UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0) - .Sum(t => Math.Abs(t.Amount)) - }) - .FirstOrDefaultAsync(); - - var daysInPeriod = (endDate.Date - startDate.Date).Days + 1; - - return new AuditSummary - { - TotalTransactions = stats?.TotalCount ?? 0, - TotalIncome = stats?.TotalIncome ?? 0, - TotalExpenses = stats?.TotalExpenses ?? 0, - NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0), - DaysInPeriod = daysInPeriod, - AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0, - UncategorizedTransactions = stats?.UncategorizedCount ?? 0, - UncategorizedAmount = stats?.UncategorizedAmount ?? 0 - }; - } - - private async Task> GetBudgetStatusesAsync() - { - var statuses = await _budgetService.GetAllBudgetStatusesAsync(); - - return statuses.Select(s => new BudgetStatusDto - { - BudgetId = s.Budget.Id, - Category = s.Budget.DisplayName, - Period = s.Budget.Period.ToString(), - Limit = s.Budget.Amount, - Spent = s.Spent, - Remaining = s.Remaining, - PercentUsed = s.PercentUsed, - IsOverBudget = s.IsOverBudget, - PeriodRange = s.PeriodDisplay - }).ToList(); - } - - private async Task> GetCategorySpendingAsync( - IQueryable transactions, - List budgets) - { - var categorySpending = await transactions - .ExcludeTransfers() - .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) - .GroupBy(t => t.Category) - .Select(g => new - { - Category = g.Key, - TotalSpent = g.Sum(t => Math.Abs(t.Amount)), - Count = g.Count() - }) - .OrderByDescending(x => x.TotalSpent) - .ToListAsync(); - - var totalSpending = categorySpending.Sum(c => c.TotalSpent); - - // Create a lookup for budget data by category - var budgetLookup = budgets - .Where(b => b.Category != "Total Spending") - .ToDictionary(b => b.Category.ToLowerInvariant(), b => b); - - return categorySpending.Select(c => - { - var dto = new CategorySpendingDto - { - Category = c.Category ?? "Uncategorized", - TotalSpent = c.TotalSpent, - TransactionCount = c.Count, - PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0, - AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0 - }; - - // Add budget correlation if available - if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget)) - { - dto.BudgetLimit = budget.Limit; - dto.BudgetRemaining = budget.Remaining; - dto.IsOverBudget = budget.IsOverBudget; - } - - return dto; - }).ToList(); - } - - private async Task> GetMerchantSpendingAsync( - IQueryable transactions) - { - var merchantSpending = await transactions - .ExcludeTransfers() - .Where(t => t.Amount < 0 && t.MerchantId != null) - .GroupBy(t => new { t.MerchantId, t.Merchant!.Name }) - .Select(g => new - { - MerchantName = g.Key.Name, - Category = g.Max(t => t.Category), - TotalSpent = g.Sum(t => Math.Abs(t.Amount)), - Count = g.Count(), - FirstDate = g.Min(t => t.Date), - LastDate = g.Max(t => t.Date) - }) - .OrderByDescending(x => x.TotalSpent) - .Take(20) - .ToListAsync(); - - return merchantSpending.Select(m => new MerchantSpendingDto - { - MerchantName = m.MerchantName, - Category = m.Category, - TotalSpent = m.TotalSpent, - TransactionCount = m.Count, - AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0, - FirstTransaction = m.FirstDate, - LastTransaction = m.LastDate - }).ToList(); - } - - private async Task> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate) - { - var monthlyData = await _db.Transactions - .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) - .ExcludeTransfers() - .GroupBy(t => new { t.Date.Year, t.Date.Month }) - .Select(g => new - { - g.Key.Year, - g.Key.Month, - Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount), - Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), - Count = g.Count() - }) - .OrderBy(x => x.Year) - .ThenBy(x => x.Month) - .ToListAsync(); - - // Get top categories per month - var categoryByMonth = await _db.Transactions - .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) - .ExcludeTransfers() - .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) - .GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category }) - .Select(g => new - { - g.Key.Year, - g.Key.Month, - g.Key.Category, - Total = g.Sum(t => Math.Abs(t.Amount)) - }) - .ToListAsync(); - - return monthlyData.Select(m => - { - var topCategories = categoryByMonth - .Where(c => c.Year == m.Year && c.Month == m.Month) - .OrderByDescending(c => c.Total) - .Take(5) - .ToDictionary(c => c.Category ?? "Other", c => c.Total); - - return new MonthlyTrendDto - { - Month = $"{m.Year}-{m.Month:D2}", - Year = m.Year, - Income = m.Income, - Expenses = m.Expenses, - NetCashFlow = m.Income - m.Expenses, - TransactionCount = m.Count, - TopCategories = topCategories - }; - }).ToList(); - } - - private async Task> GetAccountSummariesAsync( - IQueryable transactions) - { - // Use only mapped columns in the GroupBy, compute DisplayLabel in memory - var accountStats = await transactions - .GroupBy(t => new { - t.AccountId, - t.Account.Institution, - t.Account.Last4, - t.Account.Nickname, - t.Account.AccountType - }) - .Select(g => new - { - g.Key.AccountId, - g.Key.Institution, - g.Key.Last4, - g.Key.Nickname, - g.Key.AccountType, - Count = g.Count(), - Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), - Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) - }) - .ToListAsync(); - - return accountStats.Select(a => new AccountSummaryDto - { - AccountId = a.AccountId, - AccountName = string.IsNullOrEmpty(a.Nickname) - ? $"{a.Institution} {a.Last4} ({a.AccountType})" - : $"{a.Nickname} ({a.Institution} {a.Last4})", - Institution = a.Institution, - AccountType = a.AccountType.ToString(), - TransactionCount = a.Count, - TotalDebits = a.Debits, - TotalCredits = a.Credits, - NetFlow = a.Credits - a.Debits - }).ToList(); - } - - private async Task> GetTransactionListAsync( - IQueryable transactions) - { - // Fetch raw data without computed properties - var rawTxns = await transactions - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .Select(t => new - { - t.Id, - t.Date, - t.Name, - t.Memo, - t.Amount, - t.Category, - MerchantName = t.Merchant != null ? t.Merchant.Name : null, - AccountInstitution = t.Account.Institution, - AccountLast4 = t.Account.Last4, - AccountNickname = t.Account.Nickname, - AccountType = t.Account.AccountType, - CardIssuer = t.Card != null ? t.Card.Issuer : null, - CardLast4 = t.Card != null ? t.Card.Last4 : null, - CardNickname = t.Card != null ? t.Card.Nickname : null, - IsTransfer = t.TransferToAccountId != null - }) - .ToListAsync(); - - // Map to DTOs with computed labels - return rawTxns.Select(t => new TransactionDto - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Memo = t.Memo, - Amount = t.Amount, - Category = t.Category, - MerchantName = t.MerchantName, - AccountName = string.IsNullOrEmpty(t.AccountNickname) - ? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})" - : $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})", - CardLabel = t.CardIssuer != null - ? (string.IsNullOrEmpty(t.CardNickname) - ? $"{t.CardIssuer} {t.CardLast4}" - : $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})") - : null, - IsTransfer = t.IsTransfer - }).ToList(); - } - - private List GenerateAuditFlags(FinancialAuditResponse response) - { - var flags = new List(); - - // Flag: Over-budget categories - foreach (var budget in response.Budgets.Where(b => b.IsOverBudget)) - { - var overBy = budget.Spent - budget.Limit; - flags.Add(new AuditFlagDto - { - Type = "OverBudget", - Severity = "Alert", - Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)", - Details = new - { - budget.BudgetId, - budget.Category, - budget.Limit, - budget.Spent, - OverAmount = overBy, - budget.PercentUsed - } - }); - } - - // Flag: High budget utilization (>80% but not over) - foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80)) - { - flags.Add(new AuditFlagDto - { - Type = "HighBudgetUtilization", - Severity = "Warning", - Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)", - Details = new - { - budget.BudgetId, - budget.Category, - budget.Limit, - budget.Spent, - budget.Remaining, - budget.PercentUsed - } - }); - } - - // Flag: Uncategorized transactions - if (response.Summary.UncategorizedTransactions > 0) - { - flags.Add(new AuditFlagDto - { - Type = "Uncategorized", - Severity = "Warning", - Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized", - Details = new - { - Count = response.Summary.UncategorizedTransactions, - Amount = response.Summary.UncategorizedAmount - } - }); - } - - // Flag: Negative net cash flow - if (response.Summary.NetCashFlow < 0) - { - flags.Add(new AuditFlagDto - { - Type = "NegativeCashFlow", - Severity = "Alert", - Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period", - Details = new - { - response.Summary.TotalIncome, - response.Summary.TotalExpenses, - response.Summary.NetCashFlow - } - }); - } - - // Flag: Large single category spending (>30% of total) - foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30)) - { - flags.Add(new AuditFlagDto - { - Type = "HighCategoryConcentration", - Severity = "Info", - Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})", - Details = new - { - category.Category, - category.TotalSpent, - category.PercentOfTotal, - category.TransactionCount - } - }); - } - - // Flag: Month-over-month spending increases - if (response.MonthlyTrends.Count >= 2) - { - var recentMonths = response.MonthlyTrends.TakeLast(2).ToList(); - var previousMonth = recentMonths[0]; - var currentMonth = recentMonths[1]; - - if (previousMonth.Expenses > 0) - { - var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100; - if (percentChange > 20) - { - flags.Add(new AuditFlagDto - { - Type = "SpendingIncrease", - Severity = "Warning", - Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})", - Details = new - { - PreviousMonth = previousMonth.Month, - PreviousExpenses = previousMonth.Expenses, - CurrentMonth = currentMonth.Month, - CurrentExpenses = currentMonth.Expenses, - PercentChange = percentChange - } - }); - } - } - } - - // Flag: Categories without budgets (top spending categories) - var topUnbudgetedCategories = response.SpendingByCategory - .Where(c => c.BudgetLimit == null && c.TotalSpent > 100) - .Take(3) - .ToList(); - - if (topUnbudgetedCategories.Any()) - { - flags.Add(new AuditFlagDto - { - Type = "NoBudget", - Severity = "Info", - Message = $"Top spending categories without budgets: {string.Join(", ", topUnbudgetedCategories.Select(c => $"{c.Category} ({c.TotalSpent:C})"))}", - Details = topUnbudgetedCategories.Select(c => new { c.Category, c.TotalSpent }).ToList() - }); - } - - return flags.OrderByDescending(f => f.Severity switch - { - "Alert" => 3, - "Warning" => 2, - "Info" => 1, - _ => 0 - }).ToList(); - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Models.Api; + +namespace MoneyMap.Services; + +public interface IFinancialAuditService +{ + Task GenerateAuditAsync( + DateTime startDate, + DateTime endDate, + bool includeTransactions = false); +} + +public class FinancialAuditService : IFinancialAuditService +{ + private readonly MoneyMapContext _db; + private readonly IBudgetService _budgetService; + + public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService) + { + _db = db; + _budgetService = budgetService; + } + + public async Task GenerateAuditAsync( + DateTime startDate, + DateTime endDate, + bool includeTransactions = false) + { + var response = new FinancialAuditResponse + { + GeneratedAt = DateTime.UtcNow, + PeriodStart = startDate.Date, + PeriodEnd = endDate.Date + }; + + // Base query for the period + var periodTransactions = _db.Transactions + .Include(t => t.Account) + .Include(t => t.Card) + .Include(t => t.Merchant) + .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) + .AsNoTracking(); + + // Calculate all sections in parallel where possible + response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate); + response.Budgets = await GetBudgetStatusesAsync(); + response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets); + response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions); + response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate); + response.Accounts = await GetAccountSummariesAsync(periodTransactions); + response.Flags = GenerateAuditFlags(response); + + if (includeTransactions) + { + response.Transactions = await GetTransactionListAsync(periodTransactions); + } + + return response; + } + + private async Task CalculateSummaryAsync( + IQueryable transactions, + DateTime startDate, + DateTime endDate) + { + // Exclude transfers for spending calculations + var nonTransferTxns = transactions.ExcludeTransfers(); + + var stats = await nonTransferTxns + .GroupBy(_ => 1) + .Select(g => new + { + TotalCount = g.Count(), + TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount), + TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), + UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)), + UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0) + .Sum(t => Math.Abs(t.Amount)) + }) + .FirstOrDefaultAsync(); + + var daysInPeriod = (endDate.Date - startDate.Date).Days + 1; + + return new AuditSummary + { + TotalTransactions = stats?.TotalCount ?? 0, + TotalIncome = stats?.TotalIncome ?? 0, + TotalExpenses = stats?.TotalExpenses ?? 0, + NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0), + DaysInPeriod = daysInPeriod, + AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0, + UncategorizedTransactions = stats?.UncategorizedCount ?? 0, + UncategorizedAmount = stats?.UncategorizedAmount ?? 0 + }; + } + + private async Task> GetBudgetStatusesAsync() + { + var statuses = await _budgetService.GetAllBudgetStatusesAsync(); + + return statuses.Select(s => new BudgetStatusDto + { + BudgetId = s.Budget.Id, + Category = s.Budget.DisplayName, + Period = s.Budget.Period.ToString(), + Limit = s.Budget.Amount, + Spent = s.Spent, + Remaining = s.Remaining, + PercentUsed = s.PercentUsed, + IsOverBudget = s.IsOverBudget, + PeriodRange = s.PeriodDisplay + }).ToList(); + } + + private async Task> GetCategorySpendingAsync( + IQueryable transactions, + List budgets) + { + var categorySpending = await transactions + .ExcludeTransfers() + .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) + .GroupBy(t => t.Category) + .Select(g => new + { + Category = g.Key, + TotalSpent = g.Sum(t => Math.Abs(t.Amount)), + Count = g.Count() + }) + .OrderByDescending(x => x.TotalSpent) + .ToListAsync(); + + var totalSpending = categorySpending.Sum(c => c.TotalSpent); + + // Create a lookup for budget data by category + var budgetLookup = budgets + .Where(b => b.Category != "Total Spending") + .ToDictionary(b => b.Category.ToLowerInvariant(), b => b); + + return categorySpending.Select(c => + { + var dto = new CategorySpendingDto + { + Category = c.Category ?? "Uncategorized", + TotalSpent = c.TotalSpent, + TransactionCount = c.Count, + PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0, + AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0 + }; + + // Add budget correlation if available + if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget)) + { + dto.BudgetLimit = budget.Limit; + dto.BudgetRemaining = budget.Remaining; + dto.IsOverBudget = budget.IsOverBudget; + } + + return dto; + }).ToList(); + } + + private async Task> GetMerchantSpendingAsync( + IQueryable transactions) + { + var merchantSpending = await transactions + .ExcludeTransfers() + .Where(t => t.Amount < 0 && t.MerchantId != null) + .GroupBy(t => new { t.MerchantId, t.Merchant!.Name }) + .Select(g => new + { + MerchantName = g.Key.Name, + Category = g.Max(t => t.Category), + TotalSpent = g.Sum(t => Math.Abs(t.Amount)), + Count = g.Count(), + FirstDate = g.Min(t => t.Date), + LastDate = g.Max(t => t.Date) + }) + .OrderByDescending(x => x.TotalSpent) + .Take(20) + .ToListAsync(); + + return merchantSpending.Select(m => new MerchantSpendingDto + { + MerchantName = m.MerchantName, + Category = m.Category, + TotalSpent = m.TotalSpent, + TransactionCount = m.Count, + AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0, + FirstTransaction = m.FirstDate, + LastTransaction = m.LastDate + }).ToList(); + } + + private async Task> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate) + { + var monthlyData = await _db.Transactions + .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) + .ExcludeTransfers() + .GroupBy(t => new { t.Date.Year, t.Date.Month }) + .Select(g => new + { + g.Key.Year, + g.Key.Month, + Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount), + Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), + Count = g.Count() + }) + .OrderBy(x => x.Year) + .ThenBy(x => x.Month) + .ToListAsync(); + + // Get top categories per month + var categoryByMonth = await _db.Transactions + .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) + .ExcludeTransfers() + .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) + .GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category }) + .Select(g => new + { + g.Key.Year, + g.Key.Month, + g.Key.Category, + Total = g.Sum(t => Math.Abs(t.Amount)) + }) + .ToListAsync(); + + return monthlyData.Select(m => + { + var topCategories = categoryByMonth + .Where(c => c.Year == m.Year && c.Month == m.Month) + .OrderByDescending(c => c.Total) + .Take(5) + .ToDictionary(c => c.Category ?? "Other", c => c.Total); + + return new MonthlyTrendDto + { + Month = $"{m.Year}-{m.Month:D2}", + Year = m.Year, + Income = m.Income, + Expenses = m.Expenses, + NetCashFlow = m.Income - m.Expenses, + TransactionCount = m.Count, + TopCategories = topCategories + }; + }).ToList(); + } + + private async Task> GetAccountSummariesAsync( + IQueryable transactions) + { + // Use only mapped columns in the GroupBy, compute DisplayLabel in memory + var accountStats = await transactions + .GroupBy(t => new { + t.AccountId, + t.Account.Institution, + t.Account.Last4, + t.Account.Nickname, + t.Account.AccountType + }) + .Select(g => new + { + g.Key.AccountId, + g.Key.Institution, + g.Key.Last4, + g.Key.Nickname, + g.Key.AccountType, + Count = g.Count(), + Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), + Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) + }) + .ToListAsync(); + + return accountStats.Select(a => new AccountSummaryDto + { + AccountId = a.AccountId, + AccountName = string.IsNullOrEmpty(a.Nickname) + ? $"{a.Institution} {a.Last4} ({a.AccountType})" + : $"{a.Nickname} ({a.Institution} {a.Last4})", + Institution = a.Institution, + AccountType = a.AccountType.ToString(), + TransactionCount = a.Count, + TotalDebits = a.Debits, + TotalCredits = a.Credits, + NetFlow = a.Credits - a.Debits + }).ToList(); + } + + private async Task> GetTransactionListAsync( + IQueryable transactions) + { + // Fetch raw data without computed properties + var rawTxns = await transactions + .OrderByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .Select(t => new + { + t.Id, + t.Date, + t.Name, + t.Memo, + t.Amount, + t.Category, + MerchantName = t.Merchant != null ? t.Merchant.Name : null, + AccountInstitution = t.Account.Institution, + AccountLast4 = t.Account.Last4, + AccountNickname = t.Account.Nickname, + AccountType = t.Account.AccountType, + CardIssuer = t.Card != null ? t.Card.Issuer : null, + CardLast4 = t.Card != null ? t.Card.Last4 : null, + CardNickname = t.Card != null ? t.Card.Nickname : null, + IsTransfer = t.TransferToAccountId != null + }) + .ToListAsync(); + + // Map to DTOs with computed labels + return rawTxns.Select(t => new TransactionDto + { + Id = t.Id, + Date = t.Date, + Name = t.Name, + Memo = t.Memo, + Amount = t.Amount, + Category = t.Category, + MerchantName = t.MerchantName, + AccountName = string.IsNullOrEmpty(t.AccountNickname) + ? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})" + : $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})", + CardLabel = t.CardIssuer != null + ? (string.IsNullOrEmpty(t.CardNickname) + ? $"{t.CardIssuer} {t.CardLast4}" + : $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})") + : null, + IsTransfer = t.IsTransfer + }).ToList(); + } + + private List GenerateAuditFlags(FinancialAuditResponse response) + { + var flags = new List(); + + // Flag: Over-budget categories + foreach (var budget in response.Budgets.Where(b => b.IsOverBudget)) + { + var overBy = budget.Spent - budget.Limit; + flags.Add(new AuditFlagDto + { + Type = "OverBudget", + Severity = "Alert", + Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)", + Details = new + { + budget.BudgetId, + budget.Category, + budget.Limit, + budget.Spent, + OverAmount = overBy, + budget.PercentUsed + } + }); + } + + // Flag: High budget utilization (>80% but not over) + foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80)) + { + flags.Add(new AuditFlagDto + { + Type = "HighBudgetUtilization", + Severity = "Warning", + Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)", + Details = new + { + budget.BudgetId, + budget.Category, + budget.Limit, + budget.Spent, + budget.Remaining, + budget.PercentUsed + } + }); + } + + // Flag: Uncategorized transactions + if (response.Summary.UncategorizedTransactions > 0) + { + flags.Add(new AuditFlagDto + { + Type = "Uncategorized", + Severity = "Warning", + Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized", + Details = new + { + Count = response.Summary.UncategorizedTransactions, + Amount = response.Summary.UncategorizedAmount + } + }); + } + + // Flag: Negative net cash flow + if (response.Summary.NetCashFlow < 0) + { + flags.Add(new AuditFlagDto + { + Type = "NegativeCashFlow", + Severity = "Alert", + Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period", + Details = new + { + response.Summary.TotalIncome, + response.Summary.TotalExpenses, + response.Summary.NetCashFlow + } + }); + } + + // Flag: Large single category spending (>30% of total) + foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30)) + { + flags.Add(new AuditFlagDto + { + Type = "HighCategoryConcentration", + Severity = "Info", + Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})", + Details = new + { + category.Category, + category.TotalSpent, + category.PercentOfTotal, + category.TransactionCount + } + }); + } + + // Flag: Month-over-month spending increases + if (response.MonthlyTrends.Count >= 2) + { + var recentMonths = response.MonthlyTrends.TakeLast(2).ToList(); + var previousMonth = recentMonths[0]; + var currentMonth = recentMonths[1]; + + if (previousMonth.Expenses > 0) + { + var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100; + if (percentChange > 20) + { + flags.Add(new AuditFlagDto + { + Type = "SpendingIncrease", + Severity = "Warning", + Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})", + Details = new + { + PreviousMonth = previousMonth.Month, + PreviousExpenses = previousMonth.Expenses, + CurrentMonth = currentMonth.Month, + CurrentExpenses = currentMonth.Expenses, + PercentChange = percentChange + } + }); + } + } + } + + // Flag: Categories without budgets (top spending categories) + var topUnbudgetedCategories = response.SpendingByCategory + .Where(c => c.BudgetLimit == null && c.TotalSpent > 100) + .Take(3) + .ToList(); + + if (topUnbudgetedCategories.Any()) + { + flags.Add(new AuditFlagDto + { + Type = "NoBudget", + Severity = "Info", + Message = $"Top spending categories without budgets: {string.Join(", ", topUnbudgetedCategories.Select(c => $"{c.Category} ({c.TotalSpent:C})"))}", + Details = topUnbudgetedCategories.Select(c => new { c.Category, c.TotalSpent }).ToList() + }); + } + + return flags.OrderByDescending(f => f.Severity switch + { + "Alert" => 3, + "Warning" => 2, + "Info" => 1, + _ => 0 + }).ToList(); + } +} diff --git a/MoneyMap.Core/Services/IReceiptStorageOptions.cs b/MoneyMap.Core/Services/IReceiptStorageOptions.cs index e424648..8102de1 100644 --- a/MoneyMap.Core/Services/IReceiptStorageOptions.cs +++ b/MoneyMap.Core/Services/IReceiptStorageOptions.cs @@ -1,6 +1,6 @@ -namespace MoneyMap.Services; - -public interface IReceiptStorageOptions -{ - string ReceiptsBasePath { get; } -} +namespace MoneyMap.Services; + +public interface IReceiptStorageOptions +{ + string ReceiptsBasePath { get; } +} diff --git a/MoneyMap.Core/Services/MerchantService.cs b/MoneyMap.Core/Services/MerchantService.cs index e819ba3..d024b09 100644 --- a/MoneyMap.Core/Services/MerchantService.cs +++ b/MoneyMap.Core/Services/MerchantService.cs @@ -1,184 +1,184 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services -{ - public interface IMerchantService - { - Task FindByNameAsync(string name); - Task GetOrCreateAsync(string name); - Task GetOrCreateIdAsync(string? name); - Task GetMerchantByIdAsync(int id, bool includeRelated = false); - Task> GetAllMerchantsWithStatsAsync(); - Task UpdateMerchantAsync(int id, string newName); - Task DeleteMerchantAsync(int id); - } - - public class MerchantService : IMerchantService - { - private readonly MoneyMapContext _db; - - public MerchantService(MoneyMapContext db) - { - _db = db; - } - - public async Task FindByNameAsync(string name) - { - if (string.IsNullOrWhiteSpace(name)) - return null; - - return await _db.Merchants - .FirstOrDefaultAsync(m => m.Name == name.Trim()); - } - - public async Task GetOrCreateAsync(string name) - { - var trimmedName = name.Trim(); - - var existing = await _db.Merchants - .FirstOrDefaultAsync(m => m.Name == trimmedName); - - if (existing != null) - return existing; - - var merchant = new Merchant { Name = trimmedName }; - _db.Merchants.Add(merchant); - await _db.SaveChangesAsync(); - - return merchant; - } - - public async Task GetOrCreateIdAsync(string? name) - { - if (string.IsNullOrWhiteSpace(name)) - return null; - - var merchant = await GetOrCreateAsync(name); - return merchant.Id; - } - - public async Task GetMerchantByIdAsync(int id, bool includeRelated = false) - { - var query = _db.Merchants.AsQueryable(); - - if (includeRelated) - { - query = query - .Include(m => m.Transactions) - .Include(m => m.CategoryMappings); - } - - return await query.FirstOrDefaultAsync(m => m.Id == id); - } - - public async Task> GetAllMerchantsWithStatsAsync() - { - var merchants = await _db.Merchants - .Include(m => m.Transactions) - .Include(m => m.CategoryMappings) - .OrderBy(m => m.Name) - .ToListAsync(); - - return merchants.Select(m => new MerchantWithStats - { - Id = m.Id, - Name = m.Name, - TransactionCount = m.Transactions.Count, - MappingCount = m.CategoryMappings.Count - }).ToList(); - } - - public async Task UpdateMerchantAsync(int id, string newName) - { - var merchant = await _db.Merchants.FindAsync(id); - if (merchant == null) - { - return new MerchantUpdateResult - { - Success = false, - Message = "Merchant not found." - }; - } - - var trimmedName = newName.Trim(); - - // Check if another merchant with the same name exists - var existing = await _db.Merchants - .FirstOrDefaultAsync(m => m.Name == trimmedName && m.Id != id); - - if (existing != null) - { - return new MerchantUpdateResult - { - Success = false, - Message = $"Merchant '{trimmedName}' already exists." - }; - } - - merchant.Name = trimmedName; - await _db.SaveChangesAsync(); - - return new MerchantUpdateResult - { - Success = true, - Message = "Merchant updated successfully." - }; - } - - public async Task DeleteMerchantAsync(int id) - { - var merchant = await _db.Merchants - .Include(m => m.Transactions) - .Include(m => m.CategoryMappings) - .FirstOrDefaultAsync(m => m.Id == id); - - if (merchant == null) - { - return new MerchantDeleteResult - { - Success = false, - Message = "Merchant not found." - }; - } - - var transactionCount = merchant.Transactions.Count; - var mappingCount = merchant.CategoryMappings.Count; - - _db.Merchants.Remove(merchant); - await _db.SaveChangesAsync(); - - return new MerchantDeleteResult - { - Success = true, - Message = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.", - TransactionCount = transactionCount, - MappingCount = mappingCount - }; - } - } - - // DTOs - public class MerchantWithStats - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public int TransactionCount { get; set; } - public int MappingCount { get; set; } - } - - public class MerchantUpdateResult - { - public bool Success { get; set; } - public string Message { get; set; } = ""; - } - - public class MerchantDeleteResult - { - public bool Success { get; set; } - public string Message { get; set; } = ""; - public int TransactionCount { get; set; } - public int MappingCount { get; set; } - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + public interface IMerchantService + { + Task FindByNameAsync(string name); + Task GetOrCreateAsync(string name); + Task GetOrCreateIdAsync(string? name); + Task GetMerchantByIdAsync(int id, bool includeRelated = false); + Task> GetAllMerchantsWithStatsAsync(); + Task UpdateMerchantAsync(int id, string newName); + Task DeleteMerchantAsync(int id); + } + + public class MerchantService : IMerchantService + { + private readonly MoneyMapContext _db; + + public MerchantService(MoneyMapContext db) + { + _db = db; + } + + public async Task FindByNameAsync(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + return await _db.Merchants + .FirstOrDefaultAsync(m => m.Name == name.Trim()); + } + + public async Task GetOrCreateAsync(string name) + { + var trimmedName = name.Trim(); + + var existing = await _db.Merchants + .FirstOrDefaultAsync(m => m.Name == trimmedName); + + if (existing != null) + return existing; + + var merchant = new Merchant { Name = trimmedName }; + _db.Merchants.Add(merchant); + await _db.SaveChangesAsync(); + + return merchant; + } + + public async Task GetOrCreateIdAsync(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var merchant = await GetOrCreateAsync(name); + return merchant.Id; + } + + public async Task GetMerchantByIdAsync(int id, bool includeRelated = false) + { + var query = _db.Merchants.AsQueryable(); + + if (includeRelated) + { + query = query + .Include(m => m.Transactions) + .Include(m => m.CategoryMappings); + } + + return await query.FirstOrDefaultAsync(m => m.Id == id); + } + + public async Task> GetAllMerchantsWithStatsAsync() + { + var merchants = await _db.Merchants + .Include(m => m.Transactions) + .Include(m => m.CategoryMappings) + .OrderBy(m => m.Name) + .ToListAsync(); + + return merchants.Select(m => new MerchantWithStats + { + Id = m.Id, + Name = m.Name, + TransactionCount = m.Transactions.Count, + MappingCount = m.CategoryMappings.Count + }).ToList(); + } + + public async Task UpdateMerchantAsync(int id, string newName) + { + var merchant = await _db.Merchants.FindAsync(id); + if (merchant == null) + { + return new MerchantUpdateResult + { + Success = false, + Message = "Merchant not found." + }; + } + + var trimmedName = newName.Trim(); + + // Check if another merchant with the same name exists + var existing = await _db.Merchants + .FirstOrDefaultAsync(m => m.Name == trimmedName && m.Id != id); + + if (existing != null) + { + return new MerchantUpdateResult + { + Success = false, + Message = $"Merchant '{trimmedName}' already exists." + }; + } + + merchant.Name = trimmedName; + await _db.SaveChangesAsync(); + + return new MerchantUpdateResult + { + Success = true, + Message = "Merchant updated successfully." + }; + } + + public async Task DeleteMerchantAsync(int id) + { + var merchant = await _db.Merchants + .Include(m => m.Transactions) + .Include(m => m.CategoryMappings) + .FirstOrDefaultAsync(m => m.Id == id); + + if (merchant == null) + { + return new MerchantDeleteResult + { + Success = false, + Message = "Merchant not found." + }; + } + + var transactionCount = merchant.Transactions.Count; + var mappingCount = merchant.CategoryMappings.Count; + + _db.Merchants.Remove(merchant); + await _db.SaveChangesAsync(); + + return new MerchantDeleteResult + { + Success = true, + Message = $"Deleted merchant '{merchant.Name}'. {transactionCount} transactions and {mappingCount} category mappings are now unlinked.", + TransactionCount = transactionCount, + MappingCount = mappingCount + }; + } + } + + // DTOs + public class MerchantWithStats + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public int TransactionCount { get; set; } + public int MappingCount { get; set; } + } + + public class MerchantUpdateResult + { + public bool Success { get; set; } + public string Message { get; set; } = ""; + } + + public class MerchantDeleteResult + { + public bool Success { get; set; } + public string Message { get; set; } = ""; + public int TransactionCount { get; set; } + public int MappingCount { get; set; } + } +} diff --git a/MoneyMap.Core/Services/PdfToImageConverter.cs b/MoneyMap.Core/Services/PdfToImageConverter.cs index 0aaeaa2..95a062b 100644 --- a/MoneyMap.Core/Services/PdfToImageConverter.cs +++ b/MoneyMap.Core/Services/PdfToImageConverter.cs @@ -1,61 +1,61 @@ -using ImageMagick; - -namespace MoneyMap.Services -{ - /// - /// Service for converting PDF files to images for AI processing. - /// - public interface IPdfToImageConverter - { - /// - /// Converts the first page of a PDF to a base64-encoded PNG image. - /// - Task ConvertFirstPageToBase64Async(string pdfPath); - - /// - /// Converts PDF bytes to a base64-encoded PNG image. - /// - Task ConvertFirstPageToBase64Async(byte[] pdfBytes); - } - - public class PdfToImageConverter : IPdfToImageConverter - { - private const int DefaultDpi = 220; - - public Task ConvertFirstPageToBase64Async(string pdfPath) - { - var pdfBytes = File.ReadAllBytes(pdfPath); - return ConvertFirstPageToBase64Async(pdfBytes); - } - - public Task ConvertFirstPageToBase64Async(byte[] pdfBytes) - { - return Task.Run(() => - { - var settings = new MagickReadSettings - { - Density = new Density(DefaultDpi), - BackgroundColor = MagickColors.White, - ColorSpace = ColorSpace.sRGB - }; - - using var pages = new MagickImageCollection(); - pages.Read(pdfBytes, settings); - - if (pages.Count == 0) - throw new InvalidOperationException("PDF has no pages"); - - using var img = (MagickImage)pages[0].Clone(); - - // Ensure we have a clean 8-bit RGB canvas - img.ColorType = ColorType.TrueColor; - img.Alpha(AlphaOption.Remove); // flatten onto white - img.ResetPage(); - - // Convert to PNG bytes - var imageBytes = img.ToByteArray(MagickFormat.Png); - return Convert.ToBase64String(imageBytes); - }); - } - } -} +using ImageMagick; + +namespace MoneyMap.Services +{ + /// + /// Service for converting PDF files to images for AI processing. + /// + public interface IPdfToImageConverter + { + /// + /// Converts the first page of a PDF to a base64-encoded PNG image. + /// + Task ConvertFirstPageToBase64Async(string pdfPath); + + /// + /// Converts PDF bytes to a base64-encoded PNG image. + /// + Task ConvertFirstPageToBase64Async(byte[] pdfBytes); + } + + public class PdfToImageConverter : IPdfToImageConverter + { + private const int DefaultDpi = 220; + + public Task ConvertFirstPageToBase64Async(string pdfPath) + { + var pdfBytes = File.ReadAllBytes(pdfPath); + return ConvertFirstPageToBase64Async(pdfBytes); + } + + public Task ConvertFirstPageToBase64Async(byte[] pdfBytes) + { + return Task.Run(() => + { + var settings = new MagickReadSettings + { + Density = new Density(DefaultDpi), + BackgroundColor = MagickColors.White, + ColorSpace = ColorSpace.sRGB + }; + + using var pages = new MagickImageCollection(); + pages.Read(pdfBytes, settings); + + if (pages.Count == 0) + throw new InvalidOperationException("PDF has no pages"); + + using var img = (MagickImage)pages[0].Clone(); + + // Ensure we have a clean 8-bit RGB canvas + img.ColorType = ColorType.TrueColor; + img.Alpha(AlphaOption.Remove); // flatten onto white + img.ResetPage(); + + // Convert to PNG bytes + var imageBytes = img.ToByteArray(MagickFormat.Png); + return Convert.ToBase64String(imageBytes); + }); + } + } +} diff --git a/MoneyMap.Core/Services/ReceiptAutoMapper.cs b/MoneyMap.Core/Services/ReceiptAutoMapper.cs index fb628c2..96e68b8 100644 --- a/MoneyMap.Core/Services/ReceiptAutoMapper.cs +++ b/MoneyMap.Core/Services/ReceiptAutoMapper.cs @@ -1,495 +1,495 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using System.Text; -using System.Text.Json; - -namespace MoneyMap.Services -{ - public interface IReceiptAutoMapper - { - Task AutoMapReceiptAsync(long receiptId); - Task AutoMapUnmappedReceiptsAsync(); - Task> GetScoredCandidatesAsync(long receiptId); - } - - public class ReceiptAutoMapper : IReceiptAutoMapper - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - private readonly LlamaCppVisionClient _llmClient; - private readonly ILogger _logger; - - // Confidence thresholds - private const double AutoMapThreshold = 0.85; // Auto-map if score >= 85% - private const double LlmReviewThreshold = 0.50; // Use LLM if score between 50-85% - - public ReceiptAutoMapper( - MoneyMapContext db, - IReceiptManager receiptManager, - LlamaCppVisionClient llmClient, - ILogger logger) - { - _db = db; - _receiptManager = receiptManager; - _llmClient = llmClient; - _logger = logger; - } - - public async Task AutoMapReceiptAsync(long receiptId) - { - var receipt = await _db.Receipts - .Include(r => r.Transaction) - .FirstOrDefaultAsync(r => r.Id == receiptId); - - if (receipt == null) - return ReceiptAutoMapResult.Failure("Receipt not found."); - - if (receipt.TransactionId.HasValue) - return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value); - - if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue) - return ReceiptAutoMapResult.NotParsed(); - - var scoredCandidates = await FindAndScoreCandidatesAsync(receipt); - - if (scoredCandidates.Count == 0) - return ReceiptAutoMapResult.NoMatch(); - - var bestMatch = scoredCandidates[0]; - - // High confidence - auto-map directly - if (bestMatch.Score >= AutoMapThreshold) - { - _logger.LogInformation( - "Auto-mapping receipt {ReceiptId} to transaction {TransactionId} with score {Score:P0}", - receiptId, bestMatch.Transaction.Id, bestMatch.Score); - - var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, bestMatch.Transaction.Id); - return success - ? ReceiptAutoMapResult.Success(bestMatch.Transaction.Id) - : ReceiptAutoMapResult.Failure("Failed to map receipt to transaction."); - } - - // Medium confidence - use LLM to decide - if (bestMatch.Score >= LlmReviewThreshold) - { - var topCandidates = scoredCandidates.Take(5).ToList(); - var llmResult = await GetLlmMatchDecisionAsync(receipt, topCandidates); - - if (llmResult != null && llmResult.Confidence >= 0.7) - { - _logger.LogInformation( - "LLM matched receipt {ReceiptId} to transaction {TransactionId} with confidence {Confidence:P0}", - receiptId, llmResult.TransactionId, llmResult.Confidence); - - var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, llmResult.TransactionId); - return success - ? ReceiptAutoMapResult.Success(llmResult.TransactionId) - : ReceiptAutoMapResult.Failure("Failed to map receipt to transaction."); - } - - // LLM uncertain - return multiple matches for manual review - return ReceiptAutoMapResult.WithMultipleMatches( - topCandidates.Select(c => c.Transaction).ToList()); - } - - // Low confidence - no good matches - if (scoredCandidates.Count > 1) - { - return ReceiptAutoMapResult.WithMultipleMatches( - scoredCandidates.Take(5).Select(c => c.Transaction).ToList()); - } - - return ReceiptAutoMapResult.NoMatch(); - } - - public async Task AutoMapUnmappedReceiptsAsync() - { - var unmappedReceipts = await _db.Receipts - .Where(r => r.TransactionId == null) - .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) - .ToListAsync(); - - var result = new BulkAutoMapResult(); - - foreach (var receipt in unmappedReceipts) - { - var mapResult = await AutoMapReceiptAsync(receipt.Id); - - if (mapResult.Status == AutoMapStatus.Success) - result.MappedCount++; - else if (mapResult.Status == AutoMapStatus.MultipleMatches) - result.MultipleMatchesCount++; - else if (mapResult.Status == AutoMapStatus.NoMatch) - result.NoMatchCount++; - } - - result.TotalProcessed = unmappedReceipts.Count; - return result; - } - - public async Task> GetScoredCandidatesAsync(long receiptId) - { - var receipt = await _db.Receipts - .FirstOrDefaultAsync(r => r.Id == receiptId); - - if (receipt == null) - return new List(); - - return await FindAndScoreCandidatesAsync(receipt); - } - - private async Task> FindAndScoreCandidatesAsync(Receipt receipt) - { - // Get transactions in a reasonable date range - var query = _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .AsQueryable(); - - // Date range: use receipt date or due date - // Transactions can't occur before the receipt date (you get a receipt when you buy something) - DateTime? targetDate = receipt.ReceiptDate; - DateTime? dueDate = receipt.DueDate; - - if (targetDate.HasValue || dueDate.HasValue) - { - // Min date is the receipt date - transactions can't precede the receipt - var minDate = targetDate ?? dueDate!.Value; - var maxDate = (dueDate ?? targetDate!.Value).AddDays(7); - query = query.Where(t => t.Date >= minDate && t.Date <= maxDate); - } - else - { - // No date info - can't match reliably - return new List(); - } - - var candidates = await query.ToListAsync(); - - // Exclude transactions that already have receipts - var transactionsWithReceipts = await _db.Receipts - .Where(r => r.TransactionId != null && r.Id != receipt.Id) - .Select(r => r.TransactionId!.Value) - .Distinct() - .ToListAsync(); - - candidates = candidates - .Where(t => !transactionsWithReceipts.Contains(t.Id)) - .ToList(); - - // Score each candidate - var scored = candidates - .Select(t => new ScoredCandidate - { - Transaction = t, - Score = CalculateMatchScore(receipt, t) - }) - .Where(s => s.Score > 0.1) // Filter out very poor matches - .OrderByDescending(s => s.Score) - .ToList(); - - return scored; - } - - private double CalculateMatchScore(Receipt receipt, Transaction transaction) - { - double score = 0; - double totalWeight = 0; - - // Amount matching (weight: 40%) - if (receipt.Total.HasValue) - { - const double amountWeight = 0.40; - totalWeight += amountWeight; - - var receiptAmount = Math.Abs(receipt.Total.Value); - var transactionAmount = Math.Abs(transaction.Amount); - - if (receiptAmount > 0) - { - var difference = (double)(Math.Abs(receiptAmount - transactionAmount) / receiptAmount); - - if (difference == 0) - score += amountWeight * 1.0; - else if (difference <= 0.01) // Within 1% - score += amountWeight * 0.95; - else if (difference <= 0.05) // Within 5% - score += amountWeight * 0.80; - else if (difference <= 0.10) // Within 10% - score += amountWeight * 0.60; - else if (difference <= 0.20) // Within 20% - score += amountWeight * 0.30; - // Beyond 20% = 0 points - } - } - - // Date matching (weight: 25%) - if (receipt.ReceiptDate.HasValue) - { - const double dateWeight = 0.25; - totalWeight += dateWeight; - - var daysDiff = Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays); - - if (daysDiff == 0) - score += dateWeight * 1.0; - else if (daysDiff <= 1) - score += dateWeight * 0.90; - else if (daysDiff <= 3) - score += dateWeight * 0.70; - else if (daysDiff <= 5) - score += dateWeight * 0.50; - else if (daysDiff <= 7) - score += dateWeight * 0.30; - // Beyond 7 days = 0 points - } - - // Due date matching for bills (weight: 10% bonus) - if (receipt.DueDate.HasValue) - { - const double dueDateWeight = 0.10; - totalWeight += dueDateWeight; - - var daysDiff = Math.Abs((transaction.Date - receipt.DueDate.Value).TotalDays); - - if (daysDiff <= 1) - score += dueDateWeight * 1.0; - else if (daysDiff <= 3) - score += dueDateWeight * 0.70; - else if (daysDiff <= 5) - score += dueDateWeight * 0.40; - } - - // Merchant/Name matching (weight: 35%) - if (!string.IsNullOrWhiteSpace(receipt.Merchant)) - { - const double merchantWeight = 0.35; - totalWeight += merchantWeight; - - var merchantScore = CalculateMerchantMatchScore( - receipt.Merchant, - transaction.Merchant?.Name, - transaction.Name); - - score += merchantWeight * merchantScore; - } - - // Normalize score if we didn't have all data points - if (totalWeight > 0 && totalWeight < 1.0) - { - score = score / totalWeight; - } - - return Math.Min(score, 1.0); - } - - private double CalculateMerchantMatchScore(string receiptMerchant, string? transactionMerchant, string? transactionName) - { - var receiptLower = receiptMerchant.ToLowerInvariant().Trim(); - var merchantLower = transactionMerchant?.ToLowerInvariant().Trim() ?? ""; - var nameLower = transactionName?.ToLowerInvariant().Trim() ?? ""; - - // Exact match - if (receiptLower == merchantLower || receiptLower == nameLower) - return 1.0; - - // Contains match - if (merchantLower.Contains(receiptLower) || receiptLower.Contains(merchantLower)) - return 0.90; - if (nameLower.Contains(receiptLower) || receiptLower.Contains(nameLower)) - return 0.85; - - // Word-based matching - var receiptWords = ExtractWords(receiptLower); - var merchantWords = ExtractWords(merchantLower); - var nameWords = ExtractWords(nameLower); - - var merchantMatchRatio = CalculateWordMatchRatio(receiptWords, merchantWords); - var nameMatchRatio = CalculateWordMatchRatio(receiptWords, nameWords); - - return Math.Max(merchantMatchRatio, nameMatchRatio); - } - - private static HashSet ExtractWords(string text) - { - return text - .Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries) - .Where(w => w.Length > 1) // Skip single chars - .ToHashSet(); - } - - private static double CalculateWordMatchRatio(HashSet words1, HashSet words2) - { - if (words1.Count == 0 || words2.Count == 0) - return 0; - - int matches = 0; - foreach (var w1 in words1) - { - foreach (var w2 in words2) - { - if (w1 == w2 || w1.Contains(w2) || w2.Contains(w1)) - { - matches++; - break; - } - } - } - - // Return ratio of matched words from the smaller set - var smallerCount = Math.Min(words1.Count, words2.Count); - return (double)matches / smallerCount; - } - - private async Task GetLlmMatchDecisionAsync(Receipt receipt, List candidates) - { - try - { - var prompt = BuildLlmPrompt(receipt, candidates); - - _logger.LogInformation("Sending receipt matching prompt to LLM for receipt {ReceiptId}", receipt.Id); - - var result = await _llmClient.SendTextPromptAsync(prompt); - - if (!result.IsSuccess) - { - _logger.LogWarning("LLM matching failed: {Error}", result.ErrorMessage); - return null; - } - - _logger.LogInformation("LLM response: {Content}", result.Content); - - return ParseLlmResponse(result.Content, candidates); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during LLM match decision"); - return null; - } - } - - private static string BuildLlmPrompt(Receipt receipt, List candidates) - { - var sb = new StringBuilder(); - sb.AppendLine("You are matching a receipt to bank transactions. Analyze and pick the best match."); - sb.AppendLine(); - sb.AppendLine("RECEIPT:"); - sb.AppendLine($" Merchant: {receipt.Merchant ?? "Unknown"}"); - sb.AppendLine($" Date: {receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "Unknown"}"); - if (receipt.DueDate.HasValue) - sb.AppendLine($" Due Date: {receipt.DueDate.Value:yyyy-MM-dd}"); - sb.AppendLine($" Total: {receipt.Total?.ToString("C") ?? "Unknown"}"); - sb.AppendLine(); - sb.AppendLine("CANDIDATE TRANSACTIONS:"); - - for (int i = 0; i < candidates.Count; i++) - { - var t = candidates[i].Transaction; - sb.AppendLine($" [{i + 1}] ID={t.Id}"); - sb.AppendLine($" Name: {t.Name}"); - if (t.Merchant != null) - sb.AppendLine($" Merchant: {t.Merchant.Name}"); - sb.AppendLine($" Date: {t.Date:yyyy-MM-dd}"); - sb.AppendLine($" Amount: {t.Amount:C}"); - sb.AppendLine($" Current Score: {candidates[i].Score:P0}"); - sb.AppendLine(); - } - - sb.AppendLine("Respond with JSON only:"); - sb.AppendLine("{"); - sb.AppendLine(" \"match_index\": <1-based index of best match, or 0 if none match>,"); - sb.AppendLine(" \"confidence\": <0.0 to 1.0>,"); - sb.AppendLine(" \"reason\": \"\""); - sb.AppendLine("}"); - - return sb.ToString(); - } - - private LlmMatchResult? ParseLlmResponse(string? content, List candidates) - { - if (string.IsNullOrWhiteSpace(content)) - return null; - - try - { - var json = JsonSerializer.Deserialize(content); - - var matchIndex = json.GetProperty("match_index").GetInt32(); - var confidence = json.GetProperty("confidence").GetDouble(); - - if (matchIndex <= 0 || matchIndex > candidates.Count) - return null; - - return new LlmMatchResult - { - TransactionId = candidates[matchIndex - 1].Transaction.Id, - Confidence = confidence - }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to parse LLM response: {Content}", content); - return null; - } - } - } - - public class ScoredCandidate - { - public required Transaction Transaction { get; set; } - public double Score { get; set; } - } - - public class LlmMatchResult - { - public long TransactionId { get; set; } - public double Confidence { get; set; } - } - - public class ReceiptAutoMapResult - { - public AutoMapStatus Status { get; init; } - public long? TransactionId { get; init; } - public List MultipleMatches { get; init; } = new(); - public string? Message { get; init; } - - public static ReceiptAutoMapResult Success(long transactionId) => - new() { Status = AutoMapStatus.Success, TransactionId = transactionId }; - - public static ReceiptAutoMapResult AlreadyMapped(long transactionId) => - new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId }; - - public static ReceiptAutoMapResult NoMatch() => - new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." }; - - public static ReceiptAutoMapResult WithMultipleMatches(List matches) => - new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." }; - - public static ReceiptAutoMapResult NotParsed() => - new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." }; - - public static ReceiptAutoMapResult Failure(string message) => - new() { Status = AutoMapStatus.Failed, Message = message }; - } - - public class BulkAutoMapResult - { - public int TotalProcessed { get; set; } - public int MappedCount { get; set; } - public int NoMatchCount { get; set; } - public int MultipleMatchesCount { get; set; } - } - - public enum AutoMapStatus - { - Success, - AlreadyMapped, - NoMatch, - MultipleMatches, - NotParsed, - Failed - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using System.Text; +using System.Text.Json; + +namespace MoneyMap.Services +{ + public interface IReceiptAutoMapper + { + Task AutoMapReceiptAsync(long receiptId); + Task AutoMapUnmappedReceiptsAsync(); + Task> GetScoredCandidatesAsync(long receiptId); + } + + public class ReceiptAutoMapper : IReceiptAutoMapper + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly LlamaCppVisionClient _llmClient; + private readonly ILogger _logger; + + // Confidence thresholds + private const double AutoMapThreshold = 0.85; // Auto-map if score >= 85% + private const double LlmReviewThreshold = 0.50; // Use LLM if score between 50-85% + + public ReceiptAutoMapper( + MoneyMapContext db, + IReceiptManager receiptManager, + LlamaCppVisionClient llmClient, + ILogger logger) + { + _db = db; + _receiptManager = receiptManager; + _llmClient = llmClient; + _logger = logger; + } + + public async Task AutoMapReceiptAsync(long receiptId) + { + var receipt = await _db.Receipts + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return ReceiptAutoMapResult.Failure("Receipt not found."); + + if (receipt.TransactionId.HasValue) + return ReceiptAutoMapResult.AlreadyMapped(receipt.TransactionId.Value); + + if (string.IsNullOrWhiteSpace(receipt.Merchant) && !receipt.ReceiptDate.HasValue && !receipt.Total.HasValue) + return ReceiptAutoMapResult.NotParsed(); + + var scoredCandidates = await FindAndScoreCandidatesAsync(receipt); + + if (scoredCandidates.Count == 0) + return ReceiptAutoMapResult.NoMatch(); + + var bestMatch = scoredCandidates[0]; + + // High confidence - auto-map directly + if (bestMatch.Score >= AutoMapThreshold) + { + _logger.LogInformation( + "Auto-mapping receipt {ReceiptId} to transaction {TransactionId} with score {Score:P0}", + receiptId, bestMatch.Transaction.Id, bestMatch.Score); + + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, bestMatch.Transaction.Id); + return success + ? ReceiptAutoMapResult.Success(bestMatch.Transaction.Id) + : ReceiptAutoMapResult.Failure("Failed to map receipt to transaction."); + } + + // Medium confidence - use LLM to decide + if (bestMatch.Score >= LlmReviewThreshold) + { + var topCandidates = scoredCandidates.Take(5).ToList(); + var llmResult = await GetLlmMatchDecisionAsync(receipt, topCandidates); + + if (llmResult != null && llmResult.Confidence >= 0.7) + { + _logger.LogInformation( + "LLM matched receipt {ReceiptId} to transaction {TransactionId} with confidence {Confidence:P0}", + receiptId, llmResult.TransactionId, llmResult.Confidence); + + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, llmResult.TransactionId); + return success + ? ReceiptAutoMapResult.Success(llmResult.TransactionId) + : ReceiptAutoMapResult.Failure("Failed to map receipt to transaction."); + } + + // LLM uncertain - return multiple matches for manual review + return ReceiptAutoMapResult.WithMultipleMatches( + topCandidates.Select(c => c.Transaction).ToList()); + } + + // Low confidence - no good matches + if (scoredCandidates.Count > 1) + { + return ReceiptAutoMapResult.WithMultipleMatches( + scoredCandidates.Take(5).Select(c => c.Transaction).ToList()); + } + + return ReceiptAutoMapResult.NoMatch(); + } + + public async Task AutoMapUnmappedReceiptsAsync() + { + var unmappedReceipts = await _db.Receipts + .Where(r => r.TransactionId == null) + .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) + .ToListAsync(); + + var result = new BulkAutoMapResult(); + + foreach (var receipt in unmappedReceipts) + { + var mapResult = await AutoMapReceiptAsync(receipt.Id); + + if (mapResult.Status == AutoMapStatus.Success) + result.MappedCount++; + else if (mapResult.Status == AutoMapStatus.MultipleMatches) + result.MultipleMatchesCount++; + else if (mapResult.Status == AutoMapStatus.NoMatch) + result.NoMatchCount++; + } + + result.TotalProcessed = unmappedReceipts.Count; + return result; + } + + public async Task> GetScoredCandidatesAsync(long receiptId) + { + var receipt = await _db.Receipts + .FirstOrDefaultAsync(r => r.Id == receiptId); + + if (receipt == null) + return new List(); + + return await FindAndScoreCandidatesAsync(receipt); + } + + private async Task> FindAndScoreCandidatesAsync(Receipt receipt) + { + // Get transactions in a reasonable date range + var query = _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .AsQueryable(); + + // Date range: use receipt date or due date + // Transactions can't occur before the receipt date (you get a receipt when you buy something) + DateTime? targetDate = receipt.ReceiptDate; + DateTime? dueDate = receipt.DueDate; + + if (targetDate.HasValue || dueDate.HasValue) + { + // Min date is the receipt date - transactions can't precede the receipt + var minDate = targetDate ?? dueDate!.Value; + var maxDate = (dueDate ?? targetDate!.Value).AddDays(7); + query = query.Where(t => t.Date >= minDate && t.Date <= maxDate); + } + else + { + // No date info - can't match reliably + return new List(); + } + + var candidates = await query.ToListAsync(); + + // Exclude transactions that already have receipts + var transactionsWithReceipts = await _db.Receipts + .Where(r => r.TransactionId != null && r.Id != receipt.Id) + .Select(r => r.TransactionId!.Value) + .Distinct() + .ToListAsync(); + + candidates = candidates + .Where(t => !transactionsWithReceipts.Contains(t.Id)) + .ToList(); + + // Score each candidate + var scored = candidates + .Select(t => new ScoredCandidate + { + Transaction = t, + Score = CalculateMatchScore(receipt, t) + }) + .Where(s => s.Score > 0.1) // Filter out very poor matches + .OrderByDescending(s => s.Score) + .ToList(); + + return scored; + } + + private double CalculateMatchScore(Receipt receipt, Transaction transaction) + { + double score = 0; + double totalWeight = 0; + + // Amount matching (weight: 40%) + if (receipt.Total.HasValue) + { + const double amountWeight = 0.40; + totalWeight += amountWeight; + + var receiptAmount = Math.Abs(receipt.Total.Value); + var transactionAmount = Math.Abs(transaction.Amount); + + if (receiptAmount > 0) + { + var difference = (double)(Math.Abs(receiptAmount - transactionAmount) / receiptAmount); + + if (difference == 0) + score += amountWeight * 1.0; + else if (difference <= 0.01) // Within 1% + score += amountWeight * 0.95; + else if (difference <= 0.05) // Within 5% + score += amountWeight * 0.80; + else if (difference <= 0.10) // Within 10% + score += amountWeight * 0.60; + else if (difference <= 0.20) // Within 20% + score += amountWeight * 0.30; + // Beyond 20% = 0 points + } + } + + // Date matching (weight: 25%) + if (receipt.ReceiptDate.HasValue) + { + const double dateWeight = 0.25; + totalWeight += dateWeight; + + var daysDiff = Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays); + + if (daysDiff == 0) + score += dateWeight * 1.0; + else if (daysDiff <= 1) + score += dateWeight * 0.90; + else if (daysDiff <= 3) + score += dateWeight * 0.70; + else if (daysDiff <= 5) + score += dateWeight * 0.50; + else if (daysDiff <= 7) + score += dateWeight * 0.30; + // Beyond 7 days = 0 points + } + + // Due date matching for bills (weight: 10% bonus) + if (receipt.DueDate.HasValue) + { + const double dueDateWeight = 0.10; + totalWeight += dueDateWeight; + + var daysDiff = Math.Abs((transaction.Date - receipt.DueDate.Value).TotalDays); + + if (daysDiff <= 1) + score += dueDateWeight * 1.0; + else if (daysDiff <= 3) + score += dueDateWeight * 0.70; + else if (daysDiff <= 5) + score += dueDateWeight * 0.40; + } + + // Merchant/Name matching (weight: 35%) + if (!string.IsNullOrWhiteSpace(receipt.Merchant)) + { + const double merchantWeight = 0.35; + totalWeight += merchantWeight; + + var merchantScore = CalculateMerchantMatchScore( + receipt.Merchant, + transaction.Merchant?.Name, + transaction.Name); + + score += merchantWeight * merchantScore; + } + + // Normalize score if we didn't have all data points + if (totalWeight > 0 && totalWeight < 1.0) + { + score = score / totalWeight; + } + + return Math.Min(score, 1.0); + } + + private double CalculateMerchantMatchScore(string receiptMerchant, string? transactionMerchant, string? transactionName) + { + var receiptLower = receiptMerchant.ToLowerInvariant().Trim(); + var merchantLower = transactionMerchant?.ToLowerInvariant().Trim() ?? ""; + var nameLower = transactionName?.ToLowerInvariant().Trim() ?? ""; + + // Exact match + if (receiptLower == merchantLower || receiptLower == nameLower) + return 1.0; + + // Contains match + if (merchantLower.Contains(receiptLower) || receiptLower.Contains(merchantLower)) + return 0.90; + if (nameLower.Contains(receiptLower) || receiptLower.Contains(nameLower)) + return 0.85; + + // Word-based matching + var receiptWords = ExtractWords(receiptLower); + var merchantWords = ExtractWords(merchantLower); + var nameWords = ExtractWords(nameLower); + + var merchantMatchRatio = CalculateWordMatchRatio(receiptWords, merchantWords); + var nameMatchRatio = CalculateWordMatchRatio(receiptWords, nameWords); + + return Math.Max(merchantMatchRatio, nameMatchRatio); + } + + private static HashSet ExtractWords(string text) + { + return text + .Split(new[] { ' ', '-', '_', '.', ',', '#', '/', '\\', '*' }, StringSplitOptions.RemoveEmptyEntries) + .Where(w => w.Length > 1) // Skip single chars + .ToHashSet(); + } + + private static double CalculateWordMatchRatio(HashSet words1, HashSet words2) + { + if (words1.Count == 0 || words2.Count == 0) + return 0; + + int matches = 0; + foreach (var w1 in words1) + { + foreach (var w2 in words2) + { + if (w1 == w2 || w1.Contains(w2) || w2.Contains(w1)) + { + matches++; + break; + } + } + } + + // Return ratio of matched words from the smaller set + var smallerCount = Math.Min(words1.Count, words2.Count); + return (double)matches / smallerCount; + } + + private async Task GetLlmMatchDecisionAsync(Receipt receipt, List candidates) + { + try + { + var prompt = BuildLlmPrompt(receipt, candidates); + + _logger.LogInformation("Sending receipt matching prompt to LLM for receipt {ReceiptId}", receipt.Id); + + var result = await _llmClient.SendTextPromptAsync(prompt); + + if (!result.IsSuccess) + { + _logger.LogWarning("LLM matching failed: {Error}", result.ErrorMessage); + return null; + } + + _logger.LogInformation("LLM response: {Content}", result.Content); + + return ParseLlmResponse(result.Content, candidates); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during LLM match decision"); + return null; + } + } + + private static string BuildLlmPrompt(Receipt receipt, List candidates) + { + var sb = new StringBuilder(); + sb.AppendLine("You are matching a receipt to bank transactions. Analyze and pick the best match."); + sb.AppendLine(); + sb.AppendLine("RECEIPT:"); + sb.AppendLine($" Merchant: {receipt.Merchant ?? "Unknown"}"); + sb.AppendLine($" Date: {receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "Unknown"}"); + if (receipt.DueDate.HasValue) + sb.AppendLine($" Due Date: {receipt.DueDate.Value:yyyy-MM-dd}"); + sb.AppendLine($" Total: {receipt.Total?.ToString("C") ?? "Unknown"}"); + sb.AppendLine(); + sb.AppendLine("CANDIDATE TRANSACTIONS:"); + + for (int i = 0; i < candidates.Count; i++) + { + var t = candidates[i].Transaction; + sb.AppendLine($" [{i + 1}] ID={t.Id}"); + sb.AppendLine($" Name: {t.Name}"); + if (t.Merchant != null) + sb.AppendLine($" Merchant: {t.Merchant.Name}"); + sb.AppendLine($" Date: {t.Date:yyyy-MM-dd}"); + sb.AppendLine($" Amount: {t.Amount:C}"); + sb.AppendLine($" Current Score: {candidates[i].Score:P0}"); + sb.AppendLine(); + } + + sb.AppendLine("Respond with JSON only:"); + sb.AppendLine("{"); + sb.AppendLine(" \"match_index\": <1-based index of best match, or 0 if none match>,"); + sb.AppendLine(" \"confidence\": <0.0 to 1.0>,"); + sb.AppendLine(" \"reason\": \"\""); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private LlmMatchResult? ParseLlmResponse(string? content, List candidates) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + try + { + var json = JsonSerializer.Deserialize(content); + + var matchIndex = json.GetProperty("match_index").GetInt32(); + var confidence = json.GetProperty("confidence").GetDouble(); + + if (matchIndex <= 0 || matchIndex > candidates.Count) + return null; + + return new LlmMatchResult + { + TransactionId = candidates[matchIndex - 1].Transaction.Id, + Confidence = confidence + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse LLM response: {Content}", content); + return null; + } + } + } + + public class ScoredCandidate + { + public required Transaction Transaction { get; set; } + public double Score { get; set; } + } + + public class LlmMatchResult + { + public long TransactionId { get; set; } + public double Confidence { get; set; } + } + + public class ReceiptAutoMapResult + { + public AutoMapStatus Status { get; init; } + public long? TransactionId { get; init; } + public List MultipleMatches { get; init; } = new(); + public string? Message { get; init; } + + public static ReceiptAutoMapResult Success(long transactionId) => + new() { Status = AutoMapStatus.Success, TransactionId = transactionId }; + + public static ReceiptAutoMapResult AlreadyMapped(long transactionId) => + new() { Status = AutoMapStatus.AlreadyMapped, TransactionId = transactionId }; + + public static ReceiptAutoMapResult NoMatch() => + new() { Status = AutoMapStatus.NoMatch, Message = "No matching transaction found." }; + + public static ReceiptAutoMapResult WithMultipleMatches(List matches) => + new() { Status = AutoMapStatus.MultipleMatches, MultipleMatches = matches, Message = $"Found {matches.Count} potential matches." }; + + public static ReceiptAutoMapResult NotParsed() => + new() { Status = AutoMapStatus.NotParsed, Message = "Receipt has not been parsed yet." }; + + public static ReceiptAutoMapResult Failure(string message) => + new() { Status = AutoMapStatus.Failed, Message = message }; + } + + public class BulkAutoMapResult + { + public int TotalProcessed { get; set; } + public int MappedCount { get; set; } + public int NoMatchCount { get; set; } + public int MultipleMatchesCount { get; set; } + } + + public enum AutoMapStatus + { + Success, + AlreadyMapped, + NoMatch, + MultipleMatches, + NotParsed, + Failed + } +} diff --git a/MoneyMap.Core/Services/ReceiptManager.cs b/MoneyMap.Core/Services/ReceiptManager.cs index d2f2358..f12c7c3 100644 --- a/MoneyMap.Core/Services/ReceiptManager.cs +++ b/MoneyMap.Core/Services/ReceiptManager.cs @@ -1,405 +1,405 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using System.Security.Cryptography; -using System.Text; - -namespace MoneyMap.Services -{ - public interface IReceiptManager - { - Task UploadReceiptAsync(long transactionId, IFormFile file); - Task UploadUnmappedReceiptAsync(IFormFile file); - Task UploadManyUnmappedReceiptsAsync(IReadOnlyList files); - Task DeleteReceiptAsync(long receiptId); - Task MapReceiptToTransactionAsync(long receiptId, long transactionId); - Task UnmapReceiptAsync(long receiptId); - string GetReceiptPhysicalPath(Receipt receipt); - Task GetReceiptAsync(long receiptId); - } - - public class ReceiptManager : IReceiptManager - { - private readonly MoneyMapContext _db; - private readonly IReceiptStorageOptions _receiptStorage; - private readonly IServiceProvider _serviceProvider; - private readonly IReceiptParseQueue _parseQueue; - private readonly ILogger _logger; - private const long MaxFileSize = 10 * 1024 * 1024; // 10MB - private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" }; - - // Magic bytes for file type validation (prevents extension spoofing) - private static readonly Dictionary FileSignatures = new() - { - { ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, - { ".jpeg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, - { ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } }, - { ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a - { ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF - { ".heic", new[] { - new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic - new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic (variant) - new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check) - }} - }; - - public ReceiptManager( - MoneyMapContext db, - IReceiptStorageOptions receiptStorage, - IServiceProvider serviceProvider, - IReceiptParseQueue parseQueue, - ILogger logger) - { - _db = db; - _receiptStorage = receiptStorage; - _serviceProvider = serviceProvider; - _parseQueue = parseQueue; - _logger = logger; - } - - private string GetReceiptsBasePath() - { - return _receiptStorage.ReceiptsBasePath; - } - - public async Task UploadReceiptAsync(long transactionId, IFormFile file) - { - // Verify transaction exists - var transaction = await _db.Transactions.FindAsync(transactionId); - if (transaction == null) - return ReceiptUploadResult.Failure("Transaction not found."); - - return await UploadReceiptInternalAsync(file, transactionId); - } - - public async Task UploadUnmappedReceiptAsync(IFormFile file) - { - return await UploadReceiptInternalAsync(file, null); - } - - private async Task UploadReceiptInternalAsync(IFormFile file, long? transactionId) - { - // Validate file - if (file == null || file.Length == 0) - return ReceiptUploadResult.Failure("No file selected."); - - if (file.Length > MaxFileSize) - return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit."); - - var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); - if (!AllowedExtensions.Contains(extension)) - return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}"); - - // Validate file content matches extension (magic bytes check) - if (!await ValidateFileSignatureAsync(file, extension)) - return ReceiptUploadResult.Failure($"File content does not match {extension} format. The file may be corrupted or have an incorrect extension."); - - // Create receipts directory if it doesn't exist - var receiptsBasePath = GetReceiptsBasePath(); - if (!Directory.Exists(receiptsBasePath)) - Directory.CreateDirectory(receiptsBasePath); - - // Calculate SHA256 hash - string fileHash; - using (var sha256 = SHA256.Create()) - { - using var stream = file.OpenReadStream(); - var hashBytes = await sha256.ComputeHashAsync(stream); - fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - } - - // Check for exact duplicate (same transaction + same hash) - if (transactionId.HasValue) - { - var existingReceipt = await _db.Receipts - .FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash); - - if (existingReceipt != null) - return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction."); - } - - // Check for potential duplicates (same hash, same name+size) - var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length); - - // Generate unique filename - var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}"; - var filePath = Path.Combine(receiptsBasePath, storedFileName); - - // Save file - using (var fileStream = new FileStream(filePath, FileMode.Create)) - { - await file.CopyToAsync(fileStream); - } - - // Store just the filename in database (base path comes from config) - var relativeStoragePath = storedFileName; - - // Create receipt record - var receipt = new Receipt - { - TransactionId = transactionId, - FileName = SanitizeFileName(file.FileName), - StoragePath = relativeStoragePath, - FileSizeBytes = file.Length, - ContentType = file.ContentType, - FileHashSha256 = fileHash, - UploadedAtUtc = DateTime.UtcNow - }; - - receipt.ParseStatus = ReceiptParseStatus.Queued; - _db.Receipts.Add(receipt); - await _db.SaveChangesAsync(); - - await _parseQueue.EnqueueAsync(receipt.Id); - _logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id); - - return ReceiptUploadResult.Success(receipt, duplicateWarnings); - } - - public async Task UploadManyUnmappedReceiptsAsync(IReadOnlyList files) - { - var uploaded = new List(); - var failed = new List(); - - 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> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize) - { - var warnings = new List(); - - // Check for receipts with same hash - var hashMatches = await _db.Receipts - .Include(r => r.Transaction) - .Where(r => r.FileHashSha256 == fileHash) - .ToListAsync(); - - foreach (var match in hashMatches) - { - warnings.Add(new DuplicateWarning - { - ReceiptId = match.Id, - FileName = match.FileName, - UploadedAtUtc = match.UploadedAtUtc, - TransactionId = match.TransactionId, - TransactionName = match.Transaction?.Name, - Reason = "Identical file content (same hash)" - }); - } - - // Check for receipts with same name and size (but different hash - might be resaved/edited) - if (!warnings.Any()) - { - var nameAndSizeMatches = await _db.Receipts - .Include(r => r.Transaction) - .Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize) - .ToListAsync(); - - foreach (var match in nameAndSizeMatches) - { - warnings.Add(new DuplicateWarning - { - ReceiptId = match.Id, - FileName = match.FileName, - UploadedAtUtc = match.UploadedAtUtc, - TransactionId = match.TransactionId, - TransactionName = match.Transaction?.Name, - Reason = "Same file name and size" - }); - } - } - - return warnings; - } - - public async Task MapReceiptToTransactionAsync(long receiptId, long transactionId) - { - var receipt = await _db.Receipts.FindAsync(receiptId); - if (receipt == null) - return false; - - var transaction = await _db.Transactions.FindAsync(transactionId); - if (transaction == null) - return false; - - // Allow remapping: simply update the TransactionId - if (receipt.TransactionId == transactionId) - return true; - - receipt.TransactionId = transactionId; - await _db.SaveChangesAsync(); - - return true; - } - - public async Task UnmapReceiptAsync(long receiptId) - { - var receipt = await _db.Receipts.FindAsync(receiptId); - if (receipt == null) - return false; - - // Set TransactionId to null to unmap - receipt.TransactionId = null; - await _db.SaveChangesAsync(); - - return true; - } - - private static async Task ValidateFileSignatureAsync(IFormFile file, string extension) - { - if (!FileSignatures.TryGetValue(extension, out var signatures)) - return true; // No signature check for unknown extensions - - var maxSignatureLength = signatures.Max(s => s.Length); - var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)]; - - await using var stream = file.OpenReadStream(); - _ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length)); - - // Check if file starts with any of the valid signatures for this extension - return signatures.Any(signature => - headerBytes.Length >= signature.Length && - headerBytes.Take(signature.Length).SequenceEqual(signature)); - } - - private static string SanitizeFileName(string fileName) - { - if (string.IsNullOrWhiteSpace(fileName)) - return "receipt"; - - // Remove non-ASCII characters and replace them with safe equivalents - var sanitized = new StringBuilder(); - foreach (var c in fileName) - { - if (c == '�' || c == '�' || c == '�') - { - // Skip trademark/copyright symbols - continue; - } - else if (c >= 32 && c <= 126) - { - // Keep ASCII printable characters - sanitized.Append(c); - } - else - { - // Replace other non-ASCII with underscore - sanitized.Append('_'); - } - } - - var result = sanitized.ToString().Trim(); - return string.IsNullOrWhiteSpace(result) ? "receipt" : result; - } - - public async Task DeleteReceiptAsync(long receiptId) - { - var receipt = await _db.Receipts.FindAsync(receiptId); - if (receipt == null) - return false; - - // Delete physical file - var filePath = GetReceiptPhysicalPath(receipt); - if (File.Exists(filePath)) - { - try - { - File.Delete(filePath); - } - catch - { - // Continue even if file delete fails - } - } - - // Delete database record (cascade will handle ParseLogs and LineItems) - _db.Receipts.Remove(receipt); - await _db.SaveChangesAsync(); - - return true; - } - - public string GetReceiptPhysicalPath(Receipt receipt) - { - // StoragePath is just the filename, combine with configured base path - return Path.Combine(GetReceiptsBasePath(), receipt.StoragePath); - } - - public async Task GetReceiptAsync(long receiptId) - { - return await _db.Receipts - .Include(r => r.Transaction) - .FirstOrDefaultAsync(r => r.Id == receiptId); - } - } - - public class ReceiptUploadResult - { - public bool IsSuccess { get; init; } - public Receipt? Receipt { get; init; } - public string? ErrorMessage { get; init; } - public List DuplicateWarnings { get; init; } = new(); - - public static ReceiptUploadResult Success(Receipt receipt, List? warnings = null) => - new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() }; - - public static ReceiptUploadResult Failure(string error) => - new() { IsSuccess = false, ErrorMessage = error }; - } - - public class DuplicateWarning - { - public long ReceiptId { get; set; } - public string FileName { get; set; } = ""; - public DateTime UploadedAtUtc { get; set; } - public long? TransactionId { get; set; } - public string? TransactionName { get; set; } - public string Reason { get; set; } = ""; - } - - public class BulkUploadResult - { - public List Uploaded { get; init; } = new(); - public List 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 DuplicateWarnings { get; set; } = new(); - } - - public class BulkUploadFailure - { - public string FileName { get; set; } = ""; - public string ErrorMessage { get; set; } = ""; - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using System.Security.Cryptography; +using System.Text; + +namespace MoneyMap.Services +{ + public interface IReceiptManager + { + Task UploadReceiptAsync(long transactionId, IFormFile file); + Task UploadUnmappedReceiptAsync(IFormFile file); + Task UploadManyUnmappedReceiptsAsync(IReadOnlyList files); + Task DeleteReceiptAsync(long receiptId); + Task MapReceiptToTransactionAsync(long receiptId, long transactionId); + Task UnmapReceiptAsync(long receiptId); + string GetReceiptPhysicalPath(Receipt receipt); + Task GetReceiptAsync(long receiptId); + } + + public class ReceiptManager : IReceiptManager + { + private readonly MoneyMapContext _db; + private readonly IReceiptStorageOptions _receiptStorage; + private readonly IServiceProvider _serviceProvider; + private readonly IReceiptParseQueue _parseQueue; + private readonly ILogger _logger; + private const long MaxFileSize = 10 * 1024 * 1024; // 10MB + private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" }; + + // Magic bytes for file type validation (prevents extension spoofing) + private static readonly Dictionary FileSignatures = new() + { + { ".jpg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, + { ".jpeg", new[] { new byte[] { 0xFF, 0xD8, 0xFF } } }, + { ".png", new[] { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } }, + { ".gif", new[] { new byte[] { 0x47, 0x49, 0x46, 0x38 } } }, // GIF87a or GIF89a + { ".pdf", new[] { new byte[] { 0x25, 0x50, 0x44, 0x46 } } }, // %PDF + { ".heic", new[] { + new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic + new byte[] { 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 }, // ftypheic (variant) + new byte[] { 0x00, 0x00, 0x00 } // Generic ftyp header (relaxed check) + }} + }; + + public ReceiptManager( + MoneyMapContext db, + IReceiptStorageOptions receiptStorage, + IServiceProvider serviceProvider, + IReceiptParseQueue parseQueue, + ILogger logger) + { + _db = db; + _receiptStorage = receiptStorage; + _serviceProvider = serviceProvider; + _parseQueue = parseQueue; + _logger = logger; + } + + private string GetReceiptsBasePath() + { + return _receiptStorage.ReceiptsBasePath; + } + + public async Task UploadReceiptAsync(long transactionId, IFormFile file) + { + // Verify transaction exists + var transaction = await _db.Transactions.FindAsync(transactionId); + if (transaction == null) + return ReceiptUploadResult.Failure("Transaction not found."); + + return await UploadReceiptInternalAsync(file, transactionId); + } + + public async Task UploadUnmappedReceiptAsync(IFormFile file) + { + return await UploadReceiptInternalAsync(file, null); + } + + private async Task UploadReceiptInternalAsync(IFormFile file, long? transactionId) + { + // Validate file + if (file == null || file.Length == 0) + return ReceiptUploadResult.Failure("No file selected."); + + if (file.Length > MaxFileSize) + return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit."); + + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(extension)) + return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}"); + + // Validate file content matches extension (magic bytes check) + if (!await ValidateFileSignatureAsync(file, extension)) + return ReceiptUploadResult.Failure($"File content does not match {extension} format. The file may be corrupted or have an incorrect extension."); + + // Create receipts directory if it doesn't exist + var receiptsBasePath = GetReceiptsBasePath(); + if (!Directory.Exists(receiptsBasePath)) + Directory.CreateDirectory(receiptsBasePath); + + // Calculate SHA256 hash + string fileHash; + using (var sha256 = SHA256.Create()) + { + using var stream = file.OpenReadStream(); + var hashBytes = await sha256.ComputeHashAsync(stream); + fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + // Check for exact duplicate (same transaction + same hash) + if (transactionId.HasValue) + { + var existingReceipt = await _db.Receipts + .FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash); + + if (existingReceipt != null) + return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction."); + } + + // Check for potential duplicates (same hash, same name+size) + var duplicateWarnings = await CheckForDuplicatesAsync(fileHash, file.FileName, file.Length); + + // Generate unique filename + var storedFileName = $"{transactionId?.ToString() ?? "unmapped"}_{Guid.NewGuid()}{extension}"; + var filePath = Path.Combine(receiptsBasePath, storedFileName); + + // Save file + using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(fileStream); + } + + // Store just the filename in database (base path comes from config) + var relativeStoragePath = storedFileName; + + // Create receipt record + var receipt = new Receipt + { + TransactionId = transactionId, + FileName = SanitizeFileName(file.FileName), + StoragePath = relativeStoragePath, + FileSizeBytes = file.Length, + ContentType = file.ContentType, + FileHashSha256 = fileHash, + UploadedAtUtc = DateTime.UtcNow + }; + + receipt.ParseStatus = ReceiptParseStatus.Queued; + _db.Receipts.Add(receipt); + await _db.SaveChangesAsync(); + + await _parseQueue.EnqueueAsync(receipt.Id); + _logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id); + + return ReceiptUploadResult.Success(receipt, duplicateWarnings); + } + + public async Task UploadManyUnmappedReceiptsAsync(IReadOnlyList files) + { + var uploaded = new List(); + var failed = new List(); + + 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> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize) + { + var warnings = new List(); + + // Check for receipts with same hash + var hashMatches = await _db.Receipts + .Include(r => r.Transaction) + .Where(r => r.FileHashSha256 == fileHash) + .ToListAsync(); + + foreach (var match in hashMatches) + { + warnings.Add(new DuplicateWarning + { + ReceiptId = match.Id, + FileName = match.FileName, + UploadedAtUtc = match.UploadedAtUtc, + TransactionId = match.TransactionId, + TransactionName = match.Transaction?.Name, + Reason = "Identical file content (same hash)" + }); + } + + // Check for receipts with same name and size (but different hash - might be resaved/edited) + if (!warnings.Any()) + { + var nameAndSizeMatches = await _db.Receipts + .Include(r => r.Transaction) + .Where(r => r.FileName == fileName && r.FileSizeBytes == fileSize) + .ToListAsync(); + + foreach (var match in nameAndSizeMatches) + { + warnings.Add(new DuplicateWarning + { + ReceiptId = match.Id, + FileName = match.FileName, + UploadedAtUtc = match.UploadedAtUtc, + TransactionId = match.TransactionId, + TransactionName = match.Transaction?.Name, + Reason = "Same file name and size" + }); + } + } + + return warnings; + } + + public async Task MapReceiptToTransactionAsync(long receiptId, long transactionId) + { + var receipt = await _db.Receipts.FindAsync(receiptId); + if (receipt == null) + return false; + + var transaction = await _db.Transactions.FindAsync(transactionId); + if (transaction == null) + return false; + + // Allow remapping: simply update the TransactionId + if (receipt.TransactionId == transactionId) + return true; + + receipt.TransactionId = transactionId; + await _db.SaveChangesAsync(); + + return true; + } + + public async Task UnmapReceiptAsync(long receiptId) + { + var receipt = await _db.Receipts.FindAsync(receiptId); + if (receipt == null) + return false; + + // Set TransactionId to null to unmap + receipt.TransactionId = null; + await _db.SaveChangesAsync(); + + return true; + } + + private static async Task ValidateFileSignatureAsync(IFormFile file, string extension) + { + if (!FileSignatures.TryGetValue(extension, out var signatures)) + return true; // No signature check for unknown extensions + + var maxSignatureLength = signatures.Max(s => s.Length); + var headerBytes = new byte[Math.Min(maxSignatureLength, (int)file.Length)]; + + await using var stream = file.OpenReadStream(); + _ = await stream.ReadAsync(headerBytes.AsMemory(0, headerBytes.Length)); + + // Check if file starts with any of the valid signatures for this extension + return signatures.Any(signature => + headerBytes.Length >= signature.Length && + headerBytes.Take(signature.Length).SequenceEqual(signature)); + } + + private static string SanitizeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return "receipt"; + + // Remove non-ASCII characters and replace them with safe equivalents + var sanitized = new StringBuilder(); + foreach (var c in fileName) + { + if (c == '�' || c == '�' || c == '�') + { + // Skip trademark/copyright symbols + continue; + } + else if (c >= 32 && c <= 126) + { + // Keep ASCII printable characters + sanitized.Append(c); + } + else + { + // Replace other non-ASCII with underscore + sanitized.Append('_'); + } + } + + var result = sanitized.ToString().Trim(); + return string.IsNullOrWhiteSpace(result) ? "receipt" : result; + } + + public async Task DeleteReceiptAsync(long receiptId) + { + var receipt = await _db.Receipts.FindAsync(receiptId); + if (receipt == null) + return false; + + // Delete physical file + var filePath = GetReceiptPhysicalPath(receipt); + if (File.Exists(filePath)) + { + try + { + File.Delete(filePath); + } + catch + { + // Continue even if file delete fails + } + } + + // Delete database record (cascade will handle ParseLogs and LineItems) + _db.Receipts.Remove(receipt); + await _db.SaveChangesAsync(); + + return true; + } + + public string GetReceiptPhysicalPath(Receipt receipt) + { + // StoragePath is just the filename, combine with configured base path + return Path.Combine(GetReceiptsBasePath(), receipt.StoragePath); + } + + public async Task GetReceiptAsync(long receiptId) + { + return await _db.Receipts + .Include(r => r.Transaction) + .FirstOrDefaultAsync(r => r.Id == receiptId); + } + } + + public class ReceiptUploadResult + { + public bool IsSuccess { get; init; } + public Receipt? Receipt { get; init; } + public string? ErrorMessage { get; init; } + public List DuplicateWarnings { get; init; } = new(); + + public static ReceiptUploadResult Success(Receipt receipt, List? warnings = null) => + new() { IsSuccess = true, Receipt = receipt, DuplicateWarnings = warnings ?? new() }; + + public static ReceiptUploadResult Failure(string error) => + new() { IsSuccess = false, ErrorMessage = error }; + } + + public class DuplicateWarning + { + public long ReceiptId { get; set; } + public string FileName { get; set; } = ""; + public DateTime UploadedAtUtc { get; set; } + public long? TransactionId { get; set; } + public string? TransactionName { get; set; } + public string Reason { get; set; } = ""; + } + + public class BulkUploadResult + { + public List Uploaded { get; init; } = new(); + public List 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 DuplicateWarnings { get; set; } = new(); + } + + public class BulkUploadFailure + { + public string FileName { get; set; } = ""; + public string ErrorMessage { get; set; } = ""; + } +} diff --git a/MoneyMap.Core/Services/ReceiptMatchingService.cs b/MoneyMap.Core/Services/ReceiptMatchingService.cs index a08510d..d64f227 100644 --- a/MoneyMap.Core/Services/ReceiptMatchingService.cs +++ b/MoneyMap.Core/Services/ReceiptMatchingService.cs @@ -1,237 +1,237 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for matching receipts to transactions based on date, merchant, and amount. -/// -public interface IReceiptMatchingService -{ - /// - /// Finds matching transactions for a receipt based on date range, merchant name, - /// and amount tolerance. Returns transactions sorted by relevance. - /// - Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria); - - /// - /// Gets a set of transaction IDs that already have receipts mapped to them. - /// - Task> GetTransactionIdsWithReceiptsAsync(); -} - -public class ReceiptMatchingService : IReceiptMatchingService -{ - private readonly MoneyMapContext _db; - - public ReceiptMatchingService(MoneyMapContext db) - { - _db = db; - } - - public async Task> GetTransactionIdsWithReceiptsAsync() - { - var transactionIds = await _db.Receipts - .Where(r => r.TransactionId != null) - .Select(r => r.TransactionId!.Value) - .ToListAsync(); - - return new HashSet(transactionIds); - } - - public async Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria) - { - var query = _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id)) - .AsQueryable(); - - // Apply date filtering based on receipt type - query = ApplyDateFilter(query, criteria); - - // Get all candidates within date range - var candidates = await query.ToListAsync(); - - // Sort by merchant/name relevance using word matching - if (!string.IsNullOrWhiteSpace(criteria.MerchantName)) - { - candidates = SortByMerchantRelevance(candidates, criteria.MerchantName); - } - else - { - // No merchant filter, just sort by date - candidates = candidates - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .ToList(); - } - - // Filter by amount (±10% tolerance) if receipt has a total - if (criteria.Total.HasValue) - { - candidates = FilterByAmountTolerance(candidates, criteria.Total.Value); - } - - // Convert to match results with scoring - var matches = ConvertToMatches(candidates, criteria); - - // If no date-filtered matches, fall back to recent transactions - if (!matches.Any() && !criteria.ReceiptDate.HasValue) - { - matches = await GetFallbackMatches(criteria.ExcludeTransactionIds); - } - - return matches; - } - - private static IQueryable ApplyDateFilter(IQueryable query, ReceiptMatchCriteria criteria) - { - // For bills with due dates: use range from bill date to due date + 5 days - // (to account for auto-pay processing delays, weekends, etc.) - if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue) - { - var minDate = criteria.ReceiptDate.Value; - var maxDate = criteria.DueDate.Value.AddDays(5); - return query.Where(t => t.Date >= minDate && t.Date <= maxDate); - } - - // For regular receipts: use +/- 3 days - if (criteria.ReceiptDate.HasValue) - { - var minDate = criteria.ReceiptDate.Value.AddDays(-3); - var maxDate = criteria.ReceiptDate.Value.AddDays(3); - return query.Where(t => t.Date >= minDate && t.Date <= maxDate); - } - - return query; - } - - private static List SortByMerchantRelevance(List candidates, string merchantName) - { - var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - - return candidates - .OrderByDescending(t => - { - var merchantNameLower = t.Merchant?.Name?.ToLower() ?? ""; - var transactionNameLower = t.Name?.ToLower() ?? ""; - - // Exact match gets highest score - if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower()) - return 1000; - - // Count matching words - var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - var transactionWords = transactionNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); - - var merchantMatches = receiptWords.Count(rw => merchantWords.Any(mw => mw.Contains(rw) || rw.Contains(mw))); - var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw))); - - // Return the higher match count - return Math.Max(merchantMatches * 10, transactionMatches * 10); - }) - .ThenByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .ToList(); - } - - private static List FilterByAmountTolerance(List candidates, decimal total) - { - var receiptTotal = Math.Round(Math.Abs(total), 2); - var tolerance = receiptTotal * 0.10m; // 10% tolerance - var minAmount = receiptTotal - tolerance; - var maxAmount = receiptTotal + tolerance; - - return candidates - .Where(t => - { - var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); - return transactionAmount >= minAmount && transactionAmount <= maxAmount; - }) - .ToList(); - } - - private static List ConvertToMatches(List candidates, ReceiptMatchCriteria criteria) - { - return candidates.Select(t => - { - var match = new TransactionMatch - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Amount = t.Amount, - MerchantName = t.Merchant?.Name, - PaymentMethod = t.PaymentMethodLabel, - IsExactAmount = false, - IsCloseAmount = false - }; - - // Amount matching flags - if (criteria.Total.HasValue) - { - var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2); - var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); - match.IsExactAmount = transactionAmount == receiptTotal; - var tolerance = receiptTotal * 0.10m; - match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance; - } - - return match; - }).ToList(); - } - - private async Task> GetFallbackMatches(HashSet excludeIds) - { - return await _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Where(t => !excludeIds.Contains(t.Id)) - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .Take(50) - .Select(t => new TransactionMatch - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Amount = t.Amount, - MerchantName = t.Merchant != null ? t.Merchant.Name : null, - PaymentMethod = t.PaymentMethodLabel, - IsExactAmount = false, - IsCloseAmount = false - }) - .ToListAsync(); - } -} - -/// -/// Criteria for matching receipts to transactions. -/// -public class ReceiptMatchCriteria -{ - public DateTime? ReceiptDate { get; set; } - public DateTime? DueDate { get; set; } - public decimal? Total { get; set; } - public string? MerchantName { get; set; } - public HashSet ExcludeTransactionIds { get; set; } = new(); -} - -/// -/// Represents a transaction that matches a receipt, with scoring information. -/// -public class TransactionMatch -{ - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public decimal Amount { get; set; } - public string? MerchantName { get; set; } - public string PaymentMethod { get; set; } = ""; - public bool IsExactAmount { get; set; } - public bool IsCloseAmount { get; set; } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for matching receipts to transactions based on date, merchant, and amount. +/// +public interface IReceiptMatchingService +{ + /// + /// Finds matching transactions for a receipt based on date range, merchant name, + /// and amount tolerance. Returns transactions sorted by relevance. + /// + Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria); + + /// + /// Gets a set of transaction IDs that already have receipts mapped to them. + /// + Task> GetTransactionIdsWithReceiptsAsync(); +} + +public class ReceiptMatchingService : IReceiptMatchingService +{ + private readonly MoneyMapContext _db; + + public ReceiptMatchingService(MoneyMapContext db) + { + _db = db; + } + + public async Task> GetTransactionIdsWithReceiptsAsync() + { + var transactionIds = await _db.Receipts + .Where(r => r.TransactionId != null) + .Select(r => r.TransactionId!.Value) + .ToListAsync(); + + return new HashSet(transactionIds); + } + + public async Task> FindMatchingTransactionsAsync(ReceiptMatchCriteria criteria) + { + var query = _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .Where(t => !criteria.ExcludeTransactionIds.Contains(t.Id)) + .AsQueryable(); + + // Apply date filtering based on receipt type + query = ApplyDateFilter(query, criteria); + + // Get all candidates within date range + var candidates = await query.ToListAsync(); + + // Sort by merchant/name relevance using word matching + if (!string.IsNullOrWhiteSpace(criteria.MerchantName)) + { + candidates = SortByMerchantRelevance(candidates, criteria.MerchantName); + } + else + { + // No merchant filter, just sort by date + candidates = candidates + .OrderByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .ToList(); + } + + // Filter by amount (±10% tolerance) if receipt has a total + if (criteria.Total.HasValue) + { + candidates = FilterByAmountTolerance(candidates, criteria.Total.Value); + } + + // Convert to match results with scoring + var matches = ConvertToMatches(candidates, criteria); + + // If no date-filtered matches, fall back to recent transactions + if (!matches.Any() && !criteria.ReceiptDate.HasValue) + { + matches = await GetFallbackMatches(criteria.ExcludeTransactionIds); + } + + return matches; + } + + private static IQueryable ApplyDateFilter(IQueryable query, ReceiptMatchCriteria criteria) + { + // For bills with due dates: use range from bill date to due date + 5 days + // (to account for auto-pay processing delays, weekends, etc.) + if (criteria.ReceiptDate.HasValue && criteria.DueDate.HasValue) + { + var minDate = criteria.ReceiptDate.Value; + var maxDate = criteria.DueDate.Value.AddDays(5); + return query.Where(t => t.Date >= minDate && t.Date <= maxDate); + } + + // For regular receipts: use +/- 3 days + if (criteria.ReceiptDate.HasValue) + { + var minDate = criteria.ReceiptDate.Value.AddDays(-3); + var maxDate = criteria.ReceiptDate.Value.AddDays(3); + return query.Where(t => t.Date >= minDate && t.Date <= maxDate); + } + + return query; + } + + private static List SortByMerchantRelevance(List candidates, string merchantName) + { + var receiptWords = merchantName.ToLower().Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); + + return candidates + .OrderByDescending(t => + { + var merchantNameLower = t.Merchant?.Name?.ToLower() ?? ""; + var transactionNameLower = t.Name?.ToLower() ?? ""; + + // Exact match gets highest score + if (merchantNameLower == merchantName.ToLower() || transactionNameLower == merchantName.ToLower()) + return 1000; + + // Count matching words + var merchantWords = merchantNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); + var transactionWords = transactionNameLower.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); + + var merchantMatches = receiptWords.Count(rw => merchantWords.Any(mw => mw.Contains(rw) || rw.Contains(mw))); + var transactionMatches = receiptWords.Count(rw => transactionWords.Any(tw => tw.Contains(rw) || rw.Contains(tw))); + + // Return the higher match count + return Math.Max(merchantMatches * 10, transactionMatches * 10); + }) + .ThenByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .ToList(); + } + + private static List FilterByAmountTolerance(List candidates, decimal total) + { + var receiptTotal = Math.Round(Math.Abs(total), 2); + var tolerance = receiptTotal * 0.10m; // 10% tolerance + var minAmount = receiptTotal - tolerance; + var maxAmount = receiptTotal + tolerance; + + return candidates + .Where(t => + { + var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); + return transactionAmount >= minAmount && transactionAmount <= maxAmount; + }) + .ToList(); + } + + private static List ConvertToMatches(List candidates, ReceiptMatchCriteria criteria) + { + return candidates.Select(t => + { + var match = new TransactionMatch + { + Id = t.Id, + Date = t.Date, + Name = t.Name, + Amount = t.Amount, + MerchantName = t.Merchant?.Name, + PaymentMethod = t.PaymentMethodLabel, + IsExactAmount = false, + IsCloseAmount = false + }; + + // Amount matching flags + if (criteria.Total.HasValue) + { + var receiptTotal = Math.Round(Math.Abs(criteria.Total.Value), 2); + var transactionAmount = Math.Round(Math.Abs(t.Amount), 2); + match.IsExactAmount = transactionAmount == receiptTotal; + var tolerance = receiptTotal * 0.10m; + match.IsCloseAmount = !match.IsExactAmount && Math.Abs(transactionAmount - receiptTotal) <= tolerance; + } + + return match; + }).ToList(); + } + + private async Task> GetFallbackMatches(HashSet excludeIds) + { + return await _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .Where(t => !excludeIds.Contains(t.Id)) + .OrderByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .Take(50) + .Select(t => new TransactionMatch + { + Id = t.Id, + Date = t.Date, + Name = t.Name, + Amount = t.Amount, + MerchantName = t.Merchant != null ? t.Merchant.Name : null, + PaymentMethod = t.PaymentMethodLabel, + IsExactAmount = false, + IsCloseAmount = false + }) + .ToListAsync(); + } +} + +/// +/// Criteria for matching receipts to transactions. +/// +public class ReceiptMatchCriteria +{ + public DateTime? ReceiptDate { get; set; } + public DateTime? DueDate { get; set; } + public decimal? Total { get; set; } + public string? MerchantName { get; set; } + public HashSet ExcludeTransactionIds { get; set; } = new(); +} + +/// +/// Represents a transaction that matches a receipt, with scoring information. +/// +public class TransactionMatch +{ + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public decimal Amount { get; set; } + public string? MerchantName { get; set; } + public string PaymentMethod { get; set; } = ""; + public bool IsExactAmount { get; set; } + public bool IsCloseAmount { get; set; } +} diff --git a/MoneyMap.Core/Services/ReceiptParseQueue.cs b/MoneyMap.Core/Services/ReceiptParseQueue.cs index 0713466..e6dbef3 100644 --- a/MoneyMap.Core/Services/ReceiptParseQueue.cs +++ b/MoneyMap.Core/Services/ReceiptParseQueue.cs @@ -1,56 +1,56 @@ -using System.Threading.Channels; - -namespace MoneyMap.Services -{ - public interface IReceiptParseQueue - { - ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default); - ValueTask EnqueueManyAsync(IEnumerable receiptIds, CancellationToken ct = default); - ValueTask DequeueAsync(CancellationToken ct); - int QueueLength { get; } - long? CurrentlyProcessingId { get; } - void SetCurrentlyProcessing(long? receiptId); - } - - public class ReceiptParseQueue : IReceiptParseQueue - { - private readonly Channel _channel = Channel.CreateUnbounded( - 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 receiptIds, CancellationToken ct = default) - { - foreach (var id in receiptIds) - { - await _channel.Writer.WriteAsync(id, ct); - } - } - - public async ValueTask DequeueAsync(CancellationToken ct) - { - return await _channel.Reader.ReadAsync(ct); - } - } -} +using System.Threading.Channels; + +namespace MoneyMap.Services +{ + public interface IReceiptParseQueue + { + ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default); + ValueTask EnqueueManyAsync(IEnumerable receiptIds, CancellationToken ct = default); + ValueTask DequeueAsync(CancellationToken ct); + int QueueLength { get; } + long? CurrentlyProcessingId { get; } + void SetCurrentlyProcessing(long? receiptId); + } + + public class ReceiptParseQueue : IReceiptParseQueue + { + private readonly Channel _channel = Channel.CreateUnbounded( + 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 receiptIds, CancellationToken ct = default) + { + foreach (var id in receiptIds) + { + await _channel.Writer.WriteAsync(id, ct); + } + } + + public async ValueTask DequeueAsync(CancellationToken ct) + { + return await _channel.Reader.ReadAsync(ct); + } + } +} diff --git a/MoneyMap.Core/Services/ReferenceDataService.cs b/MoneyMap.Core/Services/ReferenceDataService.cs index d85f7ae..a613020 100644 --- a/MoneyMap.Core/Services/ReferenceDataService.cs +++ b/MoneyMap.Core/Services/ReferenceDataService.cs @@ -1,81 +1,81 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for retrieving reference/lookup data used in dropdowns and filters. -/// -public interface IReferenceDataService -{ - /// - /// Gets all distinct categories from transactions, sorted alphabetically. - /// - Task> GetAvailableCategoriesAsync(); - - /// - /// Gets all merchants, sorted by name. - /// - Task> GetAvailableMerchantsAsync(); - - /// - /// Gets all cards with optional account information included. - /// - Task> GetAvailableCardsAsync(bool includeAccount = true); - - /// - /// Gets all accounts, sorted by institution and last4. - /// - Task> GetAvailableAccountsAsync(); -} - -public class ReferenceDataService : IReferenceDataService -{ - private readonly MoneyMapContext _db; - - public ReferenceDataService(MoneyMapContext db) - { - _db = db; - } - - public async Task> GetAvailableCategoriesAsync() - { - return await _db.Transactions - .Select(t => t.Category ?? "") - .Where(c => !string.IsNullOrWhiteSpace(c)) - .Distinct() - .OrderBy(c => c) - .ToListAsync(); - } - - public async Task> GetAvailableMerchantsAsync() - { - return await _db.Merchants - .OrderBy(m => m.Name) - .ToListAsync(); - } - - public async Task> GetAvailableCardsAsync(bool includeAccount = true) - { - var query = _db.Cards.AsQueryable(); - - if (includeAccount) - { - query = query.Include(c => c.Account); - } - - return await query - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .ToListAsync(); - } - - public async Task> GetAvailableAccountsAsync() - { - return await _db.Accounts - .OrderBy(a => a.Institution) - .ThenBy(a => a.Last4) - .ToListAsync(); - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for retrieving reference/lookup data used in dropdowns and filters. +/// +public interface IReferenceDataService +{ + /// + /// Gets all distinct categories from transactions, sorted alphabetically. + /// + Task> GetAvailableCategoriesAsync(); + + /// + /// Gets all merchants, sorted by name. + /// + Task> GetAvailableMerchantsAsync(); + + /// + /// Gets all cards with optional account information included. + /// + Task> GetAvailableCardsAsync(bool includeAccount = true); + + /// + /// Gets all accounts, sorted by institution and last4. + /// + Task> GetAvailableAccountsAsync(); +} + +public class ReferenceDataService : IReferenceDataService +{ + private readonly MoneyMapContext _db; + + public ReferenceDataService(MoneyMapContext db) + { + _db = db; + } + + public async Task> GetAvailableCategoriesAsync() + { + return await _db.Transactions + .Select(t => t.Category ?? "") + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + } + + public async Task> GetAvailableMerchantsAsync() + { + return await _db.Merchants + .OrderBy(m => m.Name) + .ToListAsync(); + } + + public async Task> GetAvailableCardsAsync(bool includeAccount = true) + { + var query = _db.Cards.AsQueryable(); + + if (includeAccount) + { + query = query.Include(c => c.Account); + } + + return await query + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + } + + public async Task> GetAvailableAccountsAsync() + { + return await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + } +} diff --git a/MoneyMap.Core/Services/TransactionAICategorizer.cs b/MoneyMap.Core/Services/TransactionAICategorizer.cs index 7cf03a3..4a066c9 100644 --- a/MoneyMap.Core/Services/TransactionAICategorizer.cs +++ b/MoneyMap.Core/Services/TransactionAICategorizer.cs @@ -1,467 +1,467 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MoneyMap.Services; - -public interface ITransactionAICategorizer -{ - Task ProposeCategorizationAsync(Transaction transaction, string? model = null); - Task> ProposeBatchCategorizationAsync(List transactions, string? model = null); - Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true); -} - -public class TransactionAICategorizer : ITransactionAICategorizer -{ - private readonly HttpClient _httpClient; - private readonly MoneyMapContext _db; - private readonly IConfiguration _config; - private readonly LlamaCppVisionClient _llamaClient; - private readonly ILogger _logger; - - public TransactionAICategorizer( - HttpClient httpClient, - MoneyMapContext db, - IConfiguration config, - LlamaCppVisionClient llamaClient, - ILogger logger) - { - _httpClient = httpClient; - _db = db; - _config = config; - _llamaClient = llamaClient; - _logger = logger; - } - - public async Task ProposeCategorizationAsync(Transaction transaction, string? model = null) - { - var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; - var prompt = await BuildPromptAsync(transaction); - - var response = await CallModelAsync(prompt, selectedModel); - - if (response == null) - return null; - - return new AICategoryProposal - { - TransactionId = transaction.Id, - Category = response.Category ?? "", - CanonicalMerchant = response.CanonicalMerchant, - Pattern = response.Pattern, - Priority = response.Priority, - Confidence = response.Confidence, - Reasoning = response.Reasoning, - CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule - }; - } - - public async Task> ProposeBatchCategorizationAsync(List transactions, string? model = null) - { - var proposals = new List(); - - // 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, allRules, model); - if (result != null) - proposals.Add(result); - } - - return proposals; - } - - private async Task ProposeCategorizationWithCategoriesAsync( - Transaction transaction, - List existingCategories, - List allRules, - string? model = null) - { - var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; - - // 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(); - - var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules); - - var response = await CallModelAsync(prompt, selectedModel); - - if (response == null) - return null; - - return new AICategoryProposal - { - TransactionId = transaction.Id, - Category = response.Category ?? "", - CanonicalMerchant = response.CanonicalMerchant, - Pattern = response.Pattern, - Priority = response.Priority, - Confidence = response.Confidence, - Reasoning = response.Reasoning, - CreateRule = response.Confidence >= 0.7m - }; - } - - public async Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true) - { - var transaction = await _db.Transactions.FindAsync(transactionId); - if (transaction == null) - return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" }; - - // Update transaction category - transaction.Category = proposal.Category; - - // Handle merchant - if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant)) - { - var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant); - if (merchant == null) - { - merchant = new Merchant { Name = proposal.CanonicalMerchant }; - _db.Merchants.Add(merchant); - await _db.SaveChangesAsync(); - } - transaction.MerchantId = merchant.Id; - } - - bool ruleCreated = false; - bool ruleUpdated = false; - - // Create or update category mapping rule if requested - if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)) - { - var existingRule = await _db.CategoryMappings - .FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern); - - if (existingRule == null) - { - var newMapping = new CategoryMapping - { - Category = proposal.Category, - Pattern = proposal.Pattern, - 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; - } - } - - await _db.SaveChangesAsync(); - - return new ApplyProposalResult - { - Success = true, - RuleCreated = ruleCreated, - RuleUpdated = ruleUpdated - }; - } - - private async Task BuildPromptAsync(Transaction transaction) - { - // Get existing categories from database for better suggestions - var existingCategories = await _db.CategoryMappings - .Select(m => m.Category) - .Distinct() - .OrderBy(c => c) - .ToListAsync(); - - // 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 existingCategories) - { - return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List()); - } - - private string BuildPromptWithCategoriesAndRules(Transaction transaction, List existingCategories, List matchingRules) - { - var categoryList = existingCategories.Any() - ? string.Join(", ", existingCategories) - : "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance"; - - var sb = new StringBuilder(); - sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name."); - sb.AppendLine(); - sb.AppendLine("Transaction Details:"); - sb.AppendLine($"- Name: \"{transaction.Name}\""); - sb.AppendLine($"- Memo: \"{transaction.Memo}\""); - sb.AppendLine($"- Amount: {transaction.Amount:C}"); - sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}"); - sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}"); - - if (!string.IsNullOrWhiteSpace(transaction.Category)) - sb.AppendLine($"- Current Category: \"{transaction.Category}\""); - - if (transaction.Merchant != null) - sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\""); - - if (transaction.Card != null) - sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}"); - - if (transaction.Account != null) - sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}"); - - if (!string.IsNullOrWhiteSpace(transaction.Notes)) - sb.AppendLine($"- Notes: \"{transaction.Notes}\""); - - if (!string.IsNullOrWhiteSpace(transaction.Last4)) - sb.AppendLine($"- Last 4 digits: {transaction.Last4}"); - - 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(); - sb.AppendLine("Provide your analysis in JSON format:"); - sb.AppendLine("{"); - sb.AppendLine(" \"category\": \"Category name\","); - sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\","); - 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 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 CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini") - { - try - { - var requestBody = new - { - model = model, - messages = new[] - { - new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." }, - new { role = "user", content = prompt } - }, - temperature = 0.1, - max_tokens = 300 - }; - - var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions"); - request.Headers.Add("Authorization", $"Bearer {apiKey}"); - request.Content = new StringContent( - JsonSerializer.Serialize(requestBody), - Encoding.UTF8, - "application/json" - ); - - var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - return null; - - var json = await response.Content.ReadAsStringAsync(); - var apiResponse = JsonSerializer.Deserialize(json); - - if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0) - return null; - - var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content); - if (string.IsNullOrWhiteSpace(content)) - return null; - - return JsonSerializer.Deserialize(content, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "OpenAI API request failed: {Message}", ex.Message); - return null; - } - catch (JsonException ex) - { - _logger.LogError(ex, "Failed to parse OpenAI response JSON: {Message}", ex.Message); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error calling OpenAI API: {Message}", ex.Message); - return null; - } - } - - private async Task CallLlamaCppAsync(string prompt, string? model = null) - { - try - { - var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; - var systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only."; - var fullPrompt = $"{systemPrompt}\n\n{prompt}"; - - var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel); - - if (!result.IsSuccess) - { - _logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage); - return null; - } - - return JsonSerializer.Deserialize(result.Content ?? "", new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - } - catch (JsonException ex) - { - _logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message); - return null; - } - } - - // OpenAI API response models - private class OpenAIChatResponse - { - [JsonPropertyName("choices")] - public Choice[]? Choices { get; set; } - } - - private class Choice - { - [JsonPropertyName("message")] - public Message? Message { get; set; } - } - - private class Message - { - [JsonPropertyName("content")] - public string? Content { get; set; } - } - - private class AICategorizationResponse - { - [JsonPropertyName("category")] - public string? Category { get; set; } - - [JsonPropertyName("canonical_merchant")] - public string? CanonicalMerchant { get; set; } - - [JsonPropertyName("pattern")] - public string? Pattern { get; set; } - - [JsonPropertyName("priority")] - public int Priority { get; set; } - - [JsonPropertyName("confidence")] - public decimal Confidence { get; set; } - - [JsonPropertyName("reasoning")] - public string? Reasoning { get; set; } - } -} - -public class AICategoryProposal -{ - public long TransactionId { get; set; } - public string Category { get; set; } = ""; - public string? CanonicalMerchant { get; set; } - public string? Pattern { get; set; } - public int Priority { get; set; } - public decimal Confidence { get; set; } - public string? Reasoning { get; set; } - public bool CreateRule { get; set; } -} - -public class ApplyProposalResult -{ - public bool Success { get; set; } - public bool RuleCreated { get; set; } - public bool RuleUpdated { get; set; } - public string? ErrorMessage { get; set; } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MoneyMap.Services; + +public interface ITransactionAICategorizer +{ + Task ProposeCategorizationAsync(Transaction transaction, string? model = null); + Task> ProposeBatchCategorizationAsync(List transactions, string? model = null); + Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true); +} + +public class TransactionAICategorizer : ITransactionAICategorizer +{ + private readonly HttpClient _httpClient; + private readonly MoneyMapContext _db; + private readonly IConfiguration _config; + private readonly LlamaCppVisionClient _llamaClient; + private readonly ILogger _logger; + + public TransactionAICategorizer( + HttpClient httpClient, + MoneyMapContext db, + IConfiguration config, + LlamaCppVisionClient llamaClient, + ILogger logger) + { + _httpClient = httpClient; + _db = db; + _config = config; + _llamaClient = llamaClient; + _logger = logger; + } + + public async Task ProposeCategorizationAsync(Transaction transaction, string? model = null) + { + var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + var prompt = await BuildPromptAsync(transaction); + + var response = await CallModelAsync(prompt, selectedModel); + + if (response == null) + return null; + + return new AICategoryProposal + { + TransactionId = transaction.Id, + Category = response.Category ?? "", + CanonicalMerchant = response.CanonicalMerchant, + Pattern = response.Pattern, + Priority = response.Priority, + Confidence = response.Confidence, + Reasoning = response.Reasoning, + CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule + }; + } + + public async Task> ProposeBatchCategorizationAsync(List transactions, string? model = null) + { + var proposals = new List(); + + // 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, allRules, model); + if (result != null) + proposals.Add(result); + } + + return proposals; + } + + private async Task ProposeCategorizationWithCategoriesAsync( + Transaction transaction, + List existingCategories, + List allRules, + string? model = null) + { + var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + + // 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(); + + var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules); + + var response = await CallModelAsync(prompt, selectedModel); + + if (response == null) + return null; + + return new AICategoryProposal + { + TransactionId = transaction.Id, + Category = response.Category ?? "", + CanonicalMerchant = response.CanonicalMerchant, + Pattern = response.Pattern, + Priority = response.Priority, + Confidence = response.Confidence, + Reasoning = response.Reasoning, + CreateRule = response.Confidence >= 0.7m + }; + } + + public async Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true) + { + var transaction = await _db.Transactions.FindAsync(transactionId); + if (transaction == null) + return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" }; + + // Update transaction category + transaction.Category = proposal.Category; + + // Handle merchant + if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant)) + { + var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant); + if (merchant == null) + { + merchant = new Merchant { Name = proposal.CanonicalMerchant }; + _db.Merchants.Add(merchant); + await _db.SaveChangesAsync(); + } + transaction.MerchantId = merchant.Id; + } + + bool ruleCreated = false; + bool ruleUpdated = false; + + // Create or update category mapping rule if requested + if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)) + { + var existingRule = await _db.CategoryMappings + .FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern); + + if (existingRule == null) + { + var newMapping = new CategoryMapping + { + Category = proposal.Category, + Pattern = proposal.Pattern, + 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; + } + } + + await _db.SaveChangesAsync(); + + return new ApplyProposalResult + { + Success = true, + RuleCreated = ruleCreated, + RuleUpdated = ruleUpdated + }; + } + + private async Task BuildPromptAsync(Transaction transaction) + { + // Get existing categories from database for better suggestions + var existingCategories = await _db.CategoryMappings + .Select(m => m.Category) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + + // 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 existingCategories) + { + return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List()); + } + + private string BuildPromptWithCategoriesAndRules(Transaction transaction, List existingCategories, List matchingRules) + { + var categoryList = existingCategories.Any() + ? string.Join(", ", existingCategories) + : "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance"; + + var sb = new StringBuilder(); + sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name."); + sb.AppendLine(); + sb.AppendLine("Transaction Details:"); + sb.AppendLine($"- Name: \"{transaction.Name}\""); + sb.AppendLine($"- Memo: \"{transaction.Memo}\""); + sb.AppendLine($"- Amount: {transaction.Amount:C}"); + sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}"); + sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}"); + + if (!string.IsNullOrWhiteSpace(transaction.Category)) + sb.AppendLine($"- Current Category: \"{transaction.Category}\""); + + if (transaction.Merchant != null) + sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\""); + + if (transaction.Card != null) + sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}"); + + if (transaction.Account != null) + sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}"); + + if (!string.IsNullOrWhiteSpace(transaction.Notes)) + sb.AppendLine($"- Notes: \"{transaction.Notes}\""); + + if (!string.IsNullOrWhiteSpace(transaction.Last4)) + sb.AppendLine($"- Last 4 digits: {transaction.Last4}"); + + 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(); + sb.AppendLine("Provide your analysis in JSON format:"); + sb.AppendLine("{"); + sb.AppendLine(" \"category\": \"Category name\","); + sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\","); + 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 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 CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini") + { + try + { + var requestBody = new + { + model = model, + messages = new[] + { + new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." }, + new { role = "user", content = prompt } + }, + temperature = 0.1, + max_tokens = 300 + }; + + var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions"); + request.Headers.Add("Authorization", $"Bearer {apiKey}"); + request.Content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json" + ); + + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + var apiResponse = JsonSerializer.Deserialize(json); + + if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0) + return null; + + var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content); + if (string.IsNullOrWhiteSpace(content)) + return null; + + return JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "OpenAI API request failed: {Message}", ex.Message); + return null; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse OpenAI response JSON: {Message}", ex.Message); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error calling OpenAI API: {Message}", ex.Message); + return null; + } + } + + private async Task CallLlamaCppAsync(string prompt, string? model = null) + { + try + { + var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + var systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only."; + var fullPrompt = $"{systemPrompt}\n\n{prompt}"; + + var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel); + + if (!result.IsSuccess) + { + _logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage); + return null; + } + + return JsonSerializer.Deserialize(result.Content ?? "", new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message); + return null; + } + } + + // OpenAI API response models + private class OpenAIChatResponse + { + [JsonPropertyName("choices")] + public Choice[]? Choices { get; set; } + } + + private class Choice + { + [JsonPropertyName("message")] + public Message? Message { get; set; } + } + + private class Message + { + [JsonPropertyName("content")] + public string? Content { get; set; } + } + + private class AICategorizationResponse + { + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("canonical_merchant")] + public string? CanonicalMerchant { get; set; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; set; } + + [JsonPropertyName("priority")] + public int Priority { get; set; } + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } + + [JsonPropertyName("reasoning")] + public string? Reasoning { get; set; } + } +} + +public class AICategoryProposal +{ + public long TransactionId { get; set; } + public string Category { get; set; } = ""; + public string? CanonicalMerchant { get; set; } + public string? Pattern { get; set; } + public int Priority { get; set; } + public decimal Confidence { get; set; } + public string? Reasoning { get; set; } + public bool CreateRule { get; set; } +} + +public class ApplyProposalResult +{ + public bool Success { get; set; } + public bool RuleCreated { get; set; } + public bool RuleUpdated { get; set; } + public string? ErrorMessage { get; set; } +} diff --git a/MoneyMap.Core/Services/TransactionCategorizer.cs b/MoneyMap.Core/Services/TransactionCategorizer.cs index e961707..0758d25 100644 --- a/MoneyMap.Core/Services/TransactionCategorizer.cs +++ b/MoneyMap.Core/Services/TransactionCategorizer.cs @@ -1,263 +1,263 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services -{ - - public interface ITransactionCategorizer - { - Task CategorizeAsync(string merchantName, decimal? amount = null); - Task> GetAllMappingsAsync(); - Task SeedDefaultMappingsAsync(); - void InvalidateMappingsCache(); - } - - public class CategorizationResult - { - public string Category { get; set; } = string.Empty; - public int? MerchantId { get; set; } - } - - // ===== Service Implementation ===== - - public class TransactionCategorizer : ITransactionCategorizer - { - private readonly MoneyMapContext _db; - private readonly IMemoryCache _cache; - private const decimal GasStationThreshold = -20m; - private const string MappingsCacheKey = "CategoryMappings"; - private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); - - public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache) - { - _db = db; - _cache = cache; - } - - public void InvalidateMappingsCache() - { - _cache.Remove(MappingsCacheKey); - } - - public async Task CategorizeAsync(string merchantName, decimal? amount = null) - { - if (string.IsNullOrWhiteSpace(merchantName)) - return new CategorizationResult(); - - var merchantUpper = merchantName.ToUpperInvariant(); - - // Get cached mappings or load from database - var mappings = await GetCachedMappingsAsync(); - - // Special case: Gas stations with small purchases - if (amount.HasValue && amount.Value > GasStationThreshold) - { - var gasMapping = mappings.FirstOrDefault(m => - m.Category == "Gas & Auto" && - merchantUpper.Contains(m.Pattern.ToUpperInvariant())); - - if (gasMapping != null) - return new CategorizationResult - { - Category = "Convenience Store", - MerchantId = gasMapping.MerchantId - }; - } - - // Check each category's patterns - foreach (var mapping in mappings) - { - if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant())) - return new CategorizationResult - { - Category = mapping.Category, - MerchantId = mapping.MerchantId - }; - } - - return new CategorizationResult(); // No match - needs manual categorization - } - - private async Task> GetCachedMappingsAsync() - { - if (_cache.TryGetValue(MappingsCacheKey, out List? cachedMappings) && cachedMappings != null) - { - return cachedMappings; - } - - var mappings = await _db.CategoryMappings - .OrderByDescending(m => m.Priority) - .ThenBy(m => m.Category) - .ToListAsync(); - - _cache.Set(MappingsCacheKey, mappings, CacheDuration); - return mappings; - } - - public async Task> GetAllMappingsAsync() - { - var mappings = await GetCachedMappingsAsync(); - return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList(); - } - - public async Task SeedDefaultMappingsAsync() - { - // Check if mappings already exist - if (await _db.CategoryMappings.AnyAsync()) - return; - - var defaultMappings = GetDefaultMappings(); - - _db.CategoryMappings.AddRange(defaultMappings); - await _db.SaveChangesAsync(); - } - - private static List GetDefaultMappings() - { - var mappings = new List(); - - // Online Shopping - AddMappings("Online shopping", mappings, - "AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY", - "SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM", - "NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM", - "JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM"); - - // Walmart - AddMappings("Walmart Online", mappings, "WALMART.COM"); - AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C"); - - // Pizza - AddMappings("Pizza", mappings, - "CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ", - "PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES"); - - // Retail Stores - AddMappings("Brick/mortar store", mappings, - "DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE", - "WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES", - "GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT"); - - // Restaurants - AddMappings("Eat out / Restaurants", mappings, - "KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT", - "STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL", - "RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE", - "EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR", - "ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL"); - - // School - AddMappings("School", mappings, - "INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE"); - - // Health - AddMappings("Health", mappings, - "MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS", - "WHITEWATER EYE", "GIESTING FAMILY DENTIS"); - - // Gas & Auto (higher priority for special handling) - AddMappings("Gas & Auto", mappings, 100, - "SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO", - "WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR", - "COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC"); - - // Utilities - AddMappings("Utilities/Services", mappings, - "SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT", - "US MOBILE", "WHITEWATER VALLE", "RUMPKE"); - - // Entertainment - AddMappings("Entertainment", mappings, - "SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE", - "GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX", - "SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB"); - - // Banking (high priority to catch these first) - AddMappings("Banking", mappings, 200, - "ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE", - "MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT", - "MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE", - "FRGN TRANS FEE", "START SCHEDULED TRANSFER"); - - // Mortgage - AddMappings("Mortgage", mappings, "WAYNE BANK"); - - // Car Payment - AddMappings("Car Payment", mappings, "UNION SAVINGS AN"); - - // Convenience Store - AddMappings("Convenience Store", mappings, - "PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK"); - - // Income (high priority) - AddMappings("Income", mappings, 200, - "MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS", - "RPA PA", "REWARDS REDEEMED"); - - // Taxes - AddMappings("Taxes", mappings, "MYERS INCOME TAX"); - - // Insurance - AddMappings("Insurance", mappings, - "BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS"); - - // Credit Card Payment (high priority to catch before Banking) - AddMappings("Credit Card Payment", mappings, 200, - "PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU"); - - // Ice Cream - AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE"); - - // Government - AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK"); - - // Home Services - AddMappings("Home Services", mappings, "DUNGAN PLUMBING"); - - // Special Occasions - AddMappings("Special Occasions", mappings, - "CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR"); - - // Home Improvement - AddMappings("Home Improvement", mappings, - "SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907", - "123FILTER", "O-RING STORE"); - - // Software/Subscriptions - AddMappings("Software/Subscriptions", mappings, - "GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP", - "AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List"); - - return mappings; - } - - private static void AddMappings(string category, List mappings, params string[] patterns) - { - AddMappings(category, mappings, 0, patterns); - } - - private static void AddMappings(string category, List mappings, int priority, params string[] patterns) - { - foreach (var pattern in patterns) - { - mappings.Add(new CategoryMapping - { - Category = category, - Pattern = pattern, - MerchantId = null, // Will be set by users via UI - Priority = priority - }); - } - } - } - - // ===== Database Migration ===== - // Add this to your DbContext: - // public DbSet CategoryMappings { get; set; } - // - // Then create a migration: - // dotnet ef migrations add AddCategoryMappings - // dotnet ef database update +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + + public interface ITransactionCategorizer + { + Task CategorizeAsync(string merchantName, decimal? amount = null); + Task> GetAllMappingsAsync(); + Task SeedDefaultMappingsAsync(); + void InvalidateMappingsCache(); + } + + public class CategorizationResult + { + public string Category { get; set; } = string.Empty; + public int? MerchantId { get; set; } + } + + // ===== Service Implementation ===== + + public class TransactionCategorizer : ITransactionCategorizer + { + private readonly MoneyMapContext _db; + private readonly IMemoryCache _cache; + private const decimal GasStationThreshold = -20m; + private const string MappingsCacheKey = "CategoryMappings"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); + + public TransactionCategorizer(MoneyMapContext db, IMemoryCache cache) + { + _db = db; + _cache = cache; + } + + public void InvalidateMappingsCache() + { + _cache.Remove(MappingsCacheKey); + } + + public async Task CategorizeAsync(string merchantName, decimal? amount = null) + { + if (string.IsNullOrWhiteSpace(merchantName)) + return new CategorizationResult(); + + var merchantUpper = merchantName.ToUpperInvariant(); + + // Get cached mappings or load from database + var mappings = await GetCachedMappingsAsync(); + + // Special case: Gas stations with small purchases + if (amount.HasValue && amount.Value > GasStationThreshold) + { + var gasMapping = mappings.FirstOrDefault(m => + m.Category == "Gas & Auto" && + merchantUpper.Contains(m.Pattern.ToUpperInvariant())); + + if (gasMapping != null) + return new CategorizationResult + { + Category = "Convenience Store", + MerchantId = gasMapping.MerchantId + }; + } + + // Check each category's patterns + foreach (var mapping in mappings) + { + if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant())) + return new CategorizationResult + { + Category = mapping.Category, + MerchantId = mapping.MerchantId + }; + } + + return new CategorizationResult(); // No match - needs manual categorization + } + + private async Task> GetCachedMappingsAsync() + { + if (_cache.TryGetValue(MappingsCacheKey, out List? cachedMappings) && cachedMappings != null) + { + return cachedMappings; + } + + var mappings = await _db.CategoryMappings + .OrderByDescending(m => m.Priority) + .ThenBy(m => m.Category) + .ToListAsync(); + + _cache.Set(MappingsCacheKey, mappings, CacheDuration); + return mappings; + } + + public async Task> GetAllMappingsAsync() + { + var mappings = await GetCachedMappingsAsync(); + return mappings.OrderBy(m => m.Category).ThenByDescending(m => m.Priority).ToList(); + } + + public async Task SeedDefaultMappingsAsync() + { + // Check if mappings already exist + if (await _db.CategoryMappings.AnyAsync()) + return; + + var defaultMappings = GetDefaultMappings(); + + _db.CategoryMappings.AddRange(defaultMappings); + await _db.SaveChangesAsync(); + } + + private static List GetDefaultMappings() + { + var mappings = new List(); + + // Online Shopping + AddMappings("Online shopping", mappings, + "AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY", + "SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM", + "NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM", + "JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS", "TARGET.COM"); + + // Walmart + AddMappings("Walmart Online", mappings, "WALMART.COM"); + AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C"); + + // Pizza + AddMappings("Pizza", mappings, + "CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ", + "PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES"); + + // Retail Stores + AddMappings("Brick/mortar store", mappings, + "DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE", + "WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES", + "GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT"); + + // Restaurants + AddMappings("Eat out / Restaurants", mappings, + "KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT", + "STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL", + "RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE", + "EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR", + "ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL"); + + // School + AddMappings("School", mappings, + "INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE"); + + // Health + AddMappings("Health", mappings, + "MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS", + "WHITEWATER EYE", "GIESTING FAMILY DENTIS"); + + // Gas & Auto (higher priority for special handling) + AddMappings("Gas & Auto", mappings, 100, + "SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO", + "WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR", + "COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC"); + + // Utilities + AddMappings("Utilities/Services", mappings, + "SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT", + "US MOBILE", "WHITEWATER VALLE", "RUMPKE"); + + // Entertainment + AddMappings("Entertainment", mappings, + "SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE", + "GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX", + "SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB"); + + // Banking (high priority to catch these first) + AddMappings("Banking", mappings, 200, + "ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE", + "MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT", + "MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE", + "FRGN TRANS FEE", "START SCHEDULED TRANSFER"); + + // Mortgage + AddMappings("Mortgage", mappings, "WAYNE BANK"); + + // Car Payment + AddMappings("Car Payment", mappings, "UNION SAVINGS AN"); + + // Convenience Store + AddMappings("Convenience Store", mappings, + "PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK"); + + // Income (high priority) + AddMappings("Income", mappings, 200, + "MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS", + "RPA PA", "REWARDS REDEEMED"); + + // Taxes + AddMappings("Taxes", mappings, "MYERS INCOME TAX"); + + // Insurance + AddMappings("Insurance", mappings, + "BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS"); + + // Credit Card Payment (high priority to catch before Banking) + AddMappings("Credit Card Payment", mappings, 200, + "PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU"); + + // Ice Cream + AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE"); + + // Government + AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK"); + + // Home Services + AddMappings("Home Services", mappings, "DUNGAN PLUMBING"); + + // Special Occasions + AddMappings("Special Occasions", mappings, + "CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR"); + + // Home Improvement + AddMappings("Home Improvement", mappings, + "SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907", + "123FILTER", "O-RING STORE"); + + // Software/Subscriptions + AddMappings("Software/Subscriptions", mappings, + "GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP", + "AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List"); + + return mappings; + } + + private static void AddMappings(string category, List mappings, params string[] patterns) + { + AddMappings(category, mappings, 0, patterns); + } + + private static void AddMappings(string category, List mappings, int priority, params string[] patterns) + { + foreach (var pattern in patterns) + { + mappings.Add(new CategoryMapping + { + Category = category, + Pattern = pattern, + MerchantId = null, // Will be set by users via UI + Priority = priority + }); + } + } + } + + // ===== Database Migration ===== + // Add this to your DbContext: + // public DbSet CategoryMappings { get; set; } + // + // Then create a migration: + // dotnet ef migrations add AddCategoryMappings + // dotnet ef database update } \ No newline at end of file diff --git a/MoneyMap.Core/Services/TransactionFilters.cs b/MoneyMap.Core/Services/TransactionFilters.cs index 074e372..abb9328 100644 --- a/MoneyMap.Core/Services/TransactionFilters.cs +++ b/MoneyMap.Core/Services/TransactionFilters.cs @@ -1,37 +1,37 @@ -using MoneyMap.Models; - -namespace MoneyMap.Services -{ - /// - /// Helper class for filtering transactions in queries - /// - public static class TransactionFilters - { - /// - /// Categories that represent transfers between accounts, not actual spending. - /// These should be excluded from spending reports and analytics. - /// - public static readonly string[] TransferCategories = new[] - { - "Credit Card Payment", - "Bank Transfer", - "Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere - }; - - /// - /// Filter to exclude transfer transactions from spending queries - /// - public static IQueryable ExcludeTransfers(this IQueryable query) - { - return query.Where(t => !TransferCategories.Contains(t.Category ?? "")); - } - - /// - /// Check if a category represents a transfer (not actual spending) - /// - public static bool IsTransferCategory(string? category) - { - return TransferCategories.Contains(category ?? ""); - } - } -} +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + /// + /// Helper class for filtering transactions in queries + /// + public static class TransactionFilters + { + /// + /// Categories that represent transfers between accounts, not actual spending. + /// These should be excluded from spending reports and analytics. + /// + public static readonly string[] TransferCategories = new[] + { + "Credit Card Payment", + "Bank Transfer", + "Banking" // Includes ATM withdrawals, transfers, fees that offset elsewhere + }; + + /// + /// Filter to exclude transfer transactions from spending queries + /// + public static IQueryable ExcludeTransfers(this IQueryable query) + { + return query.Where(t => !TransferCategories.Contains(t.Category ?? "")); + } + + /// + /// Check if a category represents a transfer (not actual spending) + /// + public static bool IsTransferCategory(string? category) + { + return TransferCategories.Contains(category ?? ""); + } + } +} diff --git a/MoneyMap.Core/Services/TransactionImporter.cs b/MoneyMap.Core/Services/TransactionImporter.cs index dbee359..25cb89f 100644 --- a/MoneyMap.Core/Services/TransactionImporter.cs +++ b/MoneyMap.Core/Services/TransactionImporter.cs @@ -1,167 +1,167 @@ -using CsvHelper; -using CsvHelper.Configuration; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using MoneyMap.Models.Import; -using System.Globalization; - -namespace MoneyMap.Services -{ - /// - /// Service for importing transactions from CSV files. - /// - public interface ITransactionImporter - { - Task PreviewAsync(Stream csvStream, ImportContext context); - Task ImportAsync(List transactions); - } - - public class TransactionImporter : ITransactionImporter - { - private readonly MoneyMapContext _db; - private readonly ICardResolver _cardResolver; - - public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver) - { - _db = db; - _cardResolver = cardResolver; - } - - public async Task PreviewAsync(Stream csvStream, ImportContext context) - { - var previewItems = new List(); - var addedInThisBatch = new HashSet(); - - // First pass: read CSV to get date range and all transactions - var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>(); - DateTime? minDate = null; - DateTime? maxDate = null; - - using (var reader = new StreamReader(csvStream)) - using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) - { - HasHeaderRecord = true, - HeaderValidated = null, - MissingFieldFound = null - })) - { - csv.Read(); - csv.ReadHeader(); - var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false; - csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory)); - - while (csv.Read()) - { - var row = csv.GetRecord(); - - var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context); - if (!paymentResolution.IsSuccess) - return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!); - - var transaction = MapToTransaction(row, paymentResolution); - var key = new TransactionKey(transaction); - - csvTransactions.Add((row, transaction, key)); - - // Track date range - if (minDate == null || transaction.Date < minDate) minDate = transaction.Date; - if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date; - } - } - - // Load existing transactions within the date range for fast duplicate checking - HashSet existingTransactions; - if (minDate.HasValue && maxDate.HasValue) - { - // Add a buffer of 1 day on each side to catch any edge cases - var startDate = minDate.Value.AddDays(-1); - var endDate = maxDate.Value.AddDays(1); - - existingTransactions = await _db.Transactions - .Where(t => t.Date >= startDate && t.Date <= endDate) - .Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId)) - .ToHashSetAsync(); - } - else - { - existingTransactions = new HashSet(); - } - - // Second pass: check for duplicates and build preview - foreach (var (row, transaction, key) in csvTransactions) - { - // Fast in-memory duplicate checking - bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key); - - previewItems.Add(new TransactionPreview - { - Transaction = transaction, - IsDuplicate = isDuplicate, - PaymentMethodLabel = GetPaymentLabel(transaction, context) - }); - - addedInThisBatch.Add(key); - } - - // Order by date descending (newest first) - var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList(); - - return PreviewOperationResult.Success(orderedPreview); - } - - public async Task ImportAsync(List transactions) - { - int inserted = 0; - int skipped = 0; - - foreach (var transaction in transactions) - { - _db.Transactions.Add(transaction); - inserted++; - } - - await _db.SaveChangesAsync(); - - var result = new ImportResult( - transactions.Count, - inserted, - skipped, - null - ); - - return ImportOperationResult.Success(result); - } - - private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution) - { - return new Transaction - { - Date = row.Date, - TransactionType = row.Transaction?.Trim() ?? "", - Name = row.Name?.Trim() ?? "", - Memo = row.Memo?.Trim() ?? "", - Amount = row.Amount, - Category = (row.Category ?? "").Trim(), - Last4 = paymentResolution.Last4, - CardId = paymentResolution.CardId, - AccountId = paymentResolution.AccountId!.Value - }; - } - - private string GetPaymentLabel(Transaction transaction, ImportContext context) - { - var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId); - var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}"; - - if (transaction.CardId.HasValue) - { - var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId); - var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}"; - return $"{cardLabel} → {accountLabel}"; - } - - return accountLabel; - } - } -} +using CsvHelper; +using CsvHelper.Configuration; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Models.Import; +using System.Globalization; + +namespace MoneyMap.Services +{ + /// + /// Service for importing transactions from CSV files. + /// + public interface ITransactionImporter + { + Task PreviewAsync(Stream csvStream, ImportContext context); + Task ImportAsync(List transactions); + } + + public class TransactionImporter : ITransactionImporter + { + private readonly MoneyMapContext _db; + private readonly ICardResolver _cardResolver; + + public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver) + { + _db = db; + _cardResolver = cardResolver; + } + + public async Task PreviewAsync(Stream csvStream, ImportContext context) + { + var previewItems = new List(); + var addedInThisBatch = new HashSet(); + + // First pass: read CSV to get date range and all transactions + var csvTransactions = new List<(TransactionCsvRow Row, Transaction Transaction, TransactionKey Key)>(); + DateTime? minDate = null; + DateTime? maxDate = null; + + using (var reader = new StreamReader(csvStream)) + using (var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + HeaderValidated = null, + MissingFieldFound = null + })) + { + csv.Read(); + csv.ReadHeader(); + var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false; + csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory)); + + while (csv.Read()) + { + var row = csv.GetRecord(); + + var paymentResolution = await _cardResolver.ResolvePaymentAsync(row.Memo, context); + if (!paymentResolution.IsSuccess) + return PreviewOperationResult.Failure(paymentResolution.ErrorMessage!); + + var transaction = MapToTransaction(row, paymentResolution); + var key = new TransactionKey(transaction); + + csvTransactions.Add((row, transaction, key)); + + // Track date range + if (minDate == null || transaction.Date < minDate) minDate = transaction.Date; + if (maxDate == null || transaction.Date > maxDate) maxDate = transaction.Date; + } + } + + // Load existing transactions within the date range for fast duplicate checking + HashSet existingTransactions; + if (minDate.HasValue && maxDate.HasValue) + { + // Add a buffer of 1 day on each side to catch any edge cases + var startDate = minDate.Value.AddDays(-1); + var endDate = maxDate.Value.AddDays(1); + + existingTransactions = await _db.Transactions + .Where(t => t.Date >= startDate && t.Date <= endDate) + .Select(t => new TransactionKey(t.Date, t.Amount, t.Name, t.Memo, t.AccountId, t.CardId)) + .ToHashSetAsync(); + } + else + { + existingTransactions = new HashSet(); + } + + // Second pass: check for duplicates and build preview + foreach (var (row, transaction, key) in csvTransactions) + { + // Fast in-memory duplicate checking + bool isDuplicate = addedInThisBatch.Contains(key) || existingTransactions.Contains(key); + + previewItems.Add(new TransactionPreview + { + Transaction = transaction, + IsDuplicate = isDuplicate, + PaymentMethodLabel = GetPaymentLabel(transaction, context) + }); + + addedInThisBatch.Add(key); + } + + // Order by date descending (newest first) + var orderedPreview = previewItems.OrderByDescending(p => p.Transaction.Date).ToList(); + + return PreviewOperationResult.Success(orderedPreview); + } + + public async Task ImportAsync(List transactions) + { + int inserted = 0; + int skipped = 0; + + foreach (var transaction in transactions) + { + _db.Transactions.Add(transaction); + inserted++; + } + + await _db.SaveChangesAsync(); + + var result = new ImportResult( + transactions.Count, + inserted, + skipped, + null + ); + + return ImportOperationResult.Success(result); + } + + private static Transaction MapToTransaction(TransactionCsvRow row, PaymentResolutionResult paymentResolution) + { + return new Transaction + { + Date = row.Date, + TransactionType = row.Transaction?.Trim() ?? "", + Name = row.Name?.Trim() ?? "", + Memo = row.Memo?.Trim() ?? "", + Amount = row.Amount, + Category = (row.Category ?? "").Trim(), + Last4 = paymentResolution.Last4, + CardId = paymentResolution.CardId, + AccountId = paymentResolution.AccountId!.Value + }; + } + + private string GetPaymentLabel(Transaction transaction, ImportContext context) + { + var account = context.AvailableAccounts.FirstOrDefault(a => a.Id == transaction.AccountId); + var accountLabel = account?.DisplayLabel ?? $"Account ···· {transaction.Last4}"; + + if (transaction.CardId.HasValue) + { + var card = context.AvailableCards.FirstOrDefault(c => c.Id == transaction.CardId); + var cardLabel = card?.DisplayLabel ?? $"Card ···· {transaction.Last4}"; + return $"{cardLabel} → {accountLabel}"; + } + + return accountLabel; + } + } +} diff --git a/MoneyMap.Core/Services/TransactionService.cs b/MoneyMap.Core/Services/TransactionService.cs index 3ab7f94..ece7811 100644 --- a/MoneyMap.Core/Services/TransactionService.cs +++ b/MoneyMap.Core/Services/TransactionService.cs @@ -1,79 +1,79 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for core transaction operations including duplicate detection, -/// retrieval, and deletion. -/// -public interface ITransactionService -{ - /// - /// Checks if a transaction is a duplicate based on date, amount, name, memo, - /// account, and card. - /// - Task IsDuplicateAsync(Transaction transaction); - - /// - /// Gets a transaction by ID with optional related data. - /// - Task GetTransactionByIdAsync(long id, bool includeRelated = false); - - /// - /// Deletes a transaction and all related data (receipts, parse logs, line items). - /// - Task DeleteTransactionAsync(long id); -} - -public class TransactionService : ITransactionService -{ - private readonly MoneyMapContext _db; - - public TransactionService(MoneyMapContext db) - { - _db = db; - } - - public async Task IsDuplicateAsync(Transaction transaction) - { - return await _db.Transactions.AnyAsync(t => - t.Date == transaction.Date && - t.Amount == transaction.Amount && - t.Name == transaction.Name && - t.Memo == transaction.Memo && - t.AccountId == transaction.AccountId && - t.CardId == transaction.CardId); - } - - public async Task GetTransactionByIdAsync(long id, bool includeRelated = false) - { - var query = _db.Transactions.AsQueryable(); - - if (includeRelated) - { - query = query - .Include(t => t.Card) - .ThenInclude(c => c!.Account) - .Include(t => t.Account) - .Include(t => t.TransferToAccount) - .Include(t => t.Merchant) - .Include(t => t.Receipts) - .ThenInclude(r => r.LineItems); - } - - return await query.FirstOrDefaultAsync(t => t.Id == id); - } - - public async Task DeleteTransactionAsync(long id) - { - var transaction = await _db.Transactions.FindAsync(id); - if (transaction == null) - return false; - - _db.Transactions.Remove(transaction); - await _db.SaveChangesAsync(); - return true; - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for core transaction operations including duplicate detection, +/// retrieval, and deletion. +/// +public interface ITransactionService +{ + /// + /// Checks if a transaction is a duplicate based on date, amount, name, memo, + /// account, and card. + /// + Task IsDuplicateAsync(Transaction transaction); + + /// + /// Gets a transaction by ID with optional related data. + /// + Task GetTransactionByIdAsync(long id, bool includeRelated = false); + + /// + /// Deletes a transaction and all related data (receipts, parse logs, line items). + /// + Task DeleteTransactionAsync(long id); +} + +public class TransactionService : ITransactionService +{ + private readonly MoneyMapContext _db; + + public TransactionService(MoneyMapContext db) + { + _db = db; + } + + public async Task IsDuplicateAsync(Transaction transaction) + { + return await _db.Transactions.AnyAsync(t => + t.Date == transaction.Date && + t.Amount == transaction.Amount && + t.Name == transaction.Name && + t.Memo == transaction.Memo && + t.AccountId == transaction.AccountId && + t.CardId == transaction.CardId); + } + + public async Task GetTransactionByIdAsync(long id, bool includeRelated = false) + { + var query = _db.Transactions.AsQueryable(); + + if (includeRelated) + { + query = query + .Include(t => t.Card) + .ThenInclude(c => c!.Account) + .Include(t => t.Account) + .Include(t => t.TransferToAccount) + .Include(t => t.Merchant) + .Include(t => t.Receipts) + .ThenInclude(r => r.LineItems); + } + + return await query.FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task DeleteTransactionAsync(long id) + { + var transaction = await _db.Transactions.FindAsync(id); + if (transaction == null) + return false; + + _db.Transactions.Remove(transaction); + await _db.SaveChangesAsync(); + return true; + } +} diff --git a/MoneyMap.Core/Services/TransactionStatisticsService.cs b/MoneyMap.Core/Services/TransactionStatisticsService.cs index 6fb5816..53a16f5 100644 --- a/MoneyMap.Core/Services/TransactionStatisticsService.cs +++ b/MoneyMap.Core/Services/TransactionStatisticsService.cs @@ -1,105 +1,105 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services; - -/// -/// Service for calculating transaction statistics and aggregates. -/// -public interface ITransactionStatisticsService -{ - /// - /// Calculates statistics for a filtered set of transactions. - /// - Task CalculateStatsAsync(IQueryable query); - - /// - /// Gets categorization statistics for the entire database. - /// - Task GetCategorizationStatsAsync(); - - /// - /// Gets card statistics for a specific account. - /// - Task> GetCardStatsForAccountAsync(int accountId); -} - -public class TransactionStatisticsService : ITransactionStatisticsService -{ - private readonly MoneyMapContext _db; - - public TransactionStatisticsService(MoneyMapContext db) - { - _db = db; - } - - public async Task CalculateStatsAsync(IQueryable query) - { - // Calculate stats at database level instead of loading all transactions into memory - var stats = await query - .GroupBy(_ => 1) // Group all into one group to aggregate - .Select(g => new TransactionStats - { - Count = g.Count(), - TotalDebits = g.Where(t => t.Amount < 0).Sum(t => t.Amount), - TotalCredits = g.Where(t => t.Amount > 0).Sum(t => t.Amount), - NetAmount = g.Sum(t => t.Amount) - }) - .FirstOrDefaultAsync(); - - return stats ?? new TransactionStats(); - } - - public async Task GetCategorizationStatsAsync() - { - var totalTransactions = await _db.Transactions.CountAsync(); - var uncategorized = await _db.Transactions - .CountAsync(t => string.IsNullOrWhiteSpace(t.Category)); - var categorized = totalTransactions - uncategorized; - - return new CategorizationStats - { - TotalTransactions = totalTransactions, - Categorized = categorized, - Uncategorized = uncategorized - }; - } - - public async Task> GetCardStatsForAccountAsync(int accountId) - { - // Single query with projection to avoid N+1 - return await _db.Cards - .Where(c => c.AccountId == accountId) - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .Select(c => new CardStats - { - Card = c, - TransactionCount = c.Transactions.Count - }) - .ToListAsync(); - } -} - -// DTOs -public class TransactionStats -{ - public int Count { get; set; } - public decimal TotalDebits { get; set; } - public decimal TotalCredits { get; set; } - public decimal NetAmount { get; set; } -} - -public class CategorizationStats -{ - public int TotalTransactions { get; set; } - public int Categorized { get; set; } - public int Uncategorized { get; set; } -} - -public class CardStats -{ - public Card Card { get; set; } = null!; - public int TransactionCount { get; set; } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +/// +/// Service for calculating transaction statistics and aggregates. +/// +public interface ITransactionStatisticsService +{ + /// + /// Calculates statistics for a filtered set of transactions. + /// + Task CalculateStatsAsync(IQueryable query); + + /// + /// Gets categorization statistics for the entire database. + /// + Task GetCategorizationStatsAsync(); + + /// + /// Gets card statistics for a specific account. + /// + Task> GetCardStatsForAccountAsync(int accountId); +} + +public class TransactionStatisticsService : ITransactionStatisticsService +{ + private readonly MoneyMapContext _db; + + public TransactionStatisticsService(MoneyMapContext db) + { + _db = db; + } + + public async Task CalculateStatsAsync(IQueryable query) + { + // Calculate stats at database level instead of loading all transactions into memory + var stats = await query + .GroupBy(_ => 1) // Group all into one group to aggregate + .Select(g => new TransactionStats + { + Count = g.Count(), + TotalDebits = g.Where(t => t.Amount < 0).Sum(t => t.Amount), + TotalCredits = g.Where(t => t.Amount > 0).Sum(t => t.Amount), + NetAmount = g.Sum(t => t.Amount) + }) + .FirstOrDefaultAsync(); + + return stats ?? new TransactionStats(); + } + + public async Task GetCategorizationStatsAsync() + { + var totalTransactions = await _db.Transactions.CountAsync(); + var uncategorized = await _db.Transactions + .CountAsync(t => string.IsNullOrWhiteSpace(t.Category)); + var categorized = totalTransactions - uncategorized; + + return new CategorizationStats + { + TotalTransactions = totalTransactions, + Categorized = categorized, + Uncategorized = uncategorized + }; + } + + public async Task> GetCardStatsForAccountAsync(int accountId) + { + // Single query with projection to avoid N+1 + return await _db.Cards + .Where(c => c.AccountId == accountId) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .Select(c => new CardStats + { + Card = c, + TransactionCount = c.Transactions.Count + }) + .ToListAsync(); + } +} + +// DTOs +public class TransactionStats +{ + public int Count { get; set; } + public decimal TotalDebits { get; set; } + public decimal TotalCredits { get; set; } + public decimal NetAmount { get; set; } +} + +public class CategorizationStats +{ + public int TotalTransactions { get; set; } + public int Categorized { get; set; } + public int Uncategorized { get; set; } +} + +public class CardStats +{ + public Card Card { get; set; } = null!; + public int TransactionCount { get; set; } +} diff --git a/MoneyMap.Tests/MoneyMap.Tests.csproj b/MoneyMap.Tests/MoneyMap.Tests.csproj index fd72023..656e9e6 100644 --- a/MoneyMap.Tests/MoneyMap.Tests.csproj +++ b/MoneyMap.Tests/MoneyMap.Tests.csproj @@ -1,28 +1,28 @@ - - - - net9.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/MoneyMap.Tests/Services/AccountServiceTests.cs b/MoneyMap.Tests/Services/AccountServiceTests.cs index cd4a3e9..39859fb 100644 --- a/MoneyMap.Tests/Services/AccountServiceTests.cs +++ b/MoneyMap.Tests/Services/AccountServiceTests.cs @@ -1,236 +1,236 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class AccountServiceTests -{ - [Fact] - public async Task GetAllAccountsWithStatsAsync_ReturnsAccountsWithTransactionCounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account1 = new Account - { - Id = 1, - Institution = "Bank A", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "John Doe" - }; - var account2 = new Account - { - Id = 2, - Institution = "Bank B", - AccountType = AccountType.Savings, - Last4 = "5678", - Owner = "Jane Smith" - }; - context.Accounts.AddRange(account1, account2); - - var transaction1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1 - }; - var transaction2 = new Transaction - { - Date = DateTime.Now, - Amount = -25.00m, - Name = "Test", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.AddRange(transaction1, transaction2); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAllAccountsWithStatsAsync(); - - // Assert - Assert.Equal(2, result.Count); - var account1Stats = result.First(a => a.Id == 1); - Assert.Equal(2, account1Stats.TransactionCount); - var account2Stats = result.First(a => a.Id == 2); - Assert.Equal(0, account2Stats.TransactionCount); - } - - [Fact] - public async Task CanDeleteAccountAsync_ReturnsFalse_WhenAccountHasTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.CanDeleteAccountAsync(1); - - // Assert - Assert.False(result.CanDelete); - Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CanDeleteAccountAsync_ReturnsTrue_WhenAccountHasNoTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - await context.SaveChangesAsync(); - - // Act - var result = await service.CanDeleteAccountAsync(1); - - // Assert - Assert.True(result.CanDelete); - } - - [Fact] - public async Task DeleteAccountAsync_SuccessfullyDeletesAccount_WhenNoTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteAccountAsync(1); - - // Assert - Assert.True(result.Success); - Assert.Empty(context.Accounts); - } - - [Fact] - public async Task DeleteAccountAsync_FailsToDelete_WhenAccountHasTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteAccountAsync(1); - - // Assert - Assert.False(result.Success); - Assert.Single(context.Accounts); - } - - [Fact] - public async Task GetAccountDetailsAsync_ReturnsFullDetails_WithCardsAndCounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new AccountService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "9999", - Owner = "Test Owner" - }; - context.Cards.Add(card); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAccountDetailsAsync(1); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.Account.Id); - Assert.Single(result.Cards); - Assert.Equal(1, result.Cards[0].TransactionCount); - Assert.Equal(1, result.TransactionCount); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class AccountServiceTests +{ + [Fact] + public async Task GetAllAccountsWithStatsAsync_ReturnsAccountsWithTransactionCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account1 = new Account + { + Id = 1, + Institution = "Bank A", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "John Doe" + }; + var account2 = new Account + { + Id = 2, + Institution = "Bank B", + AccountType = AccountType.Savings, + Last4 = "5678", + Owner = "Jane Smith" + }; + context.Accounts.AddRange(account1, account2); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -25.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllAccountsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var account1Stats = result.First(a => a.Id == 1); + Assert.Equal(2, account1Stats.TransactionCount); + var account2Stats = result.First(a => a.Id == 2); + Assert.Equal(0, account2Stats.TransactionCount); + } + + [Fact] + public async Task CanDeleteAccountAsync_ReturnsFalse_WhenAccountHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteAccountAsync(1); + + // Assert + Assert.False(result.CanDelete); + Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDeleteAccountAsync_ReturnsTrue_WhenAccountHasNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteAccountAsync(1); + + // Assert + Assert.True(result.CanDelete); + } + + [Fact] + public async Task DeleteAccountAsync_SuccessfullyDeletesAccount_WhenNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteAccountAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Accounts); + } + + [Fact] + public async Task DeleteAccountAsync_FailsToDelete_WhenAccountHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteAccountAsync(1); + + // Assert + Assert.False(result.Success); + Assert.Single(context.Accounts); + } + + [Fact] + public async Task GetAccountDetailsAsync_ReturnsFullDetails_WithCardsAndCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAccountDetailsAsync(1); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Account.Id); + Assert.Single(result.Cards); + Assert.Equal(1, result.Cards[0].TransactionCount); + Assert.Equal(1, result.TransactionCount); + } +} diff --git a/MoneyMap.Tests/Services/CardServiceTests.cs b/MoneyMap.Tests/Services/CardServiceTests.cs index 1fc51a3..749424d 100644 --- a/MoneyMap.Tests/Services/CardServiceTests.cs +++ b/MoneyMap.Tests/Services/CardServiceTests.cs @@ -1,231 +1,231 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class CardServiceTests -{ - [Fact] - public async Task GetAllCardsWithStatsAsync_ReturnsCardsWithTransactionCounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new CardService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card1 = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "1111", - Owner = "John Doe" - }; - var card2 = new Card - { - Id = 2, - AccountId = 1, - Issuer = "Mastercard", - Last4 = "2222", - Owner = "Jane Smith" - }; - context.Cards.AddRange(card1, card2); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAllCardsWithStatsAsync(); - - // Assert - Assert.Equal(2, result.Count); - var card1Stats = result.First(c => c.Card.Id == 1); - Assert.Equal(1, card1Stats.TransactionCount); - var card2Stats = result.First(c => c.Card.Id == 2); - Assert.Equal(0, card2Stats.TransactionCount); - } - - [Fact] - public async Task CanDeleteCardAsync_ReturnsFalse_WhenCardHasTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new CardService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "9999", - Owner = "Test Owner" - }; - context.Cards.Add(card); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.CanDeleteCardAsync(1); - - // Assert - Assert.False(result.CanDelete); - Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CanDeleteCardAsync_ReturnsTrue_WhenCardHasNoTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new CardService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "9999", - Owner = "Test Owner" - }; - context.Cards.Add(card); - await context.SaveChangesAsync(); - - // Act - var result = await service.CanDeleteCardAsync(1); - - // Assert - Assert.True(result.CanDelete); - } - - [Fact] - public async Task DeleteCardAsync_SuccessfullyDeletesCard_WhenNoTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new CardService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "9999", - Owner = "Test Owner" - }; - context.Cards.Add(card); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteCardAsync(1); - - // Assert - Assert.True(result.Success); - Assert.Empty(context.Cards); - } - - [Fact] - public async Task DeleteCardAsync_FailsToDelete_WhenCardHasTransactions() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new CardService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "9999", - Owner = "Test Owner" - }; - context.Cards.Add(card); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteCardAsync(1); - - // Assert - Assert.False(result.Success); - Assert.Single(context.Cards); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class CardServiceTests +{ + [Fact] + public async Task GetAllCardsWithStatsAsync_ReturnsCardsWithTransactionCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "1111", + Owner = "John Doe" + }; + var card2 = new Card + { + Id = 2, + AccountId = 1, + Issuer = "Mastercard", + Last4 = "2222", + Owner = "Jane Smith" + }; + context.Cards.AddRange(card1, card2); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllCardsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var card1Stats = result.First(c => c.Card.Id == 1); + Assert.Equal(1, card1Stats.TransactionCount); + var card2Stats = result.First(c => c.Card.Id == 2); + Assert.Equal(0, card2Stats.TransactionCount); + } + + [Fact] + public async Task CanDeleteCardAsync_ReturnsFalse_WhenCardHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteCardAsync(1); + + // Assert + Assert.False(result.CanDelete); + Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDeleteCardAsync_ReturnsTrue_WhenCardHasNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteCardAsync(1); + + // Assert + Assert.True(result.CanDelete); + } + + [Fact] + public async Task DeleteCardAsync_SuccessfullyDeletesCard_WhenNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteCardAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Cards); + } + + [Fact] + public async Task DeleteCardAsync_FailsToDelete_WhenCardHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteCardAsync(1); + + // Assert + Assert.False(result.Success); + Assert.Single(context.Cards); + } +} diff --git a/MoneyMap.Tests/Services/MerchantServiceTests.cs b/MoneyMap.Tests/Services/MerchantServiceTests.cs index c25f4bc..afa348d 100644 --- a/MoneyMap.Tests/Services/MerchantServiceTests.cs +++ b/MoneyMap.Tests/Services/MerchantServiceTests.cs @@ -1,188 +1,188 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class MerchantServiceTests -{ - [Fact] - public async Task GetOrCreateAsync_CreatesNewMerchant_WhenDoesNotExist() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - // Act - var result = await service.GetOrCreateAsync("Walmart"); - - // Assert - Assert.NotNull(result); - Assert.Equal("Walmart", result.Name); - Assert.Single(context.Merchants); - } - - [Fact] - public async Task GetOrCreateAsync_ReturnsExistingMerchant_WhenExists() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var existingMerchant = new Merchant { Id = 1, Name = "Walmart" }; - context.Merchants.Add(existingMerchant); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetOrCreateAsync("Walmart"); - - // Assert - Assert.NotNull(result); - Assert.Equal(1, result.Id); - Assert.Single(context.Merchants); - } - - [Fact] - public async Task GetAllMerchantsWithStatsAsync_ReturnsMerchantsWithStats() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; - var merchant2 = new Merchant { Id = 2, Name = "Target" }; - context.Merchants.AddRange(merchant1, merchant2); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - MerchantId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAllMerchantsWithStatsAsync(); - - // Assert - Assert.Equal(2, result.Count); - var walmart = result.First(m => m.Name == "Walmart"); - Assert.Equal(1, walmart.TransactionCount); - var target = result.First(m => m.Name == "Target"); - Assert.Equal(0, target.TransactionCount); - } - - [Fact] - public async Task UpdateMerchantAsync_SuccessfullyUpdatesName() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var merchant = new Merchant { Id = 1, Name = "Walmart" }; - context.Merchants.Add(merchant); - await context.SaveChangesAsync(); - - // Act - var result = await service.UpdateMerchantAsync(1, "Walmart Supercenter"); - - // Assert - Assert.True(result.Success); - var updated = await context.Merchants.FindAsync(1); - Assert.Equal("Walmart Supercenter", updated!.Name); - } - - [Fact] - public async Task UpdateMerchantAsync_FailsWhenDuplicateName() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; - var merchant2 = new Merchant { Id = 2, Name = "Target" }; - context.Merchants.AddRange(merchant1, merchant2); - await context.SaveChangesAsync(); - - // Act - var result = await service.UpdateMerchantAsync(1, "Target"); - - // Assert - Assert.False(result.Success); - Assert.Contains("already exists", result.Message); - } - - [Fact] - public async Task DeleteMerchantAsync_SuccessfullyDeletes() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var merchant = new Merchant { Id = 1, Name = "Walmart" }; - context.Merchants.Add(merchant); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteMerchantAsync(1); - - // Assert - Assert.True(result.Success); - Assert.Empty(context.Merchants); - } - - [Fact] - public async Task DeleteMerchantAsync_ReportsTransactionAndMappingCounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new MerchantService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var merchant = new Merchant { Id = 1, Name = "Walmart" }; - context.Merchants.Add(merchant); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - MerchantId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteMerchantAsync(1); - - // Assert - Assert.True(result.Success); - Assert.Equal(1, result.TransactionCount); - Assert.Contains("1 transaction", result.Message); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class MerchantServiceTests +{ + [Fact] + public async Task GetOrCreateAsync_CreatesNewMerchant_WhenDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + // Act + var result = await service.GetOrCreateAsync("Walmart"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Walmart", result.Name); + Assert.Single(context.Merchants); + } + + [Fact] + public async Task GetOrCreateAsync_ReturnsExistingMerchant_WhenExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var existingMerchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(existingMerchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetOrCreateAsync("Walmart"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Id); + Assert.Single(context.Merchants); + } + + [Fact] + public async Task GetAllMerchantsWithStatsAsync_ReturnsMerchantsWithStats() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; + var merchant2 = new Merchant { Id = 2, Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + MerchantId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllMerchantsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var walmart = result.First(m => m.Name == "Walmart"); + Assert.Equal(1, walmart.TransactionCount); + var target = result.First(m => m.Name == "Target"); + Assert.Equal(0, target.TransactionCount); + } + + [Fact] + public async Task UpdateMerchantAsync_SuccessfullyUpdatesName() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.UpdateMerchantAsync(1, "Walmart Supercenter"); + + // Assert + Assert.True(result.Success); + var updated = await context.Merchants.FindAsync(1); + Assert.Equal("Walmart Supercenter", updated!.Name); + } + + [Fact] + public async Task UpdateMerchantAsync_FailsWhenDuplicateName() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; + var merchant2 = new Merchant { Id = 2, Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2); + await context.SaveChangesAsync(); + + // Act + var result = await service.UpdateMerchantAsync(1, "Target"); + + // Assert + Assert.False(result.Success); + Assert.Contains("already exists", result.Message); + } + + [Fact] + public async Task DeleteMerchantAsync_SuccessfullyDeletes() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteMerchantAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Merchants); + } + + [Fact] + public async Task DeleteMerchantAsync_ReportsTransactionAndMappingCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + MerchantId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteMerchantAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Equal(1, result.TransactionCount); + Assert.Contains("1 transaction", result.Message); + } +} diff --git a/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs b/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs index bcbdb3e..97e6e95 100644 --- a/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs +++ b/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs @@ -1,329 +1,329 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class ReceiptMatchingServiceTests -{ - [Fact] - public async Task GetTransactionIdsWithReceiptsAsync_ReturnsTransactionIds() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction1 = new Transaction - { - Id = 1, - Date = DateTime.Now, - Amount = -50.00m, - Name = "Store A", - Memo = "Test", - AccountId = 1 - }; - var transaction2 = new Transaction - { - Id = 2, - Date = DateTime.Now, - Amount = -30.00m, - Name = "Store B", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.AddRange(transaction1, transaction2); - - var receipt = new Receipt - { - Id = 1, - TransactionId = 1, - FileName = "receipt.pdf", - ContentType = "application/pdf", - StoragePath = "/receipts/receipt.pdf", - FileSizeBytes = 1024, - FileHashSha256 = "hash123", - UploadedAtUtc = DateTime.UtcNow - }; - context.Receipts.Add(receipt); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetTransactionIdsWithReceiptsAsync(); - - // Assert - Assert.Single(result); - Assert.Contains(1L, result); - Assert.DoesNotContain(2L, result); - } - - [Fact] - public async Task FindMatchingTransactionsAsync_FiltersByDateRange() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var receiptDate = new DateTime(2025, 1, 15); - - var withinRange = new Transaction - { - Id = 1, - Date = receiptDate.AddDays(2), // Within +/- 3 days - Amount = -50.00m, - Name = "Store A", - Memo = "Test", - AccountId = 1 - }; - var outsideRange = new Transaction - { - Id = 2, - Date = receiptDate.AddDays(5), // Outside +/- 3 days - Amount = -50.00m, - Name = "Store B", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.AddRange(withinRange, outsideRange); - await context.SaveChangesAsync(); - - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receiptDate, - ExcludeTransactionIds = new HashSet() - }; - - // Act - var result = await service.FindMatchingTransactionsAsync(criteria); - - // Assert - Assert.Single(result); - Assert.Equal(1, result[0].Id); - } - - [Fact] - public async Task FindMatchingTransactionsAsync_FiltersByAmountTolerance() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var receiptDate = DateTime.Now; - var receiptTotal = 100.00m; - - var withinTolerance = new Transaction - { - Id = 1, - Date = receiptDate, - Amount = -105.00m, // Within 10% tolerance - Name = "Store A", - Memo = "Test", - AccountId = 1 - }; - var outsideTolerance = new Transaction - { - Id = 2, - Date = receiptDate, - Amount = -150.00m, // Outside 10% tolerance - Name = "Store B", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.AddRange(withinTolerance, outsideTolerance); - await context.SaveChangesAsync(); - - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receiptDate, - Total = receiptTotal, - ExcludeTransactionIds = new HashSet() - }; - - // Act - var result = await service.FindMatchingTransactionsAsync(criteria); - - // Assert - Assert.Single(result); - Assert.Equal(1, result[0].Id); - Assert.True(result[0].IsCloseAmount); - } - - [Fact] - public async Task FindMatchingTransactionsAsync_MarksExactAmountMatch() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var receiptDate = DateTime.Now; - var receiptTotal = 100.00m; - - var exactMatch = new Transaction - { - Id = 1, - Date = receiptDate, - Amount = -100.00m, // Exact match - Name = "Store A", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.Add(exactMatch); - await context.SaveChangesAsync(); - - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receiptDate, - Total = receiptTotal, - ExcludeTransactionIds = new HashSet() - }; - - // Act - var result = await service.FindMatchingTransactionsAsync(criteria); - - // Assert - Assert.Single(result); - Assert.True(result[0].IsExactAmount); - Assert.False(result[0].IsCloseAmount); - } - - [Fact] - public async Task FindMatchingTransactionsAsync_ExcludesTransactionsWithReceipts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var receiptDate = DateTime.Now; - - var transaction1 = new Transaction - { - Id = 1, - Date = receiptDate, - Amount = -50.00m, - Name = "Store A", - Memo = "Test", - AccountId = 1 - }; - var transaction2 = new Transaction - { - Id = 2, - Date = receiptDate, - Amount = -50.00m, - Name = "Store B", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.AddRange(transaction1, transaction2); - await context.SaveChangesAsync(); - - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receiptDate, - ExcludeTransactionIds = new HashSet { 1 } // Exclude transaction 1 - }; - - // Act - var result = await service.FindMatchingTransactionsAsync(criteria); - - // Assert - Assert.Single(result); - Assert.Equal(2, result[0].Id); - } - - [Fact] - public async Task FindMatchingTransactionsAsync_UsesDueDateForBills() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReceiptMatchingService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var receiptDate = new DateTime(2025, 1, 1); - var dueDate = new DateTime(2025, 1, 15); - - // Transaction on due date + 3 days (should match for bills) - var transaction = new Transaction - { - Id = 1, - Date = dueDate.AddDays(3), - Amount = -50.00m, - Name = "Utility Company", - Memo = "Test", - AccountId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receiptDate, - DueDate = dueDate, // Bill with due date - ExcludeTransactionIds = new HashSet() - }; - - // Act - var result = await service.FindMatchingTransactionsAsync(criteria); - - // Assert - Assert.Single(result); - Assert.Equal(1, result[0].Id); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class ReceiptMatchingServiceTests +{ + [Fact] + public async Task GetTransactionIdsWithReceiptsAsync_ReturnsTransactionIds() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Id = 2, + Date = DateTime.Now, + Amount = -30.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + + var receipt = new Receipt + { + Id = 1, + TransactionId = 1, + FileName = "receipt.pdf", + ContentType = "application/pdf", + StoragePath = "/receipts/receipt.pdf", + FileSizeBytes = 1024, + FileHashSha256 = "hash123", + UploadedAtUtc = DateTime.UtcNow + }; + context.Receipts.Add(receipt); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetTransactionIdsWithReceiptsAsync(); + + // Assert + Assert.Single(result); + Assert.Contains(1L, result); + Assert.DoesNotContain(2L, result); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_FiltersByDateRange() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = new DateTime(2025, 1, 15); + + var withinRange = new Transaction + { + Id = 1, + Date = receiptDate.AddDays(2), // Within +/- 3 days + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var outsideRange = new Transaction + { + Id = 2, + Date = receiptDate.AddDays(5), // Outside +/- 3 days + Amount = -50.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(withinRange, outsideRange); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_FiltersByAmountTolerance() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + var receiptTotal = 100.00m; + + var withinTolerance = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -105.00m, // Within 10% tolerance + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var outsideTolerance = new Transaction + { + Id = 2, + Date = receiptDate, + Amount = -150.00m, // Outside 10% tolerance + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(withinTolerance, outsideTolerance); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + Total = receiptTotal, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + Assert.True(result[0].IsCloseAmount); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_MarksExactAmountMatch() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + var receiptTotal = 100.00m; + + var exactMatch = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -100.00m, // Exact match + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(exactMatch); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + Total = receiptTotal, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.True(result[0].IsExactAmount); + Assert.False(result[0].IsCloseAmount); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_ExcludesTransactionsWithReceipts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + + var transaction1 = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Id = 2, + Date = receiptDate, + Amount = -50.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + ExcludeTransactionIds = new HashSet { 1 } // Exclude transaction 1 + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(2, result[0].Id); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_UsesDueDateForBills() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = new DateTime(2025, 1, 1); + var dueDate = new DateTime(2025, 1, 15); + + // Transaction on due date + 3 days (should match for bills) + var transaction = new Transaction + { + Id = 1, + Date = dueDate.AddDays(3), + Amount = -50.00m, + Name = "Utility Company", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + DueDate = dueDate, // Bill with due date + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + } +} diff --git a/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs b/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs index 1049983..5091615 100644 --- a/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs +++ b/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs @@ -1,220 +1,220 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class ReferenceDataServiceTests -{ - [Fact] - public async Task GetAvailableCategoriesAsync_ReturnsDistinctSortedCategories() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReferenceDataService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Groceries" - }; - var transaction2 = new Transaction - { - Date = DateTime.Now, - Amount = -30.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Gas" - }; - var transaction3 = new Transaction - { - Date = DateTime.Now, - Amount = -20.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Groceries" // Duplicate - }; - context.Transactions.AddRange(transaction1, transaction2, transaction3); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAvailableCategoriesAsync(); - - // Assert - Assert.Equal(2, result.Count); - Assert.Contains("Groceries", result); - Assert.Contains("Gas", result); - Assert.Equal("Gas", result[0]); // Alphabetically sorted - Assert.Equal("Groceries", result[1]); - } - - [Fact] - public async Task GetAvailableCategoriesAsync_ExcludesEmptyCategories() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReferenceDataService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Groceries" - }; - var transaction2 = new Transaction - { - Date = DateTime.Now, - Amount = -30.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "" // Empty - }; - var transaction3 = new Transaction - { - Date = DateTime.Now, - Amount = -20.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = " " // Whitespace - }; - context.Transactions.AddRange(transaction1, transaction2, transaction3); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAvailableCategoriesAsync(); - - // Assert - Assert.Single(result); - Assert.Equal("Groceries", result[0]); - } - - [Fact] - public async Task GetAvailableMerchantsAsync_ReturnsSortedMerchants() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReferenceDataService(context); - - var merchant1 = new Merchant { Name = "Walmart" }; - var merchant2 = new Merchant { Name = "Amazon" }; - var merchant3 = new Merchant { Name = "Target" }; - context.Merchants.AddRange(merchant1, merchant2, merchant3); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAvailableMerchantsAsync(); - - // Assert - Assert.Equal(3, result.Count); - Assert.Equal("Amazon", result[0].Name); // Alphabetically sorted - Assert.Equal("Target", result[1].Name); - Assert.Equal("Walmart", result[2].Name); - } - - [Fact] - public async Task GetAvailableCardsAsync_ReturnsSortedCards() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReferenceDataService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card1 = new Card - { - AccountId = 1, - Issuer = "VISA", - Last4 = "3333", - Owner = "Bob" - }; - var card2 = new Card - { - AccountId = 1, - Issuer = "Mastercard", - Last4 = "1111", - Owner = "Alice" - }; - context.Cards.AddRange(card1, card2); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAvailableCardsAsync(); - - // Assert - Assert.Equal(2, result.Count); - Assert.Equal("Alice", result[0].Owner); // Sorted by owner - Assert.Equal("Bob", result[1].Owner); - } - - [Fact] - public async Task GetAvailableAccountsAsync_ReturnsSortedAccounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new ReferenceDataService(context); - - var account1 = new Account - { - Institution = "Wells Fargo", - AccountType = AccountType.Checking, - Last4 = "5678", - Owner = "Test" - }; - var account2 = new Account - { - Institution = "Bank of America", - AccountType = AccountType.Savings, - Last4 = "1234", - Owner = "Test" - }; - context.Accounts.AddRange(account1, account2); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetAvailableAccountsAsync(); - - // Assert - Assert.Equal(2, result.Count); - Assert.Equal("Bank of America", result[0].Institution); // Sorted by institution - Assert.Equal("Wells Fargo", result[1].Institution); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class ReferenceDataServiceTests +{ + [Fact] + public async Task GetAvailableCategoriesAsync_ReturnsDistinctSortedCategories() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Gas" + }; + var transaction3 = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" // Duplicate + }; + context.Transactions.AddRange(transaction1, transaction2, transaction3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCategoriesAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains("Groceries", result); + Assert.Contains("Gas", result); + Assert.Equal("Gas", result[0]); // Alphabetically sorted + Assert.Equal("Groceries", result[1]); + } + + [Fact] + public async Task GetAvailableCategoriesAsync_ExcludesEmptyCategories() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "" // Empty + }; + var transaction3 = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = " " // Whitespace + }; + context.Transactions.AddRange(transaction1, transaction2, transaction3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCategoriesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal("Groceries", result[0]); + } + + [Fact] + public async Task GetAvailableMerchantsAsync_ReturnsSortedMerchants() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var merchant1 = new Merchant { Name = "Walmart" }; + var merchant2 = new Merchant { Name = "Amazon" }; + var merchant3 = new Merchant { Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2, merchant3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableMerchantsAsync(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("Amazon", result[0].Name); // Alphabetically sorted + Assert.Equal("Target", result[1].Name); + Assert.Equal("Walmart", result[2].Name); + } + + [Fact] + public async Task GetAvailableCardsAsync_ReturnsSortedCards() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + AccountId = 1, + Issuer = "VISA", + Last4 = "3333", + Owner = "Bob" + }; + var card2 = new Card + { + AccountId = 1, + Issuer = "Mastercard", + Last4 = "1111", + Owner = "Alice" + }; + context.Cards.AddRange(card1, card2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCardsAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Alice", result[0].Owner); // Sorted by owner + Assert.Equal("Bob", result[1].Owner); + } + + [Fact] + public async Task GetAvailableAccountsAsync_ReturnsSortedAccounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account1 = new Account + { + Institution = "Wells Fargo", + AccountType = AccountType.Checking, + Last4 = "5678", + Owner = "Test" + }; + var account2 = new Account + { + Institution = "Bank of America", + AccountType = AccountType.Savings, + Last4 = "1234", + Owner = "Test" + }; + context.Accounts.AddRange(account1, account2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableAccountsAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Bank of America", result[0].Institution); // Sorted by institution + Assert.Equal("Wells Fargo", result[1].Institution); + } +} diff --git a/MoneyMap.Tests/Services/TransactionServiceTests.cs b/MoneyMap.Tests/Services/TransactionServiceTests.cs index 1872eeb..27f6ba7 100644 --- a/MoneyMap.Tests/Services/TransactionServiceTests.cs +++ b/MoneyMap.Tests/Services/TransactionServiceTests.cs @@ -1,228 +1,228 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class TransactionServiceTests -{ - [Fact] - public async Task IsDuplicateAsync_ReturnsFalse_WhenTransactionDoesNotExist() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - var transaction = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1, - CardId = 1 - }; - - // Act - var result = await service.IsDuplicateAsync(transaction); - - // Assert - Assert.False(result); - } - - [Fact] - public async Task IsDuplicateAsync_ReturnsTrue_WhenExactDuplicateExists() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - // Add an account first (required for transaction) - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var existingTransaction = new Transaction - { - Date = new DateTime(2025, 1, 15), - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(existingTransaction); - await context.SaveChangesAsync(); - - var duplicateTransaction = new Transaction - { - Date = new DateTime(2025, 1, 15), - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1, - CardId = 1 - }; - - // Act - var result = await service.IsDuplicateAsync(duplicateTransaction); - - // Assert - Assert.True(result); - } - - [Fact] - public async Task IsDuplicateAsync_ReturnsFalse_WhenAmountDiffers() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var existingTransaction = new Transaction - { - Date = new DateTime(2025, 1, 15), - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1, - CardId = 1 - }; - context.Transactions.Add(existingTransaction); - await context.SaveChangesAsync(); - - var differentTransaction = new Transaction - { - Date = new DateTime(2025, 1, 15), - Amount = -51.00m, // Different amount - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1, - CardId = 1 - }; - - // Act - var result = await service.IsDuplicateAsync(differentTransaction); - - // Assert - Assert.False(result); - } - - [Fact] - public async Task GetTransactionByIdAsync_ReturnsNull_WhenTransactionDoesNotExist() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - // Act - var result = await service.GetTransactionByIdAsync(999); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetTransactionByIdAsync_ReturnsTransaction_WhenExists() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction = new Transaction - { - Id = 1, - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetTransactionByIdAsync(1); - - // Assert - Assert.NotNull(result); - Assert.Equal("Test Store", result.Name); - Assert.Equal(-50.00m, result.Amount); - } - - [Fact] - public async Task DeleteTransactionAsync_ReturnsTrue_WhenTransactionExists() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var transaction = new Transaction - { - Id = 1, - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test Store", - Memo = "Test purchase", - AccountId = 1 - }; - context.Transactions.Add(transaction); - await context.SaveChangesAsync(); - - // Act - var result = await service.DeleteTransactionAsync(1); - - // Assert - Assert.True(result); - Assert.Empty(context.Transactions); - } - - [Fact] - public async Task DeleteTransactionAsync_ReturnsFalse_WhenTransactionDoesNotExist() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionService(context); - - // Act - var result = await service.DeleteTransactionAsync(999); - - // Assert - Assert.False(result); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class TransactionServiceTests +{ + [Fact] + public async Task IsDuplicateAsync_ReturnsFalse_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(transaction); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsDuplicateAsync_ReturnsTrue_WhenExactDuplicateExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Add an account first (required for transaction) + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var existingTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(existingTransaction); + await context.SaveChangesAsync(); + + var duplicateTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(duplicateTransaction); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsDuplicateAsync_ReturnsFalse_WhenAmountDiffers() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var existingTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(existingTransaction); + await context.SaveChangesAsync(); + + var differentTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -51.00m, // Different amount + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(differentTransaction); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task GetTransactionByIdAsync_ReturnsNull_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Act + var result = await service.GetTransactionByIdAsync(999); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetTransactionByIdAsync_ReturnsTransaction_WhenExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetTransactionByIdAsync(1); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Store", result.Name); + Assert.Equal(-50.00m, result.Amount); + } + + [Fact] + public async Task DeleteTransactionAsync_ReturnsTrue_WhenTransactionExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteTransactionAsync(1); + + // Assert + Assert.True(result); + Assert.Empty(context.Transactions); + } + + [Fact] + public async Task DeleteTransactionAsync_ReturnsFalse_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Act + var result = await service.DeleteTransactionAsync(999); + + // Assert + Assert.False(result); + } +} diff --git a/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs b/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs index 2d1f899..65462c2 100644 --- a/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs +++ b/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs @@ -1,187 +1,187 @@ -using MoneyMap.Models; -using MoneyMap.Services; -using MoneyMap.Tests.TestHelpers; - -namespace MoneyMap.Tests.Services; - -public class TransactionStatisticsServiceTests -{ - [Fact] - public async Task CalculateStatsAsync_ReturnsCorrectStatistics() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionStatisticsService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var debit1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Store A", - Memo = "Purchase", - AccountId = 1 - }; - var debit2 = new Transaction - { - Date = DateTime.Now, - Amount = -30.00m, - Name = "Store B", - Memo = "Purchase", - AccountId = 1 - }; - var credit = new Transaction - { - Date = DateTime.Now, - Amount = 100.00m, - Name = "Deposit", - Memo = "Paycheck", - AccountId = 1 - }; - context.Transactions.AddRange(debit1, debit2, credit); - await context.SaveChangesAsync(); - - var query = context.Transactions.AsQueryable(); - - // Act - var result = await service.CalculateStatsAsync(query); - - // Assert - Assert.Equal(3, result.Count); - Assert.Equal(-80.00m, result.TotalDebits); - Assert.Equal(100.00m, result.TotalCredits); - Assert.Equal(20.00m, result.NetAmount); - } - - [Fact] - public async Task GetCategorizationStatsAsync_ReturnsCorrectCounts() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionStatisticsService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var categorized1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Groceries" - }; - var categorized2 = new Transaction - { - Date = DateTime.Now, - Amount = -30.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "Gas" - }; - var uncategorized = new Transaction - { - Date = DateTime.Now, - Amount = -20.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - Category = "" - }; - context.Transactions.AddRange(categorized1, categorized2, uncategorized); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetCategorizationStatsAsync(); - - // Assert - Assert.Equal(3, result.TotalTransactions); - Assert.Equal(2, result.Categorized); - Assert.Equal(1, result.Uncategorized); - } - - [Fact] - public async Task GetCardStatsForAccountAsync_ReturnsStatsForLinkedCards() - { - // Arrange - using var context = DbContextHelper.CreateInMemoryContext(); - var service = new TransactionStatisticsService(context); - - var account = new Account - { - Id = 1, - Institution = "Test Bank", - AccountType = AccountType.Checking, - Last4 = "1234", - Owner = "Test Owner" - }; - context.Accounts.Add(account); - - var card1 = new Card - { - Id = 1, - AccountId = 1, - Issuer = "VISA", - Last4 = "1111", - Owner = "Test" - }; - var card2 = new Card - { - Id = 2, - AccountId = 1, - Issuer = "Mastercard", - Last4 = "2222", - Owner = "Test" - }; - context.Cards.AddRange(card1, card2); - - var transaction1 = new Transaction - { - Date = DateTime.Now, - Amount = -50.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - var transaction2 = new Transaction - { - Date = DateTime.Now, - Amount = -30.00m, - Name = "Test", - Memo = "Test", - AccountId = 1, - CardId = 1 - }; - context.Transactions.AddRange(transaction1, transaction2); - await context.SaveChangesAsync(); - - // Act - var result = await service.GetCardStatsForAccountAsync(1); - - // Assert - Assert.Equal(2, result.Count); - var card1Stats = result.First(c => c.Card.Id == 1); - Assert.Equal(2, card1Stats.TransactionCount); - var card2Stats = result.First(c => c.Card.Id == 2); - Assert.Equal(0, card2Stats.TransactionCount); - } -} +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; + +namespace MoneyMap.Tests.Services; + +public class TransactionStatisticsServiceTests +{ + [Fact] + public async Task CalculateStatsAsync_ReturnsCorrectStatistics() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var debit1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Store A", + Memo = "Purchase", + AccountId = 1 + }; + var debit2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Store B", + Memo = "Purchase", + AccountId = 1 + }; + var credit = new Transaction + { + Date = DateTime.Now, + Amount = 100.00m, + Name = "Deposit", + Memo = "Paycheck", + AccountId = 1 + }; + context.Transactions.AddRange(debit1, debit2, credit); + await context.SaveChangesAsync(); + + var query = context.Transactions.AsQueryable(); + + // Act + var result = await service.CalculateStatsAsync(query); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(-80.00m, result.TotalDebits); + Assert.Equal(100.00m, result.TotalCredits); + Assert.Equal(20.00m, result.NetAmount); + } + + [Fact] + public async Task GetCategorizationStatsAsync_ReturnsCorrectCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var categorized1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var categorized2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Gas" + }; + var uncategorized = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "" + }; + context.Transactions.AddRange(categorized1, categorized2, uncategorized); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetCategorizationStatsAsync(); + + // Assert + Assert.Equal(3, result.TotalTransactions); + Assert.Equal(2, result.Categorized); + Assert.Equal(1, result.Uncategorized); + } + + [Fact] + public async Task GetCardStatsForAccountAsync_ReturnsStatsForLinkedCards() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "1111", + Owner = "Test" + }; + var card2 = new Card + { + Id = 2, + AccountId = 1, + Issuer = "Mastercard", + Last4 = "2222", + Owner = "Test" + }; + context.Cards.AddRange(card1, card2); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetCardStatsForAccountAsync(1); + + // Assert + Assert.Equal(2, result.Count); + var card1Stats = result.First(c => c.Card.Id == 1); + Assert.Equal(2, card1Stats.TransactionCount); + var card2Stats = result.First(c => c.Card.Id == 2); + Assert.Equal(0, card2Stats.TransactionCount); + } +} diff --git a/MoneyMap.Tests/TestHelpers/DbContextHelper.cs b/MoneyMap.Tests/TestHelpers/DbContextHelper.cs index 780d34f..3eb9798 100644 --- a/MoneyMap.Tests/TestHelpers/DbContextHelper.cs +++ b/MoneyMap.Tests/TestHelpers/DbContextHelper.cs @@ -1,20 +1,20 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; - -namespace MoneyMap.Tests.TestHelpers; - -public static class DbContextHelper -{ - /// - /// Creates an in-memory database context for testing. - /// Each call creates a unique database to ensure test isolation. - /// - public static MoneyMapContext CreateInMemoryContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - - return new MoneyMapContext(options); - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; + +namespace MoneyMap.Tests.TestHelpers; + +public static class DbContextHelper +{ + /// + /// Creates an in-memory database context for testing. + /// Each call creates a unique database to ensure test isolation. + /// + public static MoneyMapContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + return new MoneyMapContext(options); + } +} diff --git a/MoneyMap.sln b/MoneyMap.sln index 0871417..d8e9446 100644 --- a/MoneyMap.sln +++ b/MoneyMap.sln @@ -1,79 +1,79 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36429.23 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}" -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 - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {9BC6A70A-C19A-442D-A77E-74662945CACE} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36429.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}" +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 + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9BC6A70A-C19A-442D-A77E-74662945CACE} + EndGlobalSection +EndGlobal diff --git a/MoneyMap/.dockerignore b/MoneyMap/.dockerignore index 6fd8373..8c012bd 100644 --- a/MoneyMap/.dockerignore +++ b/MoneyMap/.dockerignore @@ -1,7 +1,7 @@ -bin/ -obj/ -.vs/ -*.user -*.suo -appsettings.Development.json -wwwroot/receipts/ +bin/ +obj/ +.vs/ +*.user +*.suo +appsettings.Development.json +wwwroot/receipts/ diff --git a/MoneyMap/Controllers/RecurringPlacesController.cs b/MoneyMap/Controllers/RecurringPlacesController.cs new file mode 100644 index 0000000..66e0963 --- /dev/null +++ b/MoneyMap/Controllers/RecurringPlacesController.cs @@ -0,0 +1,163 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class RecurringPlacesController : ControllerBase +{ + private readonly MoneyMapContext _db; + + public RecurringPlacesController(MoneyMapContext db) => _db = db; + + [HttpGet] + public async Task List() + { + var recurringPlaces = await _db.RecurringPlaces + .Include(rp => rp.Merchant) + .Include(rp => rp.Card) + .Include(rp => rp.Category) + .OrderBy(rp => rp.Merchant.Name) + .Select(rp => new + { + rp.Id, + MerchantName = rp.Merchant.Name, + rp.MerchantId, + rp.CardId, + CardLast4 = rp.Card != null ? rp.Card.Last4 : null, + CategoryName = rp.Category != null ? rp.Category.Name : null, + rp.Amount, + rp.Frequency, + rp.NextDueDate, + rp.Notes + }) + .ToListAsync(); + + return Ok(recurringPlaces); + } + + [HttpGet("{id}")] + public async Task Get(int id) + { + var recurringPlace = await _db.RecurringPlaces + .Include(rp => rp.Merchant) + .Include(rp => rp.Card) + .Include(rp => rp.Category) + .FirstOrDefaultAsync(rp => rp.Id == id); + + if (recurringPlace == null) + return NotFound(); + + return Ok(new + { + recurringPlace.Id, + MerchantName = recurringPlace.Merchant.Name, + recurringPlace.MerchantId, + recurringPlace.CardId, + CardLast4 = recurringPlace.Card != null ? recurringPlace.Card.Last4 : null, + CategoryName = recurringPlace.Category != null ? recurringPlace.Category.Name : null, + recurringPlace.Amount, + recurringPlace.Frequency, + recurringPlace.NextDueDate, + recurringPlace.Notes + }); + } + + [HttpPost] + public async Task Create([FromBody] RecurringPlaceCreateModel model) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var merchant = await _db.Merchants.FindAsync(model.MerchantId); + var card = await _db.Cards.FindAsync(model.CardId); + var category = await _db.Categories.FindAsync(model.CategoryId); + + if (merchant == null || card == null || category == null) + return BadRequest("Invalid merchant, card, or category"); + + var recurringPlace = new RecurringPlace + { + MerchantId = model.MerchantId, + CardId = model.CardId, + CategoryId = model.CategoryId, + Amount = model.Amount, + Frequency = model.Frequency, + NextDueDate = model.NextDueDate, + Notes = model.Notes + }; + + _db.RecurringPlaces.Add(recurringPlace); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(Get), new { id = recurringPlace.Id }, recurringPlace); + } + + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] RecurringPlaceUpdateModel model) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var recurringPlace = await _db.RecurringPlaces.FindAsync(id); + if (recurringPlace == null) + return NotFound(); + + var merchant = await _db.Merchants.FindAsync(model.MerchantId); + var card = await _db.Cards.FindAsync(model.CardId); + var category = await _db.Categories.FindAsync(model.CategoryId); + + if (merchant == null || card == null || category == null) + return BadRequest("Invalid merchant, card, or category"); + + recurringPlace.MerchantId = model.MerchantId; + recurringPlace.CardId = model.CardId; + recurringPlace.CategoryId = model.CategoryId; + recurringPlace.Amount = model.Amount; + recurringPlace.Frequency = model.Frequency; + recurringPlace.NextDueDate = model.NextDueDate; + recurringPlace.Notes = model.Notes; + + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var recurringPlace = await _db.RecurringPlaces.FindAsync(id); + if (recurringPlace == null) + return NotFound(); + + _db.RecurringPlaces.Remove(recurringPlace); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} + +public class RecurringPlaceCreateModel +{ + public int MerchantId { get; set; } + public int CardId { get; set; } + public int CategoryId { get; set; } + public decimal Amount { get; set; } + public string Frequency { get; set; } + public DateTime NextDueDate { get; set; } + public string Notes { get; set; } +} + +public class RecurringPlaceUpdateModel +{ + public int MerchantId { get; set; } + public int CardId { get; set; } + public int CategoryId { get; set; } + public decimal Amount { get; set; } + public string Frequency { get; set; } + public DateTime NextDueDate { get; set; } + public string Notes { get; set; } +} \ No newline at end of file diff --git a/MoneyMap/Controllers/TransactionsController.cs b/MoneyMap/Controllers/TransactionsController.cs index 5a8a682..1b71918 100644 --- a/MoneyMap/Controllers/TransactionsController.cs +++ b/MoneyMap/Controllers/TransactionsController.cs @@ -31,8 +31,11 @@ public class TransactionsController : ControllerBase [FromQuery] int? cardId = null, [FromQuery] string? type = null, [FromQuery] bool? uncategorizedOnly = null, - [FromQuery] int? limit = null) + [FromQuery] int? limit = null, + [FromQuery] int? offset = null) { + int pageLimit = Math.Min(limit ?? 50, 500); + int pageOffset = offset ?? 0; var q = _db.Transactions .Include(t => t.Merchant) .Include(t => t.Card) @@ -75,9 +78,12 @@ public class TransactionsController : ControllerBase if (uncategorizedOnly == true) q = q.Where(t => t.Category == null || t.Category == ""); + var totalCount = await q.CountAsync(); + var results = await q .OrderByDescending(t => t.Date).ThenByDescending(t => t.Id) - .Take(limit ?? 50) + .Skip(pageOffset) + .Take(pageLimit) .Select(t => new { t.Id, @@ -94,7 +100,17 @@ public class TransactionsController : ControllerBase }) .ToListAsync(); - return Ok(new { Count = results.Count, Transactions = results }); + int totalPages = (int)Math.Ceiling(totalCount / (double)pageLimit); + + return Ok(new + { + Count = results.Count, + TotalCount = totalCount, + Page = (pageOffset / pageLimit) + 1, + PageSize = pageLimit, + TotalPages = totalPages, + Transactions = results + }); } [HttpGet("{id}")] diff --git a/MoneyMap/Migrations/20251004000603_InitialCreate.Designer.cs b/MoneyMap/Migrations/20251004000603_InitialCreate.Designer.cs index cb65fea..efdfbf7 100644 --- a/MoneyMap/Migrations/20251004000603_InitialCreate.Designer.cs +++ b/MoneyMap/Migrations/20251004000603_InitialCreate.Designer.cs @@ -1,352 +1,352 @@ -// -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("20251004000603_InitialCreate")] - partial class InitialCreate - { - /// - 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.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Issuer", "Last4", "Owner"); - - b.ToTable("Cards"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("CardLast4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") - .IsUnique(); - - b.ToTable("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Card", "Card") - .WithMany("Transactions") - .HasForeignKey("CardId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Card"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - 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 - } - } -} +// +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("20251004000603_InitialCreate")] + partial class InitialCreate + { + /// + 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.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CardLast4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") + .IsUnique(); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Card", "Card") + .WithMany("Transactions") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251004000603_InitialCreate.cs b/MoneyMap/Migrations/20251004000603_InitialCreate.cs index ad1dd49..46df78a 100644 --- a/MoneyMap/Migrations/20251004000603_InitialCreate.cs +++ b/MoneyMap/Migrations/20251004000603_InitialCreate.cs @@ -1,209 +1,209 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Cards", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Issuer = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Last4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), - Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Cards", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Transactions", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Date = table.Column(type: "datetime2", nullable: false), - TransactionType = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), - Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), - Memo = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), - Amount = table.Column(type: "decimal(18,2)", nullable: false), - Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CardId = table.Column(type: "int", nullable: false), - CardLast4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Transactions", x => x.Id); - table.ForeignKey( - name: "FK_Transactions_Cards_CardId", - column: x => x.CardId, - principalTable: "Cards", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "Receipts", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - TransactionId = table.Column(type: "bigint", nullable: false), - FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), - ContentType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false, defaultValue: "application/octet-stream"), - StoragePath = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), - FileSizeBytes = table.Column(type: "bigint", nullable: false), - FileHashSha256 = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - UploadedAtUtc = table.Column(type: "datetime2", nullable: false), - Merchant = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - ReceiptDate = table.Column(type: "datetime2", nullable: true), - Subtotal = table.Column(type: "decimal(18,2)", nullable: true), - Tax = table.Column(type: "decimal(18,2)", nullable: true), - Total = table.Column(type: "decimal(18,2)", nullable: true), - Currency = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Receipts", x => x.Id); - table.ForeignKey( - name: "FK_Receipts_Transactions_TransactionId", - column: x => x.TransactionId, - principalTable: "Transactions", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ReceiptLineItems", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - ReceiptId = table.Column(type: "bigint", nullable: false), - LineNumber = table.Column(type: "int", nullable: false), - Description = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), - Quantity = table.Column(type: "decimal(18,4)", nullable: true), - Confidence = table.Column(type: "decimal(5,4)", nullable: true), - Unit = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: true), - UnitPrice = table.Column(type: "decimal(18,4)", nullable: true), - LineTotal = table.Column(type: "decimal(18,2)", nullable: true), - Sku = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), - Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ReceiptLineItems", x => x.Id); - table.ForeignKey( - name: "FK_ReceiptLineItems_Receipts_ReceiptId", - column: x => x.ReceiptId, - principalTable: "Receipts", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ReceiptParseLogs", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - ReceiptId = table.Column(type: "bigint", nullable: false), - Provider = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), - Model = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - ProviderJobId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - StartedAtUtc = table.Column(type: "datetime2", nullable: false), - CompletedAtUtc = table.Column(type: "datetime2", nullable: true), - Success = table.Column(type: "bit", nullable: false), - Confidence = table.Column(type: "decimal(5,4)", nullable: true), - RawProviderPayloadJson = table.Column(type: "nvarchar(max)", nullable: false), - ExtractedTextPath = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), - Error = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ReceiptParseLogs", x => x.Id); - table.ForeignKey( - name: "FK_ReceiptParseLogs_Receipts_ReceiptId", - column: x => x.ReceiptId, - principalTable: "Receipts", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Cards_Issuer_Last4_Owner", - table: "Cards", - columns: new[] { "Issuer", "Last4", "Owner" }); - - migrationBuilder.CreateIndex( - name: "IX_ReceiptLineItems_ReceiptId_LineNumber", - table: "ReceiptLineItems", - columns: new[] { "ReceiptId", "LineNumber" }); - - migrationBuilder.CreateIndex( - name: "IX_ReceiptParseLogs_ReceiptId_StartedAtUtc", - table: "ReceiptParseLogs", - columns: new[] { "ReceiptId", "StartedAtUtc" }); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts", - columns: new[] { "TransactionId", "FileHashSha256" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Amount", - table: "Transactions", - column: "Amount"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_CardId", - table: "Transactions", - column: "CardId"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Category", - table: "Transactions", - column: "Category"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date", - table: "Transactions", - column: "Date"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId", - table: "Transactions", - columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ReceiptLineItems"); - - migrationBuilder.DropTable( - name: "ReceiptParseLogs"); - - migrationBuilder.DropTable( - name: "Receipts"); - - migrationBuilder.DropTable( - name: "Transactions"); - - migrationBuilder.DropTable( - name: "Cards"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Cards", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Issuer = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Last4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), + Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cards", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Date = table.Column(type: "datetime2", nullable: false), + TransactionType = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Memo = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CardId = table.Column(type: "int", nullable: false), + CardLast4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Cards_CardId", + column: x => x.CardId, + principalTable: "Cards", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Receipts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TransactionId = table.Column(type: "bigint", nullable: false), + FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + ContentType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false, defaultValue: "application/octet-stream"), + StoragePath = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + FileHashSha256 = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + UploadedAtUtc = table.Column(type: "datetime2", nullable: false), + Merchant = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ReceiptDate = table.Column(type: "datetime2", nullable: true), + Subtotal = table.Column(type: "decimal(18,2)", nullable: true), + Tax = table.Column(type: "decimal(18,2)", nullable: true), + Total = table.Column(type: "decimal(18,2)", nullable: true), + Currency = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Receipts", x => x.Id); + table.ForeignKey( + name: "FK_Receipts_Transactions_TransactionId", + column: x => x.TransactionId, + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReceiptLineItems", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ReceiptId = table.Column(type: "bigint", nullable: false), + LineNumber = table.Column(type: "int", nullable: false), + Description = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + Quantity = table.Column(type: "decimal(18,4)", nullable: true), + Confidence = table.Column(type: "decimal(5,4)", nullable: true), + Unit = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: true), + UnitPrice = table.Column(type: "decimal(18,4)", nullable: true), + LineTotal = table.Column(type: "decimal(18,2)", nullable: true), + Sku = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReceiptLineItems", x => x.Id); + table.ForeignKey( + name: "FK_ReceiptLineItems_Receipts_ReceiptId", + column: x => x.ReceiptId, + principalTable: "Receipts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReceiptParseLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ReceiptId = table.Column(type: "bigint", nullable: false), + Provider = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Model = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + ProviderJobId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + StartedAtUtc = table.Column(type: "datetime2", nullable: false), + CompletedAtUtc = table.Column(type: "datetime2", nullable: true), + Success = table.Column(type: "bit", nullable: false), + Confidence = table.Column(type: "decimal(5,4)", nullable: true), + RawProviderPayloadJson = table.Column(type: "nvarchar(max)", nullable: false), + ExtractedTextPath = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Error = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReceiptParseLogs", x => x.Id); + table.ForeignKey( + name: "FK_ReceiptParseLogs_Receipts_ReceiptId", + column: x => x.ReceiptId, + principalTable: "Receipts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cards_Issuer_Last4_Owner", + table: "Cards", + columns: new[] { "Issuer", "Last4", "Owner" }); + + migrationBuilder.CreateIndex( + name: "IX_ReceiptLineItems_ReceiptId_LineNumber", + table: "ReceiptLineItems", + columns: new[] { "ReceiptId", "LineNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_ReceiptParseLogs_ReceiptId_StartedAtUtc", + table: "ReceiptParseLogs", + columns: new[] { "ReceiptId", "StartedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts", + columns: new[] { "TransactionId", "FileHashSha256" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Amount", + table: "Transactions", + column: "Amount"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CardId", + table: "Transactions", + column: "CardId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Category", + table: "Transactions", + column: "Category"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date", + table: "Transactions", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId", + table: "Transactions", + columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReceiptLineItems"); + + migrationBuilder.DropTable( + name: "ReceiptParseLogs"); + + migrationBuilder.DropTable( + name: "Receipts"); + + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "Cards"); + } + } +} diff --git a/MoneyMap/Migrations/20251004023633_AddCategoryMappings.Designer.cs b/MoneyMap/Migrations/20251004023633_AddCategoryMappings.Designer.cs index a695d31..d4cb3f5 100644 --- a/MoneyMap/Migrations/20251004023633_AddCategoryMappings.Designer.cs +++ b/MoneyMap/Migrations/20251004023633_AddCategoryMappings.Designer.cs @@ -1,373 +1,373 @@ -// -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("20251004023633_AddCategoryMappings")] - partial class AddCategoryMappings - { - /// - 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.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Issuer", "Last4", "Owner"); - - b.ToTable("Cards"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("CardLast4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") - .IsUnique(); - - b.ToTable("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Card", "Card") - .WithMany("Transactions") - .HasForeignKey("CardId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Card"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - 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 - } - } -} +// +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("20251004023633_AddCategoryMappings")] + partial class AddCategoryMappings + { + /// + 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.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CardLast4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") + .IsUnique(); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Card", "Card") + .WithMany("Transactions") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251004023633_AddCategoryMappings.cs b/MoneyMap/Migrations/20251004023633_AddCategoryMappings.cs index 7fc3000..be04e3b 100644 --- a/MoneyMap/Migrations/20251004023633_AddCategoryMappings.cs +++ b/MoneyMap/Migrations/20251004023633_AddCategoryMappings.cs @@ -1,46 +1,46 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddCategoryMappings : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Confidence", - table: "ReceiptLineItems"); - - migrationBuilder.CreateTable( - name: "CategoryMappings", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Category = table.Column(type: "nvarchar(max)", nullable: false), - Pattern = table.Column(type: "nvarchar(max)", nullable: false), - Priority = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CategoryMappings", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CategoryMappings"); - - migrationBuilder.AddColumn( - name: "Confidence", - table: "ReceiptLineItems", - type: "decimal(5,4)", - nullable: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddCategoryMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Confidence", + table: "ReceiptLineItems"); + + migrationBuilder.CreateTable( + name: "CategoryMappings", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Category = table.Column(type: "nvarchar(max)", nullable: false), + Pattern = table.Column(type: "nvarchar(max)", nullable: false), + Priority = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoryMappings", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CategoryMappings"); + + migrationBuilder.AddColumn( + name: "Confidence", + table: "ReceiptLineItems", + type: "decimal(5,4)", + nullable: true); + } + } +} diff --git a/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.Designer.cs b/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.Designer.cs index c53c101..804b08a 100644 --- a/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.Designer.cs +++ b/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.Designer.cs @@ -1,377 +1,377 @@ -// -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("20251004034919_AddNotesToTransactions")] - partial class AddNotesToTransactions - { - /// - 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.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Issuer", "Last4", "Owner"); - - b.ToTable("Cards"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("CardLast4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") - .IsUnique(); - - b.ToTable("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Card", "Card") - .WithMany("Transactions") - .HasForeignKey("CardId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Card"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - 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 - } - } -} +// +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("20251004034919_AddNotesToTransactions")] + partial class AddNotesToTransactions + { + /// + 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.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("CardLast4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "CardId") + .IsUnique(); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Card", "Card") + .WithMany("Transactions") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.cs b/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.cs index 18d1c04..7b7575a 100644 --- a/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.cs +++ b/MoneyMap/Migrations/20251004034919_AddNotesToTransactions.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddNotesToTransactions : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Notes", - table: "Transactions", - type: "nvarchar(max)", - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Notes", - table: "Transactions"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddNotesToTransactions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notes", + table: "Transactions", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notes", + table: "Transactions"); + } + } +} diff --git a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs index 2e9bd43..870d006 100644 --- a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs +++ b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.Designer.cs @@ -1,507 +1,507 @@ -// -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("20251010003334_SplitCardsAndAccounts")] - partial class SplitCardsAndAccounts - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Issuer", "Last4", "Owner"); - - b.ToTable("Cards"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId") - .IsUnique() - .HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); - - b.ToTable("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transfer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Navigation("Account"); - - b.Navigation("Card"); - }); - - 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("DestinationTransfers"); - - b.Navigation("SourceTransfers"); - - b.Navigation("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - 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 - } - } -} +// +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("20251010003334_SplitCardsAndAccounts")] + partial class SplitCardsAndAccounts + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId") + .IsUnique() + .HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Navigation("Account"); + + b.Navigation("Card"); + }); + + 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("DestinationTransfers"); + + b.Navigation("SourceTransfers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs index 590807e..dd29aaa 100644 --- a/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs +++ b/MoneyMap/Migrations/20251010003334_SplitCardsAndAccounts.cs @@ -1,184 +1,184 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class SplitCardsAndAccounts : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId", - table: "Transactions"); - - migrationBuilder.RenameColumn( - name: "CardLast4", - table: "Transactions", - newName: "Last4"); - - migrationBuilder.AlterColumn( - name: "CardId", - table: "Transactions", - type: "int", - nullable: true, - oldClrType: typeof(int), - oldType: "int"); - - migrationBuilder.AddColumn( - name: "AccountId", - table: "Transactions", - type: "int", - nullable: true); - - migrationBuilder.CreateTable( - name: "Accounts", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Institution = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Last4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), - Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - AccountType = table.Column(type: "int", nullable: false), - Nickname = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Accounts", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Transfers", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Date = table.Column(type: "datetime2", nullable: false), - Amount = table.Column(type: "decimal(18,2)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), - Notes = table.Column(type: "nvarchar(max)", nullable: false), - SourceAccountId = table.Column(type: "int", nullable: true), - DestinationAccountId = table.Column(type: "int", nullable: true), - OriginalTransactionId = table.Column(type: "bigint", nullable: true), - CreatedAt = table.Column(type: "datetime2", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Transfers", x => x.Id); - table.ForeignKey( - name: "FK_Transfers_Accounts_DestinationAccountId", - column: x => x.DestinationAccountId, - principalTable: "Accounts", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_Transfers_Accounts_SourceAccountId", - column: x => x.SourceAccountId, - principalTable: "Accounts", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_Transfers_Transactions_OriginalTransactionId", - column: x => x.OriginalTransactionId, - principalTable: "Transactions", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_AccountId", - table: "Transactions", - column: "AccountId"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", - table: "Transactions", - columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" }, - unique: true, - filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_Accounts_Institution_Last4_Owner", - table: "Accounts", - columns: new[] { "Institution", "Last4", "Owner" }); - - migrationBuilder.CreateIndex( - name: "IX_Transfers_Date", - table: "Transfers", - column: "Date"); - - migrationBuilder.CreateIndex( - name: "IX_Transfers_DestinationAccountId", - table: "Transfers", - column: "DestinationAccountId"); - - migrationBuilder.CreateIndex( - name: "IX_Transfers_OriginalTransactionId", - table: "Transfers", - column: "OriginalTransactionId"); - - migrationBuilder.CreateIndex( - name: "IX_Transfers_SourceAccountId", - table: "Transfers", - column: "SourceAccountId"); - - migrationBuilder.AddForeignKey( - name: "FK_Transactions_Accounts_AccountId", - table: "Transactions", - column: "AccountId", - principalTable: "Accounts", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Transactions_Accounts_AccountId", - table: "Transactions"); - - migrationBuilder.DropTable( - name: "Transfers"); - - migrationBuilder.DropTable( - name: "Accounts"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_AccountId", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", - table: "Transactions"); - - migrationBuilder.DropColumn( - name: "AccountId", - table: "Transactions"); - - migrationBuilder.RenameColumn( - name: "Last4", - table: "Transactions", - newName: "CardLast4"); - - migrationBuilder.AlterColumn( - name: "CardId", - table: "Transactions", - type: "int", - nullable: false, - defaultValue: 0, - oldClrType: typeof(int), - oldType: "int", - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId", - table: "Transactions", - columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" }, - unique: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class SplitCardsAndAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId", + table: "Transactions"); + + migrationBuilder.RenameColumn( + name: "CardLast4", + table: "Transactions", + newName: "Last4"); + + migrationBuilder.AlterColumn( + name: "CardId", + table: "Transactions", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "AccountId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Institution = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Last4 = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), + Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AccountType = table.Column(type: "int", nullable: false), + Nickname = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Transfers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Date = table.Column(type: "datetime2", nullable: false), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Notes = table.Column(type: "nvarchar(max)", nullable: false), + SourceAccountId = table.Column(type: "int", nullable: true), + DestinationAccountId = table.Column(type: "int", nullable: true), + OriginalTransactionId = table.Column(type: "bigint", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Transfers", x => x.Id); + table.ForeignKey( + name: "FK_Transfers_Accounts_DestinationAccountId", + column: x => x.DestinationAccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Transfers_Accounts_SourceAccountId", + column: x => x.SourceAccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Transfers_Transactions_OriginalTransactionId", + column: x => x.OriginalTransactionId, + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId", + table: "Transactions", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", + table: "Transactions", + columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" }, + unique: true, + filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Accounts_Institution_Last4_Owner", + table: "Accounts", + columns: new[] { "Institution", "Last4", "Owner" }); + + migrationBuilder.CreateIndex( + name: "IX_Transfers_Date", + table: "Transfers", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_Transfers_DestinationAccountId", + table: "Transfers", + column: "DestinationAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transfers_OriginalTransactionId", + table: "Transfers", + column: "OriginalTransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_Transfers_SourceAccountId", + table: "Transfers", + column: "SourceAccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Accounts_AccountId", + table: "Transactions", + column: "AccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Accounts_AccountId", + table: "Transactions"); + + migrationBuilder.DropTable( + name: "Transfers"); + + migrationBuilder.DropTable( + name: "Accounts"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "AccountId", + table: "Transactions"); + + migrationBuilder.RenameColumn( + name: "Last4", + table: "Transactions", + newName: "CardLast4"); + + migrationBuilder.AlterColumn( + name: "CardId", + table: "Transactions", + type: "int", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId", + table: "Transactions", + columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" }, + unique: true); + } + } +} diff --git a/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs index 1f588b4..be6e8f4 100644 --- a/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs +++ b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.Designer.cs @@ -1,528 +1,528 @@ -// -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("20251010025545_LinkCardsToAccounts")] - partial class LinkCardsToAccounts - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId") - .IsUnique() - .HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); - - b.ToTable("Transactions"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transfer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Navigation("Account"); - - b.Navigation("Card"); - }); - - 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.Receipt", b => - { - b.Navigation("LineItems"); - - b.Navigation("ParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Navigation("Receipts"); - }); -#pragma warning restore 612, 618 - } - } -} +// +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("20251010025545_LinkCardsToAccounts")] + partial class LinkCardsToAccounts + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "CardId", "AccountId") + .IsUnique() + .HasFilter("[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Navigation("Account"); + + b.Navigation("Card"); + }); + + 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.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs index 363076c..a57a737 100644 --- a/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs +++ b/MoneyMap/Migrations/20251010025545_LinkCardsToAccounts.cs @@ -1,60 +1,60 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class LinkCardsToAccounts : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "AccountId", - table: "Cards", - type: "int", - nullable: true); - - migrationBuilder.AddColumn( - name: "Nickname", - table: "Cards", - type: "nvarchar(50)", - maxLength: 50, - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Cards_AccountId", - table: "Cards", - column: "AccountId"); - - migrationBuilder.AddForeignKey( - name: "FK_Cards_Accounts_AccountId", - table: "Cards", - column: "AccountId", - principalTable: "Accounts", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Cards_Accounts_AccountId", - table: "Cards"); - - migrationBuilder.DropIndex( - name: "IX_Cards_AccountId", - table: "Cards"); - - migrationBuilder.DropColumn( - name: "AccountId", - table: "Cards"); - - migrationBuilder.DropColumn( - name: "Nickname", - table: "Cards"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class LinkCardsToAccounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountId", + table: "Cards", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Nickname", + table: "Cards", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Cards_AccountId", + table: "Cards", + column: "AccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Cards_Accounts_AccountId", + table: "Cards", + column: "AccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Cards_Accounts_AccountId", + table: "Cards"); + + migrationBuilder.DropIndex( + name: "IX_Cards_AccountId", + table: "Cards"); + + migrationBuilder.DropColumn( + name: "AccountId", + table: "Cards"); + + migrationBuilder.DropColumn( + name: "Nickname", + table: "Cards"); + } + } +} diff --git a/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.Designer.cs b/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.Designer.cs index 28828c7..48be7f4 100644 --- a/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.Designer.cs +++ b/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.Designer.cs @@ -1,528 +1,528 @@ -// -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("20251010231349_MakeAccountRequiredOnTransaction")] - partial class MakeAccountRequiredOnTransaction - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Navigation("Account"); - - b.Navigation("Card"); - }); - - 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.Receipt", b => - { - b.Navigation("LineItems"); - - b.Navigation("ParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Navigation("Receipts"); - }); -#pragma warning restore 612, 618 - } - } -} +// +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("20251010231349_MakeAccountRequiredOnTransaction")] + partial class MakeAccountRequiredOnTransaction + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Navigation("Account"); + + b.Navigation("Card"); + }); + + 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.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.cs b/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.cs index 780776e..4b6916b 100644 --- a/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.cs +++ b/MoneyMap/Migrations/20251010231349_MakeAccountRequiredOnTransaction.cs @@ -1,89 +1,89 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class MakeAccountRequiredOnTransaction : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", - table: "Transactions"); - - // For existing transactions with CardId but no AccountId: - // Set AccountId to the card's linked account if available - migrationBuilder.Sql(@" - UPDATE Transactions - SET AccountId = c.AccountId - FROM Transactions t - INNER JOIN Cards c ON t.CardId = c.Id - WHERE t.AccountId IS NULL AND t.CardId IS NOT NULL AND c.AccountId IS NOT NULL - "); - - // For card transactions where card has no linked account, we need to handle this - // Delete or move to a default account - for now, we'll prevent the migration if this case exists - migrationBuilder.Sql(@" - IF EXISTS ( - SELECT 1 FROM Transactions t - INNER JOIN Cards c ON t.CardId = c.Id - WHERE t.AccountId IS NULL AND c.AccountId IS NULL - ) - BEGIN - RAISERROR('Cannot migrate: Some transactions have cards that are not linked to accounts. Please link all cards to accounts first.', 16, 1) - END - "); - - // For remaining transactions with no AccountId and no CardId, they must be deleted or assigned - migrationBuilder.Sql(@" - IF EXISTS (SELECT 1 FROM Transactions WHERE AccountId IS NULL AND CardId IS NULL) - BEGIN - RAISERROR('Cannot migrate: Some transactions have neither AccountId nor CardId. Please fix these transactions first.', 16, 1) - END - "); - - // Now make AccountId required - migrationBuilder.AlterColumn( - name: "AccountId", - table: "Transactions", - type: "int", - nullable: false, - oldClrType: typeof(int), - oldType: "int", - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId", - table: "Transactions", - columns: new[] { "Date", "Amount", "Name", "Memo", "AccountId", "CardId" }, - unique: true, - filter: "[CardId] IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId", - table: "Transactions"); - - migrationBuilder.AlterColumn( - name: "AccountId", - table: "Transactions", - type: "int", - nullable: true, - oldClrType: typeof(int), - oldType: "int"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", - table: "Transactions", - columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" }, - unique: true, - filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class MakeAccountRequiredOnTransaction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", + table: "Transactions"); + + // For existing transactions with CardId but no AccountId: + // Set AccountId to the card's linked account if available + migrationBuilder.Sql(@" + UPDATE Transactions + SET AccountId = c.AccountId + FROM Transactions t + INNER JOIN Cards c ON t.CardId = c.Id + WHERE t.AccountId IS NULL AND t.CardId IS NOT NULL AND c.AccountId IS NOT NULL + "); + + // For card transactions where card has no linked account, we need to handle this + // Delete or move to a default account - for now, we'll prevent the migration if this case exists + migrationBuilder.Sql(@" + IF EXISTS ( + SELECT 1 FROM Transactions t + INNER JOIN Cards c ON t.CardId = c.Id + WHERE t.AccountId IS NULL AND c.AccountId IS NULL + ) + BEGIN + RAISERROR('Cannot migrate: Some transactions have cards that are not linked to accounts. Please link all cards to accounts first.', 16, 1) + END + "); + + // For remaining transactions with no AccountId and no CardId, they must be deleted or assigned + migrationBuilder.Sql(@" + IF EXISTS (SELECT 1 FROM Transactions WHERE AccountId IS NULL AND CardId IS NULL) + BEGIN + RAISERROR('Cannot migrate: Some transactions have neither AccountId nor CardId. Please fix these transactions first.', 16, 1) + END + "); + + // Now make AccountId required + migrationBuilder.AlterColumn( + name: "AccountId", + table: "Transactions", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId", + table: "Transactions", + columns: new[] { "Date", "Amount", "Name", "Memo", "AccountId", "CardId" }, + unique: true, + filter: "[CardId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_AccountId_CardId", + table: "Transactions"); + + migrationBuilder.AlterColumn( + name: "AccountId", + table: "Transactions", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_Date_Amount_Name_Memo_CardId_AccountId", + table: "Transactions", + columns: new[] { "Date", "Amount", "Name", "Memo", "CardId", "AccountId" }, + unique: true, + filter: "[CardId] IS NOT NULL AND [AccountId] IS NOT NULL"); + } + } +} diff --git a/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs b/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs index 39303d8..e3293a4 100644 --- a/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs +++ b/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs @@ -1,539 +1,539 @@ -// -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("20251011014001_AddTransferSupport")] - partial class AddTransferSupport - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Account", "TransferToAccount") - .WithMany() - .HasForeignKey("TransferToAccountId"); - - b.Navigation("Account"); - - b.Navigation("Card"); - - 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.Receipt", b => - { - b.Navigation("LineItems"); - - b.Navigation("ParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Navigation("Receipts"); - }); -#pragma warning restore 612, 618 - } - } -} +// +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("20251011014001_AddTransferSupport")] + partial class AddTransferSupport + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + 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.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs b/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs index 3c430cc..e353301 100644 --- a/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs +++ b/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs @@ -1,48 +1,48 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddTransferSupport : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "TransferToAccountId", - table: "Transactions", - type: "int", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_TransferToAccountId", - table: "Transactions", - column: "TransferToAccountId"); - - migrationBuilder.AddForeignKey( - name: "FK_Transactions_Accounts_TransferToAccountId", - table: "Transactions", - column: "TransferToAccountId", - principalTable: "Accounts", - principalColumn: "Id"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Transactions_Accounts_TransferToAccountId", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_TransferToAccountId", - table: "Transactions"); - - migrationBuilder.DropColumn( - name: "TransferToAccountId", - table: "Transactions"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddTransferSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TransferToAccountId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_TransferToAccountId", + table: "Transactions", + column: "TransferToAccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Accounts_TransferToAccountId", + table: "Transactions", + column: "TransferToAccountId", + principalTable: "Accounts", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Accounts_TransferToAccountId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_TransferToAccountId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "TransferToAccountId", + table: "Transactions"); + } + } +} diff --git a/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.Designer.cs b/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.Designer.cs index 8d4bf4e..24e06e9 100644 --- a/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.Designer.cs +++ b/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.Designer.cs @@ -1,543 +1,543 @@ -// -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("20251012071124_AddMerchantToTransactions")] - partial class AddMerchantToTransactions - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Merchant") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Account", "TransferToAccount") - .WithMany() - .HasForeignKey("TransferToAccountId"); - - b.Navigation("Account"); - - b.Navigation("Card"); - - 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.Receipt", b => - { - b.Navigation("LineItems"); - - b.Navigation("ParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Navigation("Receipts"); - }); -#pragma warning restore 612, 618 - } - } -} +// +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("20251012071124_AddMerchantToTransactions")] + partial class AddMerchantToTransactions + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Merchant") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + 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.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.cs b/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.cs index 12109f9..481ccd2 100644 --- a/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.cs +++ b/MoneyMap/Migrations/20251012071124_AddMerchantToTransactions.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddMerchantToTransactions : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Merchant", - table: "Transactions", - type: "nvarchar(100)", - maxLength: 100, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Merchant", - table: "Transactions"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddMerchantToTransactions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Merchant", + table: "Transactions", + type: "nvarchar(100)", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Merchant", + table: "Transactions"); + } + } +} diff --git a/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.Designer.cs b/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.Designer.cs index 8a71f66..4038109 100644 --- a/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.Designer.cs +++ b/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.Designer.cs @@ -1,546 +1,546 @@ -// -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("20251012071155_AddMerchantToCategoryMappings")] - partial class AddMerchantToCategoryMappings - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Receipt", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("Merchant") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Merchant") - .HasColumnType("nvarchar(max)"); - - b.Property("Pattern") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Account", "TransferToAccount") - .WithMany() - .HasForeignKey("TransferToAccountId"); - - b.Navigation("Account"); - - b.Navigation("Card"); - - 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.Receipt", b => - { - b.Navigation("LineItems"); - - b.Navigation("ParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Navigation("Receipts"); - }); -#pragma warning restore 612, 618 - } - } -} +// +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("20251012071155_AddMerchantToCategoryMappings")] + partial class AddMerchantToCategoryMappings + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Merchant") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Merchant") + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + 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.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.cs b/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.cs index 9a093a9..1ccf6bc 100644 --- a/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.cs +++ b/MoneyMap/Migrations/20251012071155_AddMerchantToCategoryMappings.cs @@ -1,28 +1,28 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddMerchantToCategoryMappings : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Merchant", - table: "CategoryMappings", - type: "nvarchar(max)", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Merchant", - table: "CategoryMappings"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddMerchantToCategoryMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Merchant", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Merchant", + table: "CategoryMappings"); + } + } +} diff --git a/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs index 3dbf7c1..5aa4eeb 100644 --- a/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs +++ b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.Designer.cs @@ -1,596 +1,596 @@ -// -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("20251012075038_ConvertMerchantToEntity")] - partial class ConvertMerchantToEntity - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012075038_ConvertMerchantToEntity")] + partial class ConvertMerchantToEntity + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs index 365a173..2e9ccfe 100644 --- a/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs +++ b/MoneyMap/Migrations/20251012075038_ConvertMerchantToEntity.cs @@ -1,159 +1,159 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class ConvertMerchantToEntity : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Merchant", - table: "Transactions"); - - migrationBuilder.DropColumn( - name: "Merchant", - table: "CategoryMappings"); - - migrationBuilder.AddColumn( - name: "MerchantId", - table: "Transactions", - type: "int", - nullable: true); - - migrationBuilder.AlterColumn( - name: "Pattern", - table: "CategoryMappings", - type: "nvarchar(200)", - maxLength: 200, - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.AlterColumn( - name: "Category", - table: "CategoryMappings", - type: "nvarchar(100)", - maxLength: 100, - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.AddColumn( - name: "MerchantId", - table: "CategoryMappings", - type: "int", - nullable: true); - - migrationBuilder.CreateTable( - name: "Merchants", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Merchants", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_MerchantId", - table: "Transactions", - column: "MerchantId"); - - migrationBuilder.CreateIndex( - name: "IX_CategoryMappings_MerchantId", - table: "CategoryMappings", - column: "MerchantId"); - - migrationBuilder.CreateIndex( - name: "IX_Merchants_Name", - table: "Merchants", - column: "Name", - unique: true); - - migrationBuilder.AddForeignKey( - name: "FK_CategoryMappings_Merchants_MerchantId", - table: "CategoryMappings", - column: "MerchantId", - principalTable: "Merchants", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - - migrationBuilder.AddForeignKey( - name: "FK_Transactions_Merchants_MerchantId", - table: "Transactions", - column: "MerchantId", - principalTable: "Merchants", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_CategoryMappings_Merchants_MerchantId", - table: "CategoryMappings"); - - migrationBuilder.DropForeignKey( - name: "FK_Transactions_Merchants_MerchantId", - table: "Transactions"); - - migrationBuilder.DropTable( - name: "Merchants"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_MerchantId", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_CategoryMappings_MerchantId", - table: "CategoryMappings"); - - migrationBuilder.DropColumn( - name: "MerchantId", - table: "Transactions"); - - migrationBuilder.DropColumn( - name: "MerchantId", - table: "CategoryMappings"); - - migrationBuilder.AddColumn( - name: "Merchant", - table: "Transactions", - type: "nvarchar(100)", - maxLength: 100, - nullable: true); - - migrationBuilder.AlterColumn( - name: "Pattern", - table: "CategoryMappings", - type: "nvarchar(max)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(200)", - oldMaxLength: 200); - - migrationBuilder.AlterColumn( - name: "Category", - table: "CategoryMappings", - type: "nvarchar(max)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(100)", - oldMaxLength: 100); - - migrationBuilder.AddColumn( - name: "Merchant", - table: "CategoryMappings", - type: "nvarchar(max)", - nullable: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class ConvertMerchantToEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Merchant", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "Merchant", + table: "CategoryMappings"); + + migrationBuilder.AddColumn( + name: "MerchantId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Pattern", + table: "CategoryMappings", + type: "nvarchar(200)", + maxLength: 200, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AlterColumn( + name: "Category", + table: "CategoryMappings", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "MerchantId", + table: "CategoryMappings", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "Merchants", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Merchants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_MerchantId", + table: "Transactions", + column: "MerchantId"); + + migrationBuilder.CreateIndex( + name: "IX_CategoryMappings_MerchantId", + table: "CategoryMappings", + column: "MerchantId"); + + migrationBuilder.CreateIndex( + name: "IX_Merchants_Name", + table: "Merchants", + column: "Name", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_CategoryMappings_Merchants_MerchantId", + table: "CategoryMappings", + column: "MerchantId", + principalTable: "Merchants", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Merchants_MerchantId", + table: "Transactions", + column: "MerchantId", + principalTable: "Merchants", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CategoryMappings_Merchants_MerchantId", + table: "CategoryMappings"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Merchants_MerchantId", + table: "Transactions"); + + migrationBuilder.DropTable( + name: "Merchants"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_MerchantId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_CategoryMappings_MerchantId", + table: "CategoryMappings"); + + migrationBuilder.DropColumn( + name: "MerchantId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "MerchantId", + table: "CategoryMappings"); + + migrationBuilder.AddColumn( + name: "Merchant", + table: "Transactions", + type: "nvarchar(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AlterColumn( + name: "Pattern", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(200)", + oldMaxLength: 200); + + migrationBuilder.AlterColumn( + name: "Category", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "Merchant", + table: "CategoryMappings", + type: "nvarchar(max)", + nullable: true); + } + } +} diff --git a/MoneyMap/Migrations/20251012145117_AddAICategorization.Designer.cs b/MoneyMap/Migrations/20251012145117_AddAICategorization.Designer.cs index 7cc2b98..4fc29f1 100644 --- a/MoneyMap/Migrations/20251012145117_AddAICategorization.Designer.cs +++ b/MoneyMap/Migrations/20251012145117_AddAICategorization.Designer.cs @@ -1,606 +1,606 @@ -// -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("20251012145117_AddAICategorization")] - partial class AddAICategorization - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012145117_AddAICategorization")] + partial class AddAICategorization + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012145117_AddAICategorization.cs b/MoneyMap/Migrations/20251012145117_AddAICategorization.cs index 316648a..791b55e 100644 --- a/MoneyMap/Migrations/20251012145117_AddAICategorization.cs +++ b/MoneyMap/Migrations/20251012145117_AddAICategorization.cs @@ -1,49 +1,49 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddAICategorization : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Confidence", - table: "CategoryMappings", - type: "decimal(5,4)", - nullable: true); - - migrationBuilder.AddColumn( - name: "CreatedAt", - table: "CategoryMappings", - type: "datetime2", - nullable: true); - - migrationBuilder.AddColumn( - name: "CreatedBy", - table: "CategoryMappings", - type: "nvarchar(50)", - maxLength: 50, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Confidence", - table: "CategoryMappings"); - - migrationBuilder.DropColumn( - name: "CreatedAt", - table: "CategoryMappings"); - - migrationBuilder.DropColumn( - name: "CreatedBy", - table: "CategoryMappings"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddAICategorization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Confidence", + table: "CategoryMappings", + type: "decimal(5,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "CategoryMappings", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedBy", + table: "CategoryMappings", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Confidence", + table: "CategoryMappings"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "CategoryMappings"); + + migrationBuilder.DropColumn( + name: "CreatedBy", + table: "CategoryMappings"); + } + } +} diff --git a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs index 34d2763..e3a31ae 100644 --- a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs +++ b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.Designer.cs @@ -1,606 +1,606 @@ -// -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("20251012152604_MakeReceiptTransactionIdNullable")] - partial class MakeReceiptTransactionIdNullable - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique(); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.Receipt", b => - { - b.HasOne("MoneyMap.Models.Transaction", "Transaction") - .WithMany("Receipts") - .HasForeignKey("TransactionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - 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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012152604_MakeReceiptTransactionIdNullable")] + partial class MakeReceiptTransactionIdNullable + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs index 0d98ee5..4912c2c 100644 --- a/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs +++ b/MoneyMap/Migrations/20251012152604_MakeReceiptTransactionIdNullable.cs @@ -1,22 +1,22 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class MakeReceiptTransactionIdNullable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class MakeReceiptTransactionIdNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.Designer.cs b/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.Designer.cs index c21534a..8c364a7 100644 --- a/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.Designer.cs +++ b/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.Designer.cs @@ -1,606 +1,606 @@ -// -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("20251012163108_UpdateReceiptIndexForNullableTransactionId")] - partial class UpdateReceiptIndexForNullableTransactionId - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique() - .HasFilter("[TransactionId] IS NOT NULL"); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012163108_UpdateReceiptIndexForNullableTransactionId")] + partial class UpdateReceiptIndexForNullableTransactionId + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.cs b/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.cs index dbffbe6..90fd454 100644 --- a/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.cs +++ b/MoneyMap/Migrations/20251012163108_UpdateReceiptIndexForNullableTransactionId.cs @@ -1,57 +1,57 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class UpdateReceiptIndexForNullableTransactionId : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts"); - - migrationBuilder.AlterColumn( - name: "TransactionId", - table: "Receipts", - type: "bigint", - nullable: true, - oldClrType: typeof(long), - oldType: "bigint"); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts", - columns: new[] { "TransactionId", "FileHashSha256" }, - unique: true, - filter: "[TransactionId] IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts"); - - migrationBuilder.AlterColumn( - name: "TransactionId", - table: "Receipts", - type: "bigint", - nullable: false, - defaultValue: 0L, - oldClrType: typeof(long), - oldType: "bigint", - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts", - columns: new[] { "TransactionId", "FileHashSha256" }, - unique: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class UpdateReceiptIndexForNullableTransactionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts"); + + migrationBuilder.AlterColumn( + name: "TransactionId", + table: "Receipts", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts", + columns: new[] { "TransactionId", "FileHashSha256" }, + unique: true, + filter: "[TransactionId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts"); + + migrationBuilder.AlterColumn( + name: "TransactionId", + table: "Receipts", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts", + columns: new[] { "TransactionId", "FileHashSha256" }, + unique: true); + } + } +} diff --git a/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.Designer.cs b/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.Designer.cs index a815f91..4d034b2 100644 --- a/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.Designer.cs +++ b/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.Designer.cs @@ -1,606 +1,606 @@ -// -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("20251012163144_MakeReceiptTransactionOptional")] - partial class MakeReceiptTransactionOptional - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique() - .HasFilter("[TransactionId] IS NOT NULL"); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012163144_MakeReceiptTransactionOptional")] + partial class MakeReceiptTransactionOptional + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.cs b/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.cs index 6444ed0..71cb4d2 100644 --- a/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.cs +++ b/MoneyMap/Migrations/20251012163144_MakeReceiptTransactionOptional.cs @@ -1,57 +1,57 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class MakeReceiptTransactionOptional : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts"); - - migrationBuilder.AlterColumn( - name: "TransactionId", - table: "Receipts", - type: "bigint", - nullable: true, - oldClrType: typeof(long), - oldType: "bigint"); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts", - columns: new[] { "TransactionId", "FileHashSha256" }, - unique: true, - filter: "[TransactionId] IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts"); - - migrationBuilder.AlterColumn( - name: "TransactionId", - table: "Receipts", - type: "bigint", - nullable: false, - defaultValue: 0L, - oldClrType: typeof(long), - oldType: "bigint", - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_FileHashSha256", - table: "Receipts", - columns: new[] { "TransactionId", "FileHashSha256" }, - unique: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class MakeReceiptTransactionOptional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts"); + + migrationBuilder.AlterColumn( + name: "TransactionId", + table: "Receipts", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts", + columns: new[] { "TransactionId", "FileHashSha256" }, + unique: true, + filter: "[TransactionId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts"); + + migrationBuilder.AlterColumn( + name: "TransactionId", + table: "Receipts", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_FileHashSha256", + table: "Receipts", + columns: new[] { "TransactionId", "FileHashSha256" }, + unique: true); + } + } +} diff --git a/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.Designer.cs b/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.Designer.cs index bf28ffe..0317af3 100644 --- a/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.Designer.cs +++ b/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.Designer.cs @@ -1,609 +1,609 @@ -// -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("20251012185017_AddReceiptDueDate")] - partial class AddReceiptDueDate - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique() - .HasFilter("[TransactionId] IS NOT NULL"); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251012185017_AddReceiptDueDate")] + partial class AddReceiptDueDate + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.cs b/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.cs index 76fcc64..a2d58aa 100644 --- a/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.cs +++ b/MoneyMap/Migrations/20251012185017_AddReceiptDueDate.cs @@ -1,28 +1,28 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddReceiptDueDate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DueDate", - table: "Receipts", - type: "datetime2", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DueDate", - table: "Receipts"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddReceiptDueDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DueDate", + table: "Receipts", + type: "datetime2", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DueDate", + table: "Receipts"); + } + } +} diff --git a/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs index 4fe0847..463a1b2 100644 --- a/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs +++ b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.Designer.cs @@ -1,612 +1,612 @@ -// -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("20251019194640_AddVoidedToReceiptLineItem")] - partial class AddVoidedToReceiptLineItem - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("TransactionId", "FileHashSha256") - .IsUnique() - .HasFilter("[TransactionId] IS NOT NULL"); - - b.ToTable("Receipts"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("TransferToAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("AccountId"); - - b.HasIndex("Amount"); - - b.HasIndex("CardId"); - - b.HasIndex("Category"); - - b.HasIndex("Date"); - - b.HasIndex("MerchantId"); - - b.HasIndex("TransferToAccountId"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251019194640_AddVoidedToReceiptLineItem")] + partial class AddVoidedToReceiptLineItem + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs index a8997f1..1a89c4e 100644 --- a/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs +++ b/MoneyMap/Migrations/20251019194640_AddVoidedToReceiptLineItem.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddVoidedToReceiptLineItem : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Voided", - table: "ReceiptLineItems", - type: "bit", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Voided", - table: "ReceiptLineItems"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddVoidedToReceiptLineItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Voided", + table: "ReceiptLineItems", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Voided", + table: "ReceiptLineItems"); + } + } +} diff --git a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs index 3650573..5c9d836 100644 --- a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs +++ b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs @@ -1,620 +1,620 @@ -// -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("20251124231350_AddCompositeIndexes")] - partial class AddCompositeIndexes - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Owner") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.HasIndex("Institution", "Last4", "Owner"); - - b.ToTable("Accounts"); - }); - - modelBuilder.Entity("MoneyMap.Models.Card", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("FileHashSha256"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("SourceAccountId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("DestinationAccountId"); - - b.HasIndex("OriginalTransactionId"); - - b.HasIndex("SourceAccountId"); - - b.ToTable("Transfers"); - }); - - modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - 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.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.Services.CategoryMapping", b => - { - b.HasOne("MoneyMap.Models.Merchant", "Merchant") - .WithMany("CategoryMappings") - .HasForeignKey("MerchantId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Merchant"); - }); - - 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 - } - } -} +// +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("20251124231350_AddCompositeIndexes")] + partial class AddCompositeIndexes + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("FileHashSha256"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + 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.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.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + 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 + } + } +} diff --git a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs index e21ebb3..95386a0 100644 --- a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs +++ b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs @@ -1,90 +1,90 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddCompositeIndexes : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Transactions_AccountId", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_CardId", - table: "Transactions"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_AccountId_Category", - table: "Transactions", - columns: new[] { "AccountId", "Category" }); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_AccountId_Date", - table: "Transactions", - columns: new[] { "AccountId", "Date" }); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_CardId_Date", - table: "Transactions", - columns: new[] { "CardId", "Date" }); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_MerchantId_Date", - table: "Transactions", - columns: new[] { "MerchantId", "Date" }); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_FileHashSha256", - table: "Receipts", - column: "FileHashSha256"); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_TransactionId_ReceiptDate", - table: "Receipts", - columns: new[] { "TransactionId", "ReceiptDate" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Transactions_AccountId_Category", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_AccountId_Date", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_CardId_Date", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Transactions_MerchantId_Date", - table: "Transactions"); - - migrationBuilder.DropIndex( - name: "IX_Receipts_FileHashSha256", - table: "Receipts"); - - migrationBuilder.DropIndex( - name: "IX_Receipts_TransactionId_ReceiptDate", - table: "Receipts"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_AccountId", - table: "Transactions", - column: "AccountId"); - - migrationBuilder.CreateIndex( - name: "IX_Transactions_CardId", - table: "Transactions", - column: "CardId"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddCompositeIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_CardId", + table: "Transactions"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId_Category", + table: "Transactions", + columns: new[] { "AccountId", "Category" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId_Date", + table: "Transactions", + columns: new[] { "AccountId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CardId_Date", + table: "Transactions", + columns: new[] { "CardId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_MerchantId_Date", + table: "Transactions", + columns: new[] { "MerchantId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_FileHashSha256", + table: "Receipts", + column: "FileHashSha256"); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_ReceiptDate", + table: "Receipts", + columns: new[] { "TransactionId", "ReceiptDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId_Category", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_CardId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_MerchantId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Receipts_FileHashSha256", + table: "Receipts"); + + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_ReceiptDate", + table: "Receipts"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId", + table: "Transactions", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CardId", + table: "Transactions", + column: "CardId"); + } + } +} diff --git a/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs b/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs index 042201e..03858df 100644 --- a/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs +++ b/MoneyMap/Migrations/20251211020503_AddBudgets.Designer.cs @@ -1,657 +1,657 @@ -// -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("20251211020503_AddBudgets")] - partial class AddBudgets - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Notes") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("Period") - .HasColumnType("int"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("FileHashSha256"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("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 - } - } -} +// +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("20251211020503_AddBudgets")] + partial class AddBudgets + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("FileHashSha256"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20251211020503_AddBudgets.cs b/MoneyMap/Migrations/20251211020503_AddBudgets.cs index d93ff6a..8652a94 100644 --- a/MoneyMap/Migrations/20251211020503_AddBudgets.cs +++ b/MoneyMap/Migrations/20251211020503_AddBudgets.cs @@ -1,47 +1,47 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddBudgets : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Budgets", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - Amount = table.Column(type: "decimal(18,2)", nullable: false), - Period = table.Column(type: "int", nullable: false), - StartDate = table.Column(type: "datetime2", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - Notes = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Budgets", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Budgets_Category_Period", - table: "Budgets", - columns: new[] { "Category", "Period" }, - unique: true, - filter: "[IsActive] = 1"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Budgets"); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddBudgets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Budgets", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Period = table.Column(type: "int", nullable: false), + StartDate = table.Column(type: "datetime2", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + Notes = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Budgets", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Budgets_Category_Period", + table: "Budgets", + columns: new[] { "Category", "Period" }, + unique: true, + filter: "[IsActive] = 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Budgets"); + } + } +} diff --git a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs index 9db6538..0af6c60 100644 --- a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs +++ b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.Designer.cs @@ -1,661 +1,661 @@ -// -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("20260119005858_AddReceiptParsingNotes")] - partial class AddReceiptParsingNotes - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Notes") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("Period") - .HasColumnType("int"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ParsingNotes") - .HasMaxLength(2000) - .HasColumnType("nvarchar(2000)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("UploadedAtUtc") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("FileHashSha256"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("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 - } - } -} +// +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("20260119005858_AddReceiptParsingNotes")] + partial class AddReceiptParsingNotes + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParsingNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("FileHashSha256"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs index b3a521b..5f12e1a 100644 --- a/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs +++ b/MoneyMap/Migrations/20260119005858_AddReceiptParsingNotes.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddReceiptParsingNotes : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ParsingNotes", - table: "Receipts", - type: "nvarchar(2000)", - maxLength: 2000, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "ParsingNotes", - table: "Receipts"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddReceiptParsingNotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParsingNotes", + table: "Receipts", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ParsingNotes", + table: "Receipts"); + } + } +} diff --git a/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.Designer.cs b/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.Designer.cs index d2f0f60..e6c324f 100644 --- a/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.Designer.cs +++ b/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.Designer.cs @@ -1,668 +1,668 @@ -// -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 - { - /// - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Notes") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("Period") - .HasColumnType("int"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ParseStatus") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasDefaultValue(0); - - b.Property("ParsingNotes") - .HasMaxLength(2000) - .HasColumnType("nvarchar(2000)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("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 - } - } -} +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParseStatus") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("ParsingNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.cs b/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.cs index 941840e..3232163 100644 --- a/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.cs +++ b/MoneyMap/Migrations/20260215030558_AddReceiptParseStatus.cs @@ -1,38 +1,38 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MoneyMap.Migrations -{ - /// - public partial class AddReceiptParseStatus : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ParseStatus", - table: "Receipts", - type: "int", - nullable: false, - defaultValue: 0); - - migrationBuilder.CreateIndex( - name: "IX_Receipts_ParseStatus", - table: "Receipts", - column: "ParseStatus"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Receipts_ParseStatus", - table: "Receipts"); - - migrationBuilder.DropColumn( - name: "ParseStatus", - table: "Receipts"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddReceiptParseStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParseStatus", + table: "Receipts", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_ParseStatus", + table: "Receipts", + column: "ParseStatus"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Receipts_ParseStatus", + table: "Receipts"); + + migrationBuilder.DropColumn( + name: "ParseStatus", + table: "Receipts"); + } + } +} diff --git a/MoneyMap/Migrations/20260707000000_AddCategoryToRecurringPlaces.cs b/MoneyMap/Migrations/20260707000000_AddCategoryToRecurringPlaces.cs new file mode 100644 index 0000000..40c18a9 --- /dev/null +++ b/MoneyMap/Migrations/20260707000000_AddCategoryToRecurringPlaces.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + /// Adds CategoryId foreign key and ParentCategoryId to RecurringPlaces, + /// and Category ParentCategoryId column to support hierarchical categories. + /// + public partial class AddCategoryToRecurringPlaces : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // Add ParentCategoryId to Categories table (for hierarchical categories) + migrationBuilder.AddColumn( + name: "ParentCategoryId", + table: "Categories", + type: "int", + nullable: true); + + // Add CategoryId to RecurringPlaces table + migrationBuilder.AddColumn( + name: "CategoryId", + table: "RecurringPlaces", + type: "int", + nullable: false, + defaultValue: 0); + + // Add index on ParentCategoryId for Categories + migrationBuilder.CreateIndex( + name: "IX_Categories_ParentCategoryId", + table: "Categories", + column: "ParentCategoryId"); + + // Add foreign key from RecurringPlaces.CategoryId to Categories.Id + migrationBuilder.AddForeignKey( + name: "FK_RecurringPlaces_Categories_CategoryId", + table: "RecurringPlaces", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RecurringPlaces_Categories_CategoryId", + table: "RecurringPlaces"); + + migrationBuilder.DropIndex( + name: "IX_Categories_ParentCategoryId", + table: "Categories"); + + migrationBuilder.DropColumn( + name: "ParentCategoryId", + table: "Categories"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "RecurringPlaces"); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index 7275f64..f046e6d 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -1,665 +1,665 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using MoneyMap.Data; - -#nullable disable - -namespace MoneyMap.Migrations -{ - [DbContext(typeof(MoneyMapContext))] - partial class MoneyMapContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountType") - .HasColumnType("int"); - - b.Property("Institution") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Notes") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("Period") - .HasColumnType("int"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Issuer") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Last4") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Nickname") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Priority") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("MerchantId"); - - b.ToTable("CategoryMappings"); - }); - - modelBuilder.Entity("MoneyMap.Models.Merchant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)") - .HasDefaultValue("application/octet-stream"); - - b.Property("Currency") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("DueDate") - .HasColumnType("datetime2"); - - b.Property("FileHashSha256") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(260) - .HasColumnType("nvarchar(260)"); - - b.Property("FileSizeBytes") - .HasColumnType("bigint"); - - b.Property("Merchant") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("ParseStatus") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasDefaultValue(0); - - b.Property("ParsingNotes") - .HasMaxLength(2000) - .HasColumnType("nvarchar(2000)"); - - b.Property("ReceiptDate") - .HasColumnType("datetime2"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Subtotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Tax") - .HasColumnType("decimal(18,2)"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("TransactionId") - .HasColumnType("bigint"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("nvarchar(300)"); - - b.Property("LineNumber") - .HasColumnType("int"); - - b.Property("LineTotal") - .HasColumnType("decimal(18,2)"); - - b.Property("Quantity") - .HasColumnType("decimal(18,4)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("Sku") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Unit") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("UnitPrice") - .HasColumnType("decimal(18,4)"); - - b.Property("Voided") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "LineNumber"); - - b.ToTable("ReceiptLineItems"); - }); - - modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CompletedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Confidence") - .HasColumnType("decimal(5,4)"); - - b.Property("Error") - .HasColumnType("nvarchar(max)"); - - b.Property("ExtractedTextPath") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Provider") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("ProviderJobId") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("RawProviderPayloadJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReceiptId") - .HasColumnType("bigint"); - - b.Property("StartedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Success") - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.HasIndex("ReceiptId", "StartedAtUtc"); - - b.ToTable("ReceiptParseLogs"); - }); - - modelBuilder.Entity("MoneyMap.Models.Transaction", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("AccountId") - .HasColumnType("int"); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CardId") - .HasColumnType("int"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Last4") - .HasMaxLength(4) - .HasColumnType("nvarchar(4)"); - - b.Property("Memo") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("MerchantId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("TransactionType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("nvarchar(20)"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Amount") - .HasColumnType("decimal(18,2)"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("Date") - .HasColumnType("datetime2"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DestinationAccountId") - .HasColumnType("int"); - - b.Property("Notes") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginalTransactionId") - .HasColumnType("bigint"); - - b.Property("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 - } - } -} +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoneyMap.Data; + +#nullable disable + +namespace MoneyMap.Migrations +{ + [DbContext(typeof(MoneyMapContext))] + partial class MoneyMapContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Period") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParseStatus") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("ParsingNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("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 + } + } +} diff --git a/MoneyMap/MoneyMap.csproj b/MoneyMap/MoneyMap.csproj index be2d1a7..bab1ef7 100644 --- a/MoneyMap/MoneyMap.csproj +++ b/MoneyMap/MoneyMap.csproj @@ -1,41 +1,41 @@ - - - - net8.0 - enable - enable - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/MoneyMap/MoneyMap.sln b/MoneyMap/MoneyMap.sln index 06cf79b..4758083 100644 --- a/MoneyMap/MoneyMap.sln +++ b/MoneyMap/MoneyMap.sln @@ -1,24 +1,24 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap.csproj", "{30BE0C00-DF59-282D-5AB0-7D61367EBEDE}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {755752C7-EBEB-4BA4-B11B-ADA6570041E8} - EndGlobalSection -EndGlobal +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap.csproj", "{30BE0C00-DF59-282D-5AB0-7D61367EBEDE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30BE0C00-DF59-282D-5AB0-7D61367EBEDE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {755752C7-EBEB-4BA4-B11B-ADA6570041E8} + EndGlobalSection +EndGlobal diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml b/MoneyMap/Pages/AICategorizePreview.cshtml index f42111f..81d9314 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml +++ b/MoneyMap/Pages/AICategorizePreview.cshtml @@ -1,195 +1,195 @@ -@page -@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) - }; -} - -
-

AI Categorization Preview

- -
- -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -@if (!Model.Proposals.Any()) -{ -
-
- Generate AI Suggestions -
-
- @if (Model.TransactionIds != null && Model.TransactionIds.Length > 0) - { -

Generate AI categorization suggestions for @Model.SelectedTransactionCount selected transaction(s). You can review and approve them before applying.

- -
- @foreach (var id in Model.TransactionIds!) - { - - } -

- Using: @Model.SelectedModel - Change -

- -
- } - else - { -

Generate AI categorization suggestions for uncategorized transactions. You can review and approve them before applying.

- -
-

- Using: @Model.SelectedModel - Change -

- -
- } -
-
-} -else -{ - var highConfidence = Model.Proposals.Where(p => p.Confidence >= 0.8m).ToList(); - var needsReview = Model.Proposals.Where(p => p.Confidence < 0.8m).ToList(); - -
- -
-
- Review Proposals (@Model.Proposals.Count suggestions) -
- - -
-
-
- -
-
- @if (highConfidence.Any()) - { - @await Html.PartialAsync("_ProposalTable", highConfidence) - } - else - { -
No high confidence proposals.
- } -
-
- @if (needsReview.Any()) - { - @await Html.PartialAsync("_ProposalTable", needsReview) - } - else - { -
No proposals needing review.
- } -
-
-
- -
-
- -
-
- Legend -
-
-
-
-
Confidence Levels
-
    -
  • 80%+ High confidence - likely correct
  • -
  • 60-79% Medium confidence - review recommended
  • -
  • <60% Low confidence - verify carefully
  • -
-
-
-
Rule Status
-
    -
  • Create - No existing rule; check to create a new mapping rule
  • -
  • Update - Pattern exists with a different category; check to update it
  • -
  • Exists - Rule already exists with the same category
  • -
-
-
-
-
-} - -@section Scripts { - -} +@page +@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) + }; +} + +
+

AI Categorization Preview

+ +
+ +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +@if (!Model.Proposals.Any()) +{ +
+
+ Generate AI Suggestions +
+
+ @if (Model.TransactionIds != null && Model.TransactionIds.Length > 0) + { +

Generate AI categorization suggestions for @Model.SelectedTransactionCount selected transaction(s). You can review and approve them before applying.

+ +
+ @foreach (var id in Model.TransactionIds!) + { + + } +

+ Using: @Model.SelectedModel + Change +

+ +
+ } + else + { +

Generate AI categorization suggestions for uncategorized transactions. You can review and approve them before applying.

+ +
+

+ Using: @Model.SelectedModel + Change +

+ +
+ } +
+
+} +else +{ + var highConfidence = Model.Proposals.Where(p => p.Confidence >= 0.8m).ToList(); + var needsReview = Model.Proposals.Where(p => p.Confidence < 0.8m).ToList(); + +
+ +
+
+ Review Proposals (@Model.Proposals.Count suggestions) +
+ + +
+
+
+ +
+
+ @if (highConfidence.Any()) + { + @await Html.PartialAsync("_ProposalTable", highConfidence) + } + else + { +
No high confidence proposals.
+ } +
+
+ @if (needsReview.Any()) + { + @await Html.PartialAsync("_ProposalTable", needsReview) + } + else + { +
No proposals needing review.
+ } +
+
+
+ +
+
+ +
+
+ Legend +
+
+
+
+
Confidence Levels
+
    +
  • 80%+ High confidence - likely correct
  • +
  • 60-79% Medium confidence - review recommended
  • +
  • <60% Low confidence - verify carefully
  • +
+
+
+
Rule Status
+
    +
  • Create - No existing rule; check to create a new mapping rule
  • +
  • Update - Pattern exists with a different category; check to update it
  • +
  • Exists - Rule already exists with the same category
  • +
+
+
+
+
+} + +@section Scripts { + +} diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml.cs b/MoneyMap/Pages/AICategorizePreview.cshtml.cs index a423ab1..d5a395e 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml.cs +++ b/MoneyMap/Pages/AICategorizePreview.cshtml.cs @@ -1,366 +1,366 @@ -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 AICategorizePreviewModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly ITransactionAICategorizer _aiCategorizer; - private readonly IConfiguration _config; - - public AICategorizePreviewModel( - MoneyMapContext db, - ITransactionAICategorizer aiCategorizer, - IConfiguration config) - { - _db = db; - _aiCategorizer = aiCategorizer; - _config = config; - } - - public List Proposals { get; set; } = new(); - public string ModelUsed { get; set; } = ""; - 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] - public string? StoredTransactionIds { get; set; } - - public long[]? TransactionIds { get; set; } - - [TempData] - public string? ProposalsJson { get; set; } - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public int SelectedTransactionCount { get; set; } - - public async Task OnGetAsync() - { - // Load transaction IDs from TempData if available - if (!string.IsNullOrEmpty(StoredTransactionIds)) - { - TransactionIds = JsonSerializer.Deserialize(StoredTransactionIds); - // Keep in TempData for the generate form - StoredTransactionIds = JsonSerializer.Serialize(TransactionIds); - } - - // Count selected transactions if IDs provided - if (TransactionIds != null && TransactionIds.Length > 0) - { - SelectedTransactionCount = await _db.Transactions - .CountAsync(t => TransactionIds.Contains(t.Id)); - } - - // Check if we have proposals from TempData - if (!string.IsNullOrEmpty(ProposalsJson)) - { - var storedProposals = JsonSerializer.Deserialize>(ProposalsJson); - if (storedProposals != null) - { - await LoadProposalViewModels(storedProposals); - } - } - - return Page(); - } - - public IActionResult OnPostStoreIds(long[] transactionIds) - { - if (transactionIds == null || transactionIds.Length == 0) - { - return RedirectToPage("/Transactions"); - } - - StoredTransactionIds = JsonSerializer.Serialize(transactionIds); - return RedirectToPage(); - } - - public async Task OnPostGenerateAsync() - { - var uncategorized = await _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Include(t => t.TransferToAccount) - .Where(t => string.IsNullOrWhiteSpace(t.Category)) - .OrderByDescending(t => t.Date) - .Take(50) - .ToListAsync(); - - if (uncategorized.Count == 0) - { - ErrorMessage = "No uncategorized transactions to process."; - return RedirectToPage("/Recategorize"); - } - - var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, SelectedModel); - - if (proposals.Count == 0) - { - ErrorMessage = "AI could not generate any categorization proposals."; - return RedirectToPage("/Recategorize"); - } - - // Store proposals in TempData - var storedProposals = proposals.Select(p => new StoredProposal - { - TransactionId = p.TransactionId, - Category = p.Category, - CanonicalMerchant = p.CanonicalMerchant, - Pattern = p.Pattern, - Priority = p.Priority, - Confidence = p.Confidence, - Reasoning = p.Reasoning, - CreateRule = p.CreateRule - }).ToList(); - - ProposalsJson = JsonSerializer.Serialize(storedProposals); - ModelUsed = SelectedModel; - - return RedirectToPage(); - } - - public async Task OnPostGenerateForIdsAsync(long[]? transactionIds) - { - // Try to get IDs from form first, then from TempData - if ((transactionIds == null || transactionIds.Length == 0) && !string.IsNullOrEmpty(StoredTransactionIds)) - { - transactionIds = JsonSerializer.Deserialize(StoredTransactionIds); - } - - if (transactionIds == null || transactionIds.Length == 0) - { - ErrorMessage = "No transactions selected."; - return RedirectToPage("/Transactions"); - } - - var transactions = await _db.Transactions - .Include(t => t.Card) - .Include(t => t.Account) - .Include(t => t.Merchant) - .Include(t => t.TransferToAccount) - .Where(t => transactionIds.Contains(t.Id)) - .OrderByDescending(t => t.Date) - .Take(50) - .ToListAsync(); - - if (transactions.Count == 0) - { - ErrorMessage = "Selected transactions not found."; - return RedirectToPage("/Transactions"); - } - - var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, SelectedModel); - - if (proposals.Count == 0) - { - ErrorMessage = "AI could not generate any categorization proposals."; - return RedirectToPage("/Transactions"); - } - - // Store proposals in TempData - var storedProposals = proposals.Select(p => new StoredProposal - { - TransactionId = p.TransactionId, - Category = p.Category, - CanonicalMerchant = p.CanonicalMerchant, - Pattern = p.Pattern, - Priority = p.Priority, - Confidence = p.Confidence, - Reasoning = p.Reasoning, - CreateRule = p.CreateRule - }).ToList(); - - ProposalsJson = JsonSerializer.Serialize(storedProposals); - ModelUsed = SelectedModel; - - return RedirectToPage(); - } - - public async Task OnPostApplyAsync(long[] selectedIds, long[] createRules, string? proposalsData) - { - // 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>(json); - if (storedProposals == null || storedProposals.Count == 0) - { - ErrorMessage = "No proposals to apply (deserialization returned empty)."; - return RedirectToPage("/Recategorize"); - } - - var selectedSet = selectedIds?.ToHashSet() ?? new HashSet(); - var createRulesSet = createRules?.ToHashSet() ?? new HashSet(); - - 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(); - - foreach (var stored in storedProposals) - { - if (!selectedSet.Contains(stored.TransactionId)) - continue; - - var proposal = new AICategoryProposal - { - TransactionId = stored.TransactionId, - Category = stored.Category, - CanonicalMerchant = stored.CanonicalMerchant, - Pattern = stored.Pattern, - Priority = stored.Priority, - Confidence = stored.Confidence, - Reasoning = stored.Reasoning, - CreateRule = stored.CreateRule - }; - - // 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); - if (result.Success) - { - applied++; - if (result.RuleCreated) - rulesCreated++; - if (result.RuleUpdated) - rulesUpdated++; - } - else - { - errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}"); - } - } - - var parts = new List { $"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); - } - - return RedirectToPage("/Recategorize"); - } - - private async Task LoadProposalViewModels(List storedProposals) - { - var transactionIds = storedProposals.Select(p => p.TransactionId).ToList(); - var transactions = await _db.Transactions - .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, - TransactionName = txn.Name, - TransactionMemo = txn.Memo, - TransactionAmount = txn.Amount, - TransactionDate = txn.Date, - CurrentCategory = txn.Category, - ProposedCategory = stored.Category, - ProposedMerchant = stored.CanonicalMerchant, - ProposedPattern = stored.Pattern, - Confidence = stored.Confidence, - Reasoning = stored.Reasoning, - CreateRule = stored.CreateRule, - HasExistingRule = hasExisting, - ExistingRuleCategory = existingCategory - }); - } - } - - // Keep proposals in TempData for the apply action - ProposalsJson = JsonSerializer.Serialize(storedProposals); - } - - public class ProposalViewModel - { - public long TransactionId { get; set; } - public string TransactionName { get; set; } = ""; - public string? TransactionMemo { get; set; } - public decimal TransactionAmount { get; set; } - public DateTime TransactionDate { get; set; } - public string? CurrentCategory { get; set; } - public string ProposedCategory { get; set; } = ""; - public string? ProposedMerchant { get; set; } - public string? ProposedPattern { get; set; } - 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; } - - /// - /// True when the pattern exists but is mapped to a different category than proposed. - /// - public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory; - } - - public class StoredProposal - { - public long TransactionId { get; set; } - public string Category { get; set; } = ""; - public string? CanonicalMerchant { get; set; } - public string? Pattern { get; set; } - public int Priority { get; set; } - public decimal Confidence { get; set; } - public string? Reasoning { get; set; } - public bool CreateRule { get; set; } - } - } -} +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 AICategorizePreviewModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly ITransactionAICategorizer _aiCategorizer; + private readonly IConfiguration _config; + + public AICategorizePreviewModel( + MoneyMapContext db, + ITransactionAICategorizer aiCategorizer, + IConfiguration config) + { + _db = db; + _aiCategorizer = aiCategorizer; + _config = config; + } + + public List Proposals { get; set; } = new(); + public string ModelUsed { get; set; } = ""; + 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] + public string? StoredTransactionIds { get; set; } + + public long[]? TransactionIds { get; set; } + + [TempData] + public string? ProposalsJson { get; set; } + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public int SelectedTransactionCount { get; set; } + + public async Task OnGetAsync() + { + // Load transaction IDs from TempData if available + if (!string.IsNullOrEmpty(StoredTransactionIds)) + { + TransactionIds = JsonSerializer.Deserialize(StoredTransactionIds); + // Keep in TempData for the generate form + StoredTransactionIds = JsonSerializer.Serialize(TransactionIds); + } + + // Count selected transactions if IDs provided + if (TransactionIds != null && TransactionIds.Length > 0) + { + SelectedTransactionCount = await _db.Transactions + .CountAsync(t => TransactionIds.Contains(t.Id)); + } + + // Check if we have proposals from TempData + if (!string.IsNullOrEmpty(ProposalsJson)) + { + var storedProposals = JsonSerializer.Deserialize>(ProposalsJson); + if (storedProposals != null) + { + await LoadProposalViewModels(storedProposals); + } + } + + return Page(); + } + + public IActionResult OnPostStoreIds(long[] transactionIds) + { + if (transactionIds == null || transactionIds.Length == 0) + { + return RedirectToPage("/Transactions"); + } + + StoredTransactionIds = JsonSerializer.Serialize(transactionIds); + return RedirectToPage(); + } + + public async Task OnPostGenerateAsync() + { + var uncategorized = await _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .Include(t => t.TransferToAccount) + .Where(t => string.IsNullOrWhiteSpace(t.Category)) + .OrderByDescending(t => t.Date) + .Take(50) + .ToListAsync(); + + if (uncategorized.Count == 0) + { + ErrorMessage = "No uncategorized transactions to process."; + return RedirectToPage("/Recategorize"); + } + + var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, SelectedModel); + + if (proposals.Count == 0) + { + ErrorMessage = "AI could not generate any categorization proposals."; + return RedirectToPage("/Recategorize"); + } + + // Store proposals in TempData + var storedProposals = proposals.Select(p => new StoredProposal + { + TransactionId = p.TransactionId, + Category = p.Category, + CanonicalMerchant = p.CanonicalMerchant, + Pattern = p.Pattern, + Priority = p.Priority, + Confidence = p.Confidence, + Reasoning = p.Reasoning, + CreateRule = p.CreateRule + }).ToList(); + + ProposalsJson = JsonSerializer.Serialize(storedProposals); + ModelUsed = SelectedModel; + + return RedirectToPage(); + } + + public async Task OnPostGenerateForIdsAsync(long[]? transactionIds) + { + // Try to get IDs from form first, then from TempData + if ((transactionIds == null || transactionIds.Length == 0) && !string.IsNullOrEmpty(StoredTransactionIds)) + { + transactionIds = JsonSerializer.Deserialize(StoredTransactionIds); + } + + if (transactionIds == null || transactionIds.Length == 0) + { + ErrorMessage = "No transactions selected."; + return RedirectToPage("/Transactions"); + } + + var transactions = await _db.Transactions + .Include(t => t.Card) + .Include(t => t.Account) + .Include(t => t.Merchant) + .Include(t => t.TransferToAccount) + .Where(t => transactionIds.Contains(t.Id)) + .OrderByDescending(t => t.Date) + .Take(50) + .ToListAsync(); + + if (transactions.Count == 0) + { + ErrorMessage = "Selected transactions not found."; + return RedirectToPage("/Transactions"); + } + + var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, SelectedModel); + + if (proposals.Count == 0) + { + ErrorMessage = "AI could not generate any categorization proposals."; + return RedirectToPage("/Transactions"); + } + + // Store proposals in TempData + var storedProposals = proposals.Select(p => new StoredProposal + { + TransactionId = p.TransactionId, + Category = p.Category, + CanonicalMerchant = p.CanonicalMerchant, + Pattern = p.Pattern, + Priority = p.Priority, + Confidence = p.Confidence, + Reasoning = p.Reasoning, + CreateRule = p.CreateRule + }).ToList(); + + ProposalsJson = JsonSerializer.Serialize(storedProposals); + ModelUsed = SelectedModel; + + return RedirectToPage(); + } + + public async Task OnPostApplyAsync(long[] selectedIds, long[] createRules, string? proposalsData) + { + // 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>(json); + if (storedProposals == null || storedProposals.Count == 0) + { + ErrorMessage = "No proposals to apply (deserialization returned empty)."; + return RedirectToPage("/Recategorize"); + } + + var selectedSet = selectedIds?.ToHashSet() ?? new HashSet(); + var createRulesSet = createRules?.ToHashSet() ?? new HashSet(); + + 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(); + + foreach (var stored in storedProposals) + { + if (!selectedSet.Contains(stored.TransactionId)) + continue; + + var proposal = new AICategoryProposal + { + TransactionId = stored.TransactionId, + Category = stored.Category, + CanonicalMerchant = stored.CanonicalMerchant, + Pattern = stored.Pattern, + Priority = stored.Priority, + Confidence = stored.Confidence, + Reasoning = stored.Reasoning, + CreateRule = stored.CreateRule + }; + + // 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); + if (result.Success) + { + applied++; + if (result.RuleCreated) + rulesCreated++; + if (result.RuleUpdated) + rulesUpdated++; + } + else + { + errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}"); + } + } + + var parts = new List { $"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); + } + + return RedirectToPage("/Recategorize"); + } + + private async Task LoadProposalViewModels(List storedProposals) + { + var transactionIds = storedProposals.Select(p => p.TransactionId).ToList(); + var transactions = await _db.Transactions + .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, + TransactionName = txn.Name, + TransactionMemo = txn.Memo, + TransactionAmount = txn.Amount, + TransactionDate = txn.Date, + CurrentCategory = txn.Category, + ProposedCategory = stored.Category, + ProposedMerchant = stored.CanonicalMerchant, + ProposedPattern = stored.Pattern, + Confidence = stored.Confidence, + Reasoning = stored.Reasoning, + CreateRule = stored.CreateRule, + HasExistingRule = hasExisting, + ExistingRuleCategory = existingCategory + }); + } + } + + // Keep proposals in TempData for the apply action + ProposalsJson = JsonSerializer.Serialize(storedProposals); + } + + public class ProposalViewModel + { + public long TransactionId { get; set; } + public string TransactionName { get; set; } = ""; + public string? TransactionMemo { get; set; } + public decimal TransactionAmount { get; set; } + public DateTime TransactionDate { get; set; } + public string? CurrentCategory { get; set; } + public string ProposedCategory { get; set; } = ""; + public string? ProposedMerchant { get; set; } + public string? ProposedPattern { get; set; } + 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; } + + /// + /// True when the pattern exists but is mapped to a different category than proposed. + /// + public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory; + } + + public class StoredProposal + { + public long TransactionId { get; set; } + public string Category { get; set; } = ""; + public string? CanonicalMerchant { get; set; } + public string? Pattern { get; set; } + public int Priority { get; set; } + public decimal Confidence { get; set; } + public string? Reasoning { get; set; } + public bool CreateRule { get; set; } + } + } +} diff --git a/MoneyMap/Pages/AccountDetails.cshtml b/MoneyMap/Pages/AccountDetails.cshtml index f773ef3..7cbdeaf 100644 --- a/MoneyMap/Pages/AccountDetails.cshtml +++ b/MoneyMap/Pages/AccountDetails.cshtml @@ -1,141 +1,141 @@ -@page "{id:int}" -@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) - }; -} - -
-

@Model.Account.DisplayLabel

- -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - - -
-
- Account Details -
-
-
-
-
-
Institution:
-
@Model.Account.Institution
- -
Account Type:
-
@Model.Account.AccountType
- -
Last 4:
-
@Model.Account.Last4
-
-
-
-
-
Owner:
-
@Model.Account.Owner
- -
Nickname:
-
@(string.IsNullOrEmpty(Model.Account.Nickname) ? "-" : Model.Account.Nickname)
- -
Transactions:
-
@Model.TransactionCount
-
-
-
-
-
- - -
-
- Linked Cards (@Model.Cards.Count) - + Add Card -
-
- @if (Model.Cards.Any()) - { -
- - - - - - - - - - - @foreach (var item in Model.Cards) - { - - - - - - - } - -
CardOwnerTransactionsActions
-
- @item.Card.Issuer •••• @item.Card.Last4 - @if (!string.IsNullOrEmpty(item.Card.Nickname)) - { -
- @item.Card.Nickname - } -
-
@item.Card.Owner@item.TransactionCount -
- - Edit - - @if (item.TransactionCount == 0) - { -
- -
- } - else - { - - } -
-
-
- } - else - { -
-

No cards linked to this account yet.

- Add First Card -
- } -
-
+@page "{id:int}" +@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) + }; +} + +
+

@Model.Account.DisplayLabel

+ +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + + +
+
+ Account Details +
+
+
+
+
+
Institution:
+
@Model.Account.Institution
+ +
Account Type:
+
@Model.Account.AccountType
+ +
Last 4:
+
@Model.Account.Last4
+
+
+
+
+
Owner:
+
@Model.Account.Owner
+ +
Nickname:
+
@(string.IsNullOrEmpty(Model.Account.Nickname) ? "-" : Model.Account.Nickname)
+ +
Transactions:
+
@Model.TransactionCount
+
+
+
+
+
+ + +
+
+ Linked Cards (@Model.Cards.Count) + + Add Card +
+
+ @if (Model.Cards.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.Cards) + { + + + + + + + } + +
CardOwnerTransactionsActions
+
+ @item.Card.Issuer •••• @item.Card.Last4 + @if (!string.IsNullOrEmpty(item.Card.Nickname)) + { +
+ @item.Card.Nickname + } +
+
@item.Card.Owner@item.TransactionCount +
+ + Edit + + @if (item.TransactionCount == 0) + { +
+ +
+ } + else + { + + } +
+
+
+ } + else + { +
+

No cards linked to this account yet.

+ Add First Card +
+ } +
+
diff --git a/MoneyMap/Pages/AccountDetails.cshtml.cs b/MoneyMap/Pages/AccountDetails.cshtml.cs index 133d498..ea6bd5e 100644 --- a/MoneyMap/Pages/AccountDetails.cshtml.cs +++ b/MoneyMap/Pages/AccountDetails.cshtml.cs @@ -1,62 +1,62 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Models; -using MoneyMap.Services; - -namespace MoneyMap.Pages -{ - public class AccountDetailsModel : PageModel - { - private readonly IAccountService _accountService; - private readonly ICardService _cardService; - - public AccountDetailsModel(IAccountService accountService, ICardService cardService) - { - _accountService = accountService; - _cardService = cardService; - } - - public Account Account { get; set; } = null!; - public List Cards { get; set; } = new(); - public int TransactionCount { get; set; } - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync(int id) - { - var accountDetails = await _accountService.GetAccountDetailsAsync(id); - if (accountDetails == null) - return NotFound(); - - Account = accountDetails.Account; - Cards = accountDetails.Cards; - TransactionCount = accountDetails.TransactionCount; - - return Page(); - } - - public async Task OnPostDeleteCardAsync(int cardId) - { - // Get card to retrieve account ID before deletion - var card = await _cardService.GetCardByIdAsync(cardId); - var accountId = card?.AccountId; - - var result = await _cardService.DeleteCardAsync(cardId); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(new { id = accountId }); - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class AccountDetailsModel : PageModel + { + private readonly IAccountService _accountService; + private readonly ICardService _cardService; + + public AccountDetailsModel(IAccountService accountService, ICardService cardService) + { + _accountService = accountService; + _cardService = cardService; + } + + public Account Account { get; set; } = null!; + public List Cards { get; set; } = new(); + public int TransactionCount { get; set; } + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync(int id) + { + var accountDetails = await _accountService.GetAccountDetailsAsync(id); + if (accountDetails == null) + return NotFound(); + + Account = accountDetails.Account; + Cards = accountDetails.Cards; + TransactionCount = accountDetails.TransactionCount; + + return Page(); + } + + public async Task OnPostDeleteCardAsync(int cardId) + { + // Get card to retrieve account ID before deletion + var card = await _cardService.GetCardByIdAsync(cardId); + var accountId = card?.AccountId; + + var result = await _cardService.DeleteCardAsync(cardId); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(new { id = accountId }); + } + } +} diff --git a/MoneyMap/Pages/Accounts.cshtml b/MoneyMap/Pages/Accounts.cshtml index c35cd26..7c1193f 100644 --- a/MoneyMap/Pages/Accounts.cshtml +++ b/MoneyMap/Pages/Accounts.cshtml @@ -1,73 +1,73 @@ -@page -@model MoneyMap.Pages.AccountsModel -@{ - ViewData["Title"] = "Accounts"; -} - -

Bank Accounts

- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - - - -@if (Model.Accounts.Any()) -{ -
-
- - - - - - - - - - - - - - @foreach (var account in Model.Accounts) - { - - - - - - - - - - } - -
InstitutionTypeLast 4OwnerNicknameTransactions
@account.Institution - @account.AccountType - @account.Last4@account.Owner@(string.IsNullOrEmpty(account.Nickname) ? "-" : account.Nickname)@account.TransactionCount - View - Edit - @if (account.TransactionCount == 0) - { -
- -
- } -
-
-
-} -else -{ -
- No accounts added yet. Click "Add New Account" to create one. -
-} +@page +@model MoneyMap.Pages.AccountsModel +@{ + ViewData["Title"] = "Accounts"; +} + +

Bank Accounts

+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + + + +@if (Model.Accounts.Any()) +{ +
+
+ + + + + + + + + + + + + + @foreach (var account in Model.Accounts) + { + + + + + + + + + + } + +
InstitutionTypeLast 4OwnerNicknameTransactions
@account.Institution + @account.AccountType + @account.Last4@account.Owner@(string.IsNullOrEmpty(account.Nickname) ? "-" : account.Nickname)@account.TransactionCount + View + Edit + @if (account.TransactionCount == 0) + { +
+ +
+ } +
+
+
+} +else +{ +
+ No accounts added yet. Click "Add New Account" to create one. +
+} diff --git a/MoneyMap/Pages/Accounts.cshtml.cs b/MoneyMap/Pages/Accounts.cshtml.cs index 163e950..19a090e 100644 --- a/MoneyMap/Pages/Accounts.cshtml.cs +++ b/MoneyMap/Pages/Accounts.cshtml.cs @@ -1,67 +1,67 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Models; -using MoneyMap.Services; - -namespace MoneyMap.Pages -{ - public class AccountsModel : PageModel - { - private readonly IAccountService _accountService; - - public AccountsModel(IAccountService accountService) - { - _accountService = accountService; - } - - public List Accounts { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - public async Task OnGetAsync() - { - var accountsWithStats = await _accountService.GetAllAccountsWithStatsAsync(); - - Accounts = accountsWithStats.Select(a => new AccountRow - { - Id = a.Id, - Institution = a.Institution, - AccountType = a.AccountType, - Last4 = a.Last4, - Owner = a.Owner, - Nickname = a.Nickname, - TransactionCount = a.TransactionCount - }).ToList(); - } - - public async Task OnPostDeleteAsync(int id) - { - var result = await _accountService.DeleteAccountAsync(id); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ModelState.AddModelError(string.Empty, result.Message); - await OnGetAsync(); - return Page(); - } - - return RedirectToPage(); - } - - public class AccountRow - { - public int Id { get; set; } - public string Institution { get; set; } = ""; - public AccountType AccountType { get; set; } - public string Last4 { get; set; } = ""; - public string Owner { get; set; } = ""; - public string? Nickname { get; set; } - public int TransactionCount { get; set; } - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class AccountsModel : PageModel + { + private readonly IAccountService _accountService; + + public AccountsModel(IAccountService accountService) + { + _accountService = accountService; + } + + public List Accounts { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + public async Task OnGetAsync() + { + var accountsWithStats = await _accountService.GetAllAccountsWithStatsAsync(); + + Accounts = accountsWithStats.Select(a => new AccountRow + { + Id = a.Id, + Institution = a.Institution, + AccountType = a.AccountType, + Last4 = a.Last4, + Owner = a.Owner, + Nickname = a.Nickname, + TransactionCount = a.TransactionCount + }).ToList(); + } + + public async Task OnPostDeleteAsync(int id) + { + var result = await _accountService.DeleteAccountAsync(id); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ModelState.AddModelError(string.Empty, result.Message); + await OnGetAsync(); + return Page(); + } + + return RedirectToPage(); + } + + public class AccountRow + { + public int Id { get; set; } + public string Institution { get; set; } = ""; + public AccountType AccountType { get; set; } + public string Last4 { get; set; } = ""; + public string Owner { get; set; } = ""; + public string? Nickname { get; set; } + public int TransactionCount { get; set; } + } + } +} diff --git a/MoneyMap/Pages/Budgets.cshtml b/MoneyMap/Pages/Budgets.cshtml index f5cc819..5e9f4b0 100644 --- a/MoneyMap/Pages/Budgets.cshtml +++ b/MoneyMap/Pages/Budgets.cshtml @@ -1,316 +1,316 @@ -@page -@model MoneyMap.Pages.BudgetsModel -@using MoneyMap.Models -@{ - ViewData["Title"] = "Budgets"; -} - -
-

Budgets

- Back to Dashboard -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - - -
- -
- -@if (Model.BudgetStatuses.Any()) -{ - - var activeBudgets = Model.BudgetStatuses.Where(s => s.Budget.IsActive).ToList(); - if (activeBudgets.Any()) - { -
-
- Active Budgets -
-
-
- - - - - - - - - - - - - - @foreach (var status in activeBudgets) - { - var transactionsUrl = status.Budget.IsTotalBudget - ? Url.Page("/Transactions", new { startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") }) - : Url.Page("/Transactions", new { category = status.Budget.Category, startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") }); - - - - - - - - - - - } - -
CategoryPeriodBudgetSpentRemainingProgressActions
- - @status.Budget.DisplayName - - @if (status.Budget.IsTotalBudget) - { - Total - } - @if (status.IsOverBudget) - { - Over Budget - } -
- @status.PeriodDisplay -
@status.Budget.Period@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") - @status.Remaining.ToString("C") - -
- @{ - var percent = Math.Min(status.PercentUsed, 100); - var progressClass = status.StatusClass; - var textClass = progressClass == "warning" ? "text-dark" : ""; - } -
- @status.PercentUsed.ToString("F0")% -
-
- @if (status.IsOverBudget) - { - Over by @(Math.Abs(status.Remaining).ToString("C")) - } -
- -
- -
-
- -
-
-
-
-
- } - - - var inactiveBudgets = Model.BudgetStatuses.Where(s => !s.Budget.IsActive).ToList(); - if (inactiveBudgets.Any()) - { -
-
- Inactive Budgets - Paused budgets are not tracked -
-
-
- - - - - - - - - - - - @foreach (var status in inactiveBudgets) - { - - - - - - - - } - -
CategoryPeriodBudget AmountNotesActions
- @status.Budget.DisplayName - @if (status.Budget.IsTotalBudget) - { - Total - } - @status.Budget.Period@status.Budget.Amount.ToString("C")@(status.Budget.Notes ?? "-") -
- -
-
- -
-
-
-
-
- } -} -else -{ -
-
No budgets found
-

Click "Add New Budget" to create your first budget. You can create budgets for specific categories or a total spending budget.

-
-} - - - - - - - -@section Scripts { - -} +@page +@model MoneyMap.Pages.BudgetsModel +@using MoneyMap.Models +@{ + ViewData["Title"] = "Budgets"; +} + +
+

Budgets

+ Back to Dashboard +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + + +
+ +
+ +@if (Model.BudgetStatuses.Any()) +{ + + var activeBudgets = Model.BudgetStatuses.Where(s => s.Budget.IsActive).ToList(); + if (activeBudgets.Any()) + { +
+
+ Active Budgets +
+
+
+ + + + + + + + + + + + + + @foreach (var status in activeBudgets) + { + var transactionsUrl = status.Budget.IsTotalBudget + ? Url.Page("/Transactions", new { startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") }) + : Url.Page("/Transactions", new { category = status.Budget.Category, startDate = status.PeriodStart.ToString("yyyy-MM-dd"), endDate = status.PeriodEnd.ToString("yyyy-MM-dd") }); + + + + + + + + + + + } + +
CategoryPeriodBudgetSpentRemainingProgressActions
+ + @status.Budget.DisplayName + + @if (status.Budget.IsTotalBudget) + { + Total + } + @if (status.IsOverBudget) + { + Over Budget + } +
+ @status.PeriodDisplay +
@status.Budget.Period@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") + @status.Remaining.ToString("C") + +
+ @{ + var percent = Math.Min(status.PercentUsed, 100); + var progressClass = status.StatusClass; + var textClass = progressClass == "warning" ? "text-dark" : ""; + } +
+ @status.PercentUsed.ToString("F0")% +
+
+ @if (status.IsOverBudget) + { + Over by @(Math.Abs(status.Remaining).ToString("C")) + } +
+ +
+ +
+
+ +
+
+
+
+
+ } + + + var inactiveBudgets = Model.BudgetStatuses.Where(s => !s.Budget.IsActive).ToList(); + if (inactiveBudgets.Any()) + { +
+
+ Inactive Budgets + Paused budgets are not tracked +
+
+
+ + + + + + + + + + + + @foreach (var status in inactiveBudgets) + { + + + + + + + + } + +
CategoryPeriodBudget AmountNotesActions
+ @status.Budget.DisplayName + @if (status.Budget.IsTotalBudget) + { + Total + } + @status.Budget.Period@status.Budget.Amount.ToString("C")@(status.Budget.Notes ?? "-") +
+ +
+
+ +
+
+
+
+
+ } +} +else +{ +
+
No budgets found
+

Click "Add New Budget" to create your first budget. You can create budgets for specific categories or a total spending budget.

+
+} + + + + + + + +@section Scripts { + +} diff --git a/MoneyMap/Pages/Budgets.cshtml.cs b/MoneyMap/Pages/Budgets.cshtml.cs index 7573a49..5a8a4c6 100644 --- a/MoneyMap/Pages/Budgets.cshtml.cs +++ b/MoneyMap/Pages/Budgets.cshtml.cs @@ -1,220 +1,220 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.AspNetCore.Mvc.Rendering; -using MoneyMap.Models; -using MoneyMap.Services; -using System.ComponentModel.DataAnnotations; - -namespace MoneyMap.Pages; - -public class BudgetsModel : PageModel -{ - private readonly IBudgetService _budgetService; - - public BudgetsModel(IBudgetService budgetService) - { - _budgetService = budgetService; - } - - public List BudgetStatuses { get; set; } = new(); - public List CategoryOptions { get; set; } = new(); - public List PeriodOptions { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync() - { - await LoadDataAsync(); - } - - public async Task OnPostAddBudgetAsync(AddBudgetModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - var budget = new Budget - { - Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category, - Amount = model.Amount, - Period = model.Period, - StartDate = model.StartDate ?? DateTime.Today, - Notes = model.Notes, - IsActive = true - }; - - var result = await _budgetService.CreateBudgetAsync(budget); - - if (result.Success) - { - SuccessMessage = result.Message; - return RedirectToPage(); - } - - ErrorMessage = result.Message; - await LoadDataAsync(); - return Page(); - } - - public async Task OnPostUpdateBudgetAsync(UpdateBudgetModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - var budget = new Budget - { - Id = model.Id, - Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category, - Amount = model.Amount, - Period = model.Period, - StartDate = model.StartDate, - Notes = model.Notes, - IsActive = model.IsActive - }; - - var result = await _budgetService.UpdateBudgetAsync(budget); - - if (result.Success) - { - SuccessMessage = result.Message; - return RedirectToPage(); - } - - ErrorMessage = result.Message; - await LoadDataAsync(); - return Page(); - } - - public async Task OnPostDeleteBudgetAsync(int id) - { - var result = await _budgetService.DeleteBudgetAsync(id); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(); - } - - public async Task OnPostToggleActiveAsync(int id) - { - var budget = await _budgetService.GetBudgetByIdAsync(id); - if (budget == null) - { - ErrorMessage = "Budget not found."; - return RedirectToPage(); - } - - budget.IsActive = !budget.IsActive; - var result = await _budgetService.UpdateBudgetAsync(budget); - - if (result.Success) - { - SuccessMessage = budget.IsActive ? "Budget activated." : "Budget deactivated."; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(); - } - - private async Task LoadDataAsync() - { - // Get all budgets (active and inactive) with their status - var allBudgets = await _budgetService.GetAllBudgetsAsync(activeOnly: false); - BudgetStatuses = new List(); - - foreach (var budget in allBudgets) - { - if (budget.IsActive) - { - var status = await _budgetService.GetBudgetStatusAsync(budget.Id); - if (status != null) - BudgetStatuses.Add(status); - } - else - { - // For inactive budgets, create a minimal status - BudgetStatuses.Add(new BudgetStatus - { - Budget = budget, - PeriodStart = DateTime.Today, - PeriodEnd = DateTime.Today, - Spent = 0, - Remaining = budget.Amount, - PercentUsed = 0, - IsOverBudget = false - }); - } - } - - // Load category options - var categories = await _budgetService.GetAvailableCategoriesAsync(); - CategoryOptions = new List - { - new SelectListItem { Value = "", Text = "-- Total Spending --" } - }; - CategoryOptions.AddRange(categories.Select(c => new SelectListItem { Value = c, Text = c })); - - // Load period options - PeriodOptions = Enum.GetValues() - .Select(p => new SelectListItem { Value = ((int)p).ToString(), Text = p.ToString() }) - .ToList(); - } - - // Input models - public class AddBudgetModel - { - public string? Category { get; set; } - - [Required(ErrorMessage = "Budget amount is required")] - [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] - public decimal Amount { get; set; } - - public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly; - - public DateTime? StartDate { get; set; } - - [MaxLength(500)] - public string? Notes { get; set; } - } - - public class UpdateBudgetModel - { - [Required] - public int Id { get; set; } - - public string? Category { get; set; } - - [Required(ErrorMessage = "Budget amount is required")] - [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] - public decimal Amount { get; set; } - - public BudgetPeriod Period { get; set; } - - public DateTime StartDate { get; set; } - - public bool IsActive { get; set; } - - [MaxLength(500)] - public string? Notes { get; set; } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.Rendering; +using MoneyMap.Models; +using MoneyMap.Services; +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Pages; + +public class BudgetsModel : PageModel +{ + private readonly IBudgetService _budgetService; + + public BudgetsModel(IBudgetService budgetService) + { + _budgetService = budgetService; + } + + public List BudgetStatuses { get; set; } = new(); + public List CategoryOptions { get; set; } = new(); + public List PeriodOptions { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + await LoadDataAsync(); + } + + public async Task OnPostAddBudgetAsync(AddBudgetModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + var budget = new Budget + { + Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category, + Amount = model.Amount, + Period = model.Period, + StartDate = model.StartDate ?? DateTime.Today, + Notes = model.Notes, + IsActive = true + }; + + var result = await _budgetService.CreateBudgetAsync(budget); + + if (result.Success) + { + SuccessMessage = result.Message; + return RedirectToPage(); + } + + ErrorMessage = result.Message; + await LoadDataAsync(); + return Page(); + } + + public async Task OnPostUpdateBudgetAsync(UpdateBudgetModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + var budget = new Budget + { + Id = model.Id, + Category = string.IsNullOrWhiteSpace(model.Category) ? null : model.Category, + Amount = model.Amount, + Period = model.Period, + StartDate = model.StartDate, + Notes = model.Notes, + IsActive = model.IsActive + }; + + var result = await _budgetService.UpdateBudgetAsync(budget); + + if (result.Success) + { + SuccessMessage = result.Message; + return RedirectToPage(); + } + + ErrorMessage = result.Message; + await LoadDataAsync(); + return Page(); + } + + public async Task OnPostDeleteBudgetAsync(int id) + { + var result = await _budgetService.DeleteBudgetAsync(id); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(); + } + + public async Task OnPostToggleActiveAsync(int id) + { + var budget = await _budgetService.GetBudgetByIdAsync(id); + if (budget == null) + { + ErrorMessage = "Budget not found."; + return RedirectToPage(); + } + + budget.IsActive = !budget.IsActive; + var result = await _budgetService.UpdateBudgetAsync(budget); + + if (result.Success) + { + SuccessMessage = budget.IsActive ? "Budget activated." : "Budget deactivated."; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(); + } + + private async Task LoadDataAsync() + { + // Get all budgets (active and inactive) with their status + var allBudgets = await _budgetService.GetAllBudgetsAsync(activeOnly: false); + BudgetStatuses = new List(); + + foreach (var budget in allBudgets) + { + if (budget.IsActive) + { + var status = await _budgetService.GetBudgetStatusAsync(budget.Id); + if (status != null) + BudgetStatuses.Add(status); + } + else + { + // For inactive budgets, create a minimal status + BudgetStatuses.Add(new BudgetStatus + { + Budget = budget, + PeriodStart = DateTime.Today, + PeriodEnd = DateTime.Today, + Spent = 0, + Remaining = budget.Amount, + PercentUsed = 0, + IsOverBudget = false + }); + } + } + + // Load category options + var categories = await _budgetService.GetAvailableCategoriesAsync(); + CategoryOptions = new List + { + new SelectListItem { Value = "", Text = "-- Total Spending --" } + }; + CategoryOptions.AddRange(categories.Select(c => new SelectListItem { Value = c, Text = c })); + + // Load period options + PeriodOptions = Enum.GetValues() + .Select(p => new SelectListItem { Value = ((int)p).ToString(), Text = p.ToString() }) + .ToList(); + } + + // Input models + public class AddBudgetModel + { + public string? Category { get; set; } + + [Required(ErrorMessage = "Budget amount is required")] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] + public decimal Amount { get; set; } + + public BudgetPeriod Period { get; set; } = BudgetPeriod.Monthly; + + public DateTime? StartDate { get; set; } + + [MaxLength(500)] + public string? Notes { get; set; } + } + + public class UpdateBudgetModel + { + [Required] + public int Id { get; set; } + + public string? Category { get; set; } + + [Required(ErrorMessage = "Budget amount is required")] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] + public decimal Amount { get; set; } + + public BudgetPeriod Period { get; set; } + + public DateTime StartDate { get; set; } + + public bool IsActive { get; set; } + + [MaxLength(500)] + public string? Notes { get; set; } + } +} diff --git a/MoneyMap/Pages/Cards.cshtml b/MoneyMap/Pages/Cards.cshtml index d590113..6fd4101 100644 --- a/MoneyMap/Pages/Cards.cshtml +++ b/MoneyMap/Pages/Cards.cshtml @@ -1,111 +1,111 @@ -@page -@model MoneyMap.Pages.CardsModel -@{ - ViewData["Title"] = "Manage Cards"; -} - -
-

Manage Cards

- -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -@if (Model.Cards.Any()) -{ -
-
- Your Cards (@Model.Cards.Count) -
-
-
- - - - - - - - - - - - @foreach (var item in Model.Cards) - { - - - - - - - - } - -
CardOwnerLinked AccountTransactionsActions
-
- @item.Card.Issuer •••• @item.Card.Last4 - @if (!string.IsNullOrEmpty(item.Card.Nickname)) - { -
- @item.Card.Nickname - } -
-
@item.Card.Owner - @if (item.Card.Account != null) - { - @item.Card.Account.DisplayLabel - } - else - { - None - } - @item.TransactionCount -
- - Edit - - @if (item.TransactionCount == 0) - { -
- -
- } - else - { - - } -
-
-
-
-
-} -else -{ -
-
No cards found
-

Add your first card to start tracking transactions.

- Add New Card -
+@page +@model MoneyMap.Pages.CardsModel +@{ + ViewData["Title"] = "Manage Cards"; +} + +
+

Manage Cards

+ +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +@if (Model.Cards.Any()) +{ +
+
+ Your Cards (@Model.Cards.Count) +
+
+
+ + + + + + + + + + + + @foreach (var item in Model.Cards) + { + + + + + + + + } + +
CardOwnerLinked AccountTransactionsActions
+
+ @item.Card.Issuer •••• @item.Card.Last4 + @if (!string.IsNullOrEmpty(item.Card.Nickname)) + { +
+ @item.Card.Nickname + } +
+
@item.Card.Owner + @if (item.Card.Account != null) + { + @item.Card.Account.DisplayLabel + } + else + { + None + } + @item.TransactionCount +
+ + Edit + + @if (item.TransactionCount == 0) + { +
+ +
+ } + else + { + + } +
+
+
+
+
+} +else +{ +
+
No cards found
+

Add your first card to start tracking transactions.

+ Add New Card +
} \ No newline at end of file diff --git a/MoneyMap/Pages/Cards.cshtml.cs b/MoneyMap/Pages/Cards.cshtml.cs index 3b5b8bf..d2b4ef4 100644 --- a/MoneyMap/Pages/Cards.cshtml.cs +++ b/MoneyMap/Pages/Cards.cshtml.cs @@ -1,45 +1,45 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Services; - -namespace MoneyMap.Pages -{ - public class CardsModel : PageModel - { - private readonly ICardService _cardService; - - public CardsModel(ICardService cardService) - { - _cardService = cardService; - } - - public List Cards { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync() - { - Cards = await _cardService.GetAllCardsWithStatsAsync(); - } - - public async Task OnPostDeleteAsync(int id) - { - var result = await _cardService.DeleteCardAsync(id); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(); - } - } +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class CardsModel : PageModel + { + private readonly ICardService _cardService; + + public CardsModel(ICardService cardService) + { + _cardService = cardService; + } + + public List Cards { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + Cards = await _cardService.GetAllCardsWithStatsAsync(); + } + + public async Task OnPostDeleteAsync(int id) + { + var result = await _cardService.DeleteCardAsync(id); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(); + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/CategoryMappings.cshtml b/MoneyMap/Pages/CategoryMappings.cshtml index e83c5c0..4a2a6c0 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml +++ b/MoneyMap/Pages/CategoryMappings.cshtml @@ -1,308 +1,308 @@ -@page -@model MoneyMap.Pages.CategoryMappingsModel -@{ - ViewData["Title"] = "Category Mappings"; -} - -
-

Category Mappings

-
- - Export to JSON - - - Back to Dashboard -
-
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
-
-
-
-
Total Categories
-
@Model.TotalCategories
-
-
-
-
-
-
-
Total Patterns
-
@Model.TotalMappings
-
-
-
-
-
-
- @if (Model.TotalMappings == 0) - { -
- -
- } - else - { -
Mappings Loaded
-
✓ Ready
- } -
-
-
-
- - -
- -
- -@if (Model.CategoryGroups.Any()) -{ -
-
- Category Patterns - Transactions are auto-categorized when merchant names match these patterns -
-
-
- @foreach (var group in Model.CategoryGroups) - { -
-
-
- @group.Category - @group.Count -
-
- @foreach (var mapping in group.Mappings) - { -
- - @if (mapping.Priority > 0) - { - P@(mapping.Priority) - } - @mapping.Pattern - @if (mapping.Merchant != null) - { - → @mapping.Merchant.Name - } - -
- -
-
- } -
-
-
- } -
-
-
-} -else -{ -
-
No category mappings found
-

Click "Load Default Mappings" above to import the default category patterns, or add your own custom mappings.

-
-} - -
-
How Auto-Categorization Works
-
    -
  • When you upload transactions, the system automatically checks the merchant name against these patterns
  • -
  • If a match is found, the transaction is automatically categorized
  • -
  • Categories in the CSV file (if present) take precedence over auto-categorization
  • -
  • Transactions with no match will have an empty category and can be categorized manually
  • -
  • Special rule: Gas stations with purchases under $20 are categorized as "Convenience Store"
  • -
  • Priority: Higher priority patterns are checked first (useful for Banking, Income, etc.)
  • -
-
- - - - - - - - - - -@section Scripts { - -} +@page +@model MoneyMap.Pages.CategoryMappingsModel +@{ + ViewData["Title"] = "Category Mappings"; +} + +
+

Category Mappings

+
+ + Export to JSON + + + Back to Dashboard +
+
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+
Total Categories
+
@Model.TotalCategories
+
+
+
+
+
+
+
Total Patterns
+
@Model.TotalMappings
+
+
+
+
+
+
+ @if (Model.TotalMappings == 0) + { +
+ +
+ } + else + { +
Mappings Loaded
+
✓ Ready
+ } +
+
+
+
+ + +
+ +
+ +@if (Model.CategoryGroups.Any()) +{ +
+
+ Category Patterns + Transactions are auto-categorized when merchant names match these patterns +
+
+
+ @foreach (var group in Model.CategoryGroups) + { +
+
+
+ @group.Category + @group.Count +
+
+ @foreach (var mapping in group.Mappings) + { +
+ + @if (mapping.Priority > 0) + { + P@(mapping.Priority) + } + @mapping.Pattern + @if (mapping.Merchant != null) + { + → @mapping.Merchant.Name + } + +
+ +
+
+ } +
+
+
+ } +
+
+
+} +else +{ +
+
No category mappings found
+

Click "Load Default Mappings" above to import the default category patterns, or add your own custom mappings.

+
+} + +
+
How Auto-Categorization Works
+
    +
  • When you upload transactions, the system automatically checks the merchant name against these patterns
  • +
  • If a match is found, the transaction is automatically categorized
  • +
  • Categories in the CSV file (if present) take precedence over auto-categorization
  • +
  • Transactions with no match will have an empty category and can be categorized manually
  • +
  • Special rule: Gas stations with purchases under $20 are categorized as "Convenience Store"
  • +
  • Priority: Higher priority patterns are checked first (useful for Banking, Income, etc.)
  • +
+
+ + + + + + + + + + +@section Scripts { + +} diff --git a/MoneyMap/Pages/CategoryMappings.cshtml.cs b/MoneyMap/Pages/CategoryMappings.cshtml.cs index ff93f97..f845ace 100644 --- a/MoneyMap/Pages/CategoryMappings.cshtml.cs +++ b/MoneyMap/Pages/CategoryMappings.cshtml.cs @@ -1,317 +1,317 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using MoneyMap.Services; -using System.ComponentModel.DataAnnotations; -using System.Text; -using System.Text.Json; - -namespace MoneyMap.Pages -{ - public class CategoryMappingsModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly ITransactionCategorizer _categorizer; - private readonly IMerchantService _merchantService; - - public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService) - { - _db = db; - _categorizer = categorizer; - _merchantService = merchantService; - } - - public List CategoryGroups { get; set; } = new(); - public int TotalMappings { get; set; } - public int TotalCategories { get; set; } - public List AvailableCategories { get; set; } = new(); - public List AvailableMerchants { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync() - { - await LoadDataAsync(); - } - - public async Task OnPostSeedDefaultsAsync() - { - await _categorizer.SeedDefaultMappingsAsync(); - SuccessMessage = "Default category mappings loaded successfully!"; - return RedirectToPage(); - } - - public async Task OnPostAddMappingAsync(AddMappingModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - int? merchantId = null; - if (!string.IsNullOrWhiteSpace(model.Merchant)) - { - merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); - } - - var mapping = new CategoryMapping - { - Category = model.Category.Trim(), - Pattern = model.Pattern.Trim(), - MerchantId = merchantId, - Priority = model.Priority - }; - - _db.CategoryMappings.Add(mapping); - await _db.SaveChangesAsync(); - - SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'."; - return RedirectToPage(); - } - - public async Task OnPostUpdateMappingAsync(UpdateMappingModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - var mapping = await _db.CategoryMappings.FindAsync(model.Id); - if (mapping == null) - { - ErrorMessage = "Mapping not found."; - return RedirectToPage(); - } - - int? merchantId = null; - if (!string.IsNullOrWhiteSpace(model.Merchant)) - { - merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); - } - - mapping.Category = model.Category.Trim(); - mapping.Pattern = model.Pattern.Trim(); - mapping.MerchantId = merchantId; - mapping.Priority = model.Priority; - - await _db.SaveChangesAsync(); - - SuccessMessage = "Mapping updated successfully."; - return RedirectToPage(); - } - - public async Task OnPostDeleteMappingAsync(int id) - { - var mapping = await _db.CategoryMappings.FindAsync(id); - if (mapping == null) - { - ErrorMessage = "Mapping not found."; - return RedirectToPage(); - } - - _db.CategoryMappings.Remove(mapping); - await _db.SaveChangesAsync(); - - SuccessMessage = "Mapping deleted successfully."; - return RedirectToPage(); - } - - public async Task OnGetExportAsync() - { - var mappings = await _db.CategoryMappings - .OrderBy(m => m.Category) - .ThenByDescending(m => m.Priority) - .ToListAsync(); - - var exportData = mappings.Select(m => new CategoryMappingExport - { - Category = m.Category, - Pattern = m.Pattern, - Merchant = m.Merchant?.Name, - Priority = m.Priority - }).ToList(); - - var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions - { - WriteIndented = true - }); - - var bytes = Encoding.UTF8.GetBytes(json); - return File(bytes, "application/json", "category-mappings.json"); - } - - public async Task OnPostImportAsync(IFormFile jsonFile, bool replaceExisting) - { - if (jsonFile == null || jsonFile.Length == 0) - { - ErrorMessage = "Please select a JSON file to import."; - return RedirectToPage(); - } - - try - { - using var stream = jsonFile.OpenReadStream(); - var importData = await JsonSerializer.DeserializeAsync>(stream, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (importData == null || !importData.Any()) - { - ErrorMessage = "No category mappings found in the file."; - return RedirectToPage(); - } - - // Validate the data - foreach (var item in importData) - { - if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern)) - { - ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern."; - return RedirectToPage(); - } - } - - // Replace existing or merge - if (replaceExisting) - { - var existingMappings = await _db.CategoryMappings.ToListAsync(); - _db.CategoryMappings.RemoveRange(existingMappings); - } - - // Add new mappings (create merchants first if needed) - var newMappings = new List(); - foreach (var item in importData) - { - int? merchantId = null; - if (!string.IsNullOrWhiteSpace(item.Merchant)) - { - merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant); - } - - newMappings.Add(new CategoryMapping - { - Category = item.Category.Trim(), - Pattern = item.Pattern.Trim(), - MerchantId = merchantId, - Priority = item.Priority - }); - } - - _db.CategoryMappings.AddRange(newMappings); - await _db.SaveChangesAsync(); - - SuccessMessage = replaceExisting - ? $"Successfully replaced all mappings with {importData.Count} mappings from file." - : $"Successfully imported {importData.Count} mappings from file."; - - return RedirectToPage(); - } - catch (JsonException) - { - ErrorMessage = "Invalid JSON file format. Please check the file and try again."; - return RedirectToPage(); - } - catch (Exception ex) - { - ErrorMessage = $"Error importing file: {ex.Message}"; - return RedirectToPage(); - } - } - - private async Task LoadDataAsync() - { - var mappings = await _db.CategoryMappings - .Include(m => m.Merchant) - .OrderBy(m => m.Category) - .ThenByDescending(m => m.Priority) - .ToListAsync(); - - CategoryGroups = mappings - .GroupBy(m => m.Category) - .Select(g => new CategoryGroup - { - Category = g.Key, - Mappings = g.OrderByDescending(m => m.Priority).ToList(), - Count = g.Count() - }) - .OrderBy(g => g.Category) - .ToList(); - - TotalMappings = mappings.Count; - TotalCategories = CategoryGroups.Count; - - // Get distinct categories for dropdown - AvailableCategories = CategoryGroups - .Select(g => g.Category) - .OrderBy(c => c) - .ToList(); - - // Get all merchants for dropdown - AvailableMerchants = await _db.Merchants - .OrderBy(m => m.Name) - .Select(m => m.Name) - .ToListAsync(); - } - - public class CategoryGroup - { - public required string Category { get; set; } - public required List Mappings { get; set; } - public int Count { get; set; } - } - - public class AddMappingModel - { - [Required(ErrorMessage = "Category is required")] - [StringLength(100)] - public string Category { get; set; } = ""; - - [StringLength(100)] - public string? Merchant { get; set; } - - [Required(ErrorMessage = "Pattern is required")] - [StringLength(200)] - public string Pattern { get; set; } = ""; - - public int Priority { get; set; } = 0; - } - - public class UpdateMappingModel - { - [Required] - public int Id { get; set; } - - [Required(ErrorMessage = "Category is required")] - [StringLength(100)] - public string Category { get; set; } = ""; - - [StringLength(100)] - public string? Merchant { get; set; } - - [Required(ErrorMessage = "Pattern is required")] - [StringLength(200)] - public string Pattern { get; set; } = ""; - - public int Priority { get; set; } = 0; - } - - public class CategoryMappingExport - { - public string Category { get; set; } = ""; - public string Pattern { get; set; } = ""; - public string? Merchant { get; set; } - public int Priority { get; set; } = 0; - } - } +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Json; + +namespace MoneyMap.Pages +{ + public class CategoryMappingsModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly ITransactionCategorizer _categorizer; + private readonly IMerchantService _merchantService; + + public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService) + { + _db = db; + _categorizer = categorizer; + _merchantService = merchantService; + } + + public List CategoryGroups { get; set; } = new(); + public int TotalMappings { get; set; } + public int TotalCategories { get; set; } + public List AvailableCategories { get; set; } = new(); + public List AvailableMerchants { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + await LoadDataAsync(); + } + + public async Task OnPostSeedDefaultsAsync() + { + await _categorizer.SeedDefaultMappingsAsync(); + SuccessMessage = "Default category mappings loaded successfully!"; + return RedirectToPage(); + } + + public async Task OnPostAddMappingAsync(AddMappingModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(model.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); + } + + var mapping = new CategoryMapping + { + Category = model.Category.Trim(), + Pattern = model.Pattern.Trim(), + MerchantId = merchantId, + Priority = model.Priority + }; + + _db.CategoryMappings.Add(mapping); + await _db.SaveChangesAsync(); + + SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'."; + return RedirectToPage(); + } + + public async Task OnPostUpdateMappingAsync(UpdateMappingModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + var mapping = await _db.CategoryMappings.FindAsync(model.Id); + if (mapping == null) + { + ErrorMessage = "Mapping not found."; + return RedirectToPage(); + } + + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(model.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant); + } + + mapping.Category = model.Category.Trim(); + mapping.Pattern = model.Pattern.Trim(); + mapping.MerchantId = merchantId; + mapping.Priority = model.Priority; + + await _db.SaveChangesAsync(); + + SuccessMessage = "Mapping updated successfully."; + return RedirectToPage(); + } + + public async Task OnPostDeleteMappingAsync(int id) + { + var mapping = await _db.CategoryMappings.FindAsync(id); + if (mapping == null) + { + ErrorMessage = "Mapping not found."; + return RedirectToPage(); + } + + _db.CategoryMappings.Remove(mapping); + await _db.SaveChangesAsync(); + + SuccessMessage = "Mapping deleted successfully."; + return RedirectToPage(); + } + + public async Task OnGetExportAsync() + { + var mappings = await _db.CategoryMappings + .OrderBy(m => m.Category) + .ThenByDescending(m => m.Priority) + .ToListAsync(); + + var exportData = mappings.Select(m => new CategoryMappingExport + { + Category = m.Category, + Pattern = m.Pattern, + Merchant = m.Merchant?.Name, + Priority = m.Priority + }).ToList(); + + var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions + { + WriteIndented = true + }); + + var bytes = Encoding.UTF8.GetBytes(json); + return File(bytes, "application/json", "category-mappings.json"); + } + + public async Task OnPostImportAsync(IFormFile jsonFile, bool replaceExisting) + { + if (jsonFile == null || jsonFile.Length == 0) + { + ErrorMessage = "Please select a JSON file to import."; + return RedirectToPage(); + } + + try + { + using var stream = jsonFile.OpenReadStream(); + var importData = await JsonSerializer.DeserializeAsync>(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (importData == null || !importData.Any()) + { + ErrorMessage = "No category mappings found in the file."; + return RedirectToPage(); + } + + // Validate the data + foreach (var item in importData) + { + if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern)) + { + ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern."; + return RedirectToPage(); + } + } + + // Replace existing or merge + if (replaceExisting) + { + var existingMappings = await _db.CategoryMappings.ToListAsync(); + _db.CategoryMappings.RemoveRange(existingMappings); + } + + // Add new mappings (create merchants first if needed) + var newMappings = new List(); + foreach (var item in importData) + { + int? merchantId = null; + if (!string.IsNullOrWhiteSpace(item.Merchant)) + { + merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant); + } + + newMappings.Add(new CategoryMapping + { + Category = item.Category.Trim(), + Pattern = item.Pattern.Trim(), + MerchantId = merchantId, + Priority = item.Priority + }); + } + + _db.CategoryMappings.AddRange(newMappings); + await _db.SaveChangesAsync(); + + SuccessMessage = replaceExisting + ? $"Successfully replaced all mappings with {importData.Count} mappings from file." + : $"Successfully imported {importData.Count} mappings from file."; + + return RedirectToPage(); + } + catch (JsonException) + { + ErrorMessage = "Invalid JSON file format. Please check the file and try again."; + return RedirectToPage(); + } + catch (Exception ex) + { + ErrorMessage = $"Error importing file: {ex.Message}"; + return RedirectToPage(); + } + } + + private async Task LoadDataAsync() + { + var mappings = await _db.CategoryMappings + .Include(m => m.Merchant) + .OrderBy(m => m.Category) + .ThenByDescending(m => m.Priority) + .ToListAsync(); + + CategoryGroups = mappings + .GroupBy(m => m.Category) + .Select(g => new CategoryGroup + { + Category = g.Key, + Mappings = g.OrderByDescending(m => m.Priority).ToList(), + Count = g.Count() + }) + .OrderBy(g => g.Category) + .ToList(); + + TotalMappings = mappings.Count; + TotalCategories = CategoryGroups.Count; + + // Get distinct categories for dropdown + AvailableCategories = CategoryGroups + .Select(g => g.Category) + .OrderBy(c => c) + .ToList(); + + // Get all merchants for dropdown + AvailableMerchants = await _db.Merchants + .OrderBy(m => m.Name) + .Select(m => m.Name) + .ToListAsync(); + } + + public class CategoryGroup + { + public required string Category { get; set; } + public required List Mappings { get; set; } + public int Count { get; set; } + } + + public class AddMappingModel + { + [Required(ErrorMessage = "Category is required")] + [StringLength(100)] + public string Category { get; set; } = ""; + + [StringLength(100)] + public string? Merchant { get; set; } + + [Required(ErrorMessage = "Pattern is required")] + [StringLength(200)] + public string Pattern { get; set; } = ""; + + public int Priority { get; set; } = 0; + } + + public class UpdateMappingModel + { + [Required] + public int Id { get; set; } + + [Required(ErrorMessage = "Category is required")] + [StringLength(100)] + public string Category { get; set; } = ""; + + [StringLength(100)] + public string? Merchant { get; set; } + + [Required(ErrorMessage = "Pattern is required")] + [StringLength(200)] + public string Pattern { get; set; } = ""; + + public int Priority { get; set; } = 0; + } + + public class CategoryMappingExport + { + public string Category { get; set; } = ""; + public string Pattern { get; set; } = ""; + public string? Merchant { get; set; } + public int Priority { get; set; } = 0; + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/EditAccount.cshtml b/MoneyMap/Pages/EditAccount.cshtml index 16ce5a8..b137f2b 100644 --- a/MoneyMap/Pages/EditAccount.cshtml +++ b/MoneyMap/Pages/EditAccount.cshtml @@ -1,64 +1,64 @@ -@page "{id:int?}" -@using MoneyMap.Models -@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) - }; -} - -

@ViewData["Title"]

- -
-
-
- - -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - Cancel -
-
-
-
- -@section Scripts { - -} +@page "{id:int?}" +@using MoneyMap.Models +@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) + }; +} + +

@ViewData["Title"]

+ +
+
+
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + Cancel +
+
+
+
+ +@section Scripts { + +} diff --git a/MoneyMap/Pages/EditAccount.cshtml.cs b/MoneyMap/Pages/EditAccount.cshtml.cs index 8ae5751..b3aac84 100644 --- a/MoneyMap/Pages/EditAccount.cshtml.cs +++ b/MoneyMap/Pages/EditAccount.cshtml.cs @@ -1,123 +1,123 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Data; -using MoneyMap.Models; -using System.ComponentModel.DataAnnotations; - -namespace MoneyMap.Pages -{ - public class EditAccountModel : PageModel - { - private readonly MoneyMapContext _db; - - public EditAccountModel(MoneyMapContext db) - { - _db = db; - } - - [BindProperty] - public AccountEditModel Account { get; set; } = new(); - - public bool IsNew => Account.Id == 0; - - [TempData] - public string? SuccessMessage { get; set; } - - public async Task OnGetAsync(int? id) - { - if (id == null) - { - // New account - return Page(); - } - - var account = await _db.Accounts.FindAsync(id.Value); - if (account == null) - return NotFound(); - - Account = new AccountEditModel - { - Id = account.Id, - Institution = account.Institution, - AccountType = account.AccountType, - Last4 = account.Last4, - Owner = account.Owner, - Nickname = account.Nickname - }; - - return Page(); - } - - public async Task OnPostAsync() - { - if (!ModelState.IsValid) - return Page(); - - if (Account.Id == 0) - { - // Create new - var account = new Account - { - Institution = Account.Institution.Trim(), - AccountType = Account.AccountType, - Last4 = Account.Last4.Trim(), - Owner = Account.Owner.Trim(), - Nickname = Account.Nickname?.Trim() - }; - - _db.Accounts.Add(account); - await _db.SaveChangesAsync(); - - SuccessMessage = $"Created account {account.Institution} {account.Last4}"; - } - else - { - // Update existing - var account = await _db.Accounts.FindAsync(Account.Id); - if (account == null) - return NotFound(); - - account.Institution = Account.Institution.Trim(); - account.AccountType = Account.AccountType; - account.Last4 = Account.Last4.Trim(); - account.Owner = Account.Owner.Trim(); - account.Nickname = Account.Nickname?.Trim(); - - await _db.SaveChangesAsync(); - - SuccessMessage = $"Updated account {account.Institution} {account.Last4}"; - } - - return RedirectToPage("/Accounts"); - } - - public class AccountEditModel - { - public int Id { get; set; } - - [Required] - [StringLength(100)] - [Display(Name = "Institution")] - public string Institution { get; set; } = ""; - - [Required] - [Display(Name = "Account Type")] - public AccountType AccountType { get; set; } = AccountType.Checking; - - [Required] - [StringLength(4, MinimumLength = 4)] - [RegularExpression(@"^\d{4}$", ErrorMessage = "Must be exactly 4 digits")] - [Display(Name = "Last 4 Digits")] - public string Last4 { get; set; } = ""; - - [Required] - [StringLength(100)] - [Display(Name = "Owner")] - public string Owner { get; set; } = ""; - - [StringLength(50)] - [Display(Name = "Nickname")] - public string? Nickname { get; set; } - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Data; +using MoneyMap.Models; +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Pages +{ + public class EditAccountModel : PageModel + { + private readonly MoneyMapContext _db; + + public EditAccountModel(MoneyMapContext db) + { + _db = db; + } + + [BindProperty] + public AccountEditModel Account { get; set; } = new(); + + public bool IsNew => Account.Id == 0; + + [TempData] + public string? SuccessMessage { get; set; } + + public async Task OnGetAsync(int? id) + { + if (id == null) + { + // New account + return Page(); + } + + var account = await _db.Accounts.FindAsync(id.Value); + if (account == null) + return NotFound(); + + Account = new AccountEditModel + { + Id = account.Id, + Institution = account.Institution, + AccountType = account.AccountType, + Last4 = account.Last4, + Owner = account.Owner, + Nickname = account.Nickname + }; + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + return Page(); + + if (Account.Id == 0) + { + // Create new + var account = new Account + { + Institution = Account.Institution.Trim(), + AccountType = Account.AccountType, + Last4 = Account.Last4.Trim(), + Owner = Account.Owner.Trim(), + Nickname = Account.Nickname?.Trim() + }; + + _db.Accounts.Add(account); + await _db.SaveChangesAsync(); + + SuccessMessage = $"Created account {account.Institution} {account.Last4}"; + } + else + { + // Update existing + var account = await _db.Accounts.FindAsync(Account.Id); + if (account == null) + return NotFound(); + + account.Institution = Account.Institution.Trim(); + account.AccountType = Account.AccountType; + account.Last4 = Account.Last4.Trim(); + account.Owner = Account.Owner.Trim(); + account.Nickname = Account.Nickname?.Trim(); + + await _db.SaveChangesAsync(); + + SuccessMessage = $"Updated account {account.Institution} {account.Last4}"; + } + + return RedirectToPage("/Accounts"); + } + + public class AccountEditModel + { + public int Id { get; set; } + + [Required] + [StringLength(100)] + [Display(Name = "Institution")] + public string Institution { get; set; } = ""; + + [Required] + [Display(Name = "Account Type")] + public AccountType AccountType { get; set; } = AccountType.Checking; + + [Required] + [StringLength(4, MinimumLength = 4)] + [RegularExpression(@"^\d{4}$", ErrorMessage = "Must be exactly 4 digits")] + [Display(Name = "Last 4 Digits")] + public string Last4 { get; set; } = ""; + + [Required] + [StringLength(100)] + [Display(Name = "Owner")] + public string Owner { get; set; } = ""; + + [StringLength(50)] + [Display(Name = "Nickname")] + public string? Nickname { get; set; } + } + } +} diff --git a/MoneyMap/Pages/EditCard.cshtml b/MoneyMap/Pages/EditCard.cshtml index b707454..350f50a 100644 --- a/MoneyMap/Pages/EditCard.cshtml +++ b/MoneyMap/Pages/EditCard.cshtml @@ -1,116 +1,116 @@ -@page -@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) - }; -} - -
-

@(Model.IsNewCard ? "Add Card" : "Edit Card")

- Back to Accounts -
- -
-
-
-
- Card Details -
-
-
- - -
- - - -
Who owns this card?
-
- -
- - - -
Which bank or institution issued the card?
-
- -
- - - -
The last 4 digits of the card number
-
- -
- - - -
A friendly name to help identify this card
-
- -
- - - -
- Link this card to the bank account it draws from or pays to - @if (!Model.AvailableAccounts.Any()) - { -
- No accounts available. Create one first. - } -
-
- -
- - Cancel -
-
-
-
-
- -
-
-
- Tips -
-
-
    -
  • Owner: Use the cardholder's name for easy identification
  • -
  • Issuer: The bank or credit card company (e.g., Chase, Discover, Capital One)
  • -
  • Last 4: These digits help match transactions to the correct card
  • -
  • Nickname: Optional friendly name like "Personal Travel Card" or "Business Card"
  • -
  • Linked Account: Link credit cards to their payment accounts, or debit cards to their checking accounts
  • -
  • Cards with transactions cannot be deleted, only edited
  • -
  • Auto-imported cards will have "Unknown" as the owner - update them here
  • -
-
-
- - @if (!Model.IsNewCard) - { -
- Note: Updating card details will not affect existing transactions, but will change how the card is displayed going forward. -
- } -
-
- -@section Scripts { - +@page +@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) + }; +} + +
+

@(Model.IsNewCard ? "Add Card" : "Edit Card")

+ Back to Accounts +
+ +
+
+
+
+ Card Details +
+
+
+ + +
+ + + +
Who owns this card?
+
+ +
+ + + +
Which bank or institution issued the card?
+
+ +
+ + + +
The last 4 digits of the card number
+
+ +
+ + + +
A friendly name to help identify this card
+
+ +
+ + + +
+ Link this card to the bank account it draws from or pays to + @if (!Model.AvailableAccounts.Any()) + { +
+ No accounts available. Create one first. + } +
+
+ +
+ + Cancel +
+
+
+
+
+ +
+
+
+ Tips +
+
+
    +
  • Owner: Use the cardholder's name for easy identification
  • +
  • Issuer: The bank or credit card company (e.g., Chase, Discover, Capital One)
  • +
  • Last 4: These digits help match transactions to the correct card
  • +
  • Nickname: Optional friendly name like "Personal Travel Card" or "Business Card"
  • +
  • Linked Account: Link credit cards to their payment accounts, or debit cards to their checking accounts
  • +
  • Cards with transactions cannot be deleted, only edited
  • +
  • Auto-imported cards will have "Unknown" as the owner - update them here
  • +
+
+
+ + @if (!Model.IsNewCard) + { +
+ Note: Updating card details will not affect existing transactions, but will change how the card is displayed going forward. +
+ } +
+
+ +@section Scripts { + } \ No newline at end of file diff --git a/MoneyMap/Pages/EditCard.cshtml.cs b/MoneyMap/Pages/EditCard.cshtml.cs index c846d0f..2449b61 100644 --- a/MoneyMap/Pages/EditCard.cshtml.cs +++ b/MoneyMap/Pages/EditCard.cshtml.cs @@ -1,150 +1,150 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using System.ComponentModel.DataAnnotations; - -namespace MoneyMap.Pages -{ - public class EditCardModel : PageModel - { - private readonly MoneyMapContext _db; - - public EditCardModel(MoneyMapContext db) - { - _db = db; - } - - [BindProperty] - public CardEditModel Card { get; set; } = new(); - - public bool IsNewCard { get; set; } - - public List AvailableAccounts { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - public async Task OnGetAsync(int? id, int? accountId) - { - await LoadAvailableAccountsAsync(); - - if (id.HasValue) - { - var card = await _db.Cards.FindAsync(id.Value); - if (card == null) - return NotFound(); - - Card = new CardEditModel - { - Id = card.Id, - Owner = card.Owner, - Issuer = card.Issuer, - Last4 = card.Last4, - Nickname = card.Nickname, - AccountId = card.AccountId - }; - - IsNewCard = false; - } - else - { - IsNewCard = true; - - // Pre-select account if accountId parameter is provided - if (accountId.HasValue) - { - Card.AccountId = accountId.Value; - } - } - - return Page(); - } - - public async Task OnPostAsync() - { - if (!ModelState.IsValid) - { - IsNewCard = Card.Id == 0; - await LoadAvailableAccountsAsync(); - return Page(); - } - - if (Card.Id == 0) - { - // Create new card - var card = new Card - { - Owner = Card.Owner.Trim(), - Issuer = Card.Issuer.Trim(), - Last4 = Card.Last4.Trim(), - Nickname = Card.Nickname?.Trim(), - AccountId = Card.AccountId - }; - - _db.Cards.Add(card); - SuccessMessage = "Card added successfully!"; - } - else - { - // Update existing card - var card = await _db.Cards.FindAsync(Card.Id); - if (card == null) - return NotFound(); - - card.Owner = Card.Owner.Trim(); - card.Issuer = Card.Issuer.Trim(); - card.Last4 = Card.Last4.Trim(); - card.Nickname = Card.Nickname?.Trim(); - card.AccountId = Card.AccountId; - - SuccessMessage = "Card updated successfully!"; - } - - await _db.SaveChangesAsync(); - - // Redirect back to account details if card is linked to an account - if (Card.AccountId.HasValue) - { - return RedirectToPage("/AccountDetails", new { id = Card.AccountId.Value }); - } - - // Otherwise redirect to accounts list - return RedirectToPage("/Accounts"); - } - - private async Task LoadAvailableAccountsAsync() - { - AvailableAccounts = await _db.Accounts - .OrderBy(a => a.Institution) - .ThenBy(a => a.Last4) - .ToListAsync(); - } - - public class CardEditModel - { - public int Id { get; set; } - - [Required(ErrorMessage = "Owner is required")] - [StringLength(100)] - public string Owner { get; set; } = ""; - - [Required(ErrorMessage = "Issuer is required")] - [StringLength(100)] - public string Issuer { get; set; } = ""; - - [Required(ErrorMessage = "Last 4 digits are required")] - [StringLength(4, MinimumLength = 4, ErrorMessage = "Last 4 must be exactly 4 digits")] - [RegularExpression(@"^\d{4}$", ErrorMessage = "Last 4 must be 4 digits")] - public string Last4 { get; set; } = ""; - - [StringLength(50)] - [Display(Name = "Nickname (Optional)")] - public string? Nickname { get; set; } - - [Display(Name = "Linked Account")] - public int? AccountId { get; set; } - } - } +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Pages +{ + public class EditCardModel : PageModel + { + private readonly MoneyMapContext _db; + + public EditCardModel(MoneyMapContext db) + { + _db = db; + } + + [BindProperty] + public CardEditModel Card { get; set; } = new(); + + public bool IsNewCard { get; set; } + + public List AvailableAccounts { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + public async Task OnGetAsync(int? id, int? accountId) + { + await LoadAvailableAccountsAsync(); + + if (id.HasValue) + { + var card = await _db.Cards.FindAsync(id.Value); + if (card == null) + return NotFound(); + + Card = new CardEditModel + { + Id = card.Id, + Owner = card.Owner, + Issuer = card.Issuer, + Last4 = card.Last4, + Nickname = card.Nickname, + AccountId = card.AccountId + }; + + IsNewCard = false; + } + else + { + IsNewCard = true; + + // Pre-select account if accountId parameter is provided + if (accountId.HasValue) + { + Card.AccountId = accountId.Value; + } + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + IsNewCard = Card.Id == 0; + await LoadAvailableAccountsAsync(); + return Page(); + } + + if (Card.Id == 0) + { + // Create new card + var card = new Card + { + Owner = Card.Owner.Trim(), + Issuer = Card.Issuer.Trim(), + Last4 = Card.Last4.Trim(), + Nickname = Card.Nickname?.Trim(), + AccountId = Card.AccountId + }; + + _db.Cards.Add(card); + SuccessMessage = "Card added successfully!"; + } + else + { + // Update existing card + var card = await _db.Cards.FindAsync(Card.Id); + if (card == null) + return NotFound(); + + card.Owner = Card.Owner.Trim(); + card.Issuer = Card.Issuer.Trim(); + card.Last4 = Card.Last4.Trim(); + card.Nickname = Card.Nickname?.Trim(); + card.AccountId = Card.AccountId; + + SuccessMessage = "Card updated successfully!"; + } + + await _db.SaveChangesAsync(); + + // Redirect back to account details if card is linked to an account + if (Card.AccountId.HasValue) + { + return RedirectToPage("/AccountDetails", new { id = Card.AccountId.Value }); + } + + // Otherwise redirect to accounts list + return RedirectToPage("/Accounts"); + } + + private async Task LoadAvailableAccountsAsync() + { + AvailableAccounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + } + + public class CardEditModel + { + public int Id { get; set; } + + [Required(ErrorMessage = "Owner is required")] + [StringLength(100)] + public string Owner { get; set; } = ""; + + [Required(ErrorMessage = "Issuer is required")] + [StringLength(100)] + public string Issuer { get; set; } = ""; + + [Required(ErrorMessage = "Last 4 digits are required")] + [StringLength(4, MinimumLength = 4, ErrorMessage = "Last 4 must be exactly 4 digits")] + [RegularExpression(@"^\d{4}$", ErrorMessage = "Last 4 must be 4 digits")] + public string Last4 { get; set; } = ""; + + [StringLength(50)] + [Display(Name = "Nickname (Optional)")] + public string? Nickname { get; set; } + + [Display(Name = "Linked Account")] + public int? AccountId { get; set; } + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/EditTransaction.cshtml b/MoneyMap/Pages/EditTransaction.cshtml index 94ea716..99289fc 100644 --- a/MoneyMap/Pages/EditTransaction.cshtml +++ b/MoneyMap/Pages/EditTransaction.cshtml @@ -1,278 +1,278 @@ -@page "{id:long}" -@model MoneyMap.Pages.EditTransactionModel -@{ - ViewData["Title"] = "Edit Transaction"; - ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> - { - ("Transactions", Url.Page("/Transactions")), - ($"#{Model.Transaction.Id}", null) - }; -} - -
-

Edit Transaction #@Model.Transaction.Id

- Back to Transactions -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
-
-
-
- Transaction Details -
-
-
- - - - - - - - - -
-
- -
@Model.Transaction.Date.ToString("yyyy-MM-dd")
-
-
- -
- - @Model.Transaction.Amount.ToString("C") - -
-
-
- -
- -
@Model.Transaction.Name
-
- -
- -
@Model.Transaction.Memo
-
- -
-
- -
@Model.Transaction.CardLabel
-
-
- -
@Model.Transaction.AccountLabel
-
-
- -
- - -
- - - -
- -
- -
Select a category or create a new one
- -
- -
- - - -
- -
- - - -
Select a merchant or create a new one
- -
- -
- - -
Optional: Add context or details to help you remember what this transaction was for
- -
- -
- - Cancel -
-
-
-
-
- -
-
-
- Receipts (@Model.Receipts.Count) -
-
- @if (Model.Receipts.Any()) - { - @foreach (var item in Model.Receipts) - { -
-
-
-
-
- @item.Receipt.FileName -
- - @((item.Receipt.FileSizeBytes / 1024.0).ToString("F1")) KB - � @item.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy") - - @if (!string.IsNullOrWhiteSpace(item.Receipt.Merchant)) - { -
- @item.Receipt.Merchant - @if (item.Receipt.Total.HasValue) - { - - @item.Receipt.Total.Value.ToString("C") - } -
- } -
-
- - @if (item.LineItems.Any()) - { -
-
Line Items (@item.LineItems.Count)
-
- @foreach (var lineItem in item.LineItems) - { -
- - @if (lineItem.Quantity.HasValue) - { - @lineItem.Quantity.Value.ToString("0.##")x - } - @lineItem.Description - - @(lineItem.LineTotal?.ToString("C") ?? "-") -
- } -
-
- } - -
- - View - - @if (!item.LineItems.Any()) - { -
- -
- } -
- -
-
- -
-
-
-
- } - } - else - { -

No receipts attached

- } - -
- -
- -
- - -
- -
- Accepts: JPG, PNG, PDF, GIF, HEIC (max 10MB) -
-
-
-
- -
-
- Quick Tips -
-
-
    -
  • Use the category dropdown to see existing categories
  • -
  • Leave category blank to mark as uncategorized
  • -
  • You can create new categories by typing them in
  • -
-
-
-
-
- -@section Scripts { - - - -} +@page "{id:long}" +@model MoneyMap.Pages.EditTransactionModel +@{ + ViewData["Title"] = "Edit Transaction"; + ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> + { + ("Transactions", Url.Page("/Transactions")), + ($"#{Model.Transaction.Id}", null) + }; +} + +
+

Edit Transaction #@Model.Transaction.Id

+ Back to Transactions +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+ Transaction Details +
+
+
+ + + + + + + + + +
+
+ +
@Model.Transaction.Date.ToString("yyyy-MM-dd")
+
+
+ +
+ + @Model.Transaction.Amount.ToString("C") + +
+
+
+ +
+ +
@Model.Transaction.Name
+
+ +
+ +
@Model.Transaction.Memo
+
+ +
+
+ +
@Model.Transaction.CardLabel
+
+
+ +
@Model.Transaction.AccountLabel
+
+
+ +
+ + +
+ + + +
+ +
+ +
Select a category or create a new one
+ +
+ +
+ + + +
+ +
+ + + +
Select a merchant or create a new one
+ +
+ +
+ + +
Optional: Add context or details to help you remember what this transaction was for
+ +
+ +
+ + Cancel +
+
+
+
+
+ +
+
+
+ Receipts (@Model.Receipts.Count) +
+
+ @if (Model.Receipts.Any()) + { + @foreach (var item in Model.Receipts) + { +
+
+
+
+
+ @item.Receipt.FileName +
+ + @((item.Receipt.FileSizeBytes / 1024.0).ToString("F1")) KB + � @item.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy") + + @if (!string.IsNullOrWhiteSpace(item.Receipt.Merchant)) + { +
+ @item.Receipt.Merchant + @if (item.Receipt.Total.HasValue) + { + - @item.Receipt.Total.Value.ToString("C") + } +
+ } +
+
+ + @if (item.LineItems.Any()) + { +
+
Line Items (@item.LineItems.Count)
+
+ @foreach (var lineItem in item.LineItems) + { +
+ + @if (lineItem.Quantity.HasValue) + { + @lineItem.Quantity.Value.ToString("0.##")x + } + @lineItem.Description + + @(lineItem.LineTotal?.ToString("C") ?? "-") +
+ } +
+
+ } + +
+ + View + + @if (!item.LineItems.Any()) + { +
+ +
+ } +
+ +
+
+ +
+
+
+
+ } + } + else + { +

No receipts attached

+ } + +
+ +
+ +
+ + +
+ +
+ Accepts: JPG, PNG, PDF, GIF, HEIC (max 10MB) +
+
+
+
+ +
+
+ Quick Tips +
+
+
    +
  • Use the category dropdown to see existing categories
  • +
  • Leave category blank to mark as uncategorized
  • +
  • You can create new categories by typing them in
  • +
+
+
+
+
+ +@section Scripts { + + + +} diff --git a/MoneyMap/Pages/EditTransaction.cshtml.cs b/MoneyMap/Pages/EditTransaction.cshtml.cs index 1585333..90f947e 100644 --- a/MoneyMap/Pages/EditTransaction.cshtml.cs +++ b/MoneyMap/Pages/EditTransaction.cshtml.cs @@ -1,281 +1,281 @@ -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 EditTransactionModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - private readonly IReceiptParser _receiptParser; - private readonly IReferenceDataService _referenceDataService; - private readonly IMerchantService _merchantService; - - public EditTransactionModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptParser receiptParser, IReferenceDataService referenceDataService, IMerchantService merchantService) - { - _db = db; - _receiptManager = receiptManager; - _receiptParser = receiptParser; - _referenceDataService = referenceDataService; - _merchantService = merchantService; - } - - [BindProperty] - public TransactionEditModel Transaction { get; set; } = new(); - - [BindProperty] - public IFormFile? ReceiptFile { get; set; } - - [BindProperty] - public bool UseCustomCategory { get; set; } - - [BindProperty] - public bool UseCustomMerchant { get; set; } - - public List AvailableCategories { get; set; } = new(); - public List AvailableMerchants { get; set; } = new(); - public List Receipts { get; set; } = new(); - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync(long id) - { - var transaction = await _db.Transactions - .Include(t => t.Card) - .ThenInclude(c => c!.Account) - .Include(t => t.Account) - .Include(t => t.TransferToAccount) - .Include(t => t.Merchant) - .Include(t => t.Receipts) - .ThenInclude(r => r.LineItems) - .FirstOrDefaultAsync(t => t.Id == id); - - if (transaction == null) - return NotFound(); - - Transaction = new TransactionEditModel - { - Id = transaction.Id, - Date = transaction.Date, - Name = transaction.Name, - Memo = transaction.Memo, - Amount = transaction.Amount, - Category = transaction.Category ?? "", - MerchantId = transaction.MerchantId, - Notes = transaction.Notes ?? "", - CardLabel = transaction.PaymentMethodLabel, - AccountLabel = transaction.Card?.Account?.DisplayLabel ?? transaction.Account?.DisplayLabel ?? "None" - }; - - Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems - { - Receipt = r, - LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List() - }).ToList() ?? new List(); - - await LoadAvailableCategoriesAsync(); - await LoadAvailableMerchantsAsync(); - - // Check if current category exists in list - UseCustomCategory = !string.IsNullOrWhiteSpace(Transaction.Category) - && !AvailableCategories.Contains(Transaction.Category); - - // Check if current merchant exists in list - UseCustomMerchant = Transaction.MerchantId.HasValue - && !AvailableMerchants.Any(m => m.Id == Transaction.MerchantId.Value); - - return Page(); - } - - public async Task OnPostAsync() - { - // Remove Category from model state validation if it's empty (category is optional) - if (string.IsNullOrWhiteSpace(Transaction.Category)) - { - ModelState.Remove("Transaction.Category"); - } - - // Remove Notes from model state validation if it's empty - if (string.IsNullOrWhiteSpace(Transaction.Notes)) - { - ModelState.Remove("Transaction.Notes"); - } - - // Remove MerchantName from validation (it's only used when creating new merchant) - ModelState.Remove("Transaction.MerchantName"); - - if (!ModelState.IsValid) - { - await LoadDataAsync(); - return Page(); - } - - var transaction = await _db.Transactions.FindAsync(Transaction.Id); - if (transaction == null) - return NotFound(); - - // Update category and notes - transaction.Category = string.IsNullOrWhiteSpace(Transaction.Category) - ? "" - : Transaction.Category.Trim(); - - transaction.Notes = string.IsNullOrWhiteSpace(Transaction.Notes) - ? "" - : Transaction.Notes.Trim(); - - // Update merchant - if (!string.IsNullOrWhiteSpace(Transaction.MerchantName)) - { - // Create or get merchant if custom name was entered - var merchantId = await _merchantService.GetOrCreateIdAsync(Transaction.MerchantName); - transaction.MerchantId = merchantId; - } - else if (Transaction.MerchantId.HasValue && Transaction.MerchantId.Value > 0) - { - // Existing merchant was selected - transaction.MerchantId = Transaction.MerchantId.Value; - } - else - { - // No merchant selected - transaction.MerchantId = null; - } - - await _db.SaveChangesAsync(); - - SuccessMessage = "Transaction updated successfully!"; - return RedirectToPage(new { id = Transaction.Id }); - } - - public async Task OnPostUploadReceiptAsync() - { - if (ReceiptFile == null) - { - ErrorMessage = "Please select a file to upload."; - await LoadDataAsync(); - return Page(); - } - - var result = await _receiptManager.UploadReceiptAsync(Transaction.Id, ReceiptFile); - - if (result.IsSuccess) - { - SuccessMessage = "Receipt uploaded successfully!"; - } - else - { - ErrorMessage = result.ErrorMessage; - } - - return RedirectToPage(new { id = Transaction.Id }); - } - - public async Task OnPostUnmapReceiptAsync(long receiptId) - { - var receipt = await _db.Receipts.FindAsync(receiptId); - if (receipt != null) - { - receipt.TransactionId = null; - await _db.SaveChangesAsync(); - SuccessMessage = "Receipt unmapped successfully! You can now find it on the Receipts page."; - } - else - { - ErrorMessage = "Receipt not found."; - } - - return RedirectToPage(new { id = Transaction.Id }); - } - - public async Task OnPostDeleteReceiptAsync(long receiptId) - { - var success = await _receiptManager.DeleteReceiptAsync(receiptId); - - if (success) - { - SuccessMessage = "Receipt deleted permanently!"; - } - else - { - ErrorMessage = "Failed to delete receipt."; - } - - return RedirectToPage(new { id = Transaction.Id }); - } - - public async Task OnPostParseReceiptAsync(long receiptId) - { - var result = await _receiptParser.ParseReceiptAsync(receiptId); - - if (result.IsSuccess) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(new { id = Transaction.Id }); - } - - private async Task LoadDataAsync() - { - await LoadAvailableCategoriesAsync(); - await LoadAvailableMerchantsAsync(); - - var transaction = await _db.Transactions - .Include(t => t.Receipts) - .ThenInclude(r => r.LineItems) - .FirstOrDefaultAsync(t => t.Id == Transaction.Id); - - if (transaction != null) - { - Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems - { - Receipt = r, - LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List() - }).ToList() ?? new List(); - } - } - - private async Task LoadAvailableCategoriesAsync() - { - AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync(); - } - - private async Task LoadAvailableMerchantsAsync() - { - AvailableMerchants = await _referenceDataService.GetAvailableMerchantsAsync(); - } - - public class TransactionEditModel - { - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public string Memo { get; set; } = ""; - public decimal Amount { get; set; } - public string Category { get; set; } = ""; - public int? MerchantId { get; set; } - public string? MerchantName { get; set; } = ""; - public string? Notes { get; set; } = ""; - public string CardLabel { get; set; } = ""; - public string AccountLabel { get; set; } = ""; - } - - public class ReceiptWithItems - { - public Receipt Receipt { get; set; } = null!; - public List LineItems { get; set; } = new(); - } - } +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 EditTransactionModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly IReceiptParser _receiptParser; + private readonly IReferenceDataService _referenceDataService; + private readonly IMerchantService _merchantService; + + public EditTransactionModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptParser receiptParser, IReferenceDataService referenceDataService, IMerchantService merchantService) + { + _db = db; + _receiptManager = receiptManager; + _receiptParser = receiptParser; + _referenceDataService = referenceDataService; + _merchantService = merchantService; + } + + [BindProperty] + public TransactionEditModel Transaction { get; set; } = new(); + + [BindProperty] + public IFormFile? ReceiptFile { get; set; } + + [BindProperty] + public bool UseCustomCategory { get; set; } + + [BindProperty] + public bool UseCustomMerchant { get; set; } + + public List AvailableCategories { get; set; } = new(); + public List AvailableMerchants { get; set; } = new(); + public List Receipts { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync(long id) + { + var transaction = await _db.Transactions + .Include(t => t.Card) + .ThenInclude(c => c!.Account) + .Include(t => t.Account) + .Include(t => t.TransferToAccount) + .Include(t => t.Merchant) + .Include(t => t.Receipts) + .ThenInclude(r => r.LineItems) + .FirstOrDefaultAsync(t => t.Id == id); + + if (transaction == null) + return NotFound(); + + Transaction = new TransactionEditModel + { + Id = transaction.Id, + Date = transaction.Date, + Name = transaction.Name, + Memo = transaction.Memo, + Amount = transaction.Amount, + Category = transaction.Category ?? "", + MerchantId = transaction.MerchantId, + Notes = transaction.Notes ?? "", + CardLabel = transaction.PaymentMethodLabel, + AccountLabel = transaction.Card?.Account?.DisplayLabel ?? transaction.Account?.DisplayLabel ?? "None" + }; + + Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems + { + Receipt = r, + LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List() + }).ToList() ?? new List(); + + await LoadAvailableCategoriesAsync(); + await LoadAvailableMerchantsAsync(); + + // Check if current category exists in list + UseCustomCategory = !string.IsNullOrWhiteSpace(Transaction.Category) + && !AvailableCategories.Contains(Transaction.Category); + + // Check if current merchant exists in list + UseCustomMerchant = Transaction.MerchantId.HasValue + && !AvailableMerchants.Any(m => m.Id == Transaction.MerchantId.Value); + + return Page(); + } + + public async Task OnPostAsync() + { + // Remove Category from model state validation if it's empty (category is optional) + if (string.IsNullOrWhiteSpace(Transaction.Category)) + { + ModelState.Remove("Transaction.Category"); + } + + // Remove Notes from model state validation if it's empty + if (string.IsNullOrWhiteSpace(Transaction.Notes)) + { + ModelState.Remove("Transaction.Notes"); + } + + // Remove MerchantName from validation (it's only used when creating new merchant) + ModelState.Remove("Transaction.MerchantName"); + + if (!ModelState.IsValid) + { + await LoadDataAsync(); + return Page(); + } + + var transaction = await _db.Transactions.FindAsync(Transaction.Id); + if (transaction == null) + return NotFound(); + + // Update category and notes + transaction.Category = string.IsNullOrWhiteSpace(Transaction.Category) + ? "" + : Transaction.Category.Trim(); + + transaction.Notes = string.IsNullOrWhiteSpace(Transaction.Notes) + ? "" + : Transaction.Notes.Trim(); + + // Update merchant + if (!string.IsNullOrWhiteSpace(Transaction.MerchantName)) + { + // Create or get merchant if custom name was entered + var merchantId = await _merchantService.GetOrCreateIdAsync(Transaction.MerchantName); + transaction.MerchantId = merchantId; + } + else if (Transaction.MerchantId.HasValue && Transaction.MerchantId.Value > 0) + { + // Existing merchant was selected + transaction.MerchantId = Transaction.MerchantId.Value; + } + else + { + // No merchant selected + transaction.MerchantId = null; + } + + await _db.SaveChangesAsync(); + + SuccessMessage = "Transaction updated successfully!"; + return RedirectToPage(new { id = Transaction.Id }); + } + + public async Task OnPostUploadReceiptAsync() + { + if (ReceiptFile == null) + { + ErrorMessage = "Please select a file to upload."; + await LoadDataAsync(); + return Page(); + } + + var result = await _receiptManager.UploadReceiptAsync(Transaction.Id, ReceiptFile); + + if (result.IsSuccess) + { + SuccessMessage = "Receipt uploaded successfully!"; + } + else + { + ErrorMessage = result.ErrorMessage; + } + + return RedirectToPage(new { id = Transaction.Id }); + } + + public async Task OnPostUnmapReceiptAsync(long receiptId) + { + var receipt = await _db.Receipts.FindAsync(receiptId); + if (receipt != null) + { + receipt.TransactionId = null; + await _db.SaveChangesAsync(); + SuccessMessage = "Receipt unmapped successfully! You can now find it on the Receipts page."; + } + else + { + ErrorMessage = "Receipt not found."; + } + + return RedirectToPage(new { id = Transaction.Id }); + } + + public async Task OnPostDeleteReceiptAsync(long receiptId) + { + var success = await _receiptManager.DeleteReceiptAsync(receiptId); + + if (success) + { + SuccessMessage = "Receipt deleted permanently!"; + } + else + { + ErrorMessage = "Failed to delete receipt."; + } + + return RedirectToPage(new { id = Transaction.Id }); + } + + public async Task OnPostParseReceiptAsync(long receiptId) + { + var result = await _receiptParser.ParseReceiptAsync(receiptId); + + if (result.IsSuccess) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(new { id = Transaction.Id }); + } + + private async Task LoadDataAsync() + { + await LoadAvailableCategoriesAsync(); + await LoadAvailableMerchantsAsync(); + + var transaction = await _db.Transactions + .Include(t => t.Receipts) + .ThenInclude(r => r.LineItems) + .FirstOrDefaultAsync(t => t.Id == Transaction.Id); + + if (transaction != null) + { + Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems + { + Receipt = r, + LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List() + }).ToList() ?? new List(); + } + } + + private async Task LoadAvailableCategoriesAsync() + { + AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync(); + } + + private async Task LoadAvailableMerchantsAsync() + { + AvailableMerchants = await _referenceDataService.GetAvailableMerchantsAsync(); + } + + public class TransactionEditModel + { + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public string Memo { get; set; } = ""; + public decimal Amount { get; set; } + public string Category { get; set; } = ""; + public int? MerchantId { get; set; } + public string? MerchantName { get; set; } = ""; + public string? Notes { get; set; } = ""; + public string CardLabel { get; set; } = ""; + public string AccountLabel { get; set; } = ""; + } + + public class ReceiptWithItems + { + public Receipt Receipt { get; set; } = null!; + public List LineItems { get; set; } = new(); + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/Error.cshtml b/MoneyMap/Pages/Error.cshtml index 6f92b95..09da0d2 100644 --- a/MoneyMap/Pages/Error.cshtml +++ b/MoneyMap/Pages/Error.cshtml @@ -1,26 +1,26 @@ -@page -@model ErrorModel -@{ - ViewData["Title"] = "Error"; -} - -

Error.

-

An error occurred while processing your request.

- -@if (Model.ShowRequestId) -{ -

- Request ID: @Model.RequestId -

-} - -

Development Mode

-

- Swapping to the Development environment displays detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

+@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +

Error.

+

An error occurred while processing your request.

+ +@if (Model.ShowRequestId) +{ +

+ Request ID: @Model.RequestId +

+} + +

Development Mode

+

+ Swapping to the Development environment displays detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

diff --git a/MoneyMap/Pages/Error.cshtml.cs b/MoneyMap/Pages/Error.cshtml.cs index 6c0ff08..caf06ae 100644 --- a/MoneyMap/Pages/Error.cshtml.cs +++ b/MoneyMap/Pages/Error.cshtml.cs @@ -1,28 +1,28 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using System.Diagnostics; - -namespace MoneyMap.Pages -{ - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - [IgnoreAntiforgeryToken] - public class ErrorModel : PageModel - { - public string? RequestId { get; set; } - - public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - private readonly ILogger _logger; - - public ErrorModel(ILogger logger) - { - _logger = logger; - } - - public void OnGet() - { - RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; - } - } - -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Diagnostics; + +namespace MoneyMap.Pages +{ + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + [IgnoreAntiforgeryToken] + public class ErrorModel : PageModel + { + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + private readonly ILogger _logger; + + public ErrorModel(ILogger logger) + { + _logger = logger; + } + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } + } + +} diff --git a/MoneyMap/Pages/Index.cshtml b/MoneyMap/Pages/Index.cshtml index fea28db..5d5ac58 100644 --- a/MoneyMap/Pages/Index.cshtml +++ b/MoneyMap/Pages/Index.cshtml @@ -1,384 +1,384 @@ -@page -@model MoneyMap.Pages.IndexModel -@{ - ViewData["Title"] = "MoneyMap"; -} - -
-
-
-
-
Transactions
-
@Model.Stats.TotalTransactions
-
Credits: @Model.Stats.Credits � Debits: @Model.Stats.Debits
-
-
-
-
-
-
-
Uncategorized
-
@Model.Stats.Uncategorized
-
- @if (Model.Stats.Uncategorized > 0) - { - View uncategorized ? - } - else - { - All categorized! - } -
-
-
-
-
-
-
-
Receipts
-
@Model.Stats.Receipts
-
Linked files
-
-
-
-
-
-
-
Cards
-
@Model.Stats.Cards
-
Tracked accounts
-
-
-
-
- - -
- @if (Model.TopCategories.Count > 1) - { -
-
-
Spending by category (last 90 days)
-
- -
-
-
- } -
-
-
Net cash flow (last 30 days)
-
- -
-
-
-
- -@if (Model.BudgetStatuses.Any()) -{ -
-
- Budget Status - Manage Budgets -
-
-
- - - - - - - - - - - - - @foreach (var status in Model.BudgetStatuses) - { - - - - - - - - - } - -
BudgetPeriodLimitSpentRemainingProgress
- @status.Budget.DisplayName - @if (status.Budget.IsTotalBudget) - { - Total - } - @if (status.IsOverBudget) - { - Over - } - - @status.Budget.Period -
@status.PeriodDisplay -
@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") - @status.Remaining.ToString("C") - -
- @{ - var percent = Math.Min(status.PercentUsed, 100); - var progressClass = status.StatusClass; - var textClass = progressClass == "warning" ? "text-dark" : ""; - } -
- @status.PercentUsed.ToString("F0")% -
-
- @if (status.IsOverBudget) - { - Over by @(Math.Abs(status.Remaining).ToString("C")) - } -
-
-
-
-} - -@if (Model.TopCategories.Count > 1) -{ -
-
- Top expense categories (last 90 days) - � excludes transfers -
-
- - - - - - - - - - - - @foreach (var c in Model.TopCategories) - { - - - - - - - - } - -
CategoryTotal SpendAvg Per Txn% of TotalTxns
- - @(string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category) - - @c.TotalSpend.ToString("C")@c.AveragePerTransaction.ToString("C") - @c.PercentageOfTotal.ToString("F1")% - @c.Count
-
-
-} - -
-
Recent transactions
-
- - - - - - - - - - - - - - @foreach (var t in Model.Recent) - { - - - - - - - - - - } - -
DateNameMemoAmountCategoryCardAction
@t.Date.ToString("yyyy-MM-dd") -
- @t.Name - @if (t.ReceiptCount > 0) - { - - @t.ReceiptCount - - } -
-
@t.Memo@t.Amount.ToString("C") - @if (string.IsNullOrWhiteSpace(t.Category)) - { - (uncategorized) - } - else - { - @t.Category - } - @t.CardLabel - Open -
-
-
- - - - - - +@page +@model MoneyMap.Pages.IndexModel +@{ + ViewData["Title"] = "MoneyMap"; +} + +
+
+
+
+
Transactions
+
@Model.Stats.TotalTransactions
+
Credits: @Model.Stats.Credits � Debits: @Model.Stats.Debits
+
+
+
+
+
+
+
Uncategorized
+
@Model.Stats.Uncategorized
+
+ @if (Model.Stats.Uncategorized > 0) + { + View uncategorized ? + } + else + { + All categorized! + } +
+
+
+
+
+
+
+
Receipts
+
@Model.Stats.Receipts
+
Linked files
+
+
+
+
+
+
+
Cards
+
@Model.Stats.Cards
+
Tracked accounts
+
+
+
+
+ + +
+ @if (Model.TopCategories.Count > 1) + { +
+
+
Spending by category (last 90 days)
+
+ +
+
+
+ } +
+
+
Net cash flow (last 30 days)
+
+ +
+
+
+
+ +@if (Model.BudgetStatuses.Any()) +{ +
+
+ Budget Status + Manage Budgets +
+
+
+ + + + + + + + + + + + + @foreach (var status in Model.BudgetStatuses) + { + + + + + + + + + } + +
BudgetPeriodLimitSpentRemainingProgress
+ @status.Budget.DisplayName + @if (status.Budget.IsTotalBudget) + { + Total + } + @if (status.IsOverBudget) + { + Over + } + + @status.Budget.Period +
@status.PeriodDisplay +
@status.Budget.Amount.ToString("C")@status.Spent.ToString("C") + @status.Remaining.ToString("C") + +
+ @{ + var percent = Math.Min(status.PercentUsed, 100); + var progressClass = status.StatusClass; + var textClass = progressClass == "warning" ? "text-dark" : ""; + } +
+ @status.PercentUsed.ToString("F0")% +
+
+ @if (status.IsOverBudget) + { + Over by @(Math.Abs(status.Remaining).ToString("C")) + } +
+
+
+
+} + +@if (Model.TopCategories.Count > 1) +{ +
+
+ Top expense categories (last 90 days) + � excludes transfers +
+
+ + + + + + + + + + + + @foreach (var c in Model.TopCategories) + { + + + + + + + + } + +
CategoryTotal SpendAvg Per Txn% of TotalTxns
+ + @(string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category) + + @c.TotalSpend.ToString("C")@c.AveragePerTransaction.ToString("C") + @c.PercentageOfTotal.ToString("F1")% + @c.Count
+
+
+} + +
+
Recent transactions
+
+ + + + + + + + + + + + + + @foreach (var t in Model.Recent) + { + + + + + + + + + + } + +
DateNameMemoAmountCategoryCardAction
@t.Date.ToString("yyyy-MM-dd") +
+ @t.Name + @if (t.ReceiptCount > 0) + { + + @t.ReceiptCount + + } +
+
@t.Memo@t.Amount.ToString("C") + @if (string.IsNullOrWhiteSpace(t.Category)) + { + (uncategorized) + } + else + { + @t.Category + } + @t.CardLabel + Open +
+
+
+ + + + + + diff --git a/MoneyMap/Pages/Index.cshtml.cs b/MoneyMap/Pages/Index.cshtml.cs index fb33793..ce14be2 100644 --- a/MoneyMap/Pages/Index.cshtml.cs +++ b/MoneyMap/Pages/Index.cshtml.cs @@ -1,39 +1,39 @@ -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Models.Dashboard; -using MoneyMap.Services; - -namespace MoneyMap.Pages -{ - public class IndexModel : PageModel - { - private readonly IDashboardService _dashboardService; - private readonly IBudgetService _budgetService; - - public IndexModel(IDashboardService dashboardService, IBudgetService budgetService) - { - _dashboardService = dashboardService; - _budgetService = budgetService; - } - - public DashboardStats Stats { get; set; } = new(); - public List TopCategories { get; set; } = new(); - public List Recent { get; set; } = new(); - public List TrendLabels { get; set; } = new(); - public List TrendRunningBalance { get; set; } = new(); - public List BudgetStatuses { get; set; } = new(); - - public async Task OnGet() - { - var dashboard = await _dashboardService.GetDashboardDataAsync(); - - Stats = dashboard.Stats; - TopCategories = dashboard.TopCategories; - Recent = dashboard.RecentTransactions; - TrendLabels = dashboard.Trends.Labels; - TrendRunningBalance = dashboard.Trends.RunningBalance; - - // Get active budget statuses - BudgetStatuses = await _budgetService.GetAllBudgetStatusesAsync(); - } - } -} +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Models.Dashboard; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class IndexModel : PageModel + { + private readonly IDashboardService _dashboardService; + private readonly IBudgetService _budgetService; + + public IndexModel(IDashboardService dashboardService, IBudgetService budgetService) + { + _dashboardService = dashboardService; + _budgetService = budgetService; + } + + public DashboardStats Stats { get; set; } = new(); + public List TopCategories { get; set; } = new(); + public List Recent { get; set; } = new(); + public List TrendLabels { get; set; } = new(); + public List TrendRunningBalance { get; set; } = new(); + public List BudgetStatuses { get; set; } = new(); + + public async Task OnGet() + { + var dashboard = await _dashboardService.GetDashboardDataAsync(); + + Stats = dashboard.Stats; + TopCategories = dashboard.TopCategories; + Recent = dashboard.RecentTransactions; + TrendLabels = dashboard.Trends.Labels; + TrendRunningBalance = dashboard.Trends.RunningBalance; + + // Get active budget statuses + BudgetStatuses = await _budgetService.GetAllBudgetStatusesAsync(); + } + } +} diff --git a/MoneyMap/Pages/Merchants.cshtml b/MoneyMap/Pages/Merchants.cshtml index 513bb0c..c455847 100644 --- a/MoneyMap/Pages/Merchants.cshtml +++ b/MoneyMap/Pages/Merchants.cshtml @@ -1,156 +1,156 @@ -@page -@model MoneyMap.Pages.MerchantsModel -@{ - ViewData["Title"] = "Merchants"; -} - -
-

Merchants

- Back to Dashboard -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
-
-
-
-
Total Merchants
-
@Model.TotalMerchants
-
-
-
-
- - -
- -
- -@if (Model.Merchants.Any()) -{ -
-
- All Merchants - Click to edit, or delete merchants -
-
-
- - - - - - - - - - - @foreach (var merchant in Model.Merchants) - { - - - - - - - } - -
Merchant NameTransactionsMappingsActions
- @merchant.Name - - @merchant.TransactionCount - - @merchant.MappingCount - - -
- -
-
-
-
-
-} -else -{ -
-
No merchants found
-

Click "Add New Merchant" to create your first merchant.

-
-} - - - - - - - -@section Scripts { - -} +@page +@model MoneyMap.Pages.MerchantsModel +@{ + ViewData["Title"] = "Merchants"; +} + +
+

Merchants

+ Back to Dashboard +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+
Total Merchants
+
@Model.TotalMerchants
+
+
+
+
+ + +
+ +
+ +@if (Model.Merchants.Any()) +{ +
+
+ All Merchants + Click to edit, or delete merchants +
+
+
+ + + + + + + + + + + @foreach (var merchant in Model.Merchants) + { + + + + + + + } + +
Merchant NameTransactionsMappingsActions
+ @merchant.Name + + @merchant.TransactionCount + + @merchant.MappingCount + + +
+ +
+
+
+
+
+} +else +{ +
+
No merchants found
+

Click "Add New Merchant" to create your first merchant.

+
+} + + + + + + + +@section Scripts { + +} diff --git a/MoneyMap/Pages/Merchants.cshtml.cs b/MoneyMap/Pages/Merchants.cshtml.cs index 3ef101f..720d9ad 100644 --- a/MoneyMap/Pages/Merchants.cshtml.cs +++ b/MoneyMap/Pages/Merchants.cshtml.cs @@ -1,136 +1,136 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Services; -using System.ComponentModel.DataAnnotations; - -namespace MoneyMap.Pages -{ - public class MerchantsModel : PageModel - { - private readonly IMerchantService _merchantService; - - public MerchantsModel(IMerchantService merchantService) - { - _merchantService = merchantService; - } - - public List Merchants { get; set; } = new(); - public int TotalMerchants { get; set; } - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync() - { - await LoadDataAsync(); - } - - public async Task OnPostAddMerchantAsync(AddMerchantModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - // Check if merchant already exists - var existing = await _merchantService.FindByNameAsync(model.Name); - if (existing != null) - { - ErrorMessage = $"Merchant '{model.Name}' already exists."; - await LoadDataAsync(); - return Page(); - } - - var merchant = await _merchantService.GetOrCreateAsync(model.Name); - - SuccessMessage = $"Added merchant '{merchant.Name}'."; - return RedirectToPage(); - } - - public async Task OnPostUpdateMerchantAsync(UpdateMerchantModel model) - { - if (!ModelState.IsValid) - { - ErrorMessage = "Please fill in all required fields."; - await LoadDataAsync(); - return Page(); - } - - var result = await _merchantService.UpdateMerchantAsync(model.Id, model.Name); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - await LoadDataAsync(); - return Page(); - } - - return RedirectToPage(); - } - - public async Task OnPostDeleteMerchantAsync(int id) - { - var result = await _merchantService.DeleteMerchantAsync(id); - - if (result.Success) - { - SuccessMessage = result.Message; - } - else - { - ErrorMessage = result.Message; - } - - return RedirectToPage(); - } - - private async Task LoadDataAsync() - { - var merchantsWithStats = await _merchantService.GetAllMerchantsWithStatsAsync(); - - Merchants = merchantsWithStats.Select(m => new MerchantRow - { - Id = m.Id, - Name = m.Name, - TransactionCount = m.TransactionCount, - MappingCount = m.MappingCount - }).ToList(); - - TotalMerchants = Merchants.Count; - } - - public class MerchantRow - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public int TransactionCount { get; set; } - public int MappingCount { get; set; } - } - - public class AddMerchantModel - { - [Required(ErrorMessage = "Merchant name is required")] - [StringLength(100)] - public string Name { get; set; } = ""; - } - - public class UpdateMerchantModel - { - [Required] - public int Id { get; set; } - - [Required(ErrorMessage = "Merchant name is required")] - [StringLength(100)] - public string Name { get; set; } = ""; - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Services; +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Pages +{ + public class MerchantsModel : PageModel + { + private readonly IMerchantService _merchantService; + + public MerchantsModel(IMerchantService merchantService) + { + _merchantService = merchantService; + } + + public List Merchants { get; set; } = new(); + public int TotalMerchants { get; set; } + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + await LoadDataAsync(); + } + + public async Task OnPostAddMerchantAsync(AddMerchantModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + // Check if merchant already exists + var existing = await _merchantService.FindByNameAsync(model.Name); + if (existing != null) + { + ErrorMessage = $"Merchant '{model.Name}' already exists."; + await LoadDataAsync(); + return Page(); + } + + var merchant = await _merchantService.GetOrCreateAsync(model.Name); + + SuccessMessage = $"Added merchant '{merchant.Name}'."; + return RedirectToPage(); + } + + public async Task OnPostUpdateMerchantAsync(UpdateMerchantModel model) + { + if (!ModelState.IsValid) + { + ErrorMessage = "Please fill in all required fields."; + await LoadDataAsync(); + return Page(); + } + + var result = await _merchantService.UpdateMerchantAsync(model.Id, model.Name); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + await LoadDataAsync(); + return Page(); + } + + return RedirectToPage(); + } + + public async Task OnPostDeleteMerchantAsync(int id) + { + var result = await _merchantService.DeleteMerchantAsync(id); + + if (result.Success) + { + SuccessMessage = result.Message; + } + else + { + ErrorMessage = result.Message; + } + + return RedirectToPage(); + } + + private async Task LoadDataAsync() + { + var merchantsWithStats = await _merchantService.GetAllMerchantsWithStatsAsync(); + + Merchants = merchantsWithStats.Select(m => new MerchantRow + { + Id = m.Id, + Name = m.Name, + TransactionCount = m.TransactionCount, + MappingCount = m.MappingCount + }).ToList(); + + TotalMerchants = Merchants.Count; + } + + public class MerchantRow + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public int TransactionCount { get; set; } + public int MappingCount { get; set; } + } + + public class AddMerchantModel + { + [Required(ErrorMessage = "Merchant name is required")] + [StringLength(100)] + public string Name { get; set; } = ""; + } + + public class UpdateMerchantModel + { + [Required] + public int Id { get; set; } + + [Required(ErrorMessage = "Merchant name is required")] + [StringLength(100)] + public string Name { get; set; } = ""; + } + } +} diff --git a/MoneyMap/Pages/Recategorize.cshtml b/MoneyMap/Pages/Recategorize.cshtml index a2af874..61ff1cf 100644 --- a/MoneyMap/Pages/Recategorize.cshtml +++ b/MoneyMap/Pages/Recategorize.cshtml @@ -1,155 +1,155 @@ -@page -@model MoneyMap.Pages.RecategorizeModel -@{ - ViewData["Title"] = "Recategorize Transactions"; -} - -
-

Recategorize Transactions

- -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
-
-
-
-
Total Transactions
-
@Model.Stats.TotalTransactions
-
-
-
-
-
-
-
Categorized
-
@Model.Stats.Categorized
-
-
-
-
-
-
-
Uncategorized
-
@Model.Stats.Uncategorized
-
-
-
-
- -
-
- Rule-Based Categorization -
-
-

Use the category mappings to automatically categorize transactions based on pattern matching.

- -
-
-
-
Categorize Uncategorized Only
-

- Only process transactions with empty categories. Safe option that won't overwrite existing categories. -

-
- -
-
-
- -
-
-
Recategorize All Transactions
-

- Warning: This will overwrite ALL existing categories based on current mapping rules. Use if you've updated your category mappings. -

-
- -
-
-
-
-
-
- -
-
- AI-Powered Categorization - @Model.AIProvider -
-
-

Use AI to analyze transaction names and suggest categories. High-confidence matches will automatically create new mapping rules for future use.

- -
-
-
-
AI Categorize Uncategorized
-

- Process up to 50 uncategorized transactions. Review AI suggestions before applying. -

- @if (Model.Stats.Uncategorized > 0) - { - - AI Categorize with Preview - - } - else - { - - } -
-
- -
-
-
How AI Categorization Works
-
    -
  • AI analyzes transaction name, memo, and amount
  • -
  • Suggests category and canonical merchant name
  • -
  • Creates pattern rules for 70%+ confidence matches
  • -
  • Future transactions auto-match the new rules
  • -
-
-
-
-
-
- -
-
- How It Works -
-
-
    -
  • Transactions are matched against your category mapping patterns
  • -
  • If a merchant name matches a pattern, the transaction gets that category
  • -
  • Special rule: Gas stations with purchases under $20 → "Convenience Store"
  • -
  • Transactions with no matching pattern remain uncategorized for manual review
  • -
  • The process updates the database immediately - make sure you have backups if needed
  • -
-
+@page +@model MoneyMap.Pages.RecategorizeModel +@{ + ViewData["Title"] = "Recategorize Transactions"; +} + +
+

Recategorize Transactions

+ +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+
Total Transactions
+
@Model.Stats.TotalTransactions
+
+
+
+
+
+
+
Categorized
+
@Model.Stats.Categorized
+
+
+
+
+
+
+
Uncategorized
+
@Model.Stats.Uncategorized
+
+
+
+
+ +
+
+ Rule-Based Categorization +
+
+

Use the category mappings to automatically categorize transactions based on pattern matching.

+ +
+
+
+
Categorize Uncategorized Only
+

+ Only process transactions with empty categories. Safe option that won't overwrite existing categories. +

+
+ +
+
+
+ +
+
+
Recategorize All Transactions
+

+ Warning: This will overwrite ALL existing categories based on current mapping rules. Use if you've updated your category mappings. +

+
+ +
+
+
+
+
+
+ +
+
+ AI-Powered Categorization + @Model.AIProvider +
+
+

Use AI to analyze transaction names and suggest categories. High-confidence matches will automatically create new mapping rules for future use.

+ +
+
+
+
AI Categorize Uncategorized
+

+ Process up to 50 uncategorized transactions. Review AI suggestions before applying. +

+ @if (Model.Stats.Uncategorized > 0) + { + + AI Categorize with Preview + + } + else + { + + } +
+
+ +
+
+
How AI Categorization Works
+
    +
  • AI analyzes transaction name, memo, and amount
  • +
  • Suggests category and canonical merchant name
  • +
  • Creates pattern rules for 70%+ confidence matches
  • +
  • Future transactions auto-match the new rules
  • +
+
+
+
+
+
+ +
+
+ How It Works +
+
+
    +
  • Transactions are matched against your category mapping patterns
  • +
  • If a merchant name matches a pattern, the transaction gets that category
  • +
  • Special rule: Gas stations with purchases under $20 → "Convenience Store"
  • +
  • Transactions with no matching pattern remain uncategorized for manual review
  • +
  • The process updates the database immediately - make sure you have backups if needed
  • +
+
\ No newline at end of file diff --git a/MoneyMap/Pages/Recategorize.cshtml.cs b/MoneyMap/Pages/Recategorize.cshtml.cs index 420a1c6..3a82b2f 100644 --- a/MoneyMap/Pages/Recategorize.cshtml.cs +++ b/MoneyMap/Pages/Recategorize.cshtml.cs @@ -1,148 +1,148 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Services; - -namespace MoneyMap.Pages -{ - public class RecategorizeModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly ITransactionCategorizer _categorizer; - private readonly ITransactionStatisticsService _statsService; - private readonly IConfiguration _config; - - public RecategorizeModel( - MoneyMapContext db, - ITransactionCategorizer categorizer, - ITransactionStatisticsService statsService, - IConfiguration config) - { - _db = db; - _categorizer = categorizer; - _statsService = statsService; - _config = config; - } - - public RecategorizeStats Stats { get; set; } = new(); - 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(); - } - - public async Task OnPostRecategorizeAllAsync() - { - var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: true); - - SuccessMessage = $"Processed {result.Total} transactions: {result.Updated} updated, {result.AlreadyCorrect} already correct, {result.NoMatch} no match found."; - - return RedirectToPage(); - } - - public async Task OnPostRecategorizeUncategorizedAsync() - { - var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: false); - - SuccessMessage = $"Processed {result.Total} uncategorized transactions: {result.Updated} categorized, {result.NoMatch} still need manual categorization."; - - return RedirectToPage(); - } - - private async Task LoadStatsAsync() - { - var categorizationStats = await _statsService.GetCategorizationStatsAsync(); - - Stats = new RecategorizeStats - { - TotalTransactions = categorizationStats.TotalTransactions, - Categorized = categorizationStats.Categorized, - Uncategorized = categorizationStats.Uncategorized - }; - } - - private async Task RecategorizeTransactionsAsync(bool includeAlreadyCategorized) - { - var query = _db.Transactions.AsQueryable(); - - if (!includeAlreadyCategorized) - { - query = query.Where(t => string.IsNullOrWhiteSpace(t.Category)); - } - - var transactions = await query.ToListAsync(); - - int total = transactions.Count; - int updated = 0; - int alreadyCorrect = 0; - int noMatch = 0; - - foreach (var txn in transactions) - { - var result = await _categorizer.CategorizeAsync(txn.Name, txn.Amount); - - if (string.IsNullOrWhiteSpace(result.Category)) - { - noMatch++; - continue; - } - - if (txn.Category == result.Category && txn.MerchantId == result.MerchantId) - { - alreadyCorrect++; - continue; - } - - txn.Category = result.Category; - txn.MerchantId = result.MerchantId; - updated++; - } - - if (updated > 0) - { - await _db.SaveChangesAsync(); - } - - return new RecategorizeResult - { - Total = total, - Updated = updated, - AlreadyCorrect = alreadyCorrect, - NoMatch = noMatch - }; - } - - public class RecategorizeStats - { - public int TotalTransactions { get; set; } - public int Categorized { get; set; } - public int Uncategorized { get; set; } - } - - private class RecategorizeResult - { - public int Total { get; set; } - public int Updated { get; set; } - public int AlreadyCorrect { get; set; } - public int NoMatch { get; set; } - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Services; + +namespace MoneyMap.Pages +{ + public class RecategorizeModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly ITransactionCategorizer _categorizer; + private readonly ITransactionStatisticsService _statsService; + private readonly IConfiguration _config; + + public RecategorizeModel( + MoneyMapContext db, + ITransactionCategorizer categorizer, + ITransactionStatisticsService statsService, + IConfiguration config) + { + _db = db; + _categorizer = categorizer; + _statsService = statsService; + _config = config; + } + + public RecategorizeStats Stats { get; set; } = new(); + 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(); + } + + public async Task OnPostRecategorizeAllAsync() + { + var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: true); + + SuccessMessage = $"Processed {result.Total} transactions: {result.Updated} updated, {result.AlreadyCorrect} already correct, {result.NoMatch} no match found."; + + return RedirectToPage(); + } + + public async Task OnPostRecategorizeUncategorizedAsync() + { + var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: false); + + SuccessMessage = $"Processed {result.Total} uncategorized transactions: {result.Updated} categorized, {result.NoMatch} still need manual categorization."; + + return RedirectToPage(); + } + + private async Task LoadStatsAsync() + { + var categorizationStats = await _statsService.GetCategorizationStatsAsync(); + + Stats = new RecategorizeStats + { + TotalTransactions = categorizationStats.TotalTransactions, + Categorized = categorizationStats.Categorized, + Uncategorized = categorizationStats.Uncategorized + }; + } + + private async Task RecategorizeTransactionsAsync(bool includeAlreadyCategorized) + { + var query = _db.Transactions.AsQueryable(); + + if (!includeAlreadyCategorized) + { + query = query.Where(t => string.IsNullOrWhiteSpace(t.Category)); + } + + var transactions = await query.ToListAsync(); + + int total = transactions.Count; + int updated = 0; + int alreadyCorrect = 0; + int noMatch = 0; + + foreach (var txn in transactions) + { + var result = await _categorizer.CategorizeAsync(txn.Name, txn.Amount); + + if (string.IsNullOrWhiteSpace(result.Category)) + { + noMatch++; + continue; + } + + if (txn.Category == result.Category && txn.MerchantId == result.MerchantId) + { + alreadyCorrect++; + continue; + } + + txn.Category = result.Category; + txn.MerchantId = result.MerchantId; + updated++; + } + + if (updated > 0) + { + await _db.SaveChangesAsync(); + } + + return new RecategorizeResult + { + Total = total, + Updated = updated, + AlreadyCorrect = alreadyCorrect, + NoMatch = noMatch + }; + } + + public class RecategorizeStats + { + public int TotalTransactions { get; set; } + public int Categorized { get; set; } + public int Uncategorized { get; set; } + } + + private class RecategorizeResult + { + public int Total { get; set; } + public int Updated { get; set; } + public int AlreadyCorrect { get; set; } + public int NoMatch { get; set; } + } + } +} diff --git a/MoneyMap/Pages/ReceiptQueue.cshtml b/MoneyMap/Pages/ReceiptQueue.cshtml index 9e3382b..fc770b1 100644 --- a/MoneyMap/Pages/ReceiptQueue.cshtml +++ b/MoneyMap/Pages/ReceiptQueue.cshtml @@ -1,398 +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) - }; -} - -
-

Receipt Queue

- -
- -@if (!string.IsNullOrWhiteSpace(Model.Message)) -{ - -} - - -
-
- Upload Receipts -
-
-
-
- - -
- Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once. -
-
- - -
-
-
- - -
-
- - Currently Processing -
-
- @if (Model.CurrentlyProcessing != null) - { -
- - @Model.CurrentlyProcessing.FileName - - - (uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")) - -
- } -
-
- - -
-
- -
-
-
- -
- @if (Model.QueuedItems.Any()) - { -
- - - - - - - - - - - @foreach (var item in Model.QueuedItems) - { - - - - - - - } - -
#File NameUploadedAction
@item.QueuePosition - @item.FileName - @item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") - View -
-
- } - else - { -
No items in queue.
- } -
- - -
- @if (Model.CompletedItems.Any()) - { -
- - - - - - - - - - - - - @foreach (var item in Model.CompletedItems) - { - - - - - - - - - } - -
File NameMerchantTotalConfidenceItemsAction
- @item.FileName - @(item.Merchant ?? "-")@(item.Total?.ToString("C") ?? "-") - @if (item.Confidence.HasValue) - { - var pct = item.Confidence.Value * 100; - var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger"; - @pct.ToString("F0")% - } - else - { - - - } - @item.LineItemCount - View -
-
- } - else - { -
No completed items.
- } -
- - -
- @if (Model.FailedItems.Any()) - { -
- - - - - - - - - - - @foreach (var item in Model.FailedItems) - { - - - - - - - } - -
File NameErrorUploadedActions
- @item.FileName - @(item.ErrorMessage ?? "Unknown error")@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") -
- -
- View -
-
- } - else - { -
No failed items.
- } -
-
-
-
- -@section Scripts { - -} +@page +@model MoneyMap.Pages.ReceiptQueueModel +@{ + ViewData["Title"] = "Receipt Queue"; + ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> + { + ("Receipts", Url.Page("/Receipts")), + ("Parse Queue", null) + }; +} + +
+

Receipt Queue

+ +
+ +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +
+
+ Upload Receipts +
+
+
+
+ + +
+ Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once. +
+
+ + +
+
+
+ + +
+
+ + Currently Processing +
+
+ @if (Model.CurrentlyProcessing != null) + { +
+ + @Model.CurrentlyProcessing.FileName + + + (uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")) + +
+ } +
+
+ + +
+
+ +
+
+
+ +
+ @if (Model.QueuedItems.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.QueuedItems) + { + + + + + + + } + +
#File NameUploadedAction
@item.QueuePosition + @item.FileName + @item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + View +
+
+ } + else + { +
No items in queue.
+ } +
+ + +
+ @if (Model.CompletedItems.Any()) + { +
+ + + + + + + + + + + + + @foreach (var item in Model.CompletedItems) + { + + + + + + + + + } + +
File NameMerchantTotalConfidenceItemsAction
+ @item.FileName + @(item.Merchant ?? "-")@(item.Total?.ToString("C") ?? "-") + @if (item.Confidence.HasValue) + { + var pct = item.Confidence.Value * 100; + var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger"; + @pct.ToString("F0")% + } + else + { + - + } + @item.LineItemCount + View +
+
+ } + else + { +
No completed items.
+ } +
+ + +
+ @if (Model.FailedItems.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.FailedItems) + { + + + + + + + } + +
File NameErrorUploadedActions
+ @item.FileName + @(item.ErrorMessage ?? "Unknown error")@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") +
+ +
+ View +
+
+ } + else + { +
No failed items.
+ } +
+
+
+
+ +@section Scripts { + +} diff --git a/MoneyMap/Pages/ReceiptQueue.cshtml.cs b/MoneyMap/Pages/ReceiptQueue.cshtml.cs index af95e0a..b5c2212 100644 --- a/MoneyMap/Pages/ReceiptQueue.cshtml.cs +++ b/MoneyMap/Pages/ReceiptQueue.cshtml.cs @@ -1,220 +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 QueuedItems { get; set; } = new(); - public List CompletedItems { get; set; } = new(); - public List 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 OnPostUploadAsync(List 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(); - 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 OnGetQueueStatusAsync() - { - await LoadQueueDashboardAsync(); - - var data = new - { - currentlyProcessing = CurrentlyProcessing, - queued = QueuedItems, - completed = CompletedItems, - failed = FailedItems - }; - - return new JsonResult(data); - } - - public async Task 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; } - } - } -} +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 QueuedItems { get; set; } = new(); + public List CompletedItems { get; set; } = new(); + public List 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 OnPostUploadAsync(List 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(); + 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 OnGetQueueStatusAsync() + { + await LoadQueueDashboardAsync(); + + var data = new + { + currentlyProcessing = CurrentlyProcessing, + queued = QueuedItems, + completed = CompletedItems, + failed = FailedItems + }; + + return new JsonResult(data); + } + + public async Task 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; } + } + } +} diff --git a/MoneyMap/Pages/Receipts.cshtml b/MoneyMap/Pages/Receipts.cshtml index 7bd81a9..3cfa7d7 100644 --- a/MoneyMap/Pages/Receipts.cshtml +++ b/MoneyMap/Pages/Receipts.cshtml @@ -1,558 +1,558 @@ -@page -@model MoneyMap.Pages.ReceiptsModel -@{ - ViewData["Title"] = "Receipts"; -} - -
-

Receipts

- -
- - -@if (!string.IsNullOrWhiteSpace(Model.Message)) -{ - -} - - -@if (Model.ShowDuplicateModal && Model.DuplicateWarnings.Any()) -{ - - -} - - - - - -
-
-
- All Receipts - @if (Model.TotalCount > 0) - { - - Showing @((Model.PageNumber - 1) * Model.PageSize + 1)-@(Math.Min(Model.PageNumber * Model.PageSize, Model.TotalCount)) of @Model.TotalCount - } - else - { - - 0 total - } -
-
- @if (Model.FailedParseCount > 0) - { -
- -
- } - @if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue))) - { -
- -
- } -
-
-
- @if (Model.Receipts.Any()) - { -
- - - - - - - - - - - - @foreach (var r in Model.Receipts) - { - - - - - - - - } - -
UploadedFile NameReceipt InfoMapped TransactionActions
@r.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") -
- @if (r.ContentType.StartsWith("image/")) - { - 🖼️ - } - else if (r.ContentType == "application/pdf") - { - 📄 - } - @r.FileName - (@(r.FileSizeBytes / 1024) KB) -
-
- @if (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.DueDate.HasValue || r.Total.HasValue) - { -
- @if (!string.IsNullOrWhiteSpace(r.Merchant)) - { -
@r.Merchant
- } - @if (r.ReceiptDate.HasValue) - { -
@r.ReceiptDate.Value.ToString("yyyy-MM-dd")
- } - @if (r.DueDate.HasValue) - { -
Due: @r.DueDate.Value.ToString("yyyy-MM-dd")
- } - @if (r.Total.HasValue) - { -
@r.Total.Value.ToString("C")
- } -
- } - else - { - Not parsed - } -
- @if (r.TransactionId.HasValue) - { -
Mapped
-
-
@r.TransactionName
-
- @r.TransactionDate?.ToString("yyyy-MM-dd") - @r.TransactionAmount?.ToString("C") -
- -
- } - else - { - Unmapped - } -
-
- - View - - @if (!r.TransactionId.HasValue) - { - - } - else - { -
- -
- } -
- -
-
-
-
- } - else - { -
- No receipts uploaded yet. Click "Upload Receipt" to add your first receipt. -
- } -
- @if (Model.TotalPages > 1) - { - - } -
- - -@foreach (var r in Model.Receipts.Where(r => !r.TransactionId.HasValue)) -{ - -} - - - - - - +@page +@model MoneyMap.Pages.ReceiptsModel +@{ + ViewData["Title"] = "Receipts"; +} + +
+

Receipts

+ +
+ + +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +@if (Model.ShowDuplicateModal && Model.DuplicateWarnings.Any()) +{ + + +} + + + + + +
+
+
+ All Receipts + @if (Model.TotalCount > 0) + { + - Showing @((Model.PageNumber - 1) * Model.PageSize + 1)-@(Math.Min(Model.PageNumber * Model.PageSize, Model.TotalCount)) of @Model.TotalCount + } + else + { + - 0 total + } +
+
+ @if (Model.FailedParseCount > 0) + { +
+ +
+ } + @if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue))) + { +
+ +
+ } +
+
+
+ @if (Model.Receipts.Any()) + { +
+ + + + + + + + + + + + @foreach (var r in Model.Receipts) + { + + + + + + + + } + +
UploadedFile NameReceipt InfoMapped TransactionActions
@r.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm") +
+ @if (r.ContentType.StartsWith("image/")) + { + 🖼️ + } + else if (r.ContentType == "application/pdf") + { + 📄 + } + @r.FileName + (@(r.FileSizeBytes / 1024) KB) +
+
+ @if (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.DueDate.HasValue || r.Total.HasValue) + { +
+ @if (!string.IsNullOrWhiteSpace(r.Merchant)) + { +
@r.Merchant
+ } + @if (r.ReceiptDate.HasValue) + { +
@r.ReceiptDate.Value.ToString("yyyy-MM-dd")
+ } + @if (r.DueDate.HasValue) + { +
Due: @r.DueDate.Value.ToString("yyyy-MM-dd")
+ } + @if (r.Total.HasValue) + { +
@r.Total.Value.ToString("C")
+ } +
+ } + else + { + Not parsed + } +
+ @if (r.TransactionId.HasValue) + { +
Mapped
+
+
@r.TransactionName
+
+ @r.TransactionDate?.ToString("yyyy-MM-dd") - @r.TransactionAmount?.ToString("C") +
+ +
+ } + else + { + Unmapped + } +
+
+ + View + + @if (!r.TransactionId.HasValue) + { + + } + else + { +
+ +
+ } +
+ +
+
+
+
+ } + else + { +
+ No receipts uploaded yet. Click "Upload Receipt" to add your first receipt. +
+ } +
+ @if (Model.TotalPages > 1) + { + + } +
+ + +@foreach (var r in Model.Receipts.Where(r => !r.TransactionId.HasValue)) +{ + +} + + + + + + diff --git a/MoneyMap/Pages/Receipts.cshtml.cs b/MoneyMap/Pages/Receipts.cshtml.cs index effb9ad..4c5f880 100644 --- a/MoneyMap/Pages/Receipts.cshtml.cs +++ b/MoneyMap/Pages/Receipts.cshtml.cs @@ -1,403 +1,403 @@ -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 ReceiptsModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - private readonly IReceiptAutoMapper _autoMapper; - private readonly 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 Receipts { get; set; } = new(); - public Dictionary> ReceiptTransactionMatches { get; set; } = new(); - - [BindProperty(SupportsGet = true)] - public int PageNumber { get; set; } = 1; - - [BindProperty(SupportsGet = true)] - public int PageSize { get; set; } = 25; - - public int TotalCount { get; set; } - public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); - - [BindProperty] - public IFormFile? UploadFile { get; set; } - - [BindProperty] - public bool ConfirmDuplicateUpload { get; set; } - - [TempData] - public string? Message { get; set; } - - [TempData] - public bool IsSuccess { get; set; } - - [TempData] - public string? PendingUploadFileName { get; set; } - - [TempData] - public string? DuplicateWarningsJson { get; set; } - - public List 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)) - { - DuplicateWarnings = System.Text.Json.JsonSerializer.Deserialize>(DuplicateWarningsJson) ?? new(); - ShowDuplicateModal = DuplicateWarnings.Any(); - } - } - - public async Task OnPostUploadToQueueAsync(List 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(); - 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 OnPostUploadAsync() - { - if (UploadFile == null) - { - Message = "Please select a file to upload."; - IsSuccess = false; - await LoadReceiptsAsync(); - return Page(); - } - - // If not confirmed and duplicates exist, show modal - if (!ConfirmDuplicateUpload) - { - var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile); - - if (result.IsSuccess) - { - if (result.DuplicateWarnings.Any()) - { - // Store warnings and redirect to show modal - PendingUploadFileName = UploadFile.FileName; - DuplicateWarningsJson = System.Text.Json.JsonSerializer.Serialize(result.DuplicateWarnings); - - // Delete the uploaded file since user needs to confirm - await _receiptManager.DeleteReceiptAsync(result.Receipt!.Id); - - return RedirectToPage(); - } - else - { - Message = "Receipt uploaded successfully!"; - IsSuccess = true; - return RedirectToPage(); - } - } - else - { - Message = result.ErrorMessage ?? "Failed to upload receipt."; - IsSuccess = false; - await LoadReceiptsAsync(); - return Page(); - } - } - else - { - // User confirmed, upload anyway - var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile); - - if (result.IsSuccess) - { - Message = "Receipt uploaded successfully!"; - IsSuccess = true; - return RedirectToPage(); - } - else - { - Message = result.ErrorMessage ?? "Failed to upload receipt."; - IsSuccess = false; - await LoadReceiptsAsync(); - return Page(); - } - } - } - - public async Task OnPostDeleteAsync(long receiptId) - { - var success = await _receiptManager.DeleteReceiptAsync(receiptId); - - if (success) - { - Message = "Receipt deleted successfully."; - IsSuccess = true; - } - else - { - Message = "Failed to delete receipt."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - public async Task OnPostMapToTransactionAsync(long receiptId, long transactionId) - { - if (transactionId <= 0) - { - Message = "Please select a transaction or enter a valid ID."; - IsSuccess = false; - return RedirectToPage(); - } - - var receipt = await _db.Receipts.FirstOrDefaultAsync(r => r.Id == receiptId); - if (receipt == null) - { - Message = $"Receipt not found (ID {receiptId})."; - IsSuccess = false; - return RedirectToPage(); - } - - var transactionExists = await _db.Transactions.AnyAsync(t => t.Id == transactionId); - if (!transactionExists) - { - Message = $"Transaction not found (ID {transactionId})."; - IsSuccess = false; - return RedirectToPage(); - } - - // Friendly duplicate check: same file already mapped to this transaction - if (!string.IsNullOrWhiteSpace(receipt.FileHashSha256)) - { - var duplicateExists = await _db.Receipts.AnyAsync(r => r.Id != receiptId && r.TransactionId == transactionId && r.FileHashSha256 == receipt.FileHashSha256); - if (duplicateExists) - { - Message = "This transaction already has a receipt with the same file (duplicate prevented)."; - IsSuccess = false; - return RedirectToPage(); - } - } - - var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId); - - if (success) - { - Message = "Receipt mapped to transaction successfully."; - IsSuccess = true; - } - else - { - Message = "Failed to map receipt to transaction."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - public async Task OnPostAutoMapUnmappedAsync() - { - var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); - - if (result.MappedCount > 0) - { - Message = $"Successfully auto-mapped {result.MappedCount} receipt(s). " + - $"{result.NoMatchCount} had no matching transaction. " + - $"{result.MultipleMatchesCount} had multiple potential matches."; - IsSuccess = true; - } - else if (result.TotalProcessed == 0) - { - Message = "No unmapped parsed receipts found to process."; - IsSuccess = false; - } - else - { - Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no matching transaction. " + - $"{result.MultipleMatchesCount} had multiple potential matches."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - public async Task 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 OnPostUnmapAsync(long receiptId) - { - var success = await _receiptManager.UnmapReceiptAsync(receiptId); - - if (success) - { - Message = "Receipt unmapped successfully."; - IsSuccess = true; - } - else - { - Message = "Failed to unmap receipt."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - private async Task LoadReceiptsAsync() - { - if (PageNumber < 1) PageNumber = 1; - if (PageSize < 1) PageSize = 25; - if (PageSize > 100) PageSize = 100; - - var query = _db.Receipts - .Include(r => r.Transaction) - .OrderByDescending(r => r.UploadedAtUtc); - - TotalCount = await query.CountAsync(); - - var receipts = await query - .Skip((PageNumber - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - - Receipts = receipts.Select(r => new ReceiptRow - { - Id = r.Id, - FileName = r.FileName, - ContentType = r.ContentType, - FileSizeBytes = r.FileSizeBytes, - UploadedAtUtc = r.UploadedAtUtc, - TransactionId = r.TransactionId, - TransactionName = r.Transaction?.Name, - TransactionDate = r.Transaction?.Date, - TransactionAmount = r.Transaction?.Amount, - Merchant = r.Merchant, - ReceiptDate = r.ReceiptDate, - DueDate = r.DueDate, - Total = r.Total, - StoragePath = r.StoragePath - }).ToList(); - - // Load matching transactions for each unmapped receipt - var transactionsWithReceipts = await _receiptMatchingService.GetTransactionIdsWithReceiptsAsync(); - - var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList(); - - foreach (var receipt in unmappedReceipts) - { - var criteria = new ReceiptMatchCriteria - { - ReceiptDate = receipt.ReceiptDate, - DueDate = receipt.DueDate, - Total = receipt.Total, - MerchantName = receipt.Merchant, - ExcludeTransactionIds = transactionsWithReceipts - }; - - var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria); - - // Convert TransactionMatch to TransactionOption - ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption - { - Id = m.Id, - Date = m.Date, - Name = m.Name, - Amount = m.Amount, - MerchantName = m.MerchantName, - PaymentMethod = m.PaymentMethod, - IsExactAmount = m.IsExactAmount, - IsCloseAmount = m.IsCloseAmount - }).ToList(); - } - } - - public class ReceiptRow - { - public long Id { get; set; } - public string FileName { get; set; } = ""; - public string ContentType { get; set; } = ""; - public long FileSizeBytes { get; set; } - public DateTime UploadedAtUtc { get; set; } - public long? TransactionId { get; set; } - public string? TransactionName { get; set; } - public DateTime? TransactionDate { get; set; } - public decimal? TransactionAmount { get; set; } - public string? Merchant { get; set; } - public DateTime? ReceiptDate { get; set; } - public DateTime? DueDate { get; set; } - public decimal? Total { get; set; } - public string StoragePath { get; set; } = ""; - } - - public class TransactionOption - { - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public decimal Amount { get; set; } - public string? MerchantName { get; set; } - public string PaymentMethod { get; set; } = ""; - public bool IsExactAmount { get; set; } - public bool IsCloseAmount { get; set; } - } - } -} - - - - +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 ReceiptsModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly IReceiptAutoMapper _autoMapper; + private readonly 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 Receipts { get; set; } = new(); + public Dictionary> ReceiptTransactionMatches { get; set; } = new(); + + [BindProperty(SupportsGet = true)] + public int PageNumber { get; set; } = 1; + + [BindProperty(SupportsGet = true)] + public int PageSize { get; set; } = 25; + + public int TotalCount { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + + [BindProperty] + public IFormFile? UploadFile { get; set; } + + [BindProperty] + public bool ConfirmDuplicateUpload { get; set; } + + [TempData] + public string? Message { get; set; } + + [TempData] + public bool IsSuccess { get; set; } + + [TempData] + public string? PendingUploadFileName { get; set; } + + [TempData] + public string? DuplicateWarningsJson { get; set; } + + public List 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)) + { + DuplicateWarnings = System.Text.Json.JsonSerializer.Deserialize>(DuplicateWarningsJson) ?? new(); + ShowDuplicateModal = DuplicateWarnings.Any(); + } + } + + public async Task OnPostUploadToQueueAsync(List 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(); + 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 OnPostUploadAsync() + { + if (UploadFile == null) + { + Message = "Please select a file to upload."; + IsSuccess = false; + await LoadReceiptsAsync(); + return Page(); + } + + // If not confirmed and duplicates exist, show modal + if (!ConfirmDuplicateUpload) + { + var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile); + + if (result.IsSuccess) + { + if (result.DuplicateWarnings.Any()) + { + // Store warnings and redirect to show modal + PendingUploadFileName = UploadFile.FileName; + DuplicateWarningsJson = System.Text.Json.JsonSerializer.Serialize(result.DuplicateWarnings); + + // Delete the uploaded file since user needs to confirm + await _receiptManager.DeleteReceiptAsync(result.Receipt!.Id); + + return RedirectToPage(); + } + else + { + Message = "Receipt uploaded successfully!"; + IsSuccess = true; + return RedirectToPage(); + } + } + else + { + Message = result.ErrorMessage ?? "Failed to upload receipt."; + IsSuccess = false; + await LoadReceiptsAsync(); + return Page(); + } + } + else + { + // User confirmed, upload anyway + var result = await _receiptManager.UploadUnmappedReceiptAsync(UploadFile); + + if (result.IsSuccess) + { + Message = "Receipt uploaded successfully!"; + IsSuccess = true; + return RedirectToPage(); + } + else + { + Message = result.ErrorMessage ?? "Failed to upload receipt."; + IsSuccess = false; + await LoadReceiptsAsync(); + return Page(); + } + } + } + + public async Task OnPostDeleteAsync(long receiptId) + { + var success = await _receiptManager.DeleteReceiptAsync(receiptId); + + if (success) + { + Message = "Receipt deleted successfully."; + IsSuccess = true; + } + else + { + Message = "Failed to delete receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostMapToTransactionAsync(long receiptId, long transactionId) + { + if (transactionId <= 0) + { + Message = "Please select a transaction or enter a valid ID."; + IsSuccess = false; + return RedirectToPage(); + } + + var receipt = await _db.Receipts.FirstOrDefaultAsync(r => r.Id == receiptId); + if (receipt == null) + { + Message = $"Receipt not found (ID {receiptId})."; + IsSuccess = false; + return RedirectToPage(); + } + + var transactionExists = await _db.Transactions.AnyAsync(t => t.Id == transactionId); + if (!transactionExists) + { + Message = $"Transaction not found (ID {transactionId})."; + IsSuccess = false; + return RedirectToPage(); + } + + // Friendly duplicate check: same file already mapped to this transaction + if (!string.IsNullOrWhiteSpace(receipt.FileHashSha256)) + { + var duplicateExists = await _db.Receipts.AnyAsync(r => r.Id != receiptId && r.TransactionId == transactionId && r.FileHashSha256 == receipt.FileHashSha256); + if (duplicateExists) + { + Message = "This transaction already has a receipt with the same file (duplicate prevented)."; + IsSuccess = false; + return RedirectToPage(); + } + } + + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId); + + if (success) + { + Message = "Receipt mapped to transaction successfully."; + IsSuccess = true; + } + else + { + Message = "Failed to map receipt to transaction."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostAutoMapUnmappedAsync() + { + var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); + + if (result.MappedCount > 0) + { + Message = $"Successfully auto-mapped {result.MappedCount} receipt(s). " + + $"{result.NoMatchCount} had no matching transaction. " + + $"{result.MultipleMatchesCount} had multiple potential matches."; + IsSuccess = true; + } + else if (result.TotalProcessed == 0) + { + Message = "No unmapped parsed receipts found to process."; + IsSuccess = false; + } + else + { + Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no matching transaction. " + + $"{result.MultipleMatchesCount} had multiple potential matches."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task 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 OnPostUnmapAsync(long receiptId) + { + var success = await _receiptManager.UnmapReceiptAsync(receiptId); + + if (success) + { + Message = "Receipt unmapped successfully."; + IsSuccess = true; + } + else + { + Message = "Failed to unmap receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + private async Task LoadReceiptsAsync() + { + if (PageNumber < 1) PageNumber = 1; + if (PageSize < 1) PageSize = 25; + if (PageSize > 100) PageSize = 100; + + var query = _db.Receipts + .Include(r => r.Transaction) + .OrderByDescending(r => r.UploadedAtUtc); + + TotalCount = await query.CountAsync(); + + var receipts = await query + .Skip((PageNumber - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + Receipts = receipts.Select(r => new ReceiptRow + { + Id = r.Id, + FileName = r.FileName, + ContentType = r.ContentType, + FileSizeBytes = r.FileSizeBytes, + UploadedAtUtc = r.UploadedAtUtc, + TransactionId = r.TransactionId, + TransactionName = r.Transaction?.Name, + TransactionDate = r.Transaction?.Date, + TransactionAmount = r.Transaction?.Amount, + Merchant = r.Merchant, + ReceiptDate = r.ReceiptDate, + DueDate = r.DueDate, + Total = r.Total, + StoragePath = r.StoragePath + }).ToList(); + + // Load matching transactions for each unmapped receipt + var transactionsWithReceipts = await _receiptMatchingService.GetTransactionIdsWithReceiptsAsync(); + + var unmappedReceipts = Receipts.Where(r => !r.TransactionId.HasValue).ToList(); + + foreach (var receipt in unmappedReceipts) + { + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receipt.ReceiptDate, + DueDate = receipt.DueDate, + Total = receipt.Total, + MerchantName = receipt.Merchant, + ExcludeTransactionIds = transactionsWithReceipts + }; + + var matches = await _receiptMatchingService.FindMatchingTransactionsAsync(criteria); + + // Convert TransactionMatch to TransactionOption + ReceiptTransactionMatches[receipt.Id] = matches.Select(m => new TransactionOption + { + Id = m.Id, + Date = m.Date, + Name = m.Name, + Amount = m.Amount, + MerchantName = m.MerchantName, + PaymentMethod = m.PaymentMethod, + IsExactAmount = m.IsExactAmount, + IsCloseAmount = m.IsCloseAmount + }).ToList(); + } + } + + public class ReceiptRow + { + public long Id { get; set; } + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public long FileSizeBytes { get; set; } + public DateTime UploadedAtUtc { get; set; } + public long? TransactionId { get; set; } + public string? TransactionName { get; set; } + public DateTime? TransactionDate { get; set; } + public decimal? TransactionAmount { get; set; } + public string? Merchant { get; set; } + public DateTime? ReceiptDate { get; set; } + public DateTime? DueDate { get; set; } + public decimal? Total { get; set; } + public string StoragePath { get; set; } = ""; + } + + public class TransactionOption + { + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public decimal Amount { get; set; } + public string? MerchantName { get; set; } + public string PaymentMethod { get; set; } = ""; + public bool IsExactAmount { get; set; } + public bool IsCloseAmount { get; set; } + } + } +} + + + + diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml b/MoneyMap/Pages/ReviewReceipts.cshtml index c2935e2..cbafd7a 100644 --- a/MoneyMap/Pages/ReviewReceipts.cshtml +++ b/MoneyMap/Pages/ReviewReceipts.cshtml @@ -1,288 +1,288 @@ -@page -@model MoneyMap.Pages.ReviewReceiptsModel -@{ - ViewData["Title"] = "Review Receipts"; - ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> - { - ("Receipts", Url.Page("/Receipts")), - ("Review Mappings", null) - }; -} - -
-

Review Receipts

-
- @if (Model.UnmappedReceipts.Any()) - { -
- -
- } - Back to Receipts -
-
- -@if (!string.IsNullOrWhiteSpace(Model.Message)) -{ - -} - - -
-
- Unmapped Receipts Needing Review - @Model.UnmappedReceipts.Count -
-
- @if (Model.UnmappedReceipts.Any()) - { - @foreach (var item in Model.UnmappedReceipts) - { - var receipt = item.Receipt; - var scoreClass = item.BestScore >= 0.85 ? "bg-success" : - item.BestScore >= 0.50 ? "bg-warning text-dark" : "bg-secondary"; -
-
- -
-
- @if (receipt.ContentType.StartsWith("image/")) - { - 🖼️ - } - else - { - 📄 - } -
-
@receipt.FileName
-
- Uploaded: @receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt") -
- @if (!string.IsNullOrWhiteSpace(receipt.Merchant)) - { -
Merchant: @receipt.Merchant
- } - @if (receipt.ReceiptDate.HasValue) - { -
Date: @receipt.ReceiptDate.Value.ToString("yyyy-MM-dd")
- } - @if (receipt.DueDate.HasValue) - { -
Due: @receipt.DueDate.Value.ToString("yyyy-MM-dd")
- } - @if (receipt.Total.HasValue) - { -
Total: @receipt.Total.Value.ToString("C")
- } - -
-
-
- - -
- @if (item.ScoredCandidates.Any()) - { -
- Best Match: @(item.BestScore.ToString("P0")) - @if (item.BestScore >= 0.85) - { - High confidence - will auto-map - } - else if (item.BestScore >= 0.50) - { - Medium confidence - LLM will review - } - else - { - Low confidence - manual review needed - } -
-
- - - - - - - - - - - - @foreach (var candidate in item.ScoredCandidates) - { - var rowClass = candidate.Score >= 0.85 ? "table-success" : - candidate.Score >= 0.50 ? "table-warning" : ""; - - - - - - - - } - -
ScoreDateAmountNameAction
- = 0.50 ? "bg-warning text-dark" : "bg-secondary")"> - @(candidate.Score.ToString("P0")) - - @candidate.Transaction.Date.ToString("yyyy-MM-dd")@candidate.Transaction.Amount.ToString("C") -
@candidate.Transaction.Name
- @if (candidate.Transaction.Merchant != null) - { - @candidate.Transaction.Merchant.Name - } -
-
- - - -
- View -
-
- } - else - { -
- No matching transactions found within the date range. -
- } -
- - -
-
-
-
- } - } - else - { -
- No unmapped receipts need review. All parsed receipts are mapped! -
- } -
-
- - -
-
- Recently Mapped (Last 7 Days) - @Model.RecentlyMappedReceipts.Count -
-
- @if (Model.RecentlyMappedReceipts.Any()) - { -
- - - - - - - - - - - @foreach (var item in Model.RecentlyMappedReceipts) - { - var receipt = item.Receipt; - var transaction = receipt.Transaction; - - // Calculate if amounts match - var amountMatch = receipt.Total.HasValue && transaction != null - ? Math.Abs((double)(receipt.Total.Value - Math.Abs(transaction.Amount)) / (double)receipt.Total.Value) <= 0.02 - : false; - var dateMatch = receipt.ReceiptDate.HasValue && transaction != null - ? Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays) <= 3 - : false; - - - - - - - - } - -
ReceiptMapped ToMatchActions
-
- @if (receipt.ContentType.StartsWith("image/")) - { - 🖼️ - } - else - { - 📄 - } -
-
@receipt.FileName
- - @(receipt.Merchant ?? "Unknown") | - @(receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "No date") | - @(receipt.Total?.ToString("C") ?? "No amount") - -
-
-
- @if (transaction != null) - { -
@transaction.Name
- - @transaction.Date.ToString("yyyy-MM-dd") | - @transaction.Amount.ToString("C") - @if (transaction.Merchant != null) - { - | @transaction.Merchant.Name - } - - } -
- @if (amountMatch && dateMatch) - { - Good - } - else if (amountMatch || dateMatch) - { - Partial - } - else - { - Check - } - - - View - -
- - -
-
-
- } - else - { -
- No receipts mapped in the last 7 days. -
- } -
-
+@page +@model MoneyMap.Pages.ReviewReceiptsModel +@{ + ViewData["Title"] = "Review Receipts"; + ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> + { + ("Receipts", Url.Page("/Receipts")), + ("Review Mappings", null) + }; +} + +
+

Review Receipts

+
+ @if (Model.UnmappedReceipts.Any()) + { +
+ +
+ } + Back to Receipts +
+
+ +@if (!string.IsNullOrWhiteSpace(Model.Message)) +{ + +} + + +
+
+ Unmapped Receipts Needing Review + @Model.UnmappedReceipts.Count +
+
+ @if (Model.UnmappedReceipts.Any()) + { + @foreach (var item in Model.UnmappedReceipts) + { + var receipt = item.Receipt; + var scoreClass = item.BestScore >= 0.85 ? "bg-success" : + item.BestScore >= 0.50 ? "bg-warning text-dark" : "bg-secondary"; +
+
+ +
+
+ @if (receipt.ContentType.StartsWith("image/")) + { + 🖼️ + } + else + { + 📄 + } +
+
@receipt.FileName
+
+ Uploaded: @receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt") +
+ @if (!string.IsNullOrWhiteSpace(receipt.Merchant)) + { +
Merchant: @receipt.Merchant
+ } + @if (receipt.ReceiptDate.HasValue) + { +
Date: @receipt.ReceiptDate.Value.ToString("yyyy-MM-dd")
+ } + @if (receipt.DueDate.HasValue) + { +
Due: @receipt.DueDate.Value.ToString("yyyy-MM-dd")
+ } + @if (receipt.Total.HasValue) + { +
Total: @receipt.Total.Value.ToString("C")
+ } + +
+
+
+ + +
+ @if (item.ScoredCandidates.Any()) + { +
+ Best Match: @(item.BestScore.ToString("P0")) + @if (item.BestScore >= 0.85) + { + High confidence - will auto-map + } + else if (item.BestScore >= 0.50) + { + Medium confidence - LLM will review + } + else + { + Low confidence - manual review needed + } +
+
+ + + + + + + + + + + + @foreach (var candidate in item.ScoredCandidates) + { + var rowClass = candidate.Score >= 0.85 ? "table-success" : + candidate.Score >= 0.50 ? "table-warning" : ""; + + + + + + + + } + +
ScoreDateAmountNameAction
+ = 0.50 ? "bg-warning text-dark" : "bg-secondary")"> + @(candidate.Score.ToString("P0")) + + @candidate.Transaction.Date.ToString("yyyy-MM-dd")@candidate.Transaction.Amount.ToString("C") +
@candidate.Transaction.Name
+ @if (candidate.Transaction.Merchant != null) + { + @candidate.Transaction.Merchant.Name + } +
+
+ + + +
+ View +
+
+ } + else + { +
+ No matching transactions found within the date range. +
+ } +
+ + +
+
+
+
+ } + } + else + { +
+ No unmapped receipts need review. All parsed receipts are mapped! +
+ } +
+
+ + +
+
+ Recently Mapped (Last 7 Days) + @Model.RecentlyMappedReceipts.Count +
+
+ @if (Model.RecentlyMappedReceipts.Any()) + { +
+ + + + + + + + + + + @foreach (var item in Model.RecentlyMappedReceipts) + { + var receipt = item.Receipt; + var transaction = receipt.Transaction; + + // Calculate if amounts match + var amountMatch = receipt.Total.HasValue && transaction != null + ? Math.Abs((double)(receipt.Total.Value - Math.Abs(transaction.Amount)) / (double)receipt.Total.Value) <= 0.02 + : false; + var dateMatch = receipt.ReceiptDate.HasValue && transaction != null + ? Math.Abs((transaction.Date - receipt.ReceiptDate.Value).TotalDays) <= 3 + : false; + + + + + + + + } + +
ReceiptMapped ToMatchActions
+
+ @if (receipt.ContentType.StartsWith("image/")) + { + 🖼️ + } + else + { + 📄 + } +
+
@receipt.FileName
+ + @(receipt.Merchant ?? "Unknown") | + @(receipt.ReceiptDate?.ToString("yyyy-MM-dd") ?? "No date") | + @(receipt.Total?.ToString("C") ?? "No amount") + +
+
+
+ @if (transaction != null) + { +
@transaction.Name
+ + @transaction.Date.ToString("yyyy-MM-dd") | + @transaction.Amount.ToString("C") + @if (transaction.Merchant != null) + { + | @transaction.Merchant.Name + } + + } +
+ @if (amountMatch && dateMatch) + { + Good + } + else if (amountMatch || dateMatch) + { + Partial + } + else + { + Check + } + + + View + +
+ + +
+
+
+ } + else + { +
+ No receipts mapped in the last 7 days. +
+ } +
+
diff --git a/MoneyMap/Pages/ReviewReceipts.cshtml.cs b/MoneyMap/Pages/ReviewReceipts.cshtml.cs index 178e1af..c94320c 100644 --- a/MoneyMap/Pages/ReviewReceipts.cshtml.cs +++ b/MoneyMap/Pages/ReviewReceipts.cshtml.cs @@ -1,195 +1,195 @@ -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 ReviewReceiptsModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - private readonly IReceiptAutoMapper _autoMapper; - - public ReviewReceiptsModel( - MoneyMapContext db, - IReceiptManager receiptManager, - IReceiptAutoMapper autoMapper) - { - _db = db; - _receiptManager = receiptManager; - _autoMapper = autoMapper; - } - - public List UnmappedReceipts { get; set; } = new(); - public List RecentlyMappedReceipts { get; set; } = new(); - - [TempData] - public string? Message { get; set; } - - [TempData] - public bool IsSuccess { get; set; } - - public async Task OnGetAsync() - { - await LoadReceiptsAsync(); - } - - public async Task OnPostAutoMapAsync(long receiptId) - { - var result = await _autoMapper.AutoMapReceiptAsync(receiptId); - - switch (result.Status) - { - case AutoMapStatus.Success: - Message = $"Receipt auto-mapped to transaction #{result.TransactionId}."; - IsSuccess = true; - break; - case AutoMapStatus.AlreadyMapped: - Message = "Receipt is already mapped."; - IsSuccess = false; - break; - case AutoMapStatus.MultipleMatches: - Message = $"Found {result.MultipleMatches.Count} potential matches. Please select manually."; - IsSuccess = false; - break; - case AutoMapStatus.NoMatch: - Message = "No matching transaction found."; - IsSuccess = false; - break; - case AutoMapStatus.NotParsed: - Message = "Receipt has not been parsed yet. Parse it first."; - IsSuccess = false; - break; - default: - Message = result.Message ?? "Auto-mapping failed."; - IsSuccess = false; - break; - } - - return RedirectToPage(); - } - - public async Task OnPostMapToTransactionAsync(long receiptId, long transactionId) - { - if (transactionId <= 0) - { - Message = "Please select a transaction."; - IsSuccess = false; - return RedirectToPage(); - } - - var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId); - - if (success) - { - Message = $"Receipt mapped to transaction #{transactionId}."; - IsSuccess = true; - } - else - { - Message = "Failed to map receipt."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - public async Task OnPostUnmapAsync(long receiptId) - { - var success = await _receiptManager.UnmapReceiptAsync(receiptId); - - if (success) - { - Message = "Receipt unmapped. You can now re-run auto-mapping."; - IsSuccess = true; - } - else - { - Message = "Failed to unmap receipt."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - public async Task OnPostAutoMapAllAsync() - { - var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); - - if (result.MappedCount > 0) - { - Message = $"Auto-mapped {result.MappedCount} receipt(s). " + - $"{result.NoMatchCount} had no match. " + - $"{result.MultipleMatchesCount} need manual review."; - IsSuccess = true; - } - else if (result.TotalProcessed == 0) - { - Message = "No unmapped receipts to process."; - IsSuccess = false; - } - else - { - Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no match. " + - $"{result.MultipleMatchesCount} need manual review."; - IsSuccess = false; - } - - return RedirectToPage(); - } - - private async Task LoadReceiptsAsync() - { - // Get unmapped receipts with their scored candidates - var unmapped = await _db.Receipts - .Where(r => r.TransactionId == null) - .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) - .OrderByDescending(r => r.UploadedAtUtc) - .Take(50) - .ToListAsync(); - - foreach (var receipt in unmapped) - { - var candidates = await _autoMapper.GetScoredCandidatesAsync(receipt.Id); - - UnmappedReceipts.Add(new ReceiptReviewItem - { - Receipt = receipt, - ScoredCandidates = candidates.Take(5).ToList(), - BestScore = candidates.FirstOrDefault()?.Score ?? 0 - }); - } - - // Get recently mapped receipts (last 7 days) for verification - var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); - var recentlyMapped = await _db.Receipts - .Include(r => r.Transaction) - .ThenInclude(t => t!.Merchant) - .Where(r => r.TransactionId != null) - .Where(r => r.UploadedAtUtc >= sevenDaysAgo) - .OrderByDescending(r => r.UploadedAtUtc) - .Take(20) - .ToListAsync(); - - foreach (var receipt in recentlyMapped) - { - RecentlyMappedReceipts.Add(new ReceiptReviewItem - { - Receipt = receipt, - ScoredCandidates = new List(), - BestScore = 0 - }); - } - } - - public class ReceiptReviewItem - { - public required Receipt Receipt { get; set; } - public List ScoredCandidates { get; set; } = new(); - public double BestScore { get; set; } - } - } -} +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 ReviewReceiptsModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly IReceiptAutoMapper _autoMapper; + + public ReviewReceiptsModel( + MoneyMapContext db, + IReceiptManager receiptManager, + IReceiptAutoMapper autoMapper) + { + _db = db; + _receiptManager = receiptManager; + _autoMapper = autoMapper; + } + + public List UnmappedReceipts { get; set; } = new(); + public List RecentlyMappedReceipts { get; set; } = new(); + + [TempData] + public string? Message { get; set; } + + [TempData] + public bool IsSuccess { get; set; } + + public async Task OnGetAsync() + { + await LoadReceiptsAsync(); + } + + public async Task OnPostAutoMapAsync(long receiptId) + { + var result = await _autoMapper.AutoMapReceiptAsync(receiptId); + + switch (result.Status) + { + case AutoMapStatus.Success: + Message = $"Receipt auto-mapped to transaction #{result.TransactionId}."; + IsSuccess = true; + break; + case AutoMapStatus.AlreadyMapped: + Message = "Receipt is already mapped."; + IsSuccess = false; + break; + case AutoMapStatus.MultipleMatches: + Message = $"Found {result.MultipleMatches.Count} potential matches. Please select manually."; + IsSuccess = false; + break; + case AutoMapStatus.NoMatch: + Message = "No matching transaction found."; + IsSuccess = false; + break; + case AutoMapStatus.NotParsed: + Message = "Receipt has not been parsed yet. Parse it first."; + IsSuccess = false; + break; + default: + Message = result.Message ?? "Auto-mapping failed."; + IsSuccess = false; + break; + } + + return RedirectToPage(); + } + + public async Task OnPostMapToTransactionAsync(long receiptId, long transactionId) + { + if (transactionId <= 0) + { + Message = "Please select a transaction."; + IsSuccess = false; + return RedirectToPage(); + } + + var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, transactionId); + + if (success) + { + Message = $"Receipt mapped to transaction #{transactionId}."; + IsSuccess = true; + } + else + { + Message = "Failed to map receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostUnmapAsync(long receiptId) + { + var success = await _receiptManager.UnmapReceiptAsync(receiptId); + + if (success) + { + Message = "Receipt unmapped. You can now re-run auto-mapping."; + IsSuccess = true; + } + else + { + Message = "Failed to unmap receipt."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + public async Task OnPostAutoMapAllAsync() + { + var result = await _autoMapper.AutoMapUnmappedReceiptsAsync(); + + if (result.MappedCount > 0) + { + Message = $"Auto-mapped {result.MappedCount} receipt(s). " + + $"{result.NoMatchCount} had no match. " + + $"{result.MultipleMatchesCount} need manual review."; + IsSuccess = true; + } + else if (result.TotalProcessed == 0) + { + Message = "No unmapped receipts to process."; + IsSuccess = false; + } + else + { + Message = $"Unable to auto-map any receipts. {result.NoMatchCount} had no match. " + + $"{result.MultipleMatchesCount} need manual review."; + IsSuccess = false; + } + + return RedirectToPage(); + } + + private async Task LoadReceiptsAsync() + { + // Get unmapped receipts with their scored candidates + var unmapped = await _db.Receipts + .Where(r => r.TransactionId == null) + .Where(r => r.Merchant != null || r.ReceiptDate != null || r.Total != null) + .OrderByDescending(r => r.UploadedAtUtc) + .Take(50) + .ToListAsync(); + + foreach (var receipt in unmapped) + { + var candidates = await _autoMapper.GetScoredCandidatesAsync(receipt.Id); + + UnmappedReceipts.Add(new ReceiptReviewItem + { + Receipt = receipt, + ScoredCandidates = candidates.Take(5).ToList(), + BestScore = candidates.FirstOrDefault()?.Score ?? 0 + }); + } + + // Get recently mapped receipts (last 7 days) for verification + var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); + var recentlyMapped = await _db.Receipts + .Include(r => r.Transaction) + .ThenInclude(t => t!.Merchant) + .Where(r => r.TransactionId != null) + .Where(r => r.UploadedAtUtc >= sevenDaysAgo) + .OrderByDescending(r => r.UploadedAtUtc) + .Take(20) + .ToListAsync(); + + foreach (var receipt in recentlyMapped) + { + RecentlyMappedReceipts.Add(new ReceiptReviewItem + { + Receipt = receipt, + ScoredCandidates = new List(), + BestScore = 0 + }); + } + } + + public class ReceiptReviewItem + { + public required Receipt Receipt { get; set; } + public List ScoredCandidates { get; set; } = new(); + public double BestScore { get; set; } + } + } +} diff --git a/MoneyMap/Pages/Settings.cshtml b/MoneyMap/Pages/Settings.cshtml index c28d942..dcc585f 100644 --- a/MoneyMap/Pages/Settings.cshtml +++ b/MoneyMap/Pages/Settings.cshtml @@ -1,93 +1,93 @@ -@page -@model MoneyMap.Pages.SettingsModel -@{ - ViewData["Title"] = "Settings"; -} - -

Settings

- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
-
-
-
- AI Model Configuration -
-
-

- Select the AI model to use for receipt parsing and other AI features. - Models with are currently loaded and ready. -

- -
-
- - -
- Loaded - Not loaded -
-
- -
- - -
-
-
-
- -
-
- Connection Settings -
-
-
-
Models Endpoint
-
@Model.ModelsEndpoint
-
-

- Configure in appsettings.json under AI:ModelsEndpoint -

-
-
-
-
+@page +@model MoneyMap.Pages.SettingsModel +@{ + ViewData["Title"] = "Settings"; +} + +

Settings

+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+ AI Model Configuration +
+
+

+ Select the AI model to use for receipt parsing and other AI features. + Models with are currently loaded and ready. +

+ +
+
+ + +
+ Loaded + Not loaded +
+
+ +
+ + +
+
+
+
+ +
+
+ Connection Settings +
+
+
+
Models Endpoint
+
@Model.ModelsEndpoint
+
+

+ Configure in appsettings.json under AI:ModelsEndpoint +

+
+
+
+
diff --git a/MoneyMap/Pages/Settings.cshtml.cs b/MoneyMap/Pages/Settings.cshtml.cs index 6d26ccc..5e07db0 100644 --- a/MoneyMap/Pages/Settings.cshtml.cs +++ b/MoneyMap/Pages/Settings.cshtml.cs @@ -1,114 +1,114 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using MoneyMap.Services; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace MoneyMap.Pages -{ - public class SettingsModel : PageModel - { - private readonly LlamaCppVisionClient _llamaClient; - private readonly IConfiguration _config; - private readonly IWebHostEnvironment _env; - private readonly ILogger _logger; - - public SettingsModel( - LlamaCppVisionClient llamaClient, - IConfiguration config, - IWebHostEnvironment env, - ILogger logger) - { - _llamaClient = llamaClient; - _config = config; - _env = env; - _logger = logger; - } - - public List AvailableModels { get; set; } = new(); - public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; - public string ModelsEndpoint => _config["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - public async Task OnGetAsync() - { - AvailableModels = await _llamaClient.GetAvailableModelsAsync(); - } - - public async Task OnPostSaveModelAsync(string model) - { - if (string.IsNullOrEmpty(model)) - { - ErrorMessage = "No model selected."; - return RedirectToPage(); - } - - await SaveSelectedModelAsync(model); - SuccessMessage = $"AI model updated to: {model}"; - return RedirectToPage(); - } - - public async Task OnPostLoadModelAsync(string model) - { - if (string.IsNullOrEmpty(model)) - { - ErrorMessage = "No model selected."; - return RedirectToPage(); - } - - // Save the model first - await SaveSelectedModelAsync(model); - - // Fire a warmup request in the background (don't await) - _ = WarmupModelAsync(model); - - SuccessMessage = $"Model {model} is being loaded. This may take a moment."; - return RedirectToPage(); - } - - private async Task WarmupModelAsync(string model) - { - try - { - _logger.LogInformation("Warming up model: {Model}", model); - await _llamaClient.SendTextPromptAsync("Hello", model); - _logger.LogInformation("Model warmup completed: {Model}", model); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); - } - } - - private async Task SaveSelectedModelAsync(string model) - { - try - { - var appSettingsPath = Path.Combine(_env.ContentRootPath, "appsettings.json"); - var json = await System.IO.File.ReadAllTextAsync(appSettingsPath); - var jsonNode = JsonNode.Parse(json); - - if (jsonNode == null) return; - - if (jsonNode["AI"] == null) - { - jsonNode["AI"] = new JsonObject(); - } - - jsonNode["AI"]!["ReceiptParsingModel"] = model; - - var options = new JsonSerializerOptions { WriteIndented = true }; - await System.IO.File.WriteAllTextAsync(appSettingsPath, jsonNode.ToJsonString(options)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save model selection: {Message}", ex.Message); - } - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Services; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MoneyMap.Pages +{ + public class SettingsModel : PageModel + { + private readonly LlamaCppVisionClient _llamaClient; + private readonly IConfiguration _config; + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + + public SettingsModel( + LlamaCppVisionClient llamaClient, + IConfiguration config, + IWebHostEnvironment env, + ILogger logger) + { + _llamaClient = llamaClient; + _config = config; + _env = env; + _logger = logger; + } + + public List AvailableModels { get; set; } = new(); + public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + public string ModelsEndpoint => _config["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + AvailableModels = await _llamaClient.GetAvailableModelsAsync(); + } + + public async Task OnPostSaveModelAsync(string model) + { + if (string.IsNullOrEmpty(model)) + { + ErrorMessage = "No model selected."; + return RedirectToPage(); + } + + await SaveSelectedModelAsync(model); + SuccessMessage = $"AI model updated to: {model}"; + return RedirectToPage(); + } + + public async Task OnPostLoadModelAsync(string model) + { + if (string.IsNullOrEmpty(model)) + { + ErrorMessage = "No model selected."; + return RedirectToPage(); + } + + // Save the model first + await SaveSelectedModelAsync(model); + + // Fire a warmup request in the background (don't await) + _ = WarmupModelAsync(model); + + SuccessMessage = $"Model {model} is being loaded. This may take a moment."; + return RedirectToPage(); + } + + private async Task WarmupModelAsync(string model) + { + try + { + _logger.LogInformation("Warming up model: {Model}", model); + await _llamaClient.SendTextPromptAsync("Hello", model); + _logger.LogInformation("Model warmup completed: {Model}", model); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); + } + } + + private async Task SaveSelectedModelAsync(string model) + { + try + { + var appSettingsPath = Path.Combine(_env.ContentRootPath, "appsettings.json"); + var json = await System.IO.File.ReadAllTextAsync(appSettingsPath); + var jsonNode = JsonNode.Parse(json); + + if (jsonNode == null) return; + + if (jsonNode["AI"] == null) + { + jsonNode["AI"] = new JsonObject(); + } + + jsonNode["AI"]!["ReceiptParsingModel"] = model; + + var options = new JsonSerializerOptions { WriteIndented = true }; + await System.IO.File.WriteAllTextAsync(appSettingsPath, jsonNode.ToJsonString(options)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save model selection: {Message}", ex.Message); + } + } + } +} diff --git a/MoneyMap/Pages/Shared/_Layout.cshtml b/MoneyMap/Pages/Shared/_Layout.cshtml index dad1da3..5b5d203 100644 --- a/MoneyMap/Pages/Shared/_Layout.cshtml +++ b/MoneyMap/Pages/Shared/_Layout.cshtml @@ -1,124 +1,124 @@ - - - - - - @ViewData["Title"] - MoneyMap - - - - - -
- -
-
-
- @if (ViewData["Breadcrumbs"] is List<(string Label, string? Url)> breadcrumbs && breadcrumbs.Count > 0) - { - - } - @RenderBody() -
-
- -
-
- © 2026 - MoneyMap -
-
- - - - - - @await RenderSectionAsync("Scripts", required: false) - - + + + + + + @ViewData["Title"] - MoneyMap + + + + + +
+ +
+
+
+ @if (ViewData["Breadcrumbs"] is List<(string Label, string? Url)> breadcrumbs && breadcrumbs.Count > 0) + { + + } + @RenderBody() +
+
+ +
+
+ © 2026 - MoneyMap +
+
+ + + + + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/MoneyMap/Pages/Shared/_Layout.cshtml.css b/MoneyMap/Pages/Shared/_Layout.cshtml.css index c187c02..3ff01d4 100644 --- a/MoneyMap/Pages/Shared/_Layout.cshtml.css +++ b/MoneyMap/Pages/Shared/_Layout.cshtml.css @@ -1,48 +1,48 @@ -/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification -for details on configuring this project to bundle and minify static web assets. */ - -a.navbar-brand { - white-space: normal; - text-align: center; - word-break: break-all; -} - -a { - color: #0077cc; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.nav-pills .nav-link.active, .nav-pills .show > .nav-link { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.border-top { - border-top: 1px solid #e5e5e5; -} -.border-bottom { - border-bottom: 1px solid #e5e5e5; -} - -.box-shadow { - box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); -} - -button.accept-policy { - font-size: 1rem; - line-height: inherit; -} - -.footer { - position: absolute; - bottom: 0; - width: 100%; - white-space: nowrap; - line-height: 60px; -} +/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification +for details on configuring this project to bundle and minify static web assets. */ + +a.navbar-brand { + white-space: normal; + text-align: center; + word-break: break-all; +} + +a { + color: #0077cc; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.nav-pills .nav-link.active, .nav-pills .show > .nav-link { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.border-top { + border-top: 1px solid #e5e5e5; +} +.border-bottom { + border-bottom: 1px solid #e5e5e5; +} + +.box-shadow { + box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); +} + +button.accept-policy { + font-size: 1rem; + line-height: inherit; +} + +.footer { + position: absolute; + bottom: 0; + width: 100%; + white-space: nowrap; + line-height: 60px; +} diff --git a/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml b/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml index 861d681..fe2ca41 100644 --- a/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml +++ b/MoneyMap/Pages/Shared/_ValidationScriptsPartial.cshtml @@ -1,2 +1,2 @@ - - + + diff --git a/MoneyMap/Pages/Transactions.cshtml b/MoneyMap/Pages/Transactions.cshtml index e54c193..5d7e2b1 100644 --- a/MoneyMap/Pages/Transactions.cshtml +++ b/MoneyMap/Pages/Transactions.cshtml @@ -1,422 +1,422 @@ -@page -@model MoneyMap.Pages.TransactionsModel -@{ - ViewData["Title"] = "Transactions"; -} - -
-

Transactions

- -
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - - - - -
-
-
- -
-
- @if (!string.IsNullOrWhiteSpace(Model.Search) || !string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.Merchant) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue) - { - - } -
-
- - -
-
-
-
-
Transactions
-
@Model.Stats.Count
-
- Showing @Model.Transactions.Count on page @Model.PageNumber of @Model.TotalPages -
-
-
-
-
-
-
-
Total Spent
-
@Model.Stats.TotalDebits.ToString("C")
-
-
-
-
-
-
-
Total Income
-
@Model.Stats.TotalCredits.ToString("C")
-
-
-
-
-
-
-
Net
-
- @Model.Stats.NetAmount.ToString("C") -
-
-
-
-
- - -@if (Model.CategoryBreakdowns.Any()) -{ -
-
Spending by category
-
- -
-
-} - - -
-
-
- 0 transaction(s) selected -
-
- - -
- @Html.AntiForgeryToken() - -
-
-
-
- - -@if (Model.Transactions.Any()) -{ -
-
-
- @if (!string.IsNullOrWhiteSpace(Model.Category)) - { - @Model.Category - - @Model.Stats.Count transactions - } - else - { - All Transactions - - @Model.Stats.Count total - } -
-
-
-
- - - - - - - - - - - - - - - @foreach (var t in Model.Transactions) - { - - - - - - - - - - - } - -
- - IDDateNameMemoAmountCategoryPayment/Account
- - #@t.Id@t.Date.ToString("yyyy-MM-dd") -
- @t.Name - @if (t.ReceiptCount > 0) - { - - @t.ReceiptCount - - } - @if (!string.IsNullOrWhiteSpace(t.Notes)) - { - Note - } -
-
@t.Memo - @t.Amount.ToString("C") - - @if (string.IsNullOrWhiteSpace(t.Category)) - { - (uncategorized) - } - else - { - @t.Category - } - - @t.CardLabel -
-
-
-
-} -else -{ -
- No transactions found matching the selected filters. -
-} - - -@if (Model.TotalPages > 1) -{ - -} - -@section Scripts { - - - -} - +@page +@model MoneyMap.Pages.TransactionsModel +@{ + ViewData["Title"] = "Transactions"; +} + +
+

Transactions

+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+
+ +
+
+ @if (!string.IsNullOrWhiteSpace(Model.Search) || !string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.Merchant) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue) + { + + } +
+
+ + +
+
+
+
+
Transactions
+
@Model.Stats.Count
+
+ Showing @Model.Transactions.Count on page @Model.PageNumber of @Model.TotalPages +
+
+
+
+
+
+
+
Total Spent
+
@Model.Stats.TotalDebits.ToString("C")
+
+
+
+
+
+
+
Total Income
+
@Model.Stats.TotalCredits.ToString("C")
+
+
+
+
+
+
+
Net
+
+ @Model.Stats.NetAmount.ToString("C") +
+
+
+
+
+ + +@if (Model.CategoryBreakdowns.Any()) +{ +
+
Spending by category
+
+ +
+
+} + + +
+
+
+ 0 transaction(s) selected +
+
+ + +
+ @Html.AntiForgeryToken() + +
+
+
+
+ + +@if (Model.Transactions.Any()) +{ +
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Category)) + { + @Model.Category + - @Model.Stats.Count transactions + } + else + { + All Transactions + - @Model.Stats.Count total + } +
+
+
+
+ + + + + + + + + + + + + + + @foreach (var t in Model.Transactions) + { + + + + + + + + + + + } + +
+ + IDDateNameMemoAmountCategoryPayment/Account
+ + #@t.Id@t.Date.ToString("yyyy-MM-dd") +
+ @t.Name + @if (t.ReceiptCount > 0) + { + + @t.ReceiptCount + + } + @if (!string.IsNullOrWhiteSpace(t.Notes)) + { + Note + } +
+
@t.Memo + @t.Amount.ToString("C") + + @if (string.IsNullOrWhiteSpace(t.Category)) + { + (uncategorized) + } + else + { + @t.Category + } + + @t.CardLabel +
+
+
+
+} +else +{ +
+ No transactions found matching the selected filters. +
+} + + +@if (Model.TotalPages > 1) +{ + +} + +@section Scripts { + + + +} + diff --git a/MoneyMap/Pages/Transactions.cshtml.cs b/MoneyMap/Pages/Transactions.cshtml.cs index 157baf6..fa58d60 100644 --- a/MoneyMap/Pages/Transactions.cshtml.cs +++ b/MoneyMap/Pages/Transactions.cshtml.cs @@ -1,213 +1,213 @@ -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 TransactionsModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly ITransactionStatisticsService _statsService; - private readonly IReferenceDataService _referenceDataService; - - public TransactionsModel(MoneyMapContext db, ITransactionStatisticsService statsService, IReferenceDataService referenceDataService) - { - _db = db; - _statsService = statsService; - _referenceDataService = referenceDataService; - } - - [BindProperty(SupportsGet = true)] - public string? Search { get; set; } - - [BindProperty(SupportsGet = true)] - public string? Category { get; set; } - - [BindProperty(SupportsGet = true)] - public string? Merchant { get; set; } - - [BindProperty(SupportsGet = true)] - public string? CardId { get; set; } - - [BindProperty(SupportsGet = true)] - public DateTime? StartDate { get; set; } - - [BindProperty(SupportsGet = true)] - public DateTime? EndDate { get; set; } - - [BindProperty(SupportsGet = true)] - public int PageNumber { get; set; } = 1; - - public int PageSize { get; set; } = 50; - public int TotalPages { get; set; } - public int TotalCount { get; set; } - - public List Transactions { get; set; } = new(); - public List AvailableCategories { get; set; } = new(); - public List AvailableMerchants { get; set; } = new(); - public List AvailableCards { get; set; } = new(); - public TransactionStats Stats { get; set; } = new(); - public List CategoryBreakdowns { get; set; } = new(); - - 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) - .Include(t => t.Account) - .Include(t => t.TransferToAccount) - .Include(t => t.Merchant) - .AsQueryable(); - - // Apply filters (case-insensitive search using EF.Functions.Like) - if (!string.IsNullOrWhiteSpace(Search)) - { - var searchPattern = $"%{Search}%"; - query = query.Where(t => - EF.Functions.Like(t.Name, searchPattern) || - (t.Memo != null && EF.Functions.Like(t.Memo, searchPattern)) || - (t.Category != null && EF.Functions.Like(t.Category, searchPattern)) || - (t.Notes != null && EF.Functions.Like(t.Notes, searchPattern)) || - (t.Merchant != null && EF.Functions.Like(t.Merchant.Name, searchPattern))); - } - - if (!string.IsNullOrWhiteSpace(Category)) - { - if (Category == "(blank)") - { - query = query.Where(t => string.IsNullOrWhiteSpace(t.Category)); - } - else - { - query = query.Where(t => t.Category == Category); - } - } - - if (!string.IsNullOrWhiteSpace(Merchant)) - { - if (Merchant == "(blank)") - { - query = query.Where(t => t.MerchantId == null); - } - else - { - query = query.Where(t => t.Merchant != null && t.Merchant.Name == Merchant); - } - } - - if (!string.IsNullOrWhiteSpace(CardId) && int.TryParse(CardId, out int cardIdInt)) - { - query = query.Where(t => t.CardId == cardIdInt); - } - - if (StartDate.HasValue) - { - query = query.Where(t => t.Date >= StartDate.Value); - } - - if (EndDate.HasValue) - { - query = query.Where(t => t.Date <= EndDate.Value); - } - - // Get total count for pagination - TotalCount = await query.CountAsync(); - TotalPages = (int)Math.Ceiling(TotalCount / (double)PageSize); - - // Ensure page number is valid - if (PageNumber < 1) PageNumber = 1; - if (PageNumber > TotalPages && TotalPages > 0) PageNumber = TotalPages; - - // Get paginated transactions - var transactions = await query - .OrderByDescending(t => t.Date) - .ThenByDescending(t => t.Id) - .Skip((PageNumber - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - - // Get receipt counts for the current page only - var transactionIds = transactions.Select(t => t.Id).ToList(); - var receiptCounts = await _db.Receipts - .Where(r => r.TransactionId != null && transactionIds.Contains(r.TransactionId.Value)) - .GroupBy(r => r.TransactionId) - .Select(g => new { TransactionId = g.Key!.Value, Count = g.Count() }) - .ToListAsync(); - - var receiptCountDict = receiptCounts.ToDictionary(x => x.TransactionId, x => x.Count); - - Transactions = transactions.Select(t => new TransactionRow - { - Id = t.Id, - Date = t.Date, - Name = t.Name, - Memo = t.Memo, - Amount = t.Amount, - Category = t.Category ?? "", - Notes = t.Notes ?? "", - CardLabel = t.PaymentMethodLabel, - AccountLabel = t.Card?.Account?.DisplayLabel ?? t.Account?.DisplayLabel ?? "None", - ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0 - }).ToList(); - - // Calculate stats for filtered results (all pages, not just current) - Stats = await _statsService.CalculateStatsAsync(query); - - // Calculate category breakdown for pie chart (only expenses) - var expenseQuery = query.Where(t => t.Amount < 0).ExcludeTransfers(); - var categoryGroups = await expenseQuery - .GroupBy(t => t.Category ?? "") - .Select(g => new CategoryBreakdown - { - Category = g.Key, - TotalSpend = g.Sum(x => -x.Amount), - Count = g.Count() - }) - .OrderByDescending(x => x.TotalSpend) - .ToListAsync(); - - CategoryBreakdowns = categoryGroups; - - // Get available categories for filter dropdown - AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync(); - - // Get available merchants for filter dropdown - var merchants = await _referenceDataService.GetAvailableMerchantsAsync(); - AvailableMerchants = merchants.Select(m => m.Name).ToList(); - - // Get available cards for filter dropdown - AvailableCards = await _referenceDataService.GetAvailableCardsAsync(includeAccount: false); - } - - public class TransactionRow - { - public long Id { get; set; } - public DateTime Date { get; set; } - public string Name { get; set; } = ""; - public string Memo { get; set; } = ""; - public decimal Amount { get; set; } - public string Category { get; set; } = ""; - public string Notes { get; set; } = ""; - public string CardLabel { get; set; } = ""; - public string AccountLabel { get; set; } = ""; - public int ReceiptCount { get; set; } - } - - public class CategoryBreakdown - { - public string Category { get; set; } = ""; - public decimal TotalSpend { get; set; } - public int Count { get; set; } - } - } +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 TransactionsModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly ITransactionStatisticsService _statsService; + private readonly IReferenceDataService _referenceDataService; + + public TransactionsModel(MoneyMapContext db, ITransactionStatisticsService statsService, IReferenceDataService referenceDataService) + { + _db = db; + _statsService = statsService; + _referenceDataService = referenceDataService; + } + + [BindProperty(SupportsGet = true)] + public string? Search { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Category { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Merchant { get; set; } + + [BindProperty(SupportsGet = true)] + public string? CardId { get; set; } + + [BindProperty(SupportsGet = true)] + public DateTime? StartDate { get; set; } + + [BindProperty(SupportsGet = true)] + public DateTime? EndDate { get; set; } + + [BindProperty(SupportsGet = true)] + public int PageNumber { get; set; } = 1; + + public int PageSize { get; set; } = 50; + public int TotalPages { get; set; } + public int TotalCount { get; set; } + + public List Transactions { get; set; } = new(); + public List AvailableCategories { get; set; } = new(); + public List AvailableMerchants { get; set; } = new(); + public List AvailableCards { get; set; } = new(); + public TransactionStats Stats { get; set; } = new(); + public List CategoryBreakdowns { get; set; } = new(); + + 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) + .Include(t => t.Account) + .Include(t => t.TransferToAccount) + .Include(t => t.Merchant) + .AsQueryable(); + + // Apply filters (case-insensitive search using EF.Functions.Like) + if (!string.IsNullOrWhiteSpace(Search)) + { + var searchPattern = $"%{Search}%"; + query = query.Where(t => + EF.Functions.Like(t.Name, searchPattern) || + (t.Memo != null && EF.Functions.Like(t.Memo, searchPattern)) || + (t.Category != null && EF.Functions.Like(t.Category, searchPattern)) || + (t.Notes != null && EF.Functions.Like(t.Notes, searchPattern)) || + (t.Merchant != null && EF.Functions.Like(t.Merchant.Name, searchPattern))); + } + + if (!string.IsNullOrWhiteSpace(Category)) + { + if (Category == "(blank)") + { + query = query.Where(t => string.IsNullOrWhiteSpace(t.Category)); + } + else + { + query = query.Where(t => t.Category == Category); + } + } + + if (!string.IsNullOrWhiteSpace(Merchant)) + { + if (Merchant == "(blank)") + { + query = query.Where(t => t.MerchantId == null); + } + else + { + query = query.Where(t => t.Merchant != null && t.Merchant.Name == Merchant); + } + } + + if (!string.IsNullOrWhiteSpace(CardId) && int.TryParse(CardId, out int cardIdInt)) + { + query = query.Where(t => t.CardId == cardIdInt); + } + + if (StartDate.HasValue) + { + query = query.Where(t => t.Date >= StartDate.Value); + } + + if (EndDate.HasValue) + { + query = query.Where(t => t.Date <= EndDate.Value); + } + + // Get total count for pagination + TotalCount = await query.CountAsync(); + TotalPages = (int)Math.Ceiling(TotalCount / (double)PageSize); + + // Ensure page number is valid + if (PageNumber < 1) PageNumber = 1; + if (PageNumber > TotalPages && TotalPages > 0) PageNumber = TotalPages; + + // Get paginated transactions + var transactions = await query + .OrderByDescending(t => t.Date) + .ThenByDescending(t => t.Id) + .Skip((PageNumber - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + // Get receipt counts for the current page only + var transactionIds = transactions.Select(t => t.Id).ToList(); + var receiptCounts = await _db.Receipts + .Where(r => r.TransactionId != null && transactionIds.Contains(r.TransactionId.Value)) + .GroupBy(r => r.TransactionId) + .Select(g => new { TransactionId = g.Key!.Value, Count = g.Count() }) + .ToListAsync(); + + var receiptCountDict = receiptCounts.ToDictionary(x => x.TransactionId, x => x.Count); + + Transactions = transactions.Select(t => new TransactionRow + { + Id = t.Id, + Date = t.Date, + Name = t.Name, + Memo = t.Memo, + Amount = t.Amount, + Category = t.Category ?? "", + Notes = t.Notes ?? "", + CardLabel = t.PaymentMethodLabel, + AccountLabel = t.Card?.Account?.DisplayLabel ?? t.Account?.DisplayLabel ?? "None", + ReceiptCount = receiptCountDict.ContainsKey(t.Id) ? receiptCountDict[t.Id] : 0 + }).ToList(); + + // Calculate stats for filtered results (all pages, not just current) + Stats = await _statsService.CalculateStatsAsync(query); + + // Calculate category breakdown for pie chart (only expenses) + var expenseQuery = query.Where(t => t.Amount < 0).ExcludeTransfers(); + var categoryGroups = await expenseQuery + .GroupBy(t => t.Category ?? "") + .Select(g => new CategoryBreakdown + { + Category = g.Key, + TotalSpend = g.Sum(x => -x.Amount), + Count = g.Count() + }) + .OrderByDescending(x => x.TotalSpend) + .ToListAsync(); + + CategoryBreakdowns = categoryGroups; + + // Get available categories for filter dropdown + AvailableCategories = await _referenceDataService.GetAvailableCategoriesAsync(); + + // Get available merchants for filter dropdown + var merchants = await _referenceDataService.GetAvailableMerchantsAsync(); + AvailableMerchants = merchants.Select(m => m.Name).ToList(); + + // Get available cards for filter dropdown + AvailableCards = await _referenceDataService.GetAvailableCardsAsync(includeAccount: false); + } + + public class TransactionRow + { + public long Id { get; set; } + public DateTime Date { get; set; } + public string Name { get; set; } = ""; + public string Memo { get; set; } = ""; + public decimal Amount { get; set; } + public string Category { get; set; } = ""; + public string Notes { get; set; } = ""; + public string CardLabel { get; set; } = ""; + public string AccountLabel { get; set; } = ""; + public int ReceiptCount { get; set; } + } + + public class CategoryBreakdown + { + public string Category { get; set; } = ""; + public decimal TotalSpend { get; set; } + public int Count { get; set; } + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/Upload.cshtml b/MoneyMap/Pages/Upload.cshtml index fa90418..af52de7 100644 --- a/MoneyMap/Pages/Upload.cshtml +++ b/MoneyMap/Pages/Upload.cshtml @@ -1,244 +1,244 @@ -@page -@model MoneyMap.Pages.UploadModel -@{ - ViewData["Title"] = "Upload Transactions"; - ViewData["FullWidth"] = Model.PreviewTransactions.Any(); - ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> - { - ("Transactions", Url.Page("/Transactions")), - ("Upload", null) - }; -} - -

Upload Transactions

- -@if (Model.PreviewTransactions.Any()) -{ -
- Preview: Review the transactions below before importing. Duplicates are marked in gray and will be skipped. -
- -
- - - - -
-
-
-
- -
-
- -
-
- All transactions in this file belong to this account -
-
-
-
- -
-
- Transaction Preview (@Model.PreviewTransactions.Count(p => !p.IsDuplicate) selected, @Model.PreviewTransactions.Count(p => p.IsDuplicate) duplicates) - @if (Model.PreviewTransactions.Count > 100) - { -
- Showing 1-100 of @Model.PreviewTransactions.Count - - -
- } -
-
-
- - - - - - - - - - - - - - - @for (int i = 0; i < Model.PreviewTransactions.Count; i++) - { - var preview = Model.PreviewTransactions[i]; - var displayStyle = i >= 100 ? "display: none;" : ""; - - - - - - - - - - - } - -
- - DateMerchantMemoAmountCategoryCard Used (optional)Status
- - @preview.Transaction.Date.ToString("MM/dd/yy")@preview.Transaction.Name - @preview.Transaction.Memo - - @preview.Transaction.Amount.ToString("C") - - - @if (!string.IsNullOrWhiteSpace(preview.SuggestedCategory)) - { - Auto - } - - - - @if (preview.IsDuplicate) - { - Duplicate - } - else - { - New - } -
-
-
-
- - - - @foreach (var cat in Model.PreviewTransactions.Select(p => p.Transaction.Category).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().OrderBy(c => c)) - { - - } - - -
- - Cancel -
-
-} -else if (Model.Result != null) -{ -
-

Import Complete!

-

- @Model.Result.Total total rows processed
- @Model.Result.Inserted new transactions imported
- @Model.Result.Skipped duplicates skipped -

- View Transactions - Upload More -
-} -else -{ -
- @Html.AntiForgeryToken() - -
- - - - -
- -
- Payment Method Assignment - -
- -
- - -
Extracts card info from memo or filename
-
-
- - -
-
- - -
For checking/savings account transactions
-
-
- - - - -
- - -
-
-} - -@section Scripts { - - -} +@page +@model MoneyMap.Pages.UploadModel +@{ + ViewData["Title"] = "Upload Transactions"; + ViewData["FullWidth"] = Model.PreviewTransactions.Any(); + ViewData["Breadcrumbs"] = new List<(string Label, string? Url)> + { + ("Transactions", Url.Page("/Transactions")), + ("Upload", null) + }; +} + +

Upload Transactions

+ +@if (Model.PreviewTransactions.Any()) +{ +
+ Preview: Review the transactions below before importing. Duplicates are marked in gray and will be skipped. +
+ +
+ + + + +
+
+
+
+ +
+
+ +
+
+ All transactions in this file belong to this account +
+
+
+
+ +
+
+ Transaction Preview (@Model.PreviewTransactions.Count(p => !p.IsDuplicate) selected, @Model.PreviewTransactions.Count(p => p.IsDuplicate) duplicates) + @if (Model.PreviewTransactions.Count > 100) + { +
+ Showing 1-100 of @Model.PreviewTransactions.Count + + +
+ } +
+
+
+ + + + + + + + + + + + + + + @for (int i = 0; i < Model.PreviewTransactions.Count; i++) + { + var preview = Model.PreviewTransactions[i]; + var displayStyle = i >= 100 ? "display: none;" : ""; + + + + + + + + + + + } + +
+ + DateMerchantMemoAmountCategoryCard Used (optional)Status
+ + @preview.Transaction.Date.ToString("MM/dd/yy")@preview.Transaction.Name + @preview.Transaction.Memo + + @preview.Transaction.Amount.ToString("C") + + + @if (!string.IsNullOrWhiteSpace(preview.SuggestedCategory)) + { + Auto + } + + + + @if (preview.IsDuplicate) + { + Duplicate + } + else + { + New + } +
+
+
+
+ + + + @foreach (var cat in Model.PreviewTransactions.Select(p => p.Transaction.Category).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().OrderBy(c => c)) + { + + } + + +
+ + Cancel +
+
+} +else if (Model.Result != null) +{ +
+

Import Complete!

+

+ @Model.Result.Total total rows processed
+ @Model.Result.Inserted new transactions imported
+ @Model.Result.Skipped duplicates skipped +

+ View Transactions + Upload More +
+} +else +{ +
+ @Html.AntiForgeryToken() + +
+ + + + +
+ +
+ Payment Method Assignment + +
+ +
+ + +
Extracts card info from memo or filename
+
+
+ + +
+
+ + +
For checking/savings account transactions
+
+
+ + + + +
+ + +
+
+} + +@section Scripts { + + +} diff --git a/MoneyMap/Pages/Upload.cshtml.cs b/MoneyMap/Pages/Upload.cshtml.cs index 115caaf..be3c3af 100644 --- a/MoneyMap/Pages/Upload.cshtml.cs +++ b/MoneyMap/Pages/Upload.cshtml.cs @@ -1,216 +1,216 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; -using MoneyMap.Models.Import; -using MoneyMap.Services; -using System.Text.Json; - -namespace MoneyMap.Pages -{ - public class UploadModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly ITransactionImporter _importer; - private readonly ITransactionCategorizer _categorizer; - private readonly ITransactionService _transactionService; - - public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer, ITransactionService transactionService) - { - _db = db; - _importer = importer; - _categorizer = categorizer; - _transactionService = transactionService; - } - - [BindProperty] public IFormFile? Csv { get; set; } - [BindProperty] public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto; - [BindProperty] public int? SelectedCardId { get; set; } - [BindProperty] public int? SelectedAccountId { get; set; } - - public List Cards { get; set; } = new(); - public List Accounts { get; set; } = new(); - public ImportResult? Result { get; set; } - public List PreviewTransactions { get; set; } = new(); - - private const string PreviewSessionKey = "TransactionPreview"; - - public async Task OnGetAsync() - { - Cards = await _db.Cards - .Include(c => c.Account) - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .ToListAsync(); - - Accounts = await _db.Accounts - .OrderBy(a => a.Institution) - .ThenBy(a => a.Last4) - .ToListAsync(); - } - - public async Task OnPostAsync() - { - if (!ValidateInput()) - { - await OnGetAsync(); - return Page(); - } - - Cards = await _db.Cards - .Include(c => c.Account) - .OrderBy(c => c.Owner) - .ThenBy(c => c.Last4) - .ToListAsync(); - - Accounts = await _db.Accounts - .OrderBy(a => a.Institution) - .ThenBy(a => a.Last4) - .ToListAsync(); - - var importContext = new ImportContext - { - PaymentMode = PaymentMode, - SelectedCardId = SelectedCardId, - SelectedAccountId = SelectedAccountId, - AvailableCards = Cards, - AvailableAccounts = Accounts, - FileName = Csv!.FileName - }; - - var previewResult = await _importer.PreviewAsync(Csv!.OpenReadStream(), importContext); - - if (!previewResult.IsSuccess) - { - ModelState.AddModelError(string.Empty, previewResult.ErrorMessage!); - await OnGetAsync(); - return Page(); - } - - // Apply categorization to preview - foreach (var preview in previewResult.Data!) - { - if (string.IsNullOrWhiteSpace(preview.Transaction.Category)) - { - var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); - preview.Transaction.Category = categorizationResult.Category; - preview.Transaction.MerchantId = categorizationResult.MerchantId; - preview.SuggestedCategory = categorizationResult.Category; - } - } - - PreviewTransactions = previewResult.Data!; - - // Store preview data in Session for confirm step - HttpContext.Session.SetString(PreviewSessionKey, - JsonSerializer.Serialize(PreviewTransactions.Select(p => p.Transaction).ToList())); - - return Page(); - } - - public async Task OnPostConfirmAsync(string selectedIndices, string paymentData) - { - var previewDataJson = HttpContext.Session.GetString(PreviewSessionKey); - if (string.IsNullOrWhiteSpace(previewDataJson)) - { - ModelState.AddModelError(string.Empty, "Preview data expired. Please upload again."); - await OnGetAsync(); - return Page(); - } - - var transactions = JsonSerializer.Deserialize>(previewDataJson); - if (transactions == null || !transactions.Any()) - { - ModelState.AddModelError(string.Empty, "No transactions to import."); - await OnGetAsync(); - return Page(); - } - - // Parse selected indices - var selectedIndexList = string.IsNullOrWhiteSpace(selectedIndices) - ? new List() - : selectedIndices.Split(',').Select(int.Parse).ToList(); - - // Parse payment data - var paymentSelections = string.IsNullOrWhiteSpace(paymentData) - ? new Dictionary() - : JsonSerializer.Deserialize>(paymentData) ?? new(); - - // Filter transactions based on user selection and update payment methods - var transactionsToImport = new List(); - foreach (var index in selectedIndexList) - { - if (index >= 0 && index < transactions.Count) - { - var txn = transactions[index]; - - // Update payment method based on user selection - if (paymentSelections.TryGetValue(index, out var payment)) - { - // Account is required and set globally for all transactions - if (payment.AccountId.HasValue) - { - var account = await _db.Accounts.FindAsync(payment.AccountId.Value); - if (account != null) - { - txn.AccountId = payment.AccountId.Value; - txn.Last4 = account.Last4; - } - } - - // Card is optional per transaction - if (payment.CardId.HasValue) - { - var card = await _db.Cards.FindAsync(payment.CardId.Value); - if (card != null) - { - txn.CardId = card.Id; - } - } - else - { - txn.CardId = null; // Direct account transaction, no card - } - - // Category (user can edit in preview) - if (!string.IsNullOrWhiteSpace(payment.Category)) - { - txn.Category = payment.Category.Trim(); - } - } - - // Check for duplicates - if (!await _transactionService.IsDuplicateAsync(txn)) - { - transactionsToImport.Add(txn); - } - } - } - - var result = await _importer.ImportAsync(transactionsToImport); - - if (!result.IsSuccess) - { - ModelState.AddModelError(string.Empty, result.ErrorMessage!); - await OnGetAsync(); - return Page(); - } - - Result = result.Data; - HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data - await OnGetAsync(); - return Page(); - } - - private bool ValidateInput() - { - if (Csv is null || Csv.Length == 0) - { - ModelState.AddModelError(nameof(Csv), "Please choose a CSV file."); - return false; - } - return true; - } - } -} +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Models.Import; +using MoneyMap.Services; +using System.Text.Json; + +namespace MoneyMap.Pages +{ + public class UploadModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly ITransactionImporter _importer; + private readonly ITransactionCategorizer _categorizer; + private readonly ITransactionService _transactionService; + + public UploadModel(MoneyMapContext db, ITransactionImporter importer, ITransactionCategorizer categorizer, ITransactionService transactionService) + { + _db = db; + _importer = importer; + _categorizer = categorizer; + _transactionService = transactionService; + } + + [BindProperty] public IFormFile? Csv { get; set; } + [BindProperty] public PaymentSelectMode PaymentMode { get; set; } = PaymentSelectMode.Auto; + [BindProperty] public int? SelectedCardId { get; set; } + [BindProperty] public int? SelectedAccountId { get; set; } + + public List Cards { get; set; } = new(); + public List Accounts { get; set; } = new(); + public ImportResult? Result { get; set; } + public List PreviewTransactions { get; set; } = new(); + + private const string PreviewSessionKey = "TransactionPreview"; + + public async Task OnGetAsync() + { + Cards = await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + Accounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + } + + public async Task OnPostAsync() + { + if (!ValidateInput()) + { + await OnGetAsync(); + return Page(); + } + + Cards = await _db.Cards + .Include(c => c.Account) + .OrderBy(c => c.Owner) + .ThenBy(c => c.Last4) + .ToListAsync(); + + Accounts = await _db.Accounts + .OrderBy(a => a.Institution) + .ThenBy(a => a.Last4) + .ToListAsync(); + + var importContext = new ImportContext + { + PaymentMode = PaymentMode, + SelectedCardId = SelectedCardId, + SelectedAccountId = SelectedAccountId, + AvailableCards = Cards, + AvailableAccounts = Accounts, + FileName = Csv!.FileName + }; + + var previewResult = await _importer.PreviewAsync(Csv!.OpenReadStream(), importContext); + + if (!previewResult.IsSuccess) + { + ModelState.AddModelError(string.Empty, previewResult.ErrorMessage!); + await OnGetAsync(); + return Page(); + } + + // Apply categorization to preview + foreach (var preview in previewResult.Data!) + { + if (string.IsNullOrWhiteSpace(preview.Transaction.Category)) + { + var categorizationResult = await _categorizer.CategorizeAsync(preview.Transaction.Name, preview.Transaction.Amount); + preview.Transaction.Category = categorizationResult.Category; + preview.Transaction.MerchantId = categorizationResult.MerchantId; + preview.SuggestedCategory = categorizationResult.Category; + } + } + + PreviewTransactions = previewResult.Data!; + + // Store preview data in Session for confirm step + HttpContext.Session.SetString(PreviewSessionKey, + JsonSerializer.Serialize(PreviewTransactions.Select(p => p.Transaction).ToList())); + + return Page(); + } + + public async Task OnPostConfirmAsync(string selectedIndices, string paymentData) + { + var previewDataJson = HttpContext.Session.GetString(PreviewSessionKey); + if (string.IsNullOrWhiteSpace(previewDataJson)) + { + ModelState.AddModelError(string.Empty, "Preview data expired. Please upload again."); + await OnGetAsync(); + return Page(); + } + + var transactions = JsonSerializer.Deserialize>(previewDataJson); + if (transactions == null || !transactions.Any()) + { + ModelState.AddModelError(string.Empty, "No transactions to import."); + await OnGetAsync(); + return Page(); + } + + // Parse selected indices + var selectedIndexList = string.IsNullOrWhiteSpace(selectedIndices) + ? new List() + : selectedIndices.Split(',').Select(int.Parse).ToList(); + + // Parse payment data + var paymentSelections = string.IsNullOrWhiteSpace(paymentData) + ? new Dictionary() + : JsonSerializer.Deserialize>(paymentData) ?? new(); + + // Filter transactions based on user selection and update payment methods + var transactionsToImport = new List(); + foreach (var index in selectedIndexList) + { + if (index >= 0 && index < transactions.Count) + { + var txn = transactions[index]; + + // Update payment method based on user selection + if (paymentSelections.TryGetValue(index, out var payment)) + { + // Account is required and set globally for all transactions + if (payment.AccountId.HasValue) + { + var account = await _db.Accounts.FindAsync(payment.AccountId.Value); + if (account != null) + { + txn.AccountId = payment.AccountId.Value; + txn.Last4 = account.Last4; + } + } + + // Card is optional per transaction + if (payment.CardId.HasValue) + { + var card = await _db.Cards.FindAsync(payment.CardId.Value); + if (card != null) + { + txn.CardId = card.Id; + } + } + else + { + txn.CardId = null; // Direct account transaction, no card + } + + // Category (user can edit in preview) + if (!string.IsNullOrWhiteSpace(payment.Category)) + { + txn.Category = payment.Category.Trim(); + } + } + + // Check for duplicates + if (!await _transactionService.IsDuplicateAsync(txn)) + { + transactionsToImport.Add(txn); + } + } + } + + var result = await _importer.ImportAsync(transactionsToImport); + + if (!result.IsSuccess) + { + ModelState.AddModelError(string.Empty, result.ErrorMessage!); + await OnGetAsync(); + return Page(); + } + + Result = result.Data; + HttpContext.Session.Remove(PreviewSessionKey); // Clear preview data + await OnGetAsync(); + return Page(); + } + + private bool ValidateInput() + { + if (Csv is null || Csv.Length == 0) + { + ModelState.AddModelError(nameof(Csv), "Please choose a CSV file."); + return false; + } + return true; + } + } +} diff --git a/MoneyMap/Pages/ViewReceipt.cshtml b/MoneyMap/Pages/ViewReceipt.cshtml index 5714772..1655712 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml +++ b/MoneyMap/Pages/ViewReceipt.cshtml @@ -1,230 +1,230 @@ -@page "{id:long}" -@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) - }; -} - -
-

Receipt Details

- - Back to Transaction - -
- -@if (!string.IsNullOrEmpty(Model.SuccessMessage)) -{ - -} - -@if (!string.IsNullOrEmpty(Model.ErrorMessage)) -{ - -} - -
- -
-
-
- @Model.Receipt.FileName -
-
- @if (Model.Receipt.ContentType == "application/pdf") - { - - } - else - { - Receipt - } -
-
- - - @if (Model.LineItems.Any()) - { -
-
- Line Items (@Model.LineItems.Count) -
-
-
- - - - - - - - - - - - @foreach (var item in Model.LineItems) - { - - - - - - - - } - -
#DescriptionQtyUnit PriceTotal
@item.LineNumber@item.Description - @(item.Quantity?.ToString("0.##") ?? "-") - - @(item.UnitPrice?.ToString("C") ?? "-") - - @(item.LineTotal?.ToString("C") ?? "-") -
-
-
-
- } -
- - -
- -
-
- Receipt Information -
-
-
- @if (!string.IsNullOrWhiteSpace(Model.Receipt.Merchant)) - { -
Merchant
-
@Model.Receipt.Merchant
- } - - @if (Model.Receipt.ReceiptDate.HasValue) - { -
Date
-
@Model.Receipt.ReceiptDate.Value.ToString("MMM d, yyyy")
- } - - @if (Model.Receipt.Subtotal.HasValue) - { -
Subtotal
-
@Model.Receipt.Subtotal.Value.ToString("C")
- } - - @if (Model.Receipt.Tax.HasValue) - { -
Tax
-
@Model.Receipt.Tax.Value.ToString("C")
- } - - @if (Model.Receipt.Total.HasValue) - { -
Total
-
@Model.Receipt.Total.Value.ToString("C")
- } - -
File Size
-
@((Model.Receipt.FileSizeBytes / 1024.0).ToString("F1")) KB
- -
Uploaded
-
@Model.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
- - @if (!string.IsNullOrWhiteSpace(Model.Receipt.ParsingNotes)) - { -
AI Notes
-
@Model.Receipt.ParsingNotes
- } -
-
-
- - -
-
- Parse Receipt -
-
-
-

- Using: @Model.SelectedModel - Change -

-
- - -
- -
-
-
- - - @if (Model.ParseLogs.Any()) - { -
-
- Parse History -
-
- @foreach (var log in Model.ParseLogs) - { -
-
- @log.Provider (@log.Model) - @if (log.Success) - { - Success - } - else - { - Failed - } -
- - @log.StartedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt") - - @if (log.Confidence.HasValue) - { -
Confidence: @((log.Confidence.Value * 100).ToString("F1"))%
- } - @if (!string.IsNullOrWhiteSpace(log.Error)) - { -
@log.Error
- } - @if (!string.IsNullOrWhiteSpace(log.RawProviderPayloadJson) && log.RawProviderPayloadJson != "{}") - { - -
-
@log.RawProviderPayloadJson
-
- } -
- } -
-
- } -
+@page "{id:long}" +@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) + }; +} + +
+

Receipt Details

+ + Back to Transaction + +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+ +
+
+
+ @Model.Receipt.FileName +
+
+ @if (Model.Receipt.ContentType == "application/pdf") + { + + } + else + { + Receipt + } +
+
+ + + @if (Model.LineItems.Any()) + { +
+
+ Line Items (@Model.LineItems.Count) +
+
+
+ + + + + + + + + + + + @foreach (var item in Model.LineItems) + { + + + + + + + + } + +
#DescriptionQtyUnit PriceTotal
@item.LineNumber@item.Description + @(item.Quantity?.ToString("0.##") ?? "-") + + @(item.UnitPrice?.ToString("C") ?? "-") + + @(item.LineTotal?.ToString("C") ?? "-") +
+
+
+
+ } +
+ + +
+ +
+
+ Receipt Information +
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.Receipt.Merchant)) + { +
Merchant
+
@Model.Receipt.Merchant
+ } + + @if (Model.Receipt.ReceiptDate.HasValue) + { +
Date
+
@Model.Receipt.ReceiptDate.Value.ToString("MMM d, yyyy")
+ } + + @if (Model.Receipt.Subtotal.HasValue) + { +
Subtotal
+
@Model.Receipt.Subtotal.Value.ToString("C")
+ } + + @if (Model.Receipt.Tax.HasValue) + { +
Tax
+
@Model.Receipt.Tax.Value.ToString("C")
+ } + + @if (Model.Receipt.Total.HasValue) + { +
Total
+
@Model.Receipt.Total.Value.ToString("C")
+ } + +
File Size
+
@((Model.Receipt.FileSizeBytes / 1024.0).ToString("F1")) KB
+ +
Uploaded
+
@Model.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
+ + @if (!string.IsNullOrWhiteSpace(Model.Receipt.ParsingNotes)) + { +
AI Notes
+
@Model.Receipt.ParsingNotes
+ } +
+
+
+ + +
+
+ Parse Receipt +
+
+
+

+ Using: @Model.SelectedModel + Change +

+
+ + +
+ +
+
+
+ + + @if (Model.ParseLogs.Any()) + { +
+
+ Parse History +
+
+ @foreach (var log in Model.ParseLogs) + { +
+
+ @log.Provider (@log.Model) + @if (log.Success) + { + Success + } + else + { + Failed + } +
+ + @log.StartedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt") + + @if (log.Confidence.HasValue) + { +
Confidence: @((log.Confidence.Value * 100).ToString("F1"))%
+ } + @if (!string.IsNullOrWhiteSpace(log.Error)) + { +
@log.Error
+ } + @if (!string.IsNullOrWhiteSpace(log.RawProviderPayloadJson) && log.RawProviderPayloadJson != "{}") + { + +
+
@log.RawProviderPayloadJson
+
+ } +
+ } +
+
+ } +
\ No newline at end of file diff --git a/MoneyMap/Pages/ViewReceipt.cshtml.cs b/MoneyMap/Pages/ViewReceipt.cshtml.cs index aea9f85..621cd8e 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml.cs +++ b/MoneyMap/Pages/ViewReceipt.cshtml.cs @@ -1,111 +1,111 @@ -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 ViewReceiptModel : PageModel - { - private readonly MoneyMapContext _db; - private readonly IReceiptManager _receiptManager; - private readonly IReceiptParseQueue _parseQueue; - private readonly IConfiguration _config; - - public ViewReceiptModel( - MoneyMapContext db, - IReceiptManager receiptManager, - IReceiptParseQueue parseQueue, - IConfiguration config) - { - _db = db; - _receiptManager = receiptManager; - _parseQueue = parseQueue; - _config = config; - } - - public Receipt? Receipt { get; set; } - public List LineItems { get; set; } = new(); - public List ParseLogs { get; set; } = new(); - public string ReceiptUrl { get; set; } = ""; - public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; - - [TempData] - public string? SuccessMessage { get; set; } - - [TempData] - public string? ErrorMessage { get; set; } - - [BindProperty] - public string? ParsingNotes { get; set; } - - public async Task OnGetAsync(long id) - { - Receipt = await _db.Receipts - .Include(r => r.Transaction) - .Include(r => r.LineItems) - .Include(r => r.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc)) - .FirstOrDefaultAsync(r => r.Id == id); - - if (Receipt == null) - return NotFound(); - - LineItems = Receipt.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new(); - ParseLogs = Receipt.ParseLogs?.OrderByDescending(pl => pl.StartedAtUtc).ToList() ?? new(); - - // Load saved parsing notes - ParsingNotes = Receipt.ParsingNotes; - - // Get receipt URL for display - use handler parameter - ReceiptUrl = $"/ViewReceipt/{id}?handler=file"; - - return Page(); - } - - public async Task OnGetFileAsync(long id) - { - var receipt = await _receiptManager.GetReceiptAsync(id); - if (receipt == null) - return NotFound(); - - var filePath = _receiptManager.GetReceiptPhysicalPath(receipt); - if (!System.IO.File.Exists(filePath)) - return NotFound("Receipt file not found on disk."); - - var fileBytes = await System.IO.File.ReadAllBytesAsync(filePath); - - // For PDFs, set content disposition to inline so they display in browser - if (receipt.ContentType == "application/pdf") - { - Response.Headers.Append("Content-Disposition", $"inline; filename=\"{receipt.FileName}\""); - return File(fileBytes, "application/pdf"); - } - - return File(fileBytes, receipt.ContentType); - } - - public async Task OnPostParseAsync(long id) - { - var receipt = await _db.Receipts.FindAsync(id); - - if (receipt == null) - { - ErrorMessage = "Receipt not found."; - return RedirectToPage(new { id }); - } - - // Save parsing notes to the receipt entity so the worker can use them - receipt.ParsingNotes = ParsingNotes; - receipt.ParseStatus = ReceiptParseStatus.Queued; - await _db.SaveChangesAsync(); - - // Enqueue the receipt for parsing - await _parseQueue.EnqueueAsync(id); - - SuccessMessage = "Receipt queued for parsing."; - return RedirectToPage(new { id }); - } - } +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 ViewReceiptModel : PageModel + { + private readonly MoneyMapContext _db; + private readonly IReceiptManager _receiptManager; + private readonly IReceiptParseQueue _parseQueue; + private readonly IConfiguration _config; + + public ViewReceiptModel( + MoneyMapContext db, + IReceiptManager receiptManager, + IReceiptParseQueue parseQueue, + IConfiguration config) + { + _db = db; + _receiptManager = receiptManager; + _parseQueue = parseQueue; + _config = config; + } + + public Receipt? Receipt { get; set; } + public List LineItems { get; set; } = new(); + public List ParseLogs { get; set; } = new(); + public string ReceiptUrl { get; set; } = ""; + public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + [BindProperty] + public string? ParsingNotes { get; set; } + + public async Task OnGetAsync(long id) + { + Receipt = await _db.Receipts + .Include(r => r.Transaction) + .Include(r => r.LineItems) + .Include(r => r.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc)) + .FirstOrDefaultAsync(r => r.Id == id); + + if (Receipt == null) + return NotFound(); + + LineItems = Receipt.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new(); + ParseLogs = Receipt.ParseLogs?.OrderByDescending(pl => pl.StartedAtUtc).ToList() ?? new(); + + // Load saved parsing notes + ParsingNotes = Receipt.ParsingNotes; + + // Get receipt URL for display - use handler parameter + ReceiptUrl = $"/ViewReceipt/{id}?handler=file"; + + return Page(); + } + + public async Task OnGetFileAsync(long id) + { + var receipt = await _receiptManager.GetReceiptAsync(id); + if (receipt == null) + return NotFound(); + + var filePath = _receiptManager.GetReceiptPhysicalPath(receipt); + if (!System.IO.File.Exists(filePath)) + return NotFound("Receipt file not found on disk."); + + var fileBytes = await System.IO.File.ReadAllBytesAsync(filePath); + + // For PDFs, set content disposition to inline so they display in browser + if (receipt.ContentType == "application/pdf") + { + Response.Headers.Append("Content-Disposition", $"inline; filename=\"{receipt.FileName}\""); + return File(fileBytes, "application/pdf"); + } + + return File(fileBytes, receipt.ContentType); + } + + public async Task OnPostParseAsync(long id) + { + var receipt = await _db.Receipts.FindAsync(id); + + if (receipt == null) + { + ErrorMessage = "Receipt not found."; + return RedirectToPage(new { id }); + } + + // Save parsing notes to the receipt entity so the worker can use them + receipt.ParsingNotes = ParsingNotes; + receipt.ParseStatus = ReceiptParseStatus.Queued; + await _db.SaveChangesAsync(); + + // Enqueue the receipt for parsing + await _parseQueue.EnqueueAsync(id); + + SuccessMessage = "Receipt queued for parsing."; + return RedirectToPage(new { id }); + } + } } \ No newline at end of file diff --git a/MoneyMap/Pages/_ProposalTable.cshtml b/MoneyMap/Pages/_ProposalTable.cshtml index 8f28307..8f4f4a1 100644 --- a/MoneyMap/Pages/_ProposalTable.cshtml +++ b/MoneyMap/Pages/_ProposalTable.cshtml @@ -1,101 +1,101 @@ -@model List - -
- - - - - - - - - - - - - - @foreach (var proposal in Model) - { - var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : - proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary"; - - - - - - - - - - } - -
- - TransactionCurrentProposedMerchantConfidenceRule
- - -
@proposal.TransactionName
- @if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo)) - { - @proposal.TransactionMemo - } -
- @proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C") -
-
- @if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory)) - { - @proposal.CurrentCategory - } - else - { - (none) - } - - @proposal.ProposedCategory - @if (!string.IsNullOrWhiteSpace(proposal.Reasoning)) - { -
@proposal.Reasoning
- } -
- @if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant)) - { -
@proposal.ProposedMerchant
- } - @if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) - { -
Pattern: @proposal.ProposedPattern
- } -
- @proposal.Confidence.ToString("P0") - - @if (proposal.HasExistingRule && !proposal.NeedsRuleUpdate) - { - - Exists - - } - else if (proposal.NeedsRuleUpdate) - { -
- - Update -
-
- Currently: @proposal.ExistingRuleCategory -
- } - else - { - - Create - } -
-
+@model List + +
+ + + + + + + + + + + + + + @foreach (var proposal in Model) + { + var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : + proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary"; + + + + + + + + + + } + +
+ + TransactionCurrentProposedMerchantConfidenceRule
+ + +
@proposal.TransactionName
+ @if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo)) + { + @proposal.TransactionMemo + } +
+ @proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C") +
+
+ @if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory)) + { + @proposal.CurrentCategory + } + else + { + (none) + } + + @proposal.ProposedCategory + @if (!string.IsNullOrWhiteSpace(proposal.Reasoning)) + { +
@proposal.Reasoning
+ } +
+ @if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant)) + { +
@proposal.ProposedMerchant
+ } + @if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) + { +
Pattern: @proposal.ProposedPattern
+ } +
+ @proposal.Confidence.ToString("P0") + + @if (proposal.HasExistingRule && !proposal.NeedsRuleUpdate) + { + + Exists + + } + else if (proposal.NeedsRuleUpdate) + { +
+ + Update +
+
+ Currently: @proposal.ExistingRuleCategory +
+ } + else + { + + Create + } +
+
diff --git a/MoneyMap/Pages/_ViewImports.cshtml b/MoneyMap/Pages/_ViewImports.cshtml index c552f84..5ec467f 100644 --- a/MoneyMap/Pages/_ViewImports.cshtml +++ b/MoneyMap/Pages/_ViewImports.cshtml @@ -1,3 +1,3 @@ -@using MoneyMap -@namespace MoneyMap.Pages -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using MoneyMap +@namespace MoneyMap.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/MoneyMap/Pages/_ViewStart.cshtml b/MoneyMap/Pages/_ViewStart.cshtml index a5f1004..6e88aa3 100644 --- a/MoneyMap/Pages/_ViewStart.cshtml +++ b/MoneyMap/Pages/_ViewStart.cshtml @@ -1,3 +1,3 @@ -@{ - Layout = "_Layout"; -} +@{ + Layout = "_Layout"; +} diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index bc0c3c7..a94d677 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -1,86 +1,86 @@ -using System.Globalization; -using MoneyMap.Core; -using MoneyMap.Services; -using MoneyMap.WebApp.Services; - -// Set default culture to en-US for currency formatting ($) -var culture = new CultureInfo("en-US"); -CultureInfo.DefaultThreadCurrentCulture = culture; -CultureInfo.DefaultThreadCurrentUICulture = culture; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddMoneyMapCore(builder.Configuration); -builder.Services.AddSingleton(); - -// Add session support -builder.Services.AddDistributedMemoryCache(); -builder.Services.AddSession(options => -{ - options.IdleTimeout = TimeSpan.FromMinutes(30); - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - options.IOTimeout = TimeSpan.FromMinutes(5); -}); - -// Use session-based TempData provider to avoid cookie size limits -builder.Services.AddRazorPages() - .AddSessionStateTempDataProvider(); - -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -// Receipt parse queue and background worker -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); - -// AI vision clients and tool-use support -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddHttpClient(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// AI categorization service -builder.Services.AddHttpClient(); - -// Model warmup service -builder.Services.AddHostedService(); - -var app = builder.Build(); - -// Seed default category mappings on startup -using (var scope = app.Services.CreateScope()) -{ - var categorizer = scope.ServiceProvider.GetRequiredService(); - await categorizer.SeedDefaultMappingsAsync(); -} - -// Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error"); - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseStaticFiles(); - -app.UseRouting(); - -app.UseSession(); - -app.UseAuthorization(); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.MapRazorPages(); -app.MapControllers(); - -app.Run(); +using System.Globalization; +using MoneyMap.Core; +using MoneyMap.Services; +using MoneyMap.WebApp.Services; + +// Set default culture to en-US for currency formatting ($) +var culture = new CultureInfo("en-US"); +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMoneyMapCore(builder.Configuration); +builder.Services.AddSingleton(); + +// Add session support +builder.Services.AddDistributedMemoryCache(); +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.IOTimeout = TimeSpan.FromMinutes(5); +}); + +// Use session-based TempData provider to avoid cookie size limits +builder.Services.AddRazorPages() + .AddSessionStateTempDataProvider(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Receipt parse queue and background worker +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +// AI vision clients and tool-use support +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// AI categorization service +builder.Services.AddHttpClient(); + +// Model warmup service +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// Seed default category mappings on startup +using (var scope = app.Services.CreateScope()) +{ + var categorizer = scope.ServiceProvider.GetRequiredService(); + await categorizer.SeedDefaultMappingsAsync(); +} + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseRouting(); + +app.UseSession(); + +app.UseAuthorization(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapRazorPages(); +app.MapControllers(); + +app.Run(); diff --git a/MoneyMap/Properties/launchSettings.json b/MoneyMap/Properties/launchSettings.json index 1b0cb9d..45bb15f 100644 --- a/MoneyMap/Properties/launchSettings.json +++ b/MoneyMap/Properties/launchSettings.json @@ -1,38 +1,38 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:10923", - "sslPort": 44362 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5185", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7119;http://localhost:5185", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:10923", + "sslPort": 44362 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5185", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7119;http://localhost:5185", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/MoneyMap/Services/ModelWarmupService.cs b/MoneyMap/Services/ModelWarmupService.cs index 4a45382..f459414 100644 --- a/MoneyMap/Services/ModelWarmupService.cs +++ b/MoneyMap/Services/ModelWarmupService.cs @@ -1,63 +1,63 @@ -namespace MoneyMap.Services -{ - /// - /// Background service that warms up the configured AI model on application startup. - /// Sends a simple prompt to preload the model without blocking the UI. - /// - public class ModelWarmupService : BackgroundService - { - private readonly IServiceProvider _serviceProvider; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public ModelWarmupService( - IServiceProvider serviceProvider, - IConfiguration configuration, - ILogger logger) - { - _serviceProvider = serviceProvider; - _configuration = configuration; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - var model = _configuration["AI:ReceiptParsingModel"]; - - // Only warm up local models (llamacpp: prefix) - if (string.IsNullOrEmpty(model) || !model.StartsWith("llamacpp:")) - { - _logger.LogInformation("Model warmup skipped - configured model is not a local model: {Model}", model ?? "(none)"); - return; - } - - // Small delay to let the app fully start - await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); - - _logger.LogInformation("Starting model warmup for: {Model}", model); - - try - { - using var scope = _serviceProvider.CreateScope(); - var llamaClient = scope.ServiceProvider.GetRequiredService(); - - // Send a simple prompt to load the model - var result = await llamaClient.SendTextPromptAsync("Hello", model); - - if (result.IsSuccess) - { - _logger.LogInformation("Model warmup completed successfully for: {Model}", model); - } - else - { - _logger.LogWarning("Model warmup failed for {Model}: {Error}", model, result.ErrorMessage); - } - } - catch (Exception ex) - { - // Don't crash the app if warmup fails - just log it - _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); - } - } - } -} +namespace MoneyMap.Services +{ + /// + /// Background service that warms up the configured AI model on application startup. + /// Sends a simple prompt to preload the model without blocking the UI. + /// + public class ModelWarmupService : BackgroundService + { + private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ModelWarmupService( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger) + { + _serviceProvider = serviceProvider; + _configuration = configuration; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var model = _configuration["AI:ReceiptParsingModel"]; + + // Only warm up local models (llamacpp: prefix) + if (string.IsNullOrEmpty(model) || !model.StartsWith("llamacpp:")) + { + _logger.LogInformation("Model warmup skipped - configured model is not a local model: {Model}", model ?? "(none)"); + return; + } + + // Small delay to let the app fully start + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + + _logger.LogInformation("Starting model warmup for: {Model}", model); + + try + { + using var scope = _serviceProvider.CreateScope(); + var llamaClient = scope.ServiceProvider.GetRequiredService(); + + // Send a simple prompt to load the model + var result = await llamaClient.SendTextPromptAsync("Hello", model); + + if (result.IsSuccess) + { + _logger.LogInformation("Model warmup completed successfully for: {Model}", model); + } + else + { + _logger.LogWarning("Model warmup failed for {Model}: {Error}", model, result.ErrorMessage); + } + } + catch (Exception ex) + { + // Don't crash the app if warmup fails - just log it + _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); + } + } + } +} diff --git a/MoneyMap/Services/ReceiptParseWorkerService.cs b/MoneyMap/Services/ReceiptParseWorkerService.cs index f3270c3..ca83eb0 100644 --- a/MoneyMap/Services/ReceiptParseWorkerService.cs +++ b/MoneyMap/Services/ReceiptParseWorkerService.cs @@ -1,129 +1,129 @@ -using Microsoft.EntityFrameworkCore; -using MoneyMap.Data; -using MoneyMap.Models; - -namespace MoneyMap.Services -{ - public class ReceiptParseWorkerService : BackgroundService - { - private readonly IReceiptParseQueue _queue; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public ReceiptParseWorkerService( - IReceiptParseQueue queue, - IServiceScopeFactory scopeFactory, - ILogger logger) - { - _queue = queue; - _scopeFactory = scopeFactory; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("ReceiptParseWorkerService starting"); - - await RecoverPendingItemsAsync(stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - long receiptId = 0; - try - { - receiptId = await _queue.DequeueAsync(stoppingToken); - _queue.SetCurrentlyProcessing(receiptId); - - await SetParseStatusAsync(receiptId, ReceiptParseStatus.Parsing); - - _logger.LogInformation("Processing receipt {ReceiptId} from parse queue", receiptId); - - using var scope = _scopeFactory.CreateScope(); - var parser = scope.ServiceProvider.GetRequiredService(); - var result = await parser.ParseReceiptAsync(receiptId); - - var finalStatus = result.IsSuccess - ? ReceiptParseStatus.Completed - : ReceiptParseStatus.Failed; - - await SetParseStatusAsync(receiptId, finalStatus); - - _logger.LogInformation( - "Receipt {ReceiptId} parse {Status}: {Message}", - receiptId, finalStatus, result.Message); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing receipt {ReceiptId} from parse queue", receiptId); - - if (receiptId > 0) - { - try - { - await SetParseStatusAsync(receiptId, ReceiptParseStatus.Failed); - } - catch (Exception statusEx) - { - _logger.LogError(statusEx, "Failed to update ParseStatus to Failed for receipt {ReceiptId}", receiptId); - } - } - } - finally - { - _queue.SetCurrentlyProcessing(null); - } - } - - _logger.LogInformation("ReceiptParseWorkerService stopping"); - } - - private async Task RecoverPendingItemsAsync(CancellationToken ct) - { - try - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var pendingIds = await db.Receipts - .Where(r => r.ParseStatus == ReceiptParseStatus.Queued - || r.ParseStatus == ReceiptParseStatus.Parsing) - .OrderBy(r => r.UploadedAtUtc) - .Select(r => r.Id) - .ToListAsync(ct); - - if (pendingIds.Count > 0) - { - _logger.LogInformation( - "Recovering {Count} receipts with pending parse status", pendingIds.Count); - - foreach (var id in pendingIds) - { - await db.Receipts - .Where(r => r.Id == id) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, ReceiptParseStatus.Queued), ct); - } - - await _queue.EnqueueManyAsync(pendingIds, ct); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recovering pending parse items on startup"); - } - } - - private async Task SetParseStatusAsync(long receiptId, ReceiptParseStatus status) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - await db.Receipts - .Where(r => r.Id == receiptId) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, status)); - } - } -} +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services +{ + public class ReceiptParseWorkerService : BackgroundService + { + private readonly IReceiptParseQueue _queue; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public ReceiptParseWorkerService( + IReceiptParseQueue queue, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _queue = queue; + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("ReceiptParseWorkerService starting"); + + await RecoverPendingItemsAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + long receiptId = 0; + try + { + receiptId = await _queue.DequeueAsync(stoppingToken); + _queue.SetCurrentlyProcessing(receiptId); + + await SetParseStatusAsync(receiptId, ReceiptParseStatus.Parsing); + + _logger.LogInformation("Processing receipt {ReceiptId} from parse queue", receiptId); + + using var scope = _scopeFactory.CreateScope(); + var parser = scope.ServiceProvider.GetRequiredService(); + var result = await parser.ParseReceiptAsync(receiptId); + + var finalStatus = result.IsSuccess + ? ReceiptParseStatus.Completed + : ReceiptParseStatus.Failed; + + await SetParseStatusAsync(receiptId, finalStatus); + + _logger.LogInformation( + "Receipt {ReceiptId} parse {Status}: {Message}", + receiptId, finalStatus, result.Message); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing receipt {ReceiptId} from parse queue", receiptId); + + if (receiptId > 0) + { + try + { + await SetParseStatusAsync(receiptId, ReceiptParseStatus.Failed); + } + catch (Exception statusEx) + { + _logger.LogError(statusEx, "Failed to update ParseStatus to Failed for receipt {ReceiptId}", receiptId); + } + } + } + finally + { + _queue.SetCurrentlyProcessing(null); + } + } + + _logger.LogInformation("ReceiptParseWorkerService stopping"); + } + + private async Task RecoverPendingItemsAsync(CancellationToken ct) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pendingIds = await db.Receipts + .Where(r => r.ParseStatus == ReceiptParseStatus.Queued + || r.ParseStatus == ReceiptParseStatus.Parsing) + .OrderBy(r => r.UploadedAtUtc) + .Select(r => r.Id) + .ToListAsync(ct); + + if (pendingIds.Count > 0) + { + _logger.LogInformation( + "Recovering {Count} receipts with pending parse status", pendingIds.Count); + + foreach (var id in pendingIds) + { + await db.Receipts + .Where(r => r.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, ReceiptParseStatus.Queued), ct); + } + + await _queue.EnqueueManyAsync(pendingIds, ct); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recovering pending parse items on startup"); + } + } + + private async Task SetParseStatusAsync(long receiptId, ReceiptParseStatus status) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await db.Receipts + .Where(r => r.Id == receiptId) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, status)); + } + } +} diff --git a/MoneyMap/Services/WebReceiptStorageOptions.cs b/MoneyMap/Services/WebReceiptStorageOptions.cs index df96d76..99e9e16 100644 --- a/MoneyMap/Services/WebReceiptStorageOptions.cs +++ b/MoneyMap/Services/WebReceiptStorageOptions.cs @@ -1,17 +1,17 @@ -using MoneyMap.Services; - -namespace MoneyMap.WebApp.Services; - -public class WebReceiptStorageOptions : IReceiptStorageOptions -{ - public string ReceiptsBasePath { get; } - - public WebReceiptStorageOptions(IWebHostEnvironment env, IConfiguration config) - { - var relativePath = config["Receipts:StoragePath"] ?? "receipts"; - if (Path.IsPathRooted(relativePath)) - ReceiptsBasePath = relativePath; - else - ReceiptsBasePath = Path.Combine(env.WebRootPath, relativePath); - } -} +using MoneyMap.Services; + +namespace MoneyMap.WebApp.Services; + +public class WebReceiptStorageOptions : IReceiptStorageOptions +{ + public string ReceiptsBasePath { get; } + + public WebReceiptStorageOptions(IWebHostEnvironment env, IConfiguration config) + { + var relativePath = config["Receipts:StoragePath"] ?? "receipts"; + if (Path.IsPathRooted(relativePath)) + ReceiptsBasePath = relativePath; + else + ReceiptsBasePath = Path.Combine(env.WebRootPath, relativePath); + } +} diff --git a/MoneyMap/appsettings.Development.json b/MoneyMap/appsettings.Development.json index 770d3e9..f042c67 100644 --- a/MoneyMap/appsettings.Development.json +++ b/MoneyMap/appsettings.Development.json @@ -1,9 +1,9 @@ -{ - "DetailedErrors": true, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MoneyMap/appsettings.json b/MoneyMap/appsettings.json index 9892858..dde9c8b 100644 --- a/MoneyMap/appsettings.json +++ b/MoneyMap/appsettings.json @@ -1,26 +1,26 @@ -{ - "ConnectionStrings": { - "MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;" - }, - "Receipts": { - "StoragePath": "\\\\TRUENAS\\receipts" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Kestrel": { - "Endpoints": { - "Http": { - "Url": "http://0.0.0.0:5010" - } - } - }, - "AllowedHosts": "*", - "AI": { - "ModelsEndpoint": "http://athena.lan:11434", - "ReceiptParsingModel": "llamacpp:Qwen3-VL-32B-Thinking-Q8_0" - } -} +{ + "ConnectionStrings": { + "MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;" + }, + "Receipts": { + "StoragePath": "\\\\TRUENAS\\receipts" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:5010" + } + } + }, + "AllowedHosts": "*", + "AI": { + "ModelsEndpoint": "http://athena.lan:11434", + "ReceiptParsingModel": "llamacpp:Qwen3-VL-32B-Thinking-Q8_0" + } +} diff --git a/MoneyMap/libman.json b/MoneyMap/libman.json index 1507306..03d183d 100644 --- a/MoneyMap/libman.json +++ b/MoneyMap/libman.json @@ -1,14 +1,14 @@ -{ - "version": "3.0", - "defaultProvider": "cdnjs", - "libraries": [ - { - "library": "bootstrap@5.3.8", - "destination": "wwwroot/lib/bootstrap/" - }, - { - "library": "jquery@3.7.1", - "destination": "wwwroot/lib/jquery/" - } - ] +{ + "version": "3.0", + "defaultProvider": "cdnjs", + "libraries": [ + { + "library": "bootstrap@5.3.8", + "destination": "wwwroot/lib/bootstrap/" + }, + { + "library": "jquery@3.7.1", + "destination": "wwwroot/lib/jquery/" + } + ] } \ No newline at end of file diff --git a/MoneyMap/wwwroot/css/site.css b/MoneyMap/wwwroot/css/site.css index d3b054b..b9b7aa3 100644 --- a/MoneyMap/wwwroot/css/site.css +++ b/MoneyMap/wwwroot/css/site.css @@ -1,431 +1,431 @@ -/* ============================================ - MoneyMap – Modern Fintech Light Theme - ============================================ */ - -/* --- Palette (CSS custom properties) --- */ -:root { - --mm-bg: #f7f8fc; - --mm-surface: #ffffff; - --mm-primary: #6366f1; - --mm-primary-hover:#4f46e5; - --mm-primary-soft: rgba(99,102,241,0.10); - --mm-success: #10b981; - --mm-success-soft: rgba(16,185,129,0.10); - --mm-danger: #ef4444; - --mm-danger-soft: rgba(239,68,68,0.10); - --mm-warning: #f59e0b; - --mm-warning-soft: rgba(245,158,11,0.10); - --mm-info: #06b6d4; - --mm-info-soft: rgba(6,182,212,0.10); - --mm-text: #1e293b; - --mm-text-secondary: #64748b; - --mm-border: #e2e8f0; - --mm-shadow-sm: 0 1px 3px rgba(0,0,0,0.06); - --mm-shadow: 0 4px 12px rgba(0,0,0,0.06); - --mm-radius: 12px; - --mm-radius-sm: 8px; -} - -/* --- Base --- */ -html { - font-size: 14px; - position: relative; - min-height: 100%; -} -@media (min-width: 768px) { - html { font-size: 16px; } -} - -body { - margin-bottom: 60px; - background-color: var(--mm-bg); - color: var(--mm-text); - font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - -webkit-font-smoothing: antialiased; -} - -/* --- Focus rings --- */ -.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, -.form-control:focus, .form-check-input:focus, .form-select:focus { - box-shadow: 0 0 0 0.2rem rgba(99,102,241,0.35); - border-color: var(--mm-primary); -} - -/* --- Navbar --- */ -.navbar { - background-color: var(--mm-surface); - border-bottom: 1px solid var(--mm-border) !important; - box-shadow: var(--mm-shadow-sm); - padding-top: 0.65rem; - padding-bottom: 0.65rem; -} -.navbar-brand { - color: var(--mm-primary) !important; - font-weight: 700; - font-size: 1.2rem; - letter-spacing: -0.02em; -} -.navbar .nav-link { - color: var(--mm-text-secondary) !important; - font-weight: 500; - font-size: 0.9rem; - transition: color 0.15s ease; -} -.navbar .nav-link:hover, -.navbar .nav-link:focus { - color: var(--mm-primary) !important; -} -.navbar .nav-item.dropdown .nav-link.dropdown-toggle.active-parent { - color: var(--mm-primary) !important; -} -.navbar .dropdown-menu { - border: 1px solid var(--mm-border); - border-radius: var(--mm-radius-sm); - box-shadow: var(--mm-shadow); - padding: 0.35rem 0; -} -.navbar .dropdown-item { - font-size: 0.875rem; - padding: 0.45rem 1rem; - color: var(--mm-text); -} -.navbar .dropdown-item:hover, -.navbar .dropdown-item:focus { - background-color: var(--mm-primary-soft); - color: var(--mm-primary); -} -.navbar .btn-primary, -.navbar .btn-sm.btn-primary { - background-color: var(--mm-primary); - border-color: var(--mm-primary); - border-radius: var(--mm-radius-sm); - font-weight: 600; - font-size: 0.85rem; -} -.navbar .btn-primary:hover { - background-color: var(--mm-primary-hover); - border-color: var(--mm-primary-hover); -} -/* Hamburger icon for light navbar */ -.navbar-toggler { - border-color: var(--mm-border); -} -.navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(100,116,139,0.9)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} - -/* --- Breadcrumbs --- */ -.breadcrumb-nav { margin-bottom: 1rem; } -.breadcrumb-nav .breadcrumb { - background: transparent; - padding: 0; - margin-bottom: 0; - font-size: 0.85rem; -} -.breadcrumb-nav .breadcrumb a { - color: var(--mm-primary); -} -.breadcrumb-item.active { - color: var(--mm-text-secondary); -} - -/* --- Cards --- */ -.card { - background-color: var(--mm-surface); - border: 1px solid var(--mm-border); - border-radius: var(--mm-radius) !important; - box-shadow: var(--mm-shadow-sm); - transition: box-shadow 0.2s ease; -} -.card:hover { - box-shadow: var(--mm-shadow); -} -.card-header { - background-color: transparent; - border-bottom: 1px solid var(--mm-border); - font-weight: 600; - font-size: 0.95rem; - color: var(--mm-text); - padding: 0.85rem 1.15rem; -} -.card-body { - padding: 1.15rem; -} - -/* Stat cards on dashboard */ -.card .fs-3 { - color: var(--mm-text); - font-weight: 700; -} - -/* --- Quick-action cards --- */ -.quick-action-card { - transition: transform 0.15s ease, box-shadow 0.15s ease; - text-decoration: none; - color: inherit; -} -.quick-action-card:hover { - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(99,102,241,0.12) !important; -} -.quick-action-icon { - width: 48px; - height: 48px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; -} - -/* --- Buttons --- */ -.btn-primary { - background-color: var(--mm-primary); - border-color: var(--mm-primary); - border-radius: var(--mm-radius-sm); - font-weight: 600; -} -.btn-primary:hover, .btn-primary:active { - background-color: var(--mm-primary-hover); - border-color: var(--mm-primary-hover); -} -.btn-outline-primary { - color: var(--mm-primary); - border-color: var(--mm-primary); - border-radius: var(--mm-radius-sm); -} -.btn-outline-primary:hover { - background-color: var(--mm-primary); - border-color: var(--mm-primary); - color: #fff; -} -.btn-outline-secondary { - color: var(--mm-text-secondary); - border-color: var(--mm-border); - border-radius: var(--mm-radius-sm); -} -.btn-outline-secondary:hover { - background-color: var(--mm-bg); - border-color: var(--mm-border); - color: var(--mm-text); -} -.btn-success { - background-color: var(--mm-success); - border-color: var(--mm-success); - border-radius: var(--mm-radius-sm); -} -.btn-danger { - background-color: var(--mm-danger); - border-color: var(--mm-danger); - border-radius: var(--mm-radius-sm); -} - -/* --- Tables --- */ -.table { - --bs-table-hover-bg: rgba(99,102,241,0.04); - --bs-table-striped-bg: rgba(0,0,0,0.015); - color: var(--mm-text); - font-size: 0.9rem; -} -.table > thead > tr > th { - background-color: var(--mm-bg); - color: var(--mm-text-secondary); - font-weight: 600; - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.04em; - border-bottom: 2px solid var(--mm-border); - padding: 0.7rem 0.75rem; - white-space: nowrap; -} -.table > tbody > tr > td { - padding: 0.65rem 0.75rem; - vertical-align: middle; - border-bottom: 1px solid rgba(0,0,0,0.04); -} -.table > tbody > tr:last-child > td { - border-bottom: none; -} -.table-hover > tbody > tr { - transition: background-color 0.1s ease; -} - -/* --- Forms --- */ -.form-control, .form-select { - border-radius: var(--mm-radius-sm); - border: 1px solid var(--mm-border); - font-size: 0.9rem; - padding: 0.5rem 0.75rem; -} -.form-control:hover, .form-select:hover { - border-color: #cbd5e1; -} -.form-label { - color: var(--mm-text); - font-weight: 600; - font-size: 0.875rem; -} - -/* --- Badges --- */ -.badge { - font-weight: 600; - font-size: 0.75rem; - border-radius: 6px; - padding: 0.3em 0.6em; -} -.badge.bg-success { - background-color: var(--mm-success) !important; -} -.badge.bg-danger { - background-color: var(--mm-danger) !important; -} -.badge.bg-warning { - background-color: var(--mm-warning) !important; -} -.badge.bg-info { - background-color: var(--mm-info) !important; -} -.badge.bg-primary { - background-color: var(--mm-primary) !important; -} -.badge.bg-secondary { - background-color: #e2e8f0 !important; - color: var(--mm-text-secondary) !important; -} - -/* --- Progress bars --- */ -.progress { - background-color: #e2e8f0; - border-radius: 999px; - overflow: hidden; -} -.progress-bar { - border-radius: 999px; - font-size: 0.7rem; - font-weight: 600; -} -.progress-bar.bg-success { background-color: var(--mm-success) !important; } -.progress-bar.bg-warning { background-color: var(--mm-warning) !important; } -.progress-bar.bg-danger { background-color: var(--mm-danger) !important; } - -/* --- Alerts --- */ -.alert { - border-radius: var(--mm-radius-sm); - font-size: 0.9rem; -} -.alert-success { - background-color: var(--mm-success-soft); - border-color: rgba(16,185,129,0.2); - color: #065f46; -} -.alert-danger { - background-color: var(--mm-danger-soft); - border-color: rgba(239,68,68,0.2); - color: #991b1b; -} -.alert-info { - background-color: var(--mm-info-soft); - border-color: rgba(6,182,212,0.2); - color: #155e75; -} - -/* --- Modals --- */ -.modal-content { - border-radius: var(--mm-radius); - border: 1px solid var(--mm-border); - box-shadow: 0 8px 30px rgba(0,0,0,0.12); -} -.modal-header { - border-bottom: 1px solid var(--mm-border); -} -.modal-footer { - border-top: 1px solid var(--mm-border); -} - -/* --- Footer --- */ -.footer { - background-color: var(--mm-surface); - border-top: 1px solid var(--mm-border) !important; - padding: 1rem 0; - font-size: 0.85rem; - color: var(--mm-text-secondary); -} - -/* --- Links --- */ -a { - color: var(--mm-primary); -} -a:hover { - color: var(--mm-primary-hover); -} -.text-decoration-none.text-body:hover { - color: var(--mm-primary) !important; -} - -/* --- Amount coloring --- */ -.text-success { color: var(--mm-success) !important; } -.text-danger { color: var(--mm-danger) !important; } - -/* --- Muted text --- */ -.text-muted { - color: var(--mm-text-secondary) !important; -} - -/* --- Pagination --- */ -.page-link { - border-radius: var(--mm-radius-sm); - border: 1px solid var(--mm-border); - color: var(--mm-text-secondary); - font-size: 0.875rem; - margin: 0 2px; -} -.page-link:hover { - background-color: var(--mm-primary-soft); - border-color: var(--mm-primary); - color: var(--mm-primary); -} -.page-item.active .page-link { - background-color: var(--mm-primary); - border-color: var(--mm-primary); - color: #fff; -} - -/* --- Selection bar (Transactions page) --- */ -.alert.alert-info.sticky-top { - background-color: var(--mm-primary-soft); - border-color: rgba(99,102,241,0.2); - color: var(--mm-primary); -} - -/* --- Headings --- */ -h1, h2, h3, h4, h5, h6 { - color: var(--mm-text); - font-weight: 700; - letter-spacing: -0.02em; -} - -/* --- Utility overrides for light theme --- */ -.bg-primary.bg-opacity-25 { - background-color: var(--mm-primary-soft) !important; -} -.bg-warning.bg-opacity-25 { - background-color: var(--mm-warning-soft) !important; -} -.bg-info.bg-opacity-25 { - background-color: var(--mm-info-soft) !important; -} -.bg-success.bg-opacity-25 { - background-color: var(--mm-success-soft) !important; -} -.text-primary { color: var(--mm-primary) !important; } -.text-warning { color: var(--mm-warning) !important; } -.text-info { color: var(--mm-info) !important; } - -/* --- Scrollbar (subtle for light theme) --- */ -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 999px; -} -::-webkit-scrollbar-thumb:hover { background: #94a3b8; } +/* ============================================ + MoneyMap – Modern Fintech Light Theme + ============================================ */ + +/* --- Palette (CSS custom properties) --- */ +:root { + --mm-bg: #f7f8fc; + --mm-surface: #ffffff; + --mm-primary: #6366f1; + --mm-primary-hover:#4f46e5; + --mm-primary-soft: rgba(99,102,241,0.10); + --mm-success: #10b981; + --mm-success-soft: rgba(16,185,129,0.10); + --mm-danger: #ef4444; + --mm-danger-soft: rgba(239,68,68,0.10); + --mm-warning: #f59e0b; + --mm-warning-soft: rgba(245,158,11,0.10); + --mm-info: #06b6d4; + --mm-info-soft: rgba(6,182,212,0.10); + --mm-text: #1e293b; + --mm-text-secondary: #64748b; + --mm-border: #e2e8f0; + --mm-shadow-sm: 0 1px 3px rgba(0,0,0,0.06); + --mm-shadow: 0 4px 12px rgba(0,0,0,0.06); + --mm-radius: 12px; + --mm-radius-sm: 8px; +} + +/* --- Base --- */ +html { + font-size: 14px; + position: relative; + min-height: 100%; +} +@media (min-width: 768px) { + html { font-size: 16px; } +} + +body { + margin-bottom: 60px; + background-color: var(--mm-bg); + color: var(--mm-text); + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* --- Focus rings --- */ +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, +.form-control:focus, .form-check-input:focus, .form-select:focus { + box-shadow: 0 0 0 0.2rem rgba(99,102,241,0.35); + border-color: var(--mm-primary); +} + +/* --- Navbar --- */ +.navbar { + background-color: var(--mm-surface); + border-bottom: 1px solid var(--mm-border) !important; + box-shadow: var(--mm-shadow-sm); + padding-top: 0.65rem; + padding-bottom: 0.65rem; +} +.navbar-brand { + color: var(--mm-primary) !important; + font-weight: 700; + font-size: 1.2rem; + letter-spacing: -0.02em; +} +.navbar .nav-link { + color: var(--mm-text-secondary) !important; + font-weight: 500; + font-size: 0.9rem; + transition: color 0.15s ease; +} +.navbar .nav-link:hover, +.navbar .nav-link:focus { + color: var(--mm-primary) !important; +} +.navbar .nav-item.dropdown .nav-link.dropdown-toggle.active-parent { + color: var(--mm-primary) !important; +} +.navbar .dropdown-menu { + border: 1px solid var(--mm-border); + border-radius: var(--mm-radius-sm); + box-shadow: var(--mm-shadow); + padding: 0.35rem 0; +} +.navbar .dropdown-item { + font-size: 0.875rem; + padding: 0.45rem 1rem; + color: var(--mm-text); +} +.navbar .dropdown-item:hover, +.navbar .dropdown-item:focus { + background-color: var(--mm-primary-soft); + color: var(--mm-primary); +} +.navbar .btn-primary, +.navbar .btn-sm.btn-primary { + background-color: var(--mm-primary); + border-color: var(--mm-primary); + border-radius: var(--mm-radius-sm); + font-weight: 600; + font-size: 0.85rem; +} +.navbar .btn-primary:hover { + background-color: var(--mm-primary-hover); + border-color: var(--mm-primary-hover); +} +/* Hamburger icon for light navbar */ +.navbar-toggler { + border-color: var(--mm-border); +} +.navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba(100,116,139,0.9)' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +/* --- Breadcrumbs --- */ +.breadcrumb-nav { margin-bottom: 1rem; } +.breadcrumb-nav .breadcrumb { + background: transparent; + padding: 0; + margin-bottom: 0; + font-size: 0.85rem; +} +.breadcrumb-nav .breadcrumb a { + color: var(--mm-primary); +} +.breadcrumb-item.active { + color: var(--mm-text-secondary); +} + +/* --- Cards --- */ +.card { + background-color: var(--mm-surface); + border: 1px solid var(--mm-border); + border-radius: var(--mm-radius) !important; + box-shadow: var(--mm-shadow-sm); + transition: box-shadow 0.2s ease; +} +.card:hover { + box-shadow: var(--mm-shadow); +} +.card-header { + background-color: transparent; + border-bottom: 1px solid var(--mm-border); + font-weight: 600; + font-size: 0.95rem; + color: var(--mm-text); + padding: 0.85rem 1.15rem; +} +.card-body { + padding: 1.15rem; +} + +/* Stat cards on dashboard */ +.card .fs-3 { + color: var(--mm-text); + font-weight: 700; +} + +/* --- Quick-action cards --- */ +.quick-action-card { + transition: transform 0.15s ease, box-shadow 0.15s ease; + text-decoration: none; + color: inherit; +} +.quick-action-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(99,102,241,0.12) !important; +} +.quick-action-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; +} + +/* --- Buttons --- */ +.btn-primary { + background-color: var(--mm-primary); + border-color: var(--mm-primary); + border-radius: var(--mm-radius-sm); + font-weight: 600; +} +.btn-primary:hover, .btn-primary:active { + background-color: var(--mm-primary-hover); + border-color: var(--mm-primary-hover); +} +.btn-outline-primary { + color: var(--mm-primary); + border-color: var(--mm-primary); + border-radius: var(--mm-radius-sm); +} +.btn-outline-primary:hover { + background-color: var(--mm-primary); + border-color: var(--mm-primary); + color: #fff; +} +.btn-outline-secondary { + color: var(--mm-text-secondary); + border-color: var(--mm-border); + border-radius: var(--mm-radius-sm); +} +.btn-outline-secondary:hover { + background-color: var(--mm-bg); + border-color: var(--mm-border); + color: var(--mm-text); +} +.btn-success { + background-color: var(--mm-success); + border-color: var(--mm-success); + border-radius: var(--mm-radius-sm); +} +.btn-danger { + background-color: var(--mm-danger); + border-color: var(--mm-danger); + border-radius: var(--mm-radius-sm); +} + +/* --- Tables --- */ +.table { + --bs-table-hover-bg: rgba(99,102,241,0.04); + --bs-table-striped-bg: rgba(0,0,0,0.015); + color: var(--mm-text); + font-size: 0.9rem; +} +.table > thead > tr > th { + background-color: var(--mm-bg); + color: var(--mm-text-secondary); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 2px solid var(--mm-border); + padding: 0.7rem 0.75rem; + white-space: nowrap; +} +.table > tbody > tr > td { + padding: 0.65rem 0.75rem; + vertical-align: middle; + border-bottom: 1px solid rgba(0,0,0,0.04); +} +.table > tbody > tr:last-child > td { + border-bottom: none; +} +.table-hover > tbody > tr { + transition: background-color 0.1s ease; +} + +/* --- Forms --- */ +.form-control, .form-select { + border-radius: var(--mm-radius-sm); + border: 1px solid var(--mm-border); + font-size: 0.9rem; + padding: 0.5rem 0.75rem; +} +.form-control:hover, .form-select:hover { + border-color: #cbd5e1; +} +.form-label { + color: var(--mm-text); + font-weight: 600; + font-size: 0.875rem; +} + +/* --- Badges --- */ +.badge { + font-weight: 600; + font-size: 0.75rem; + border-radius: 6px; + padding: 0.3em 0.6em; +} +.badge.bg-success { + background-color: var(--mm-success) !important; +} +.badge.bg-danger { + background-color: var(--mm-danger) !important; +} +.badge.bg-warning { + background-color: var(--mm-warning) !important; +} +.badge.bg-info { + background-color: var(--mm-info) !important; +} +.badge.bg-primary { + background-color: var(--mm-primary) !important; +} +.badge.bg-secondary { + background-color: #e2e8f0 !important; + color: var(--mm-text-secondary) !important; +} + +/* --- Progress bars --- */ +.progress { + background-color: #e2e8f0; + border-radius: 999px; + overflow: hidden; +} +.progress-bar { + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; +} +.progress-bar.bg-success { background-color: var(--mm-success) !important; } +.progress-bar.bg-warning { background-color: var(--mm-warning) !important; } +.progress-bar.bg-danger { background-color: var(--mm-danger) !important; } + +/* --- Alerts --- */ +.alert { + border-radius: var(--mm-radius-sm); + font-size: 0.9rem; +} +.alert-success { + background-color: var(--mm-success-soft); + border-color: rgba(16,185,129,0.2); + color: #065f46; +} +.alert-danger { + background-color: var(--mm-danger-soft); + border-color: rgba(239,68,68,0.2); + color: #991b1b; +} +.alert-info { + background-color: var(--mm-info-soft); + border-color: rgba(6,182,212,0.2); + color: #155e75; +} + +/* --- Modals --- */ +.modal-content { + border-radius: var(--mm-radius); + border: 1px solid var(--mm-border); + box-shadow: 0 8px 30px rgba(0,0,0,0.12); +} +.modal-header { + border-bottom: 1px solid var(--mm-border); +} +.modal-footer { + border-top: 1px solid var(--mm-border); +} + +/* --- Footer --- */ +.footer { + background-color: var(--mm-surface); + border-top: 1px solid var(--mm-border) !important; + padding: 1rem 0; + font-size: 0.85rem; + color: var(--mm-text-secondary); +} + +/* --- Links --- */ +a { + color: var(--mm-primary); +} +a:hover { + color: var(--mm-primary-hover); +} +.text-decoration-none.text-body:hover { + color: var(--mm-primary) !important; +} + +/* --- Amount coloring --- */ +.text-success { color: var(--mm-success) !important; } +.text-danger { color: var(--mm-danger) !important; } + +/* --- Muted text --- */ +.text-muted { + color: var(--mm-text-secondary) !important; +} + +/* --- Pagination --- */ +.page-link { + border-radius: var(--mm-radius-sm); + border: 1px solid var(--mm-border); + color: var(--mm-text-secondary); + font-size: 0.875rem; + margin: 0 2px; +} +.page-link:hover { + background-color: var(--mm-primary-soft); + border-color: var(--mm-primary); + color: var(--mm-primary); +} +.page-item.active .page-link { + background-color: var(--mm-primary); + border-color: var(--mm-primary); + color: #fff; +} + +/* --- Selection bar (Transactions page) --- */ +.alert.alert-info.sticky-top { + background-color: var(--mm-primary-soft); + border-color: rgba(99,102,241,0.2); + color: var(--mm-primary); +} + +/* --- Headings --- */ +h1, h2, h3, h4, h5, h6 { + color: var(--mm-text); + font-weight: 700; + letter-spacing: -0.02em; +} + +/* --- Utility overrides for light theme --- */ +.bg-primary.bg-opacity-25 { + background-color: var(--mm-primary-soft) !important; +} +.bg-warning.bg-opacity-25 { + background-color: var(--mm-warning-soft) !important; +} +.bg-info.bg-opacity-25 { + background-color: var(--mm-info-soft) !important; +} +.bg-success.bg-opacity-25 { + background-color: var(--mm-success-soft) !important; +} +.text-primary { color: var(--mm-primary) !important; } +.text-warning { color: var(--mm-warning) !important; } +.text-info { color: var(--mm-info) !important; } + +/* --- Scrollbar (subtle for light theme) --- */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; +} +::-webkit-scrollbar-thumb:hover { background: #94a3b8; } diff --git a/MoneyMap/wwwroot/js/category-mappings.js b/MoneyMap/wwwroot/js/category-mappings.js index b240db4..f1abbfe 100644 --- a/MoneyMap/wwwroot/js/category-mappings.js +++ b/MoneyMap/wwwroot/js/category-mappings.js @@ -1,69 +1,69 @@ -// Category Mappings Page JavaScript - -function openEditModal(id, category, pattern, priority, merchant) { - console.log('Opening modal for:', id, category, pattern, priority, merchant); - - document.getElementById('editId').value = id; - document.getElementById('editCategory').value = category; - document.getElementById('editPattern').value = pattern; - document.getElementById('editPriority').value = priority; - document.getElementById('editMerchant').value = merchant || ''; - - var modalElement = document.getElementById('editModal'); - if (modalElement && typeof bootstrap !== 'undefined') { - var modal = new bootstrap.Modal(modalElement); - modal.show(); - } else { - console.error('Bootstrap modal not available'); - alert('Error: Modal system not loaded. Please refresh the page.'); - } -} - -document.addEventListener('DOMContentLoaded', function() { - - // Debug: Log form values before submission - var addForm = document.querySelector('#addModal form'); - if (addForm) { - addForm.addEventListener('submit', function(e) { - var categoryInput = document.getElementById('addCategory'); - var patternInput = document.getElementById('addPattern'); - var priorityInput = document.getElementById('addPriority'); - - console.log('=== ADD FORM SUBMISSION ==='); - console.log('Category:', categoryInput.value, 'Name:', categoryInput.name); - console.log('Pattern:', patternInput.value, 'Name:', patternInput.name); - console.log('Priority:', priorityInput.value, 'Name:', priorityInput.name); - console.log('Form Data:'); - var formData = new FormData(addForm); - for (var pair of formData.entries()) { - console.log(' ' + pair[0] + ': ' + pair[1]); - } - }); - } - - var editForm = document.querySelector('#editModal form'); - if (editForm) { - editForm.addEventListener('submit', function(e) { - var id = document.getElementById('editId').value; - var category = document.getElementById('editCategory').value; - var pattern = document.getElementById('editPattern').value; - var priority = document.getElementById('editPriority').value; - console.log('Submitting Edit form:', { id, category, pattern, priority }); - console.log('Category input name:', document.getElementById('editCategory').name); - }); - } - - // Reopen modals if there are validation errors - var addCategoryInput = document.getElementById('addCategory'); - var editCategoryInput = document.getElementById('editCategory'); - - if (addCategoryInput && addCategoryInput.classList.contains('input-validation-error')) { - var addModal = new bootstrap.Modal(document.getElementById('addModal')); - addModal.show(); - } - - if (editCategoryInput && editCategoryInput.classList.contains('input-validation-error')) { - var editModal = new bootstrap.Modal(document.getElementById('editModal')); - editModal.show(); - } -}); +// Category Mappings Page JavaScript + +function openEditModal(id, category, pattern, priority, merchant) { + console.log('Opening modal for:', id, category, pattern, priority, merchant); + + document.getElementById('editId').value = id; + document.getElementById('editCategory').value = category; + document.getElementById('editPattern').value = pattern; + document.getElementById('editPriority').value = priority; + document.getElementById('editMerchant').value = merchant || ''; + + var modalElement = document.getElementById('editModal'); + if (modalElement && typeof bootstrap !== 'undefined') { + var modal = new bootstrap.Modal(modalElement); + modal.show(); + } else { + console.error('Bootstrap modal not available'); + alert('Error: Modal system not loaded. Please refresh the page.'); + } +} + +document.addEventListener('DOMContentLoaded', function() { + + // Debug: Log form values before submission + var addForm = document.querySelector('#addModal form'); + if (addForm) { + addForm.addEventListener('submit', function(e) { + var categoryInput = document.getElementById('addCategory'); + var patternInput = document.getElementById('addPattern'); + var priorityInput = document.getElementById('addPriority'); + + console.log('=== ADD FORM SUBMISSION ==='); + console.log('Category:', categoryInput.value, 'Name:', categoryInput.name); + console.log('Pattern:', patternInput.value, 'Name:', patternInput.name); + console.log('Priority:', priorityInput.value, 'Name:', priorityInput.name); + console.log('Form Data:'); + var formData = new FormData(addForm); + for (var pair of formData.entries()) { + console.log(' ' + pair[0] + ': ' + pair[1]); + } + }); + } + + var editForm = document.querySelector('#editModal form'); + if (editForm) { + editForm.addEventListener('submit', function(e) { + var id = document.getElementById('editId').value; + var category = document.getElementById('editCategory').value; + var pattern = document.getElementById('editPattern').value; + var priority = document.getElementById('editPriority').value; + console.log('Submitting Edit form:', { id, category, pattern, priority }); + console.log('Category input name:', document.getElementById('editCategory').name); + }); + } + + // Reopen modals if there are validation errors + var addCategoryInput = document.getElementById('addCategory'); + var editCategoryInput = document.getElementById('editCategory'); + + if (addCategoryInput && addCategoryInput.classList.contains('input-validation-error')) { + var addModal = new bootstrap.Modal(document.getElementById('addModal')); + addModal.show(); + } + + if (editCategoryInput && editCategoryInput.classList.contains('input-validation-error')) { + var editModal = new bootstrap.Modal(document.getElementById('editModal')); + editModal.show(); + } +}); diff --git a/MoneyMap/wwwroot/js/edit-transaction.js b/MoneyMap/wwwroot/js/edit-transaction.js index d1b1eb5..539a38c 100644 --- a/MoneyMap/wwwroot/js/edit-transaction.js +++ b/MoneyMap/wwwroot/js/edit-transaction.js @@ -1,73 +1,73 @@ -// Edit Transaction Page JavaScript - -function handleCategoryChange() { - const select = document.getElementById('categorySelect'); - const customInputDiv = document.getElementById('customCategoryInput'); - const categoryInput = document.getElementById('categoryInput'); - - if (select.value === '__custom__') { - customInputDiv.style.display = 'block'; - categoryInput.value = ''; - categoryInput.focus(); - } else { - customInputDiv.style.display = 'none'; - categoryInput.value = select.value; - } -} - -function handleMerchantChange() { - const select = document.getElementById('merchantSelect'); - const customInput = document.getElementById('customMerchantInput'); - const hiddenInput = document.getElementById('merchantHidden'); - const merchantNameInput = document.querySelector('input[name="Transaction.MerchantName"]'); - - if (select.value === '__custom__') { - customInput.style.display = 'block'; - hiddenInput.value = ''; - merchantNameInput.value = ''; - merchantNameInput.focus(); - } else { - customInput.style.display = 'none'; - hiddenInput.value = select.value; - merchantNameInput.value = ''; - } -} - -function copyTransactionId(transactionId) { - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(transactionId.toString()); - } else { - var ta = document.createElement('textarea'); - ta.value = transactionId; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } - var btns = document.querySelectorAll('button[onclick*="copyTransactionId"]'); - btns.forEach(function(btn) { - var old = btn.textContent; - btn.textContent = 'Copied!'; - setTimeout(function() { - btn.textContent = old; - }, 1500); - }); -} - -// Update hidden field when custom input changes -document.addEventListener('DOMContentLoaded', function() { - const categoryInput = document.querySelector('input[name="Transaction.Category"]'); - const categorySelect = document.getElementById('categorySelect'); - - if (categoryInput) { - categoryInput.addEventListener('input', function() { - if (categorySelect.value === '__custom__') { - // Keep custom selected when typing - } - }); - } - - // Initialize on page load - handleCategoryChange(); - handleMerchantChange(); -}); +// Edit Transaction Page JavaScript + +function handleCategoryChange() { + const select = document.getElementById('categorySelect'); + const customInputDiv = document.getElementById('customCategoryInput'); + const categoryInput = document.getElementById('categoryInput'); + + if (select.value === '__custom__') { + customInputDiv.style.display = 'block'; + categoryInput.value = ''; + categoryInput.focus(); + } else { + customInputDiv.style.display = 'none'; + categoryInput.value = select.value; + } +} + +function handleMerchantChange() { + const select = document.getElementById('merchantSelect'); + const customInput = document.getElementById('customMerchantInput'); + const hiddenInput = document.getElementById('merchantHidden'); + const merchantNameInput = document.querySelector('input[name="Transaction.MerchantName"]'); + + if (select.value === '__custom__') { + customInput.style.display = 'block'; + hiddenInput.value = ''; + merchantNameInput.value = ''; + merchantNameInput.focus(); + } else { + customInput.style.display = 'none'; + hiddenInput.value = select.value; + merchantNameInput.value = ''; + } +} + +function copyTransactionId(transactionId) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(transactionId.toString()); + } else { + var ta = document.createElement('textarea'); + ta.value = transactionId; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + var btns = document.querySelectorAll('button[onclick*="copyTransactionId"]'); + btns.forEach(function(btn) { + var old = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function() { + btn.textContent = old; + }, 1500); + }); +} + +// Update hidden field when custom input changes +document.addEventListener('DOMContentLoaded', function() { + const categoryInput = document.querySelector('input[name="Transaction.Category"]'); + const categorySelect = document.getElementById('categorySelect'); + + if (categoryInput) { + categoryInput.addEventListener('input', function() { + if (categorySelect.value === '__custom__') { + // Keep custom selected when typing + } + }); + } + + // Initialize on page load + handleCategoryChange(); + handleMerchantChange(); +}); diff --git a/MoneyMap/wwwroot/js/merchants.js b/MoneyMap/wwwroot/js/merchants.js index a684a92..74d77d5 100644 --- a/MoneyMap/wwwroot/js/merchants.js +++ b/MoneyMap/wwwroot/js/merchants.js @@ -1,31 +1,31 @@ -// Merchants Page JavaScript - -function openEditModal(id, name) { - document.getElementById('editId').value = id; - document.getElementById('editName').value = name; - - var modalElement = document.getElementById('editModal'); - if (modalElement && typeof bootstrap !== 'undefined') { - var modal = new bootstrap.Modal(modalElement); - modal.show(); - } else { - console.error('Bootstrap modal not available'); - alert('Error: Modal system not loaded. Please refresh the page.'); - } -} - -document.addEventListener('DOMContentLoaded', function() { - // Reopen modals if there are validation errors - var addNameInput = document.getElementById('addName'); - var editNameInput = document.getElementById('editName'); - - if (addNameInput && addNameInput.classList.contains('input-validation-error')) { - var addModal = new bootstrap.Modal(document.getElementById('addModal')); - addModal.show(); - } - - if (editNameInput && editNameInput.classList.contains('input-validation-error')) { - var editModal = new bootstrap.Modal(document.getElementById('editModal')); - editModal.show(); - } -}); +// Merchants Page JavaScript + +function openEditModal(id, name) { + document.getElementById('editId').value = id; + document.getElementById('editName').value = name; + + var modalElement = document.getElementById('editModal'); + if (modalElement && typeof bootstrap !== 'undefined') { + var modal = new bootstrap.Modal(modalElement); + modal.show(); + } else { + console.error('Bootstrap modal not available'); + alert('Error: Modal system not loaded. Please refresh the page.'); + } +} + +document.addEventListener('DOMContentLoaded', function() { + // Reopen modals if there are validation errors + var addNameInput = document.getElementById('addName'); + var editNameInput = document.getElementById('editName'); + + if (addNameInput && addNameInput.classList.contains('input-validation-error')) { + var addModal = new bootstrap.Modal(document.getElementById('addModal')); + addModal.show(); + } + + if (editNameInput && editNameInput.classList.contains('input-validation-error')) { + var editModal = new bootstrap.Modal(document.getElementById('editModal')); + editModal.show(); + } +}); diff --git a/MoneyMap/wwwroot/js/site.js b/MoneyMap/wwwroot/js/site.js index 711cf6a..11cf158 100644 --- a/MoneyMap/wwwroot/js/site.js +++ b/MoneyMap/wwwroot/js/site.js @@ -1,19 +1,19 @@ -// Highlight active dropdown parent when a child page is active -(function () { - var path = window.location.pathname.toLowerCase().replace(/\/+$/, '') || '/'; - document.querySelectorAll('.navbar .dropdown-menu .dropdown-item').forEach(function (item) { - var href = (item.getAttribute('href') || '').toLowerCase().replace(/\/+$/, ''); - if (href && (path === href || path.startsWith(href + '/'))) { - item.classList.add('active'); - var toggle = item.closest('.dropdown').querySelector('.dropdown-toggle'); - if (toggle) toggle.classList.add('active-parent'); - } - }); - // Direct nav-links (non-dropdown) - document.querySelectorAll('.navbar .nav-link:not(.dropdown-toggle)').forEach(function (link) { - var href = (link.getAttribute('href') || '').toLowerCase().replace(/\/+$/, ''); - if (href && path === href) { - link.classList.add('active'); - } - }); -})(); +// Highlight active dropdown parent when a child page is active +(function () { + var path = window.location.pathname.toLowerCase().replace(/\/+$/, '') || '/'; + document.querySelectorAll('.navbar .dropdown-menu .dropdown-item').forEach(function (item) { + var href = (item.getAttribute('href') || '').toLowerCase().replace(/\/+$/, ''); + if (href && (path === href || path.startsWith(href + '/'))) { + item.classList.add('active'); + var toggle = item.closest('.dropdown').querySelector('.dropdown-toggle'); + if (toggle) toggle.classList.add('active-parent'); + } + }); + // Direct nav-links (non-dropdown) + document.querySelectorAll('.navbar .nav-link:not(.dropdown-toggle)').forEach(function (link) { + var href = (link.getAttribute('href') || '').toLowerCase().replace(/\/+$/, ''); + if (href && path === href) { + link.classList.add('active'); + } + }); +})(); diff --git a/MoneyMap/wwwroot/js/transactions.js b/MoneyMap/wwwroot/js/transactions.js index 93e7c89..c6073cc 100644 --- a/MoneyMap/wwwroot/js/transactions.js +++ b/MoneyMap/wwwroot/js/transactions.js @@ -1,44 +1,44 @@ -// Transactions Page JavaScript - -// Initialize Bootstrap tooltips for notes badges -document.addEventListener('DOMContentLoaded', function() { - var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); - var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { - return new bootstrap.Tooltip(tooltipTriggerEl); - }); -}); - -// Quick date range functions -function formatDate(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function setDateRange(days) { - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - days); - - document.getElementById('startDateInput').value = formatDate(startDate); - document.getElementById('endDateInput').value = formatDate(endDate); -} - -function setDateRangeThisMonth() { - const now = new Date(); - const startDate = new Date(now.getFullYear(), now.getMonth(), 1); - const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - document.getElementById('startDateInput').value = formatDate(startDate); - document.getElementById('endDateInput').value = formatDate(endDate); -} - -function setDateRangeLastMonth() { - const now = new Date(); - const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); - const endDate = new Date(now.getFullYear(), now.getMonth(), 0); - - document.getElementById('startDateInput').value = formatDate(startDate); - document.getElementById('endDateInput').value = formatDate(endDate); -} +// Transactions Page JavaScript + +// Initialize Bootstrap tooltips for notes badges +document.addEventListener('DOMContentLoaded', function() { + var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); +}); + +// Quick date range functions +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function setDateRange(days) { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + document.getElementById('startDateInput').value = formatDate(startDate); + document.getElementById('endDateInput').value = formatDate(endDate); +} + +function setDateRangeThisMonth() { + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth(), 1); + const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + document.getElementById('startDateInput').value = formatDate(startDate); + document.getElementById('endDateInput').value = formatDate(endDate); +} + +function setDateRangeLastMonth() { + const now = new Date(); + const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const endDate = new Date(now.getFullYear(), now.getMonth(), 0); + + document.getElementById('startDateInput').value = formatDate(startDate); + document.getElementById('endDateInput').value = formatDate(endDate); +} diff --git a/MoneyMap/wwwroot/js/upload.js b/MoneyMap/wwwroot/js/upload.js index 8c8572d..3941b62 100644 --- a/MoneyMap/wwwroot/js/upload.js +++ b/MoneyMap/wwwroot/js/upload.js @@ -1,126 +1,126 @@ -// Upload Page JavaScript -let currentPage = 0; -const pageSize = 100; -let totalRows = 0; - -function togglePaymentSelection() { - const mode = document.querySelector('input[name="PaymentMode"]:checked').value; - const cardRow = document.getElementById('cardSelectRow'); - const accountRow = document.getElementById('accountSelectRow'); - - cardRow.style.display = mode === 'Card' ? 'block' : 'none'; - accountRow.style.display = mode === 'Account' ? 'block' : 'none'; -} - -function toggleAllCheckboxes() { - const selectAll = document.getElementById('selectAll'); - const checkboxes = document.querySelectorAll('.transaction-checkbox'); - checkboxes.forEach(cb => cb.checked = selectAll.checked); - updateSelectedCount(); -} - -function updateSelectedCount() { - const checkboxes = document.querySelectorAll('.transaction-checkbox:checked'); - const count = checkboxes.length; - const selectedCountEl = document.getElementById('selectedCount'); - const selectedCountButtonEl = document.getElementById('selectedCountButton'); - if (selectedCountEl) selectedCountEl.textContent = count; - if (selectedCountButtonEl) selectedCountButtonEl.textContent = count; -} - -function updateAllTransactionAccounts() { - // Called when the global account dropdown changes - // No action needed - we'll read the global account on form submit -} - -function showPage(pageNum) { - const rows = document.querySelectorAll('.transaction-row'); - const start = pageNum * pageSize; - const end = start + pageSize; - - rows.forEach((row, index) => { - row.style.display = (index >= start && index < end) ? '' : 'none'; - }); - - // Update pagination display - const pageStartEl = document.getElementById('pageStart'); - const pageEndEl = document.getElementById('pageEnd'); - if (pageStartEl && pageEndEl) { - pageStartEl.textContent = start + 1; - pageEndEl.textContent = Math.min(end, totalRows); - } - - currentPage = pageNum; -} - -function nextPage() { - const maxPage = Math.ceil(totalRows / pageSize) - 1; - if (currentPage < maxPage) { - showPage(currentPage + 1); - } -} - -function previousPage() { - if (currentPage > 0) { - showPage(currentPage - 1); - } -} - -function prepareFormData() { - // Collect selected indices - const selectedIndices = []; - document.querySelectorAll('.transaction-checkbox:checked').forEach(cb => { - selectedIndices.push(cb.getAttribute('data-index')); - }); - document.getElementById('selectedIndices').value = selectedIndices.join(','); - - // Get the global account ID - const globalAccountId = document.getElementById('globalAccountId')?.value; - if (!globalAccountId) { - alert('Please select an account for all transactions'); - return false; - } - - // Collect payment data (account + optional card + category per transaction) - const paymentData = {}; - document.querySelectorAll('.card-select').forEach((select) => { - const index = select.getAttribute('data-index'); - const cardId = select.value ? parseInt(select.value) : null; - - // Get category for this transaction - const categoryInput = document.querySelector(`.category-input[data-index="${index}"]`); - const category = categoryInput ? categoryInput.value.trim() : ''; - - paymentData[index] = { - AccountId: parseInt(globalAccountId), - CardId: cardId, - Category: category - }; - }); - document.getElementById('paymentData').value = JSON.stringify(paymentData); - - return true; // Allow form submission -} - -// Initialize on page load -document.addEventListener('DOMContentLoaded', function() { - togglePaymentSelection(); - - // Get total rows from the preview transactions count - const previewCount = document.querySelectorAll('.transaction-row').length; - totalRows = previewCount; - - if (totalRows > pageSize) { - showPage(0); - } - - // Enable/disable preview button based on file selection - const csvFileInput = document.getElementById('csvFileInput'); - const previewButton = document.getElementById('previewButton'); - - if (csvFileInput && previewButton) { - csvFileInput.addEventListener('change', function() { - previewButton.disabled = !this.files || this.files.length === 0; - }); - } -}); +// Upload Page JavaScript +let currentPage = 0; +const pageSize = 100; +let totalRows = 0; + +function togglePaymentSelection() { + const mode = document.querySelector('input[name="PaymentMode"]:checked').value; + const cardRow = document.getElementById('cardSelectRow'); + const accountRow = document.getElementById('accountSelectRow'); + + cardRow.style.display = mode === 'Card' ? 'block' : 'none'; + accountRow.style.display = mode === 'Account' ? 'block' : 'none'; +} + +function toggleAllCheckboxes() { + const selectAll = document.getElementById('selectAll'); + const checkboxes = document.querySelectorAll('.transaction-checkbox'); + checkboxes.forEach(cb => cb.checked = selectAll.checked); + updateSelectedCount(); +} + +function updateSelectedCount() { + const checkboxes = document.querySelectorAll('.transaction-checkbox:checked'); + const count = checkboxes.length; + const selectedCountEl = document.getElementById('selectedCount'); + const selectedCountButtonEl = document.getElementById('selectedCountButton'); + if (selectedCountEl) selectedCountEl.textContent = count; + if (selectedCountButtonEl) selectedCountButtonEl.textContent = count; +} + +function updateAllTransactionAccounts() { + // Called when the global account dropdown changes + // No action needed - we'll read the global account on form submit +} + +function showPage(pageNum) { + const rows = document.querySelectorAll('.transaction-row'); + const start = pageNum * pageSize; + const end = start + pageSize; + + rows.forEach((row, index) => { + row.style.display = (index >= start && index < end) ? '' : 'none'; + }); + + // Update pagination display + const pageStartEl = document.getElementById('pageStart'); + const pageEndEl = document.getElementById('pageEnd'); + if (pageStartEl && pageEndEl) { + pageStartEl.textContent = start + 1; + pageEndEl.textContent = Math.min(end, totalRows); + } + + currentPage = pageNum; +} + +function nextPage() { + const maxPage = Math.ceil(totalRows / pageSize) - 1; + if (currentPage < maxPage) { + showPage(currentPage + 1); + } +} + +function previousPage() { + if (currentPage > 0) { + showPage(currentPage - 1); + } +} + +function prepareFormData() { + // Collect selected indices + const selectedIndices = []; + document.querySelectorAll('.transaction-checkbox:checked').forEach(cb => { + selectedIndices.push(cb.getAttribute('data-index')); + }); + document.getElementById('selectedIndices').value = selectedIndices.join(','); + + // Get the global account ID + const globalAccountId = document.getElementById('globalAccountId')?.value; + if (!globalAccountId) { + alert('Please select an account for all transactions'); + return false; + } + + // Collect payment data (account + optional card + category per transaction) + const paymentData = {}; + document.querySelectorAll('.card-select').forEach((select) => { + const index = select.getAttribute('data-index'); + const cardId = select.value ? parseInt(select.value) : null; + + // Get category for this transaction + const categoryInput = document.querySelector(`.category-input[data-index="${index}"]`); + const category = categoryInput ? categoryInput.value.trim() : ''; + + paymentData[index] = { + AccountId: parseInt(globalAccountId), + CardId: cardId, + Category: category + }; + }); + document.getElementById('paymentData').value = JSON.stringify(paymentData); + + return true; // Allow form submission +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + togglePaymentSelection(); + + // Get total rows from the preview transactions count + const previewCount = document.querySelectorAll('.transaction-row').length; + totalRows = previewCount; + + if (totalRows > pageSize) { + showPage(0); + } + + // Enable/disable preview button based on file selection + const csvFileInput = document.getElementById('csvFileInput'); + const previewButton = document.getElementById('previewButton'); + + if (csvFileInput && previewButton) { + csvFileInput.addEventListener('change', function() { + previewButton.disabled = !this.files || this.files.length === 0; + }); + } +}); diff --git a/docker-compose.yml b/docker-compose.yml index dfe5d31..fdb0c21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ -services: - moneymap: - image: ${DOCKER_IMAGE:-yourusername/moneymap:latest} - container_name: moneymap - ports: - - "5010:5010" - environment: - - ConnectionStrings__MoneyMapDb=Server=${DB_SERVER};Database=MoneyMap;User Id=${DB_USER};Password=${DB_PASSWORD};TrustServerCertificate=True; - - Receipts__StoragePath=receipts - - OPENAI_API_KEY=${OPENAI_API_KEY} - volumes: - - ${RECEIPTS_HOST_PATH}:/app/wwwroot/receipts - restart: unless-stopped +services: + moneymap: + image: ${DOCKER_IMAGE:-yourusername/moneymap:latest} + container_name: moneymap + ports: + - "5010:5010" + environment: + - ConnectionStrings__MoneyMapDb=Server=${DB_SERVER};Database=MoneyMap;User Id=${DB_USER};Password=${DB_PASSWORD};TrustServerCertificate=True; + - Receipts__StoragePath=receipts + - OPENAI_API_KEY=${OPENAI_API_KEY} + volumes: + - ${RECEIPTS_HOST_PATH}:/app/wwwroot/receipts + restart: unless-stopped diff --git a/docker-publish.cmd b/docker-publish.cmd index 7f73d69..f0289c3 100644 --- a/docker-publish.cmd +++ b/docker-publish.cmd @@ -1,2 +1,2 @@ -docker build -t ajisaacs/moneymap:latest ./MoneyMap +docker build -t ajisaacs/moneymap:latest ./MoneyMap docker push ajisaacs/moneymap:latest \ No newline at end of file